/** * 按文件 / 目录记东西的一份表(文件注释、代码问答):每个工作区文件夹一份,落盘到 `.spx` 里的一个 JSON 文件, * 形状是 `{ "相对路径": 值 }`。键相对这个工作区文件夹、`/` 分隔;改名、挪位置、删除时整棵子树跟着走。 * 代码问答还记工作区外的库文件,键是 `~/…` 或绝对路径(见 [outsideKey])。 */ 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 /** 一条值带着的文件(相对工作区文件夹,`/` 分隔,例如问答的图片):改表后不再有哪条带着的,跟着删掉 */ attachments?: (value: T) => 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 toPathKey(rootPath: string, targetPath: string): string | undefined { return normalizeKey(path.posix.relative(rootPath, targetPath)) } /** * 工作区外的文件 / 目录(比如库源码)的键:在家目录下写成 `~/…`,别处写绝对路径。 * 工作区里的键是相对路径,开头不会是 `~/` 或 `/`,两边撞不上。 * * @param targetPath 文件 / 目录的 `Uri.path` * @param homePath 家目录的 `Uri.path` */ export function outsideKey(targetPath: string, homePath: string): string { const underHome = toPathKey(homePath, targetPath) return underHome === undefined ? path.posix.normalize(targetPath).replace(/\/+$/, '') : `~/${underHome}` } /** 工作区外的键(见 [outsideKey])在磁盘上的 `Uri.path`;工作区里的相对路径键返回 `undefined`。 */ export function outsidePath(key: string, homePath: string): string | undefined { if (key.startsWith('~/')) return path.posix.join(homePath, key.slice(2)) return key.startsWith('/') ? key : undefined } /** 读表时把一个键规范成统一写法:相对路径同 [normalizeKey],工作区外的 `~/…`、绝对路径(见 [outsideKey])也认;跑出家目录的、空的返回 `undefined`。 */ export function tableKey(raw: string): string | undefined { if (raw.startsWith('~/')) { const underHome = normalizeKey(raw.slice(2)) return underHome === undefined ? undefined : `~/${underHome}` } if (raw.startsWith('/')) { const absolute = path.posix.normalize(raw).replace(/\/+$/, '') return absolute === '' ? undefined : absolute } return normalizeKey(raw) } 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('/')) } /** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键认不出(见 [tableKey])、值认不出的条目跳过。 */ 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 = tableKey(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') } /** 改表前带着、改表后没有哪条再带着的文件(见 PathMapFile.attachments)。 */ export function detachedFiles(file: PathMapFile, before: PathMap, after: PathMap): string[] { const { attachments } = file if (!attachments) return [] const kept = new Set(Object.values(after).flatMap(attachments)) return [...new Set(Object.values(before).flatMap(attachments))].filter(attachment => !kept.has(attachment)) } /** 删掉工作区文件夹里的这些文件(相对路径);已经没了的不算错,跑到文件夹外面的路径不碰。 */ export async function removeFiles(workspaceRoot: string, relativePaths: readonly string[]): Promise { await Promise.all(relativePaths.flatMap((relativePath) => { const key = normalizeKey(relativePath) return key === undefined ? [] : [fsp.rm(path.join(workspaceRoot, ...key.split('/')), { force: true })] })) }