import type { FileDeleteEvent, FileRenameEvent, WorkspaceFolder } from 'vscode' import type { PathMap, PathMapFile } from './pathMap' import { homedir } from 'node:os' import * as path from 'node:path' import { Disposable, EventEmitter, RelativePattern, Uri, window, workspace } from 'vscode' import { changedKeys, detachedFiles, dropEntries, entriesUnder, moveEntries, outsideKey, outsidePath, placeEntries, readPathMap, removeFiles, 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 } /** 一份文件的问答记在哪(见 [locateQa]),和问的时候怎么跟模型说这份文件。 */ export interface QaLocation extends PathLocation { /** 发给模型时这份文件写成什么:工作区里的写相对路径(同 key),工作区外的写绝对路径 */ promptPath: string /** 工作区外的文件所在的目录:claude 在 folder 里跑,要另外放行读这个目录;工作区里的文件没有 */ outsideDir?: string } /** 家目录的 `Uri.path`:工作区外的文件在它下面时,键写成 `~/…`(见 [outsideKey])。 */ function homePath(): string { return Uri.file(homedir()).path } /** * 一份文件的问答记在哪。工作区里的同 [locate]。 * 工作区外的(比如 ⌘ 点进去的库源码)记在第一个工作区文件夹,固定用第一个,同一个库文件每次都落在同一处;键见 [outsideKey]。 * 没打开工作区文件夹、`uri` 就是工作区文件夹本身、`uri` 不是工作区里那种文件(比如 `git:` 的)时返回 `undefined`。 */ export function locateQa(uri: Uri): QaLocation | undefined { if (workspace.getWorkspaceFolder(uri)) { const at = locate(uri) return at && { ...at, promptPath: at.key } } const folder = workspace.workspaceFolders?.[0] if (!folder || uri.scheme !== folder.uri.scheme || uri.authority !== folder.uri.authority) return undefined return { folder, key: outsideKey(uri.path, homePath()), promptPath: uri.fsPath, outsideDir: path.dirname(uri.fsPath) } } /** 表里的 `key` 指的那个文件 / 目录:工作区外的键(`~/…`、绝对路径)还原成磁盘上的位置,别的在 `folder` 下面。 */ export function keyUri(folder: WorkspaceFolder, key: string): Uri { const outside = outsidePath(key, homePath()) return outside === undefined ? Uri.joinPath(folder.uri, ...key.split('/')) : folder.uri.with({ path: outside }) } /** * 每个工作区文件夹一份的按路径记的表(见 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] } /** * 读盘上最新的一份改好再写回;没变就不写,所以第一条存下之前不会生成文件。 * 写好后删掉不再有条目带着的文件(见 PathMapFile.attachments):删一条、删源文件都跟着删。 */ 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) await removeFiles(folder.uri.fsPath, detachedFiles(this.file, before, 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 => keyUri(folder, key))) } 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 } }