♻️ refactor(vscode): 文件注释的存法抽成按路径记的通用表,给问答共用
This commit is contained in:
1 parent
2282646a43
commit
badc722640
8 files changed
+377
-319
No files matched your search
@@ -0,0 +1,140 @@
|
|||||||
|
import type { FileDeleteEvent, FileRenameEvent, WorkspaceFolder } from 'vscode'
|
||||||
|
import type { PathMap, PathMapFile } from './pathMap'
|
||||||
|
import { Disposable, EventEmitter, RelativePattern, Uri, window, workspace } from 'vscode'
|
||||||
|
import { changedKeys, dropEntries, entriesUnder, moveEntries, placeEntries, readPathMap, toPathKey, writePathMap } from './pathMap'
|
||||||
|
|
||||||
|
/** 一个文件 / 目录在哪个工作区文件夹里、键是什么。 */
|
||||||
|
export interface PathLocation {
|
||||||
|
folder: WorkspaceFolder
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** uri 所在的工作区文件夹和它在那里的键;不在工作区文件夹里、或就是文件夹本身时没有。 */
|
||||||
|
export function locate(uri: Uri): PathLocation | undefined {
|
||||||
|
const folder = workspace.getWorkspaceFolder(uri)
|
||||||
|
const key = folder && toPathKey(folder.uri.path, uri.path)
|
||||||
|
return folder && key !== undefined ? { folder, key } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每个工作区文件夹一份的按路径记的表(见 pathMap):内存里留一份给界面同步取用。
|
||||||
|
* 在 VS Code 里改名、挪位置,条目跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读。
|
||||||
|
*/
|
||||||
|
export class FolderStore<T> implements Disposable {
|
||||||
|
private readonly changed = new EventEmitter<Uri[]>()
|
||||||
|
/** 条目变了的那些文件 / 目录 */
|
||||||
|
readonly onDidChange = this.changed.event
|
||||||
|
/** 工作区文件夹 Uri → 它的表 */
|
||||||
|
private readonly maps = new Map<string, PathMap<T>>()
|
||||||
|
/** 工作区文件夹 Uri → 盯它那份文件的监听 */
|
||||||
|
private readonly watchers = new Map<string, Disposable>()
|
||||||
|
private readonly disposables: Disposable[]
|
||||||
|
/** 读写排成一队:自己写到一半时文件监听触发的重读排在写完之后,读不到半截文件;几次改动也不会互相覆盖。 */
|
||||||
|
private queue: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
constructor(private readonly file: PathMapFile<T>) {
|
||||||
|
this.disposables = [
|
||||||
|
this.changed,
|
||||||
|
workspace.onDidChangeWorkspaceFolders((e) => {
|
||||||
|
e.removed.forEach(folder => this.unwatch(folder))
|
||||||
|
e.added.forEach(folder => this.watch(folder))
|
||||||
|
}),
|
||||||
|
workspace.onDidRenameFiles(e => this.moved(e)),
|
||||||
|
workspace.onDidDeleteFiles(e => this.deleted(e)),
|
||||||
|
]
|
||||||
|
for (const folder of workspace.workspaceFolders ?? [])
|
||||||
|
this.watch(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
get(folder: WorkspaceFolder, key: string): T | undefined {
|
||||||
|
return this.maps.get(folder.uri.toString())?.[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。 */
|
||||||
|
update(folder: WorkspaceFolder, change: (map: PathMap<T>) => PathMap<T>): Promise<void> {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
const before = await readPathMap(folder.uri.fsPath, this.file)
|
||||||
|
const after = change(before)
|
||||||
|
if (changedKeys(before, after).length === 0)
|
||||||
|
return
|
||||||
|
await writePathMap(folder.uri.fsPath, this.file, after)
|
||||||
|
this.apply(folder, after)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const disposable of [...this.disposables, ...this.watchers.values()])
|
||||||
|
disposable.dispose()
|
||||||
|
this.watchers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private watch(folder: WorkspaceFolder): void {
|
||||||
|
const watcher = workspace.createFileSystemWatcher(new RelativePattern(folder, this.file.path))
|
||||||
|
const reload = (): void => {
|
||||||
|
void this.enqueue(async () => this.apply(folder, await readPathMap(folder.uri.fsPath, this.file)))
|
||||||
|
}
|
||||||
|
this.watchers.set(folder.uri.toString(), Disposable.from(
|
||||||
|
watcher,
|
||||||
|
watcher.onDidChange(reload),
|
||||||
|
watcher.onDidCreate(reload),
|
||||||
|
watcher.onDidDelete(reload),
|
||||||
|
))
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwatch(folder: WorkspaceFolder): void {
|
||||||
|
const id = folder.uri.toString()
|
||||||
|
this.watchers.get(id)?.dispose()
|
||||||
|
this.watchers.delete(id)
|
||||||
|
this.maps.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private moved(e: FileRenameEvent): void {
|
||||||
|
for (const { oldUri, newUri } of e.files) {
|
||||||
|
const from = locate(oldUri)
|
||||||
|
if (!from)
|
||||||
|
continue
|
||||||
|
const to = locate(newUri)
|
||||||
|
if (to && to.folder.uri.toString() === from.folder.uri.toString()) {
|
||||||
|
void this.update(from.folder, map => moveEntries(map, from.key, to.key))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 挪到了另一个工作区文件夹:两份文件各改一次,队列保证先取出再放进去;挪出工作区就不要了
|
||||||
|
let moving: PathMap<T> = {}
|
||||||
|
void this.update(from.folder, (map) => {
|
||||||
|
moving = entriesUnder(map, from.key)
|
||||||
|
return dropEntries(map, from.key)
|
||||||
|
})
|
||||||
|
if (to)
|
||||||
|
void this.update(to.folder, map => placeEntries(map, to.key, moving))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleted(e: FileDeleteEvent): void {
|
||||||
|
for (const uri of e.files) {
|
||||||
|
const at = locate(uri)
|
||||||
|
if (at)
|
||||||
|
void this.update(at.folder, map => dropEntries(map, at.key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 换上新的一份,报出条目变了的那些文件 / 目录。 */
|
||||||
|
private apply(folder: WorkspaceFolder, map: PathMap<T>): void {
|
||||||
|
const id = folder.uri.toString()
|
||||||
|
if (!this.watchers.has(id))
|
||||||
|
return // 排队期间这个文件夹已经移出工作区
|
||||||
|
const keys = changedKeys(this.maps.get(id) ?? {}, map)
|
||||||
|
this.maps.set(id, map)
|
||||||
|
if (keys.length > 0)
|
||||||
|
this.changed.fire(keys.map(key => Uri.joinPath(folder.uri, ...key.split('/'))))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(task: () => Promise<void>): Promise<void> {
|
||||||
|
const run = this.queue.then(task).catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
void window.showErrorMessage(`${this.file.label}读写 ${this.file.path} 失败:${message}`)
|
||||||
|
})
|
||||||
|
this.queue = run
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,30 @@
|
|||||||
import type { FileDeleteEvent, FileRenameEvent, TreeItem, WorkspaceFolder } from 'vscode'
|
import type { Disposable, TreeItem } from 'vscode'
|
||||||
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
|
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
|
||||||
import type { NoteMap } from './noteMap'
|
import { MarkdownString } from 'vscode'
|
||||||
import { Disposable, EventEmitter, MarkdownString, RelativePattern, Uri, window, workspace } from 'vscode'
|
import { FolderStore } from '../folderStore'
|
||||||
|
import { toPathKey } from '../pathMap'
|
||||||
import { noteDecoration } from './noteItem'
|
import { noteDecoration } from './noteItem'
|
||||||
import { changedKeys, dropNotes, moveNotes, notesUnder, placeNotes, toNoteKey, withNote } from './noteMap'
|
import { withNote } from './noteMap'
|
||||||
import { NOTES_FILE, readNotes, writeNotes } from './store'
|
import { NOTES_FILE } from './store'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读代码时给目录 / 文件记的注释。
|
* 读代码时给目录 / 文件记的注释。
|
||||||
* 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。
|
* 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。
|
||||||
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读。
|
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。
|
||||||
*/
|
*/
|
||||||
export class FileNotes implements FileTreeDecorator, Disposable {
|
export class FileNotes implements FileTreeDecorator, Disposable {
|
||||||
private readonly changed = new EventEmitter<Uri[]>()
|
private readonly store = new FolderStore(NOTES_FILE)
|
||||||
readonly onDidChange = this.changed.event
|
/** 注释变了的那些行 */
|
||||||
/** 工作区文件夹 Uri → 它的注释 */
|
readonly onDidChange = this.store.onDidChange
|
||||||
private readonly notes = new Map<string, NoteMap>()
|
|
||||||
/** 工作区文件夹 Uri → 盯它的 code-notes.json 的监听 */
|
|
||||||
private readonly watchers = new Map<string, Disposable>()
|
|
||||||
private readonly disposables: Disposable[]
|
|
||||||
/** 读写排成一队:自己写到一半时文件监听触发的重读排在写完之后,读不到半截文件;几次改动也不会互相覆盖。 */
|
|
||||||
private queue: Promise<void> = Promise.resolve()
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.disposables = [
|
|
||||||
this.changed,
|
|
||||||
workspace.onDidChangeWorkspaceFolders((e) => {
|
|
||||||
e.removed.forEach(folder => this.unwatch(folder))
|
|
||||||
e.added.forEach(folder => this.watch(folder))
|
|
||||||
}),
|
|
||||||
workspace.onDidRenameFiles(e => this.moved(e)),
|
|
||||||
workspace.onDidDeleteFiles(e => this.deleted(e)),
|
|
||||||
]
|
|
||||||
for (const folder of workspace.workspaceFolders ?? [])
|
|
||||||
this.watch(folder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 这一行的注释;工作区根节点不记注释。 */
|
/** 这一行的注释;工作区根节点不记注释。 */
|
||||||
noteOf(node: FileNode): string | undefined {
|
noteOf(node: FileNode): string | undefined {
|
||||||
const key = toNoteKey(node.folder.uri.path, node.uri.path)
|
const key = toPathKey(node.folder.uri.path, node.uri.path)
|
||||||
return key === undefined ? undefined : this.notes.get(node.folder.uri.toString())?.[key]
|
return key === undefined ? undefined : this.store.get(node.folder, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
decorate(item: TreeItem, node: FileNode): void {
|
decorate(item: TreeItem, node: FileNode): void {
|
||||||
const key = toNoteKey(node.folder.uri.path, node.uri.path)
|
const key = toPathKey(node.folder.uri.path, node.uri.path)
|
||||||
const decoration = key === undefined ? undefined : noteDecoration(item.contextValue ?? '', key, this.noteOf(node))
|
const decoration = key === undefined ? undefined : noteDecoration(item.contextValue ?? '', key, this.noteOf(node))
|
||||||
if (!decoration)
|
if (!decoration)
|
||||||
return
|
return
|
||||||
@@ -54,99 +35,12 @@ export class FileNotes implements FileTreeDecorator, Disposable {
|
|||||||
|
|
||||||
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
|
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
|
||||||
async save(node: FileNode, text: string): Promise<void> {
|
async save(node: FileNode, text: string): Promise<void> {
|
||||||
const key = toNoteKey(node.folder.uri.path, node.uri.path)
|
const key = toPathKey(node.folder.uri.path, node.uri.path)
|
||||||
if (key !== undefined)
|
if (key !== undefined)
|
||||||
await this.update(node.folder, notes => withNote(notes, key, text))
|
await this.store.update(node.folder, notes => withNote(notes, key, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const disposable of [...this.disposables, ...this.watchers.values()])
|
this.store.dispose()
|
||||||
disposable.dispose()
|
|
||||||
this.watchers.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private watch(folder: WorkspaceFolder): void {
|
|
||||||
const watcher = workspace.createFileSystemWatcher(new RelativePattern(folder, NOTES_FILE))
|
|
||||||
const reload = (): void => {
|
|
||||||
void this.enqueue(async () => this.apply(folder, await readNotes(folder.uri.fsPath)))
|
|
||||||
}
|
|
||||||
this.watchers.set(folder.uri.toString(), Disposable.from(
|
|
||||||
watcher,
|
|
||||||
watcher.onDidChange(reload),
|
|
||||||
watcher.onDidCreate(reload),
|
|
||||||
watcher.onDidDelete(reload),
|
|
||||||
))
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
private unwatch(folder: WorkspaceFolder): void {
|
|
||||||
const id = folder.uri.toString()
|
|
||||||
this.watchers.get(id)?.dispose()
|
|
||||||
this.watchers.delete(id)
|
|
||||||
this.notes.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private moved(e: FileRenameEvent): void {
|
|
||||||
for (const { oldUri, newUri } of e.files) {
|
|
||||||
const from = workspace.getWorkspaceFolder(oldUri)
|
|
||||||
const fromKey = from && toNoteKey(from.uri.path, oldUri.path)
|
|
||||||
if (!from || fromKey === undefined)
|
|
||||||
continue
|
|
||||||
const to = workspace.getWorkspaceFolder(newUri)
|
|
||||||
const toKey = to && toNoteKey(to.uri.path, newUri.path)
|
|
||||||
if (to && toKey !== undefined && to.uri.toString() === from.uri.toString()) {
|
|
||||||
void this.update(from, notes => moveNotes(notes, fromKey, toKey))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 挪到了另一个工作区文件夹:两份文件各改一次,队列保证先取出再放进去;挪出工作区就不要了
|
|
||||||
let moving: NoteMap = {}
|
|
||||||
void this.update(from, (notes) => {
|
|
||||||
moving = notesUnder(notes, fromKey)
|
|
||||||
return dropNotes(notes, fromKey)
|
|
||||||
})
|
|
||||||
if (to && toKey !== undefined)
|
|
||||||
void this.update(to, notes => placeNotes(notes, toKey, moving))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private deleted(e: FileDeleteEvent): void {
|
|
||||||
for (const uri of e.files) {
|
|
||||||
const folder = workspace.getWorkspaceFolder(uri)
|
|
||||||
const key = folder && toNoteKey(folder.uri.path, uri.path)
|
|
||||||
if (folder && key !== undefined)
|
|
||||||
void this.update(folder, notes => dropNotes(notes, key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读盘上最新的一份改好再写回;没变就不写,所以第一条注释保存之前不会生成文件。 */
|
|
||||||
private update(folder: WorkspaceFolder, change: (notes: NoteMap) => NoteMap): Promise<void> {
|
|
||||||
return this.enqueue(async () => {
|
|
||||||
const before = await readNotes(folder.uri.fsPath)
|
|
||||||
const after = change(before)
|
|
||||||
if (changedKeys(before, after).length === 0)
|
|
||||||
return
|
|
||||||
await writeNotes(folder.uri.fsPath, after)
|
|
||||||
this.apply(folder, after)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 换上新的一份,报出注释变了的那些行。 */
|
|
||||||
private apply(folder: WorkspaceFolder, notes: NoteMap): void {
|
|
||||||
const id = folder.uri.toString()
|
|
||||||
if (!this.watchers.has(id))
|
|
||||||
return // 排队期间这个文件夹已经移出工作区
|
|
||||||
const keys = changedKeys(this.notes.get(id) ?? {}, notes)
|
|
||||||
this.notes.set(id, notes)
|
|
||||||
if (keys.length > 0)
|
|
||||||
this.changed.fire(keys.map(key => Uri.joinPath(folder.uri, ...key.split('/'))))
|
|
||||||
}
|
|
||||||
|
|
||||||
private enqueue(task: () => Promise<void>): Promise<void> {
|
|
||||||
const run = this.queue.then(task).catch((err: unknown) => {
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
void window.showErrorMessage(`文件注释读写 ${NOTES_FILE} 失败:${message}`)
|
|
||||||
})
|
|
||||||
this.queue = run
|
|
||||||
return run
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { changedKeys, dropNotes, moveNotes, normalizeKey, notesUnder, placeNotes, toNoteKey, withNote } from './noteMap'
|
import { withNote } from './noteMap'
|
||||||
|
|
||||||
describe('注释的键', () => {
|
|
||||||
it('相对工作区文件夹、用 / 分隔', () => {
|
|
||||||
expect(toNoteKey('/work/luffy', '/work/luffy/backend/src/campaign')).toBe('backend/src/campaign')
|
|
||||||
expect(toNoteKey('/work/luffy/', '/work/luffy/a.ts')).toBe('a.ts')
|
|
||||||
// Windows 上 Uri.path 也是 / 分隔,盘符在最前面
|
|
||||||
expect(toNoteKey('/c:/work/luffy', '/c:/work/luffy/src/a.ts')).toBe('src/a.ts')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('工作区文件夹本身和外面的路径没有键', () => {
|
|
||||||
expect(toNoteKey('/work/luffy', '/work/luffy')).toBeUndefined()
|
|
||||||
expect(toNoteKey('/work/luffy', '/work/other/a.ts')).toBeUndefined()
|
|
||||||
expect(toNoteKey('/work/luffy', '/work/luffy-2/a.ts')).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('手写的键规范成同一种写法,不合法的丢掉', () => {
|
|
||||||
expect(normalizeKey('./src/a.ts')).toBe('src/a.ts')
|
|
||||||
expect(normalizeKey('src//campaign/')).toBe('src/campaign')
|
|
||||||
expect(normalizeKey('src/x/../a.ts')).toBe('src/a.ts')
|
|
||||||
expect(normalizeKey('')).toBeUndefined()
|
|
||||||
expect(normalizeKey('.')).toBeUndefined()
|
|
||||||
expect(normalizeKey('../a.ts')).toBeUndefined()
|
|
||||||
expect(normalizeKey('/etc/passwd')).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('保存注释', () => {
|
describe('保存注释', () => {
|
||||||
it('去掉首尾空白,不动别的注释', () => {
|
it('去掉首尾空白,不动别的注释', () => {
|
||||||
@@ -46,70 +21,3 @@ describe('保存注释', () => {
|
|||||||
expect(notes).toEqual({ 'a.ts': 'A' })
|
expect(notes).toEqual({ 'a.ts': 'A' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('改名、挪位置', () => {
|
|
||||||
const notes = {
|
|
||||||
'campaign': '营销活动',
|
|
||||||
'campaign/benefits.py': '权益发放',
|
|
||||||
'campaign/console/views.py': '后台',
|
|
||||||
'campaign2/x.py': '名字只是前缀相同',
|
|
||||||
'user/routes.py': '用户接口',
|
|
||||||
}
|
|
||||||
|
|
||||||
it('文件改名,注释跟着走', () => {
|
|
||||||
expect(moveNotes(notes, 'user/routes.py', 'user/api.py')).toEqual({
|
|
||||||
'campaign': '营销活动',
|
|
||||||
'campaign/benefits.py': '权益发放',
|
|
||||||
'campaign/console/views.py': '后台',
|
|
||||||
'campaign2/x.py': '名字只是前缀相同',
|
|
||||||
'user/api.py': '用户接口',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('目录挪位置,里面各层的注释一起走,名字只是前缀相同的不动', () => {
|
|
||||||
expect(moveNotes(notes, 'campaign', 'marketing/campaign')).toEqual({
|
|
||||||
'marketing/campaign': '营销活动',
|
|
||||||
'marketing/campaign/benefits.py': '权益发放',
|
|
||||||
'marketing/campaign/console/views.py': '后台',
|
|
||||||
'campaign2/x.py': '名字只是前缀相同',
|
|
||||||
'user/routes.py': '用户接口',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('没有注释的目录挪走,里面有注释的文件照样跟着走', () => {
|
|
||||||
expect(moveNotes({ 'src/a/b.ts': 'B' }, 'src/a', 'lib/a')).toEqual({ 'lib/a/b.ts': 'B' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('挪到另一个工作区文件夹:先取出再放进去', () => {
|
|
||||||
const moving = notesUnder(notes, 'campaign')
|
|
||||||
expect(moving).toEqual({ '': '营销活动', 'benefits.py': '权益发放', 'console/views.py': '后台' })
|
|
||||||
expect(placeNotes({ 'other.ts': 'O' }, 'pkg/campaign', moving)).toEqual({
|
|
||||||
'other.ts': 'O',
|
|
||||||
'pkg/campaign': '营销活动',
|
|
||||||
'pkg/campaign/benefits.py': '权益发放',
|
|
||||||
'pkg/campaign/console/views.py': '后台',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('删除', () => {
|
|
||||||
it('删目录连带里面各层的注释,名字只是前缀相同的留着', () => {
|
|
||||||
expect(dropNotes({
|
|
||||||
'campaign': '营销活动',
|
|
||||||
'campaign/benefits.py': '权益发放',
|
|
||||||
'campaign/console/views.py': '后台',
|
|
||||||
'campaign2/x.py': '留着',
|
|
||||||
}, 'campaign')).toEqual({ 'campaign2/x.py': '留着' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('删文件只删它自己', () => {
|
|
||||||
expect(dropNotes({ 'a.ts': 'A', 'b.ts': 'B' }, 'a.ts')).toEqual({ 'b.ts': 'B' })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('变了哪些行', () => {
|
|
||||||
it('增、删、改都算,没变的不算', () => {
|
|
||||||
expect(changedKeys({ a: '1', b: '2', c: '3' }, { a: '1', b: '二', d: '4' }).sort()).toEqual(['b', 'c', 'd'])
|
|
||||||
expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,18 +1,7 @@
|
|||||||
import * as path from 'node:path'
|
import type { PathMap } from '../pathMap'
|
||||||
|
|
||||||
/** 一个工作区文件夹里的全部注释:键是相对这个文件夹的路径(`/` 分隔),值是注释。 */
|
/** 一个工作区文件夹里的全部注释:键是相对这个文件夹的路径(`/` 分隔),值是注释。 */
|
||||||
export type NoteMap = Record<string, string>
|
export type NoteMap = PathMap<string>
|
||||||
|
|
||||||
/** 规范成注释的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
|
|
||||||
export function normalizeKey(relativePath: string): string | undefined {
|
|
||||||
const key = path.posix.normalize(relativePath).replace(/\/+$/, '')
|
|
||||||
return key === '' || key === '.' || key === '..' || key.startsWith('../') || key.startsWith('/') ? undefined : key
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param rootPath 工作区文件夹的 `Uri.path`;@param targetPath 目录 / 文件的 `Uri.path` */
|
|
||||||
export function toNoteKey(rootPath: string, targetPath: string): string | undefined {
|
|
||||||
return normalizeKey(path.posix.relative(rootPath, targetPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存一条注释:去掉首尾空白,空的就删掉这条。 */
|
/** 保存一条注释:去掉首尾空白,空的就删掉这条。 */
|
||||||
export function withNote(notes: NoteMap, key: string, text: string): NoteMap {
|
export function withNote(notes: NoteMap, key: string, text: string): NoteMap {
|
||||||
@@ -23,36 +12,3 @@ export function withNote(notes: NoteMap, key: string, text: string): NoteMap {
|
|||||||
next[key] = note
|
next[key] = note
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUnder(candidate: string, key: string): boolean {
|
|
||||||
return candidate === key || candidate.startsWith(`${key}/`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** key 自己和它下面各层的注释,键换成相对 key 的路径(key 自己是 `''`)。 */
|
|
||||||
export function notesUnder(notes: NoteMap, key: string): NoteMap {
|
|
||||||
return Object.fromEntries(Object.entries(notes)
|
|
||||||
.filter(([candidate]) => isUnder(candidate, key))
|
|
||||||
.map(([candidate, note]) => [candidate.slice(key.length + 1), note]))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删掉 key 自己和它下面各层的注释(目录被删时连带里面的)。 */
|
|
||||||
export function dropNotes(notes: NoteMap, key: string): NoteMap {
|
|
||||||
return Object.fromEntries(Object.entries(notes).filter(([candidate]) => !isUnder(candidate, key)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把 notesUnder 取出的注释放到 key 下面。 */
|
|
||||||
export function placeNotes(notes: NoteMap, key: string, under: NoteMap): NoteMap {
|
|
||||||
const placed = Object.entries(under).map(([rest, note]) => [rest === '' ? key : `${key}/${rest}`, note])
|
|
||||||
return { ...notes, ...Object.fromEntries(placed) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 改名 / 挪位置:注释跟着走,目录连带里面各层的注释一起走。 */
|
|
||||||
export function moveNotes(notes: NoteMap, from: string, to: string): NoteMap {
|
|
||||||
return placeNotes(dropNotes(notes, from), to, notesUnder(notes, from))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 两份注释里有增、删、改的键。 */
|
|
||||||
export function changedKeys(before: NoteMap, after: NoteMap): string[] {
|
|
||||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
|
||||||
return [...keys].filter(key => before[key] !== after[key])
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,8 @@ import { promises as fsp } from 'node:fs'
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { readNotes, writeNotes } from './store'
|
import { readPathMap, writePathMap } from '../pathMap'
|
||||||
|
import { NOTES_FILE } from './store'
|
||||||
|
|
||||||
let root: string
|
let root: string
|
||||||
const notesFile = (): string => path.join(root, '.spx', 'code-notes.json')
|
const notesFile = (): string => path.join(root, '.spx', 'code-notes.json')
|
||||||
@@ -22,16 +23,16 @@ async function writeRaw(content: string): Promise<void> {
|
|||||||
|
|
||||||
describe('读注释', () => {
|
describe('读注释', () => {
|
||||||
it('文件不存在返回空表', async () => {
|
it('文件不存在返回空表', async () => {
|
||||||
expect(await readNotes(root)).toEqual({})
|
expect(await readPathMap(root, NOTES_FILE)).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('解析失败或不是对象返回空表', async () => {
|
it('解析失败或不是对象返回空表', async () => {
|
||||||
await writeRaw('{ "a.ts": ')
|
await writeRaw('{ "a.ts": ')
|
||||||
expect(await readNotes(root)).toEqual({})
|
expect(await readPathMap(root, NOTES_FILE)).toEqual({})
|
||||||
await writeRaw('["a.ts"]')
|
await writeRaw('["a.ts"]')
|
||||||
expect(await readNotes(root)).toEqual({})
|
expect(await readPathMap(root, NOTES_FILE)).toEqual({})
|
||||||
await writeRaw('null')
|
await writeRaw('null')
|
||||||
expect(await readNotes(root)).toEqual({})
|
expect(await readPathMap(root, NOTES_FILE)).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('跳过值不是字符串、只有空白、键不合法的条目,键按规范写法', async () => {
|
it('跳过值不是字符串、只有空白、键不合法的条目,键按规范写法', async () => {
|
||||||
@@ -42,13 +43,13 @@ describe('读注释', () => {
|
|||||||
'../outside.ts': '外面',
|
'../outside.ts': '外面',
|
||||||
'campaign/': '营销活动',
|
'campaign/': '营销活动',
|
||||||
}))
|
}))
|
||||||
expect(await readNotes(root)).toEqual({ 'src/a.ts': '入口', 'campaign': '营销活动' })
|
expect(await readPathMap(root, NOTES_FILE)).toEqual({ 'src/a.ts': '入口', 'campaign': '营销活动' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('写注释', () => {
|
describe('写注释', () => {
|
||||||
it('没有 .spx 目录时建出来,键排好序、缩进两格、末尾换行', async () => {
|
it('没有 .spx 目录时建出来,键排好序、缩进两格、末尾换行', async () => {
|
||||||
await writeNotes(root, { 'user/routes.py': '用户接口', 'campaign': '营销活动', 'campaign/benefits.py': '权益发放' })
|
await writePathMap(root, NOTES_FILE, { 'user/routes.py': '用户接口', 'campaign': '营销活动', 'campaign/benefits.py': '权益发放' })
|
||||||
expect(await fsp.readFile(notesFile(), 'utf8')).toBe(`${JSON.stringify({
|
expect(await fsp.readFile(notesFile(), 'utf8')).toBe(`${JSON.stringify({
|
||||||
'campaign': '营销活动',
|
'campaign': '营销活动',
|
||||||
'campaign/benefits.py': '权益发放',
|
'campaign/benefits.py': '权益发放',
|
||||||
@@ -58,13 +59,13 @@ describe('写注释', () => {
|
|||||||
|
|
||||||
it('写进去的读得回来', async () => {
|
it('写进去的读得回来', async () => {
|
||||||
const notes = { 'a.ts': '含 "引号" 和 \\ 反斜杠', 'dir': '目录' }
|
const notes = { 'a.ts': '含 "引号" 和 \\ 反斜杠', 'dir': '目录' }
|
||||||
await writeNotes(root, notes)
|
await writePathMap(root, NOTES_FILE, notes)
|
||||||
expect(await readNotes(root)).toEqual(notes)
|
expect(await readPathMap(root, NOTES_FILE)).toEqual(notes)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('删到一条不剩时文件留着,内容是空对象', async () => {
|
it('删到一条不剩时文件留着,内容是空对象', async () => {
|
||||||
await writeNotes(root, { 'a.ts': 'A' })
|
await writePathMap(root, NOTES_FILE, { 'a.ts': 'A' })
|
||||||
await writeNotes(root, {})
|
await writePathMap(root, NOTES_FILE, {})
|
||||||
expect(await fsp.readFile(notesFile(), 'utf8')).toBe('{}\n')
|
expect(await fsp.readFile(notesFile(), 'utf8')).toBe('{}\n')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,53 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* 文件注释持久化。
|
* 文件注释持久化:每个工作区文件夹一份 `<workspace>/.spx/code-notes.json`,形状是 `{ "相对路径": "注释" }`,
|
||||||
*
|
* 读写的规矩见 pathMap(文件不存在或解析失败当作没有注释,写入时按需建 `.spx`、键排好序)。
|
||||||
* 每个工作区文件夹一份,落盘到 `<workspace>/.spx/code-notes.json`,形状是
|
|
||||||
* `{ "相对路径": "注释" }`。文件不存在或解析失败时返回 `{}`;
|
|
||||||
* 写入时按需 `mkdir -p .spx`,键排好序再写,git diff 稳定。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NoteMap } from './noteMap'
|
import type { PathMapFile } from '../pathMap'
|
||||||
import { promises as fsp } from 'node:fs'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { normalizeKey } from './noteMap'
|
|
||||||
|
|
||||||
/** 相对工作区文件夹的位置,文件监听也用它。 */
|
/** 注释是去掉首尾空白后不空的字符串,别的条目跳过。 */
|
||||||
export const NOTES_FILE = '.spx/code-notes.json'
|
export const NOTES_FILE: PathMapFile<string> = {
|
||||||
|
path: '.spx/code-notes.json',
|
||||||
function notesFile(workspaceRoot: string): string {
|
label: '文件注释',
|
||||||
return path.join(workspaceRoot, ...NOTES_FILE.split('/'))
|
parse: (value) => {
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取工作区文件夹的注释。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值不是字符串或是空白的条目跳过。 */
|
|
||||||
export async function readNotes(workspaceRoot: string): Promise<NoteMap> {
|
|
||||||
let raw: string
|
|
||||||
try {
|
|
||||||
raw = await fsp.readFile(notesFile(workspaceRoot), 'utf8')
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as unknown
|
|
||||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
||||||
return {}
|
|
||||||
const notes: NoteMap = {}
|
|
||||||
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
||||||
const key = normalizeKey(rawKey)
|
|
||||||
const note = typeof value === 'string' ? value.trim() : ''
|
const note = typeof value === 'string' ? value.trim() : ''
|
||||||
if (key !== undefined && note !== '')
|
return note === '' ? undefined : note
|
||||||
notes[key] = note
|
},
|
||||||
}
|
|
||||||
return notes
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 写入工作区文件夹的注释(空表也写,文件留着)。父目录按需创建。 */
|
|
||||||
export async function writeNotes(workspaceRoot: string, notes: NoteMap): Promise<void> {
|
|
||||||
await fsp.mkdir(path.dirname(notesFile(workspaceRoot)), { recursive: true })
|
|
||||||
const sorted = Object.fromEntries(Object.entries(notes).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
|
||||||
await fsp.writeFile(notesFile(workspaceRoot), `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { changedKeys, dropEntries, entriesUnder, moveEntries, normalizeKey, placeEntries, toPathKey } from './pathMap'
|
||||||
|
|
||||||
|
describe('路径的键', () => {
|
||||||
|
it('相对工作区文件夹、用 / 分隔', () => {
|
||||||
|
expect(toPathKey('/work/luffy', '/work/luffy/backend/src/campaign')).toBe('backend/src/campaign')
|
||||||
|
expect(toPathKey('/work/luffy/', '/work/luffy/a.ts')).toBe('a.ts')
|
||||||
|
// Windows 上 Uri.path 也是 / 分隔,盘符在最前面
|
||||||
|
expect(toPathKey('/c:/work/luffy', '/c:/work/luffy/src/a.ts')).toBe('src/a.ts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工作区文件夹本身和外面的路径没有键', () => {
|
||||||
|
expect(toPathKey('/work/luffy', '/work/luffy')).toBeUndefined()
|
||||||
|
expect(toPathKey('/work/luffy', '/work/other/a.ts')).toBeUndefined()
|
||||||
|
expect(toPathKey('/work/luffy', '/work/luffy-2/a.ts')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('手写的键规范成同一种写法,不合法的丢掉', () => {
|
||||||
|
expect(normalizeKey('./src/a.ts')).toBe('src/a.ts')
|
||||||
|
expect(normalizeKey('src//campaign/')).toBe('src/campaign')
|
||||||
|
expect(normalizeKey('src/x/../a.ts')).toBe('src/a.ts')
|
||||||
|
expect(normalizeKey('')).toBeUndefined()
|
||||||
|
expect(normalizeKey('.')).toBeUndefined()
|
||||||
|
expect(normalizeKey('../a.ts')).toBeUndefined()
|
||||||
|
expect(normalizeKey('/etc/passwd')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('改名、挪位置', () => {
|
||||||
|
const notes = {
|
||||||
|
'campaign': '营销活动',
|
||||||
|
'campaign/benefits.py': '权益发放',
|
||||||
|
'campaign/console/views.py': '后台',
|
||||||
|
'campaign2/x.py': '名字只是前缀相同',
|
||||||
|
'user/routes.py': '用户接口',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('文件改名,注释跟着走', () => {
|
||||||
|
expect(moveEntries(notes, 'user/routes.py', 'user/api.py')).toEqual({
|
||||||
|
'campaign': '营销活动',
|
||||||
|
'campaign/benefits.py': '权益发放',
|
||||||
|
'campaign/console/views.py': '后台',
|
||||||
|
'campaign2/x.py': '名字只是前缀相同',
|
||||||
|
'user/api.py': '用户接口',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目录挪位置,里面各层的注释一起走,名字只是前缀相同的不动', () => {
|
||||||
|
expect(moveEntries(notes, 'campaign', 'marketing/campaign')).toEqual({
|
||||||
|
'marketing/campaign': '营销活动',
|
||||||
|
'marketing/campaign/benefits.py': '权益发放',
|
||||||
|
'marketing/campaign/console/views.py': '后台',
|
||||||
|
'campaign2/x.py': '名字只是前缀相同',
|
||||||
|
'user/routes.py': '用户接口',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有注释的目录挪走,里面有注释的文件照样跟着走', () => {
|
||||||
|
expect(moveEntries({ 'src/a/b.ts': 'B' }, 'src/a', 'lib/a')).toEqual({ 'lib/a/b.ts': 'B' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('挪到另一个工作区文件夹:先取出再放进去', () => {
|
||||||
|
const moving = entriesUnder(notes, 'campaign')
|
||||||
|
expect(moving).toEqual({ '': '营销活动', 'benefits.py': '权益发放', 'console/views.py': '后台' })
|
||||||
|
expect(placeEntries({ 'other.ts': 'O' }, 'pkg/campaign', moving)).toEqual({
|
||||||
|
'other.ts': 'O',
|
||||||
|
'pkg/campaign': '营销活动',
|
||||||
|
'pkg/campaign/benefits.py': '权益发放',
|
||||||
|
'pkg/campaign/console/views.py': '后台',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('删除', () => {
|
||||||
|
it('删目录连带里面各层的注释,名字只是前缀相同的留着', () => {
|
||||||
|
expect(dropEntries({
|
||||||
|
'campaign': '营销活动',
|
||||||
|
'campaign/benefits.py': '权益发放',
|
||||||
|
'campaign/console/views.py': '后台',
|
||||||
|
'campaign2/x.py': '留着',
|
||||||
|
}, 'campaign')).toEqual({ 'campaign2/x.py': '留着' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删文件只删它自己', () => {
|
||||||
|
expect(dropEntries({ 'a.ts': 'A', 'b.ts': 'B' }, 'a.ts')).toEqual({ 'b.ts': 'B' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('变了哪些行', () => {
|
||||||
|
it('增、删、改都算,没变的不算', () => {
|
||||||
|
expect(changedKeys({ a: '1', b: '2', c: '3' }, { a: '1', b: '二', d: '4' }).sort()).toEqual(['b', 'c', 'd'])
|
||||||
|
expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 按文件 / 目录记东西的一份表(文件注释、代码问答):每个工作区文件夹一份,落盘到 `.spx` 里的一个 JSON 文件,
|
||||||
|
* 形状是 `{ "相对路径": 值 }`。键相对这个工作区文件夹、`/` 分隔;改名、挪位置、删除时整棵子树跟着走。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
|
||||||
|
export type PathMap<T> = Record<string, T>
|
||||||
|
|
||||||
|
/** 表落在哪、叫什么、每条的值怎么认。 */
|
||||||
|
export interface PathMapFile<T> {
|
||||||
|
/** 相对工作区文件夹的位置(`/` 分隔),文件监听也用它 */
|
||||||
|
path: string
|
||||||
|
/** 读写出错时提示里怎么称呼它 */
|
||||||
|
label: string
|
||||||
|
/** 认一条的值:不合法的返回 undefined,这条就跳过;合法的可以顺手规范成统一写法 */
|
||||||
|
parse: (value: unknown) => T | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范成表的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
|
||||||
|
export function normalizeKey(relativePath: string): string | undefined {
|
||||||
|
const key = path.posix.normalize(relativePath).replace(/\/+$/, '')
|
||||||
|
return key === '' || key === '.' || key === '..' || key.startsWith('../') || key.startsWith('/') ? undefined : key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param rootPath 工作区文件夹的 `Uri.path`;@param targetPath 目录 / 文件的 `Uri.path` */
|
||||||
|
export function toPathKey(rootPath: string, targetPath: string): string | undefined {
|
||||||
|
return normalizeKey(path.posix.relative(rootPath, targetPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnder(candidate: string, key: string): boolean {
|
||||||
|
return candidate === key || candidate.startsWith(`${key}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key 自己和它下面各层的条目,键换成相对 key 的路径(key 自己是 `''`)。 */
|
||||||
|
export function entriesUnder<T>(map: PathMap<T>, key: string): PathMap<T> {
|
||||||
|
return Object.fromEntries(Object.entries(map)
|
||||||
|
.filter(([candidate]) => isUnder(candidate, key))
|
||||||
|
.map(([candidate, value]) => [candidate.slice(key.length + 1), value]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删掉 key 自己和它下面各层的条目(目录被删时连带里面的)。 */
|
||||||
|
export function dropEntries<T>(map: PathMap<T>, key: string): PathMap<T> {
|
||||||
|
return Object.fromEntries(Object.entries(map).filter(([candidate]) => !isUnder(candidate, key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 entriesUnder 取出的条目放到 key 下面。 */
|
||||||
|
export function placeEntries<T>(map: PathMap<T>, key: string, under: PathMap<T>): PathMap<T> {
|
||||||
|
const placed = Object.entries(under).map(([rest, value]) => [rest === '' ? key : `${key}/${rest}`, value])
|
||||||
|
return { ...map, ...Object.fromEntries(placed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改名 / 挪位置:条目跟着走,目录连带里面各层的条目一起走。 */
|
||||||
|
export function moveEntries<T>(map: PathMap<T>, from: string, to: string): PathMap<T> {
|
||||||
|
return placeEntries(dropEntries(map, from), to, entriesUnder(map, from))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 两份表里有增、删、改的键;值按 JSON 写法比。 */
|
||||||
|
export function changedKeys<T>(before: PathMap<T>, after: PathMap<T>): string[] {
|
||||||
|
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||||
|
return [...keys].filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIn(workspaceRoot: string, file: PathMapFile<unknown>): string {
|
||||||
|
return path.join(workspaceRoot, ...file.path.split('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值认不出的条目跳过。 */
|
||||||
|
export async function readPathMap<T>(workspaceRoot: string, file: PathMapFile<T>): Promise<PathMap<T>> {
|
||||||
|
let raw: string
|
||||||
|
try {
|
||||||
|
raw = await fsp.readFile(fileIn(workspaceRoot, file), 'utf8')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||||
|
return {}
|
||||||
|
const map: PathMap<T> = {}
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
const key = normalizeKey(rawKey)
|
||||||
|
const value = file.parse(rawValue)
|
||||||
|
if (key !== undefined && value !== undefined)
|
||||||
|
map[key] = value
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写工作区文件夹里的这份表(空表也写,文件留着):父目录按需创建,键排好序、两格缩进、末尾换行,git diff 稳定。 */
|
||||||
|
export async function writePathMap<T>(workspaceRoot: string, file: PathMapFile<T>, map: PathMap<T>): Promise<void> {
|
||||||
|
const target = fileIn(workspaceRoot, file)
|
||||||
|
await fsp.mkdir(path.dirname(target), { recursive: true })
|
||||||
|
const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
||||||
|
await fsp.writeFile(target, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
Reference in new issue
Block a user