From badc722640f3c10f7c0f5e2ac95207457ddaddf5 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 26 Sep 2026 21:12:34 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(vscode):=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=B3=A8=E9=87=8A=E7=9A=84=E5=AD=98=E6=B3=95?= =?UTF-8?q?=E6=8A=BD=E6=88=90=E6=8C=89=E8=B7=AF=E5=BE=84=E8=AE=B0=E7=9A=84?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E8=A1=A8=EF=BC=8C=E7=BB=99=E9=97=AE=E7=AD=94?= =?UTF-8?q?=E5=85=B1=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/folderStore.ts | 140 ++++++++++++++++++++ vscode/src/codeReader/notes/fileNotes.ts | 138 +++---------------- vscode/src/codeReader/notes/noteMap.test.ts | 94 +------------ vscode/src/codeReader/notes/noteMap.ts | 48 +------ vscode/src/codeReader/notes/store.test.ts | 23 ++-- vscode/src/codeReader/notes/store.ts | 59 ++------- vscode/src/codeReader/pathMap.test.ts | 94 +++++++++++++ vscode/src/codeReader/pathMap.ts | 102 ++++++++++++++ 8 files changed, 378 insertions(+), 320 deletions(-) create mode 100644 vscode/src/codeReader/folderStore.ts create mode 100644 vscode/src/codeReader/pathMap.test.ts create mode 100644 vscode/src/codeReader/pathMap.ts diff --git a/vscode/src/codeReader/folderStore.ts b/vscode/src/codeReader/folderStore.ts new file mode 100644 index 0000000..ccbdef0 --- /dev/null +++ b/vscode/src/codeReader/folderStore.ts @@ -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 implements Disposable { + private readonly changed = new EventEmitter() + /** 条目变了的那些文件 / 目录 */ + readonly onDidChange = this.changed.event + /** 工作区文件夹 Uri → 它的表 */ + private readonly maps = new Map>() + /** 工作区文件夹 Uri → 盯它那份文件的监听 */ + private readonly watchers = new Map() + private readonly disposables: Disposable[] + /** 读写排成一队:自己写到一半时文件监听触发的重读排在写完之后,读不到半截文件;几次改动也不会互相覆盖。 */ + private queue: Promise = Promise.resolve() + + constructor(private readonly file: PathMapFile) { + 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) => PathMap): Promise { + 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 = {} + 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): 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): Promise { + 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 + } +} diff --git a/vscode/src/codeReader/notes/fileNotes.ts b/vscode/src/codeReader/notes/fileNotes.ts index a6e6ead..f7d2441 100644 --- a/vscode/src/codeReader/notes/fileNotes.ts +++ b/vscode/src/codeReader/notes/fileNotes.ts @@ -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 { NoteMap } from './noteMap' -import { Disposable, EventEmitter, MarkdownString, RelativePattern, Uri, window, workspace } from 'vscode' +import { MarkdownString } from 'vscode' +import { FolderStore } from '../folderStore' +import { toPathKey } from '../pathMap' import { noteDecoration } from './noteItem' -import { changedKeys, dropNotes, moveNotes, notesUnder, placeNotes, toNoteKey, withNote } from './noteMap' -import { NOTES_FILE, readNotes, writeNotes } from './store' +import { withNote } from './noteMap' +import { NOTES_FILE } from './store' /** * 读代码时给目录 / 文件记的注释。 * 盘上每个工作区文件夹一份 `.spx/code-notes.json`;内存里留一份给侧栏树画行时同步取用。 - * 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读。 + * 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。 */ export class FileNotes implements FileTreeDecorator, Disposable { - private readonly changed = new EventEmitter() - readonly onDidChange = this.changed.event - /** 工作区文件夹 Uri → 它的注释 */ - private readonly notes = new Map() - /** 工作区文件夹 Uri → 盯它的 code-notes.json 的监听 */ - private readonly watchers = new Map() - private readonly disposables: Disposable[] - /** 读写排成一队:自己写到一半时文件监听触发的重读排在写完之后,读不到半截文件;几次改动也不会互相覆盖。 */ - private queue: Promise = 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) - } + private readonly store = new FolderStore(NOTES_FILE) + /** 注释变了的那些行 */ + readonly onDidChange = this.store.onDidChange /** 这一行的注释;工作区根节点不记注释。 */ noteOf(node: FileNode): string | undefined { - const key = toNoteKey(node.folder.uri.path, node.uri.path) - return key === undefined ? undefined : this.notes.get(node.folder.uri.toString())?.[key] + const key = toPathKey(node.folder.uri.path, node.uri.path) + return key === undefined ? undefined : this.store.get(node.folder, key) } 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)) if (!decoration) return @@ -54,99 +35,12 @@ export class FileNotes implements FileTreeDecorator, Disposable { /** 保存注释:去掉首尾空白,空的就删掉这条。 */ async save(node: FileNode, text: string): Promise { - const key = toNoteKey(node.folder.uri.path, node.uri.path) + const key = toPathKey(node.folder.uri.path, node.uri.path) 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 { - 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, 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 { - 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): Promise { - 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 + this.store.dispose() } } diff --git a/vscode/src/codeReader/notes/noteMap.test.ts b/vscode/src/codeReader/notes/noteMap.test.ts index c0c63aa..75def56 100644 --- a/vscode/src/codeReader/notes/noteMap.test.ts +++ b/vscode/src/codeReader/notes/noteMap.test.ts @@ -1,30 +1,5 @@ import { describe, expect, it } from 'vitest' -import { changedKeys, dropNotes, moveNotes, normalizeKey, notesUnder, placeNotes, toNoteKey, 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() - }) -}) +import { withNote } from './noteMap' describe('保存注释', () => { it('去掉首尾空白,不动别的注释', () => { @@ -46,70 +21,3 @@ describe('保存注释', () => { 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([]) - }) -}) diff --git a/vscode/src/codeReader/notes/noteMap.ts b/vscode/src/codeReader/notes/noteMap.ts index 59d8e92..7d4cf1a 100644 --- a/vscode/src/codeReader/notes/noteMap.ts +++ b/vscode/src/codeReader/notes/noteMap.ts @@ -1,18 +1,7 @@ -import * as path from 'node:path' +import type { PathMap } from '../pathMap' /** 一个工作区文件夹里的全部注释:键是相对这个文件夹的路径(`/` 分隔),值是注释。 */ -export type NoteMap = Record - -/** 规范成注释的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */ -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 type NoteMap = PathMap /** 保存一条注释:去掉首尾空白,空的就删掉这条。 */ 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 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]) -} diff --git a/vscode/src/codeReader/notes/store.test.ts b/vscode/src/codeReader/notes/store.test.ts index 005c726..83f8b7a 100644 --- a/vscode/src/codeReader/notes/store.test.ts +++ b/vscode/src/codeReader/notes/store.test.ts @@ -2,7 +2,8 @@ import { promises as fsp } from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' 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 const notesFile = (): string => path.join(root, '.spx', 'code-notes.json') @@ -22,16 +23,16 @@ async function writeRaw(content: string): Promise { describe('读注释', () => { it('文件不存在返回空表', async () => { - expect(await readNotes(root)).toEqual({}) + expect(await readPathMap(root, NOTES_FILE)).toEqual({}) }) it('解析失败或不是对象返回空表', async () => { await writeRaw('{ "a.ts": ') - expect(await readNotes(root)).toEqual({}) + expect(await readPathMap(root, NOTES_FILE)).toEqual({}) await writeRaw('["a.ts"]') - expect(await readNotes(root)).toEqual({}) + expect(await readPathMap(root, NOTES_FILE)).toEqual({}) await writeRaw('null') - expect(await readNotes(root)).toEqual({}) + expect(await readPathMap(root, NOTES_FILE)).toEqual({}) }) it('跳过值不是字符串、只有空白、键不合法的条目,键按规范写法', async () => { @@ -42,13 +43,13 @@ describe('读注释', () => { '../outside.ts': '外面', 'campaign/': '营销活动', })) - expect(await readNotes(root)).toEqual({ 'src/a.ts': '入口', 'campaign': '营销活动' }) + expect(await readPathMap(root, NOTES_FILE)).toEqual({ 'src/a.ts': '入口', 'campaign': '营销活动' }) }) }) describe('写注释', () => { 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({ 'campaign': '营销活动', 'campaign/benefits.py': '权益发放', @@ -58,13 +59,13 @@ describe('写注释', () => { it('写进去的读得回来', async () => { const notes = { 'a.ts': '含 "引号" 和 \\ 反斜杠', 'dir': '目录' } - await writeNotes(root, notes) - expect(await readNotes(root)).toEqual(notes) + await writePathMap(root, NOTES_FILE, notes) + expect(await readPathMap(root, NOTES_FILE)).toEqual(notes) }) it('删到一条不剩时文件留着,内容是空对象', async () => { - await writeNotes(root, { 'a.ts': 'A' }) - await writeNotes(root, {}) + await writePathMap(root, NOTES_FILE, { 'a.ts': 'A' }) + await writePathMap(root, NOTES_FILE, {}) expect(await fsp.readFile(notesFile(), 'utf8')).toBe('{}\n') }) }) diff --git a/vscode/src/codeReader/notes/store.ts b/vscode/src/codeReader/notes/store.ts index c2b02ee..45bc949 100644 --- a/vscode/src/codeReader/notes/store.ts +++ b/vscode/src/codeReader/notes/store.ts @@ -1,53 +1,16 @@ /** - * 文件注释持久化。 - * - * 每个工作区文件夹一份,落盘到 `/.spx/code-notes.json`,形状是 - * `{ "相对路径": "注释" }`。文件不存在或解析失败时返回 `{}`; - * 写入时按需 `mkdir -p .spx`,键排好序再写,git diff 稳定。 + * 文件注释持久化:每个工作区文件夹一份 `/.spx/code-notes.json`,形状是 `{ "相对路径": "注释" }`, + * 读写的规矩见 pathMap(文件不存在或解析失败当作没有注释,写入时按需建 `.spx`、键排好序)。 */ -import type { NoteMap } from './noteMap' -import { promises as fsp } from 'node:fs' -import * as path from 'node:path' -import { normalizeKey } from './noteMap' +import type { PathMapFile } from '../pathMap' -/** 相对工作区文件夹的位置,文件监听也用它。 */ -export const NOTES_FILE = '.spx/code-notes.json' - -function notesFile(workspaceRoot: string): string { - return path.join(workspaceRoot, ...NOTES_FILE.split('/')) -} - -/** 读取工作区文件夹的注释。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值不是字符串或是空白的条目跳过。 */ -export async function readNotes(workspaceRoot: string): Promise { - 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)) { - const key = normalizeKey(rawKey) - const note = typeof value === 'string' ? value.trim() : '' - if (key !== undefined && note !== '') - notes[key] = note - } - return notes - } - catch { - return {} - } -} - -/** 写入工作区文件夹的注释(空表也写,文件留着)。父目录按需创建。 */ -export async function writeNotes(workspaceRoot: string, notes: NoteMap): Promise { - 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') +/** 注释是去掉首尾空白后不空的字符串,别的条目跳过。 */ +export const NOTES_FILE: PathMapFile = { + path: '.spx/code-notes.json', + label: '文件注释', + parse: (value) => { + const note = typeof value === 'string' ? value.trim() : '' + return note === '' ? undefined : note + }, } diff --git a/vscode/src/codeReader/pathMap.test.ts b/vscode/src/codeReader/pathMap.test.ts new file mode 100644 index 0000000..0de80e3 --- /dev/null +++ b/vscode/src/codeReader/pathMap.test.ts @@ -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([]) + }) +}) diff --git a/vscode/src/codeReader/pathMap.ts b/vscode/src/codeReader/pathMap.ts new file mode 100644 index 0000000..79bfdc5 --- /dev/null +++ b/vscode/src/codeReader/pathMap.ts @@ -0,0 +1,102 @@ +/** + * 按文件 / 目录记东西的一份表(文件注释、代码问答):每个工作区文件夹一份,落盘到 `.spx` 里的一个 JSON 文件, + * 形状是 `{ "相对路径": 值 }`。键相对这个工作区文件夹、`/` 分隔;改名、挪位置、删除时整棵子树跟着走。 + */ + +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' + +export type PathMap = Record + +/** 表落在哪、叫什么、每条的值怎么认。 */ +export interface PathMapFile { + /** 相对工作区文件夹的位置(`/` 分隔),文件监听也用它 */ + 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(map: PathMap, key: string): PathMap { + return Object.fromEntries(Object.entries(map) + .filter(([candidate]) => isUnder(candidate, key)) + .map(([candidate, value]) => [candidate.slice(key.length + 1), value])) +} + +/** 删掉 key 自己和它下面各层的条目(目录被删时连带里面的)。 */ +export function dropEntries(map: PathMap, key: string): PathMap { + return Object.fromEntries(Object.entries(map).filter(([candidate]) => !isUnder(candidate, key))) +} + +/** 把 entriesUnder 取出的条目放到 key 下面。 */ +export function placeEntries(map: PathMap, key: string, under: PathMap): PathMap { + const placed = Object.entries(under).map(([rest, value]) => [rest === '' ? key : `${key}/${rest}`, value]) + return { ...map, ...Object.fromEntries(placed) } +} + +/** 改名 / 挪位置:条目跟着走,目录连带里面各层的条目一起走。 */ +export function moveEntries(map: PathMap, from: string, to: string): PathMap { + return placeEntries(dropEntries(map, from), to, entriesUnder(map, from)) +} + +/** 两份表里有增、删、改的键;值按 JSON 写法比。 */ +export function changedKeys(before: PathMap, after: PathMap): 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): string { + return path.join(workspaceRoot, ...file.path.split('/')) +} + +/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键不合法、值认不出的条目跳过。 */ +export async function readPathMap(workspaceRoot: string, file: PathMapFile): Promise> { + 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 = {} + for (const [rawKey, rawValue] of Object.entries(parsed as Record)) { + 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(workspaceRoot: string, file: PathMapFile, map: PathMap): Promise { + 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') +}