v2-refactor-temp/docs/file-manager/utils-file-migration.md
utils/file/ 整合:legacyFile.ts + fileOperations.ts 迁移方案定位:本文件回答 RFC 空白的一个问题——v1 的
src/main/utils/file.ts(现已改名legacyFile.ts)和src/main/utils/fileOperations.ts里每个导出函数,应该以什么方式合并进 v2@main/utils/file/*或其它新位置,分别在哪个 phase 落地。背景:RFC 主文档(rfc-file-manager.md)和 migration-plan.md 只在 fs-usage-audit.md 里提到过这两个文件,粒度是"P0 优先级迁移",既没给新位置也没绑 phase。本文档补齐该空白,作为 Phase 1b.1 / 1b.2 编码 PR 的待办清单。
命名前提:Phase 1a 已把
src/main/utils/file.ts改名为src/main/utils/file/legacyFile.ts,并通过src/main/utils/file/index.ts的 barrel re-export 保持原有@main/utils/file导入路径兼容。fileOperations.ts尚在src/main/utils/fileOperations.ts,本文档给出的方案第一步是把它也搬进src/main/utils/file/。
Phase 1a 已落地的新结构:
src/main/utils/file/
├── index.ts # barrel(re-export ./legacyFile,保证旧 import 不破)
├── legacyFile.ts # v1 helpers(本文档规划如何把它拆干净)
├── fs.ts # v2 原语:read / write / stat / copy / move / remove / removeDir / atomicWriteFile / statVersion / contentHash
├── metadata.ts # v2 原语:getFileType(path) / isTextFile(path) / mimeToExt(mime)
├── path.ts # v2 原语:resolvePath / isPathInside / canWrite / isNotEmptyDir / canonicalizeAbsolutePath / resolvePhysicalPath / getExtSuffix
├── search.ts # v2 原语:listDirectory (ripgrep + fuzzy)
└── shell.ts # v2 原语:open / showInFolder
以及跨进程共享层:
src/shared/file/types/
├── fileType.ts # getFileType(ext) + fileTypeMap(由 legacyFile 迁入,migration-plan §A1 已规划)
└── filename.ts # sanitizeFilename / validateFileName(可供 main + renderer 复用)
FileManager(src/main/services/file/FileManager.ts)吸收所有需要 entry 语义的调用:createInternalEntry / ensureExternalEntry / read(entryId, opts) / write / trash / restore / permanentDelete / rename / copy / withTempCopy / …
Notes 域(未来 src/main/services/notes/)承接 Notes 树扫描、笔记命名等与 Notes 业务绑定的函数。
legacyFile.ts 函数分派20 个导出(按当前文件顺序)。Callers 数 来自 grep -rln <symbol> src/,不含 legacyFile.ts 自身和 barrel。
@main/utils/file/path| 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
resolveAndValidatePath(baseDir, relativePath) | 2 | path.ts | 同名,签名不变 | 1b.1 | 防路径穿越;path.ts 实现 canonicalizeAbsolutePath 时一起落实 |
untildify(pathWithTilde) | 2 | path.ts | 同名 | 1b.1 | ~ 展开;纯 string op,无 FS |
isPathInside(childPath, parentPath) | 9 | path.ts | 同名 | 1b.1 | 已作为 path.ts 原语列在架构 §7;高 callers 数,保持 API 稳定 |
合并手法:Phase 1b.1 实现 path.ts 的 canonicalizeAbsolutePath / isPathInside 时,把上述函数原样内联进 path.ts,legacyFile.ts 留下 export { ... } from './path' 过渡 re-export 一轮(避免 9 处 isPathInside caller 一次性全改)。Phase 2 统一删 re-export。
@main/utils/file/path| 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
hasWritePermission(dir) | 5 | path.ts | canWrite(path): Promise<boolean> | 1b.1 | 架构 §7 已列 canWrite 为 path.ts 原语;改名对齐 |
directoryExists(dirPath) | 2 | path.ts | isDirectory(path): Promise<boolean> | 1b.1 | fs.stat + .isDirectory() |
fileExists(filePath) | 3 | path.ts | isFile(path): Promise<boolean> | 1b.1 | fs.stat + .isFile() |
pathExists(targetPath) | 5 | path.ts | exists(path): Promise<boolean> | 1b.1 | fs.access(R_OK) |
合并手法:同 2.1,Phase 1b.1 引入新名字后,legacyFile.ts 保留旧名 re-export 一轮。
src/shared/file/types/fileType.ts + @main/utils/file/metadata| 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
getFileType(ext) | 5 | src/shared/file/types/fileType.ts(ext 版) |
@main/utils/file/metadata.ts(path 版) | getFileTypeByExt(ext): FileType(shared)
getFileType(path): Promise<FileType>(metadata.ts 内部先 path.extname,再调 shared 版;OTHER 时 buffer 升级) | 1b.1 | 与 migration-plan.md §A1-A3 规划一致:ext 版搬 shared,供 main + renderer 复用;v2 metadata.ts.getFileType(path) 取 path,内部派生 ext 调 shared |
| getFileExt(filePath) | 2 | 废弃 | 直接用 path.extname(filePath) | 2(consumer 迁移) | 纯 node:path 薄包装,无价值;两处 callers 直接改 |
| getFileDir(filePath) | 0 | 删除 | — | Phase 1a 清理 | 无 caller;path.dirname 即可 |
| getFileName(filePath) | 0 | 删除 | — | Phase 1a 清理 | 无 caller;path.basename 即可 |合并手法:Phase 1b.1 在 shared 建 fileType.ts;metadata.ts.getFileType(path) 正式实现;legacyFile.getFileType(ext) 改为 export { getFileTypeByExt as getFileType } from '@shared/file/types/fileType' 过渡。getFileExt 的两处 callers 直接改成 path.extname(...)。getFileDir / getFileName 本 PR(Phase 1a 尾声)就可以删掉。
src/shared/file/types/filename.ts| 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
sanitizeFilename(fileName, replacement) | 6 | src/shared/file/types/filename.ts | 同名 | 1b.1 | 纯 string op;main + renderer 都用;搬 shared |
validateFileName(fileName, platform) | 0 | src/shared/file/types/filename.ts | 同名 | 1b.1 | 同上;虽无直接 caller,被 checkName 调用 |
checkName(fileName) | 1 | src/shared/file/types/filename.ts | 同名 | 1b.1 | Notes 域在用;放 shared 更恰当(renderer 也可能用) |
合并手法:Phase 1b.1 在 shared 建 filename.ts,搬入三个函数;legacyFile.ts 保留 re-export 过渡。
@main/utils/file/fs + FileManager| 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
readTextFileWithAutoEncoding(filePath) | 5 | @main/utils/file/fs.read(path, { encoding: 'text', detectEncoding: true }) | 已在 fs.ts 签名中(read overload) | 1b.1 | fs.read overload 1 就是这个:read(path, { encoding?: 'text', detectEncoding?: boolean })。把 chardet + iconv-lite 逻辑在 1b.1 实现 fs.read 时一起落 |
writeWithLock(filePath, data, options) | 1 | 被 fs.atomicWriteFile(path, data) 取代 | — | 1b.2 | v2 设计明确用 tmp+rename 替代锁文件方案(架构 §5.3、fs-usage-audit §关键发现-5)。单 caller 在 1b.2 改用 atomicWriteFile;legacyFile.writeWithLock 整个删除 |
base64Image(file: FileMetadata) | 8 | 被 FileManager.read(entryId, { encoding: 'base64' }) 取代 | — | Phase 2(consumer 迁移) | 架构 §3.3 read IPC 的 base64 overload 返回 { data, mime };base64Image 等于这个能力的 v1 早期实现。8 个 callers 随消费者迁移(FileMetadata → FileEntryId)一起改 |
getAllFiles(dirPath) | 2 | 被 listDirectory + FileManager 组合取代 | — | Phase 2(consumer 迁移) | 返回 v1 FileMetadata[] 含 uuid 生成——v2 下不应再生成游离 uuid,要么业务明确调 ensureExternalEntry 产生 FileEntry,要么只需要 listDirectory(dirPath) 获取路径列表 + 按需 FileManager.getMetadata。两个 callers 拆两套用法 |
合并手法:
readTextFileWithAutoEncoding 是 v2 fs.read 的已声明 overload,Phase 1b.1 实现 fs.read 时一起落;legacyFile 留 re-export 到 Phase 2。writeWithLock、base64Image、getAllFiles 属于语义被新 API 整体替代的情况,不是简单搬家。按 caller 迁移节奏在 Phase 1b.2 / Phase 2 分别处理。src/main/services/notes/(out of file-module)| 函数 | Callers | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
scanDir(dirPath, depth, basePath) | 1 | src/main/services/notes/(未来 NotesService 内部) | 保留或重构成 NotesService 方法 | NotesService 落地 phase | 返回 NotesTreeNode[]——Notes 域类型;RFC §1.3 明确 Notes 树不在 file-module 范围 |
getName(baseDir, fileName, isFile) | 5 | src/main/services/notes/ | Notes 域内部 helper | NotesService 落地 phase | 强耦合 .md 扩展名 + 递增编号——是 Notes 独有的命名策略,不是通用 filename 工具 |
合并手法:这两个函数随 NotesService 迁移一起移出。Phase 1b 期间可先原地保留,Phase 2 之后随 Notes 改造搬家。
| 目标桶 | 函数数量 | 函数 |
|---|---|---|
@main/utils/file/path | 7 | resolveAndValidatePath / untildify / isPathInside / hasWritePermission→canWrite / directoryExists→isDirectory / fileExists→isFile / pathExists→exists |
@main/utils/file/metadata + shared fileType.ts | 1 | getFileType(ext) 拆成 shared 的 ext 版 + metadata.ts 的 path 版 |
shared filename.ts | 3 | sanitizeFilename / validateFileName / checkName |
@main/utils/file/fs.read overload | 1 | readTextFileWithAutoEncoding |
| 被新 API 语义取代(无 1:1 映射) | 3 | writeWithLock → atomicWriteFile;base64Image → FileManager.read({encoding:'base64'});getAllFiles → listDirectory + FileManager 组合 |
| Notes 域(移出 file-module) | 2 | scanDir / getName |
| 直接删(无 caller 或纯包装) | 3 | getFileDir / getFileName / getFileExt |
| 合计 | 20 |
fileOperations.ts 函数分派3 个导出,全部外部 caller:
| 函数 | Callers(去重) | 目标位置 | 新签名(建议) | Phase | 备注 |
|---|---|---|---|---|---|
copyDirectoryRecursive(source, destination, opts) | SkillInstaller.ts | @main/utils/file/fs | copyDir(src, dest, opts?): Promise<void> | 1b.2 | 现有实现含 allowedBasePath 守卫、MAX_RECURSION_DEPTH=1000 防栈溢出、skip symlink、race-condition ENOENT 容忍——这些安全特性要在合并时保留。与 fs.copy(src, dest)(单文件版)并列导出;不建议把目录语义塞进 copy 做 overload |
deleteDirectoryRecursive(dirPath, opts) | SkillInstaller.ts, SkillService.ts | @main/utils/file/fs | removeDir(path, opts?): Promise<void> | 1b.2 | fs.ts 已有 removeDir 的 stub 签名;合并时把 allowedBasePath 守卫保留下来 |
getDirectorySize(dirPath, opts) | markdownParser.ts | @main/utils/file/metadata | getDirectorySize(path, opts?): Promise<number> | 1b.1 或 1b.2 | 纯统计类(递归 lstat + size);放 metadata.ts(因为是派生元数据)或 fs.ts(因为是目录遍历)都合理。建议:metadata.ts——与 getFileType(path) 的"查询派生信息"语义一致 |
安全特性保留清单(合并时不能丢):
allowedBasePath 白名单——所有 src/dest/delete target 必须在此目录内,防越界操作MAX_RECURSION_DEPTH = 1000——防栈溢出攻击lstat 检测并跳过 symlink——防 TOCTOU + 防跟随外部链接copyFile + chmod 保留权限位ENOENT 容忍(文件在拷贝过程中消失不中断整体流程)合并手法:Phase 1b.2 实现 fs.copy / fs.removeDir(文件版、目录版并列导出)时,把 fileOperations.ts 的三个函数带安全特性一起落进 fs.ts / metadata.ts。fileOperations.ts 整个文件删除;3 个业务 caller + 2 个测试 mock 改导入路径。
在 Phase 1b.2 真正合并之前,可以先做纯搬家:git mv src/main/utils/fileOperations.ts → src/main/utils/file/fileOperations.ts,内容不动,仅改 5 处 import(3 业务 + 2 测试 mock)。好处:
utils/file/ 目录的"文件类 helper"全部到齐(legacyFile.ts + fileOperations.ts 并列)legacyFile.ts 的动作一致,结构完整性强git mv src/main/utils/file.ts → src/main/utils/file/legacyFile.ts(已完成)src/main/utils/file/index.ts barrel re-export ./legacyFile(已完成)git mv src/main/utils/fileOperations.ts → src/main/utils/file/fileOperations.ts,更新 5 处 importgetFileDir / getFileName(两处)path.ts 内联实现 resolveAndValidatePath / untildify / isPathInside / canWrite(原 hasWritePermission)/ isDirectory / isFile / existssrc/shared/file/types/fileType.ts 新建,搬 getFileType(ext) + fileTypeMap(迁移 5 处 getFileType caller 指向 main 或 shared,按所在进程选)src/shared/file/types/filename.ts 新建,搬 sanitizeFilename / validateFileName / checkNamemetadata.ts 实现 getFileType(path):内部 path.extname + 调 shared 的 ext 版;OTHER 时 buffer 升级(chardet + isbinaryfile)fs.ts 实现 read 的 text overload(含 detectEncoding: true),语义等同 readTextFileWithAutoEncodinglegacyFile.ts 内部改为对上述新位置的 re-export,保持 @main/utils/file 旧 import 不破metadata.ts 实现 getDirectorySize(path),如果选在 1b.1 做fs.ts 实现 atomicWriteFile / atomicWriteIfUnchanged;writeWithLock 的唯一 caller 迁移到 atomicWriteFile;legacyFile.writeWithLock 删除fs.ts 实现 copy(src, dest)(单文件)和 copyDir(src, dest, opts?)(带 allowedBasePath / MAX_RECURSION_DEPTH / symlink skip 等安全特性)fs.ts 实现 remove / removeDir(opts?)(保留 allowedBasePath 守卫)SkillInstaller.ts / SkillService.ts / markdownParser.ts 改 import 至 @main/utils/file/fs / @main/utils/file/metadatafileOperations.ts 整文件删除metadata.ts 实现 getDirectorySize(path),如果 1b.1 没做则 1b.2 做base64Image 的 8 处 caller 迁移到 FileManager.read(entryId, { encoding: 'base64' });legacyFile.base64Image 删除getAllFiles 的 2 处 caller 拆用法:仅需路径列表的改用 listDirectory;需要 FileEntry 的改走 ensureExternalEntry 批量创建;legacyFile.getAllFiles 删除getFileExt 的 2 处 caller 改成 path.extname(...),legacyFile.getFileExt 删除legacyFile.ts 内残留的 re-export 全部删完后,legacyFile.ts 整文件删除(utils/file/index.ts barrel 在此时也可以从"re-export legacy"演进为"re-export v2 surface")scanDir / getName 移出 legacyFile.ts,并入 src/main/services/notes/checkName 使用改指 shared 的 filename.tsgetFileType 要拆成 ext 版 + path 版.md → 'text'),同构于 renderer 的需求(拖拽预判 / 附件分类);不能要求 renderer 把路径送过 IPC 才能知道一个文件是不是图片FILE_TYPE.OTHER)且 isbinaryfile 判定为文本时升格为 FILE_TYPE.TEXT;属于 main-only 能力(要读 buffer)fileTypeMap,但调用语义不同——分两个函数反而是清晰设计,而不是"一个带 overload 的通用函数"writeWithLock 要整体换掉而非搬家v2 主进程是单进程单实例(requireSingleInstance),根本不需要跨进程文件锁;锁文件机制是 v1 早期遗留。v2 统一用 atomicWriteFile(tmp + rename + fsync)——这既是架构 §5.3 的明确结论,也是 fs-usage-audit §关键发现-5 指出的"原子写入不统一"问题的解法。
base64Image 要整体换掉而非搬家FileMetadata;v2 下整块废弃,新签名接 FileEntryIdapplication.getPath('feature.files.data', ...) 拼接——这是 resolvePhysicalPath 的职责,不应在工具函数里重复FileManager.read(entryId, { encoding: 'base64' }) 覆盖(架构 §3.3 已定义该 overload)getAllFiles 无 1:1 替代内部做了三件不相关的事:
listDirectory)metadata.getFileType(path))FileMetadata(含 uuidv4())第三点是 v1 特有——v2 里 uuid 只在 createInternalEntry 内部生成,不能让工具函数产出"悬空 uuid"。所以 Phase 2 caller 迁移时要按场景拆:
listDirectory + metadata.getFileTypeensureExternalEntry 批量 upsert(目录内每个文件产一行 file_entry)RFC §1.3 明确:"Notes file tree(files browsed/edited inside the Notes app)由 Notes module(FS-first)拥有;文件树不整体映射进 FileEntry"。scanDir 返回 NotesTreeNode,getName 基于 .md 硬编码——它们是 Notes 业务逻辑,不是通用文件工具。搬到 src/main/services/notes/ 才是正确归属。
getDirectorySize 放 metadata.ts 还是 fs.ts?
getFileType 性质接近copyDir / removeDir 放一起更对称legacyFile.ts 过渡 re-export 的生命周期是否设硬截止?
@deprecated,Phase 2 前强制迁移完毕fileType.ts / filename.ts 文件名是否恰当?
src/shared/file/types/fileType.tssrc/shared/file/types/ 下已有 handle.ts / info.ts / ipc.ts——命名风格是"结构/形状" 而非"操作"fileType.ts(一个 enum + 映射表)和 filename.ts(validate / sanitize 函数)偏行为,放 src/shared/file/utils/ 可能更对口——但会和现有 types/ 目录分裂types/ 归档,但欢迎 reviewer 改 utils/ 分离