### What this PR does Before this PR: - `file_entry` table used `trashed_at` for the soft-delete timestamp, diverging from every other soft-deletable table in the schema (`agent`, `assistant`, `message`, `topic`), which all use `deleted_at`. After this PR: - `file_entry.deleted_at` (and BO field `deletedAt`) — naming is consistent across the entire schema. - Renamed identifiers: - Schema field: `trashedAt` → `deletedAt` - SQL column: `trashed_at` → `deleted_at` - Index: `fe_trashed_at_idx` → `fe_deleted_at_idx` - CHECK constraint: `fe_external_no_trash` → `fe_external_no_delete` - Updated all source files, tests, and architecture docs (including `v2-refactor-temp/docs/file-manager/`). - **Intentionally NOT renamed** (out of scope — these are API surface / concept names, not the column name): `moveToTrash`, `restoreFromTrash`, `inTrash` (query flag), `isTrashed`, `batchTrash`, `internalTrash`, and "Trash" as a concept in comments/docs. Fixes # ### Why we need it and why it was done in this way The following tradeoffs were made: - **Scope discipline**: kept the rename strictly at the column-identifier layer (4 identifiers). Did not change API names or concept words — switching the "Trash" concept to "Delete" is a larger semantic change that deserves its own PR. - **Migration 0026 contains a manual SQL patch.** drizzle-orm/drizzle-kit issue [#3653](https://github.com/drizzle-team/drizzle-orm/issues/3653) causes the SQLite rebuild-table path to drop the leading `ALTER TABLE … RENAME COLUMN` statement. The generated `INSERT … SELECT "deleted_at" FROM file_entry` would fail because the source table still has `trashed_at`. The migration manually prepends an explicit `ALTER TABLE file_entry RENAME COLUMN trashed_at TO deleted_at;` before the rebuild. Upstream fix landed in `drizzle-kit@1.0.0-beta`/`rc` but is not backported to the `0.31.x` stable line we depend on. - **Why keeping the manual patch is acceptable**: per `CLAUDE.md` § v2 Refactoring, `migrations/sqlite-drizzle/` is throwaway during v2 — it will be wiped and regenerated as a single clean initial migration from the final schemas before release. Mid-development DB drift is explicitly acceptable, and the manual SQL only needs to survive until that regeneration. The following alternatives were considered: - Selecting `create column` in `drizzle-kit generate` instead of `rename column`: also produces invalid SQL (same root cause — the rebuild path puts the new column name in the `SELECT` list regardless of the rename mapping). Rejected. - Skipping the `0026` migration entirely and relying on `db:push` / DB reset during dev: pollutes `_journal.json` divergence and makes the next schema change confusing. Rejected. - Upgrading to `drizzle-kit@1.0.0-beta`/`rc` to get the fix: v1 is a major rewrite with significant breaking changes (alternation engine rewrite, ORM type system rewrite, migration folder layout change). Out of scope for this PR. Rejected. Links to places where the discussion took place: N/A ### Breaking changes None. Dev-only DB column rename during v2 refactor. No user-visible behavior change. No public API surface change. v1 data never reaches this branch except through migrators in `src/main/data/migration/v2/`. ### Special notes for your reviewer - The single manual edit to drizzle-generated SQL is in `migrations/sqlite-drizzle/0026_sturdy_aqueduct.sql` — look for the `MANUAL PATCH` comment block at the top. Without it the migration will fail to apply. - "Trash" concept words still appear throughout the file-manager codebase by design (function names, comments, docs section headings). If we later want to migrate the whole concept to "Delete", that should be a follow-up PR. ### Checklist This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR. Approvers are expected to review this list. - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle) - [x] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html) - [x] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior. - [x] Self-review: I have reviewed my own code (e.g., via [`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`, or GitHub UI) before requesting review from others ### Release note ```release-note NONE ``` --------- Signed-off-by: icarus <eurfelux@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
38 KiB
File Module IPC Redesign
⚠️ OUTDATED / SUPERSEDED(2026-04-21)
本文档捕获的是早期设计,关键术语/决策已被后续评审推翻:
FileManager.createEntry({origin})已拆分为createInternalEntry+ensureExternalEntry(A-7)- External entry 不再进入 trash 生命周期(
fe_external_no_deleteCHECK)externalPath唯一性由 partial unique 升级为 global uniquepermanentDelete对 external 只删 DB 行,不触碰物理文件实现准绳请以以下文档为准:
docs/references/file/architecture.mddocs/references/file/file-manager-architecture.mdrfc-file-manager.mdfile-arch-problems-response.md本文档保留用于追溯设计演进,不要据此指导实现。
v1 有 52 个文件相关 IPC(44 File + 2 Fs + 1 Open_Path + 5 App 路径工具),v2 由 FileManager 统一管理。
架构
设计动机
Renderer 需要统一的文件操作入口(一个 read 既能读 entry 也能读外部路径),但 main process 内部 entry 管理(DB + FS 协调)和纯路径操作(直接 FS)是两种完全不同的职责。既要统一调用又要关注点分离,直接实现是矛盾的。
解法:统一调用入口 + handler 层分派。FileManager 作为唯一 lifecycle service 统管所有 IPC handler 注册,handler 内部按 target 类型分派到不同实现:
FileEntryId→ FileManager 自身方法(entry 协调: resolve → DB + FS)FilePath→ ops.ts 纯函数(直接 FS/路径操作)
Tradeoff:纯路径操作(canWrite、resolvePath 等)也交由 entry + FS 协调层管理,FileManager 承担了超出 entry 管理的 IPC 注册职责。但 handler 层只是 thin routing,其 public 方法签名仍然只认 FileEntryId,纯 path 操作不污染 public API。相比引入第二个 lifecycle service,这个代价更小。
Renderer
→ FileManager.registerIpcHandlers() (统一入口, handler 层分派)
├── target: FileEntryId → this.read / this.write / ... (entry 方法)
└── target: FilePath → ops.read / ops.write / ... (直接委托)
Main process 内部:其他 service 可根据实际需求直接调用 ops.ts 或 FileManager,不需要经过 IPC。
设计原则
- 迁移保持语义不变:v1 → v2 迁移过程中保持已有行为不变,不改变调用方语义。例如 v1 的
deleteFile是永久删除,v2 仍映射到permanentDelete,不主动改为trash。行为改进(如引入 trash)由后续需求驱动,不在迁移中混入 - 统一入口,handler 分派:Renderer 只有一个 File IPC 入口,handler 按
FileEntryId/FilePath分派到 FileManager 或 ops.ts - 不按 file/dir 拆分方法:v1 的
move/moveDir等冗余合并 - Renderer 只传必要信息:service 层推导元数据,不要求 renderer 预先获取
- FileManager public API 只认 FileEntryId:纯路径操作在 handler 层直接委托 ops.ts,不经过 FileManager 方法
v1 清单与 v2 方案
状态标记:
- ✅ 保留(可能改签名)
- 🔀 合并到其他方法
- ❌ 移除
- ❓ 待定
A. 文件选择 / 对话框
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
select |
打开文件选择对话框,返回 FileMetadata[] | 🔀 select |
与 selectFolder 合并,通过 directory 参数区分。返回路径而非 FileMetadata(入库是 createEntry 的事)。单选返回 string | null,多选返回 string[] |
selectFolder |
打开文件夹选择对话框,返回路径 | 🔀 select |
合并到 select({ directory: true }) |
open |
打开文件对话框 + 读取内容(<2GB) | ❌ | 拆为 select + read 组合,renderer 自行组装 |
save |
打开保存对话框 + 写入内容 | ✅ save |
保留,showSaveDialog 与 showOpenDialog 不同 |
v1 签名:
select(options?: OpenDialogOptions): Promise<FileMetadata[] | null>
selectFolder(options?: OpenDialogOptions): Promise<string | null>
open(options?: OpenDialogOptions): Promise<{ content: string; metadata: FileMetadata } | null>
save(path: string, content: string | NodeJS.ArrayBufferView, options?: any): Promise<string>
v2 签名:
// 选文件(单选)
select(options: { directory?: never; multiple?: false; filters?: FileFilter[]; title?: string }): Promise<string | null>
// 选文件(多选)
select(options: { directory?: never; multiple: true; filters?: FileFilter[]; title?: string }): Promise<string[]>
// 选文件夹(只能单选)
select(options: { directory: true; title?: string }): Promise<string | null>
// 保存对话框
save(options: { content: string | Uint8Array; defaultPath?: string; filters?: FileFilter[] }): Promise<string | null>
v1 兼容性审查:排查了所有
select/selectFolder/save调用方,实际使用的 options 为filters、properties(映射为multiple/directory)、title。v2 签名已全部覆盖。
B. 文件入库(写入 storage + 生成元数据)
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
upload |
复制文件到 storage,MD5 去重,图片压缩 | 🔀 createEntry |
content: FilePath |
saveBase64Image |
base64 解码 → 写入 storage | 🔀 createEntry |
content: Base64String |
savePastedImage |
Uint8Array → 写入 storage,图片压缩 | 🔀 createEntry |
content: Uint8Array |
download |
从 URL 下载 → 写入 storage | 🔀 createEntry |
content: URLString,main 负责下载 |
batchUploadMarkdown |
批量复制 .md 到目标目录 | 🔀 batchCreateEntries |
泛化为批量创建,不限 markdown |
v1 签名:
upload(file: FileMetadata): Promise<FileMetadata>
saveBase64Image(data: string): Promise<FileMetadata>
savePastedImage(imageData: Uint8Array, extension?: string): Promise<FileMetadata>
download(url: string, isUseContentType?: boolean): Promise<FileMetadata>
batchUploadMarkdown(filePaths: string[], targetPath: string): Promise<{ fileCount: number; folderCount: number; skippedFiles: string[] }>
v2 签名:
type FilePath = `/${string}` | `${string}:${string}` | `file://${string}`
type Base64String = `data:${string};base64,${string}`
type URLString = `http://${string}` | `https://${string}`
type FileContent = FilePath | Base64String | URLString | Uint8Array
type CreateEntryParams =
| { type: 'file'; parentId: FileEntryId; name: string; content: FileContent }
| { type: 'dir'; parentId: FileEntryId; name: string }
createEntry(params: CreateEntryParams): Promise<FileEntry>
// 批量创建文件条目(仅文件,不支持目录)
batchCreateEntries(params: { parentId: FileEntryId; items: Array<{ name: string; content: FileContent }> }): Promise<BatchOperationResult>
v1 兼容性审查:
upload实际调用方全部传的是路径(select返回或getPathForFile)。saveBase64Image用于 AI 生图(base64)。savePastedImage用于富文本编辑器粘贴(Uint8Array)。download用于 AI 生图(URL)。savePastedImage用于富文本编辑器粘贴(Uint8Array), v2 用createEntry({ parentId: 'mount_temp', content: uint8Array })替代,临时文件纳入条目系统。batchUploadMarkdown唯一调用方为NotesService.ts, 传入本地路径数组 + 目标目录,返回值中NotesPage只用fileCount === 0判断是否成功, 可用BatchOperationResult.succeeded.length === 0替代。folderCount(自动创建的目录数) 和skippedFiles(跳过的非 markdown 文件数)在NotesPage中完全没有使用。skippedFiles的过滤逻辑由 renderer 在调用batchCreateEntries前完成。 全部场景被FileContent联合类型覆盖。
C. 文件读取(从 storage 或外部路径)
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
read |
按 fileId 读内容(支持 doc/pdf/xlsx 提取) | 🔀 read |
统一入口,FileEntryId | FilePath 自动区分 |
readExternal |
按外部路径读内容 | 🔀 read |
合并到 read,传 FilePath |
get |
按路径获取 FileMetadata | 🔀 getMetadata |
v2 用 getMetadata 替代 |
base64Image |
按 fileId 读图片为 base64 | 🔀 read |
encoding: 'base64' 重载 |
binaryImage |
按 fileId 读图片为 Buffer | 🔀 read |
encoding: 'binary' 重载 |
base64File |
按 fileId 读文件为 base64 | 🔀 read |
encoding: 'base64' 重载 |
pdfInfo |
按 fileId 读 PDF 页数 | 🔀 getMetadata |
PdfMetadata.pageCount |
v1 签名:
read(fileId: string, detectEncoding?: boolean): Promise<string>
readExternal(filePath: string, detectEncoding?: boolean): Promise<string>
get(filePath: string): Promise<FileMetadata | null>
base64Image(fileId: string): Promise<{ mime: string; base64: string; data: string }>
binaryImage(fileId: string): Promise<{ data: Buffer; mime: string }>
base64File(fileId: string): Promise<{ data: string; mime: string }>
pdfInfo(fileId: string): Promise<number>
v2 签名:
// ─── read: 统一文件内容读取 ───
// 图片变换参数(可选,非图片文件传入时静默忽略)
// 调用方有责任确认目标文件类型,service 层不做额外校验
// 动机:#14062 — 发送图片到 LLM API 前自动压缩,避免超大 base64 payload
// 具体字段待调研 sharp API 后确定
type ImageTransform = {
maxDimension?: number
quality?: number
format?: string
}
// text(默认)
read(target: FileEntryId | FilePath, options?: { encoding?: 'text'; detectEncoding?: boolean }): Promise<string>
// base64(支持图片压缩)
read(target: FileEntryId | FilePath, options: { encoding: 'base64'; imageTransform?: ImageTransform }): Promise<{ data: string; mime: string }>
// binary(支持图片压缩)
read(target: FileEntryId | FilePath, options: { encoding: 'binary'; imageTransform?: ImageTransform }): Promise<{ data: Uint8Array; mime: string }>
// ─── getMetadata: 文件元信息(按类型返回不同字段) ───
type MetadataBase = { size: number; createdAt: number; modifiedAt: number }
// 第一层:kind = 'file' | 'directory'
type DirectoryMetadata = MetadataBase & { kind: 'directory' }
type FileMetadataCommon = MetadataBase & { kind: 'file'; mime: string }
// 第二层(仅 file):type = 'image' | 'pdf' | 'text' | 'other'
type ImageFileMetadata = FileMetadataCommon & { type: 'image'; width: number; height: number }
type PdfFileMetadata = FileMetadataCommon & { type: 'pdf'; pageCount: number }
type TextFileMetadata = FileMetadataCommon & { type: 'text'; encoding: string }
type GenericFileMetadata = FileMetadataCommon & { type: 'other' }
type FileKindMetadata = ImageFileMetadata | PdfFileMetadata | TextFileMetadata | GenericFileMetadata
type FileMetadata = DirectoryMetadata | FileKindMetadata
getMetadata(target: FileEntryId | FilePath): Promise<FileMetadata>
v1 兼容性审查:
read:传file.id + file.ext拼接字符串(如"abc123.pdf")或配置文件名('custom-minapps.json')。 v2 用FileEntryId不需要拼 ext;配置文件名场景走FilePath。readExternal:全部传绝对路径(笔记文件、外部文件),v2FilePath覆盖。get:返回FileMetadata用于 UI 预览(PasteService、拖拽、TranslatePage)。v2getMetadata返回结构不同但信息更全面,调用方需适配。base64Image/binaryImage/base64File:全部传file.id + file.ext,v2 改传FileEntryId。 新增imageTransform可选参数(#14062),AI 调用层可统一传参压缩大图,sharp已是项目依赖,service 层直接调用。非图片文件传入imageTransform时静默忽略。pdfInfo:renderer 中零调用,可安全移除。getMetadata的PdfMetadata.pageCount作为备用保留。
D. 文件删除
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
delete |
按 fileId 删 storage 文件 | 🔀 trash / permanentDelete |
通过 FileEntryId 操作,不区分 file/dir |
deleteDir |
按 ID 删 storage 目录 | 🔀 trash / permanentDelete |
renderer 零调用,合并 |
deleteExternalFile |
删外部路径文件 | 🔀 permanentDelete |
笔记纳入条目系统后由 service 按 mount type 处理 |
deleteExternalDir |
删外部路径目录 | 🔀 permanentDelete |
同上 |
clear |
清空整个 storage 目录 | ❌ | renderer 零调用,移除 |
v1 签名:
delete(fileId: string): Promise<void>
deleteDir(dirPath: string): Promise<void>
deleteExternalFile(filePath: string): Promise<void>
deleteExternalDir(dirPath: string): Promise<void>
clear(spanContext?: SpanContext): Promise<void>
v2 签名:
trash(params: { id: FileEntryId }): Promise<void>
restore(params: { id: FileEntryId }): Promise<FileEntry>
permanentDelete(params: { id: FileEntryId }): Promise<void>
batchTrash(params: { ids: FileEntryId[] }): Promise<BatchOperationResult>
batchRestore(params: { ids: FileEntryId[] }): Promise<BatchOperationResult>
batchPermanentDelete(params: { ids: FileEntryId[] }): Promise<BatchOperationResult>
v1 兼容性审查:
delete:useKnowledge.ts和FileManager.ts使用,传file.name或id + ext。v2 改传FileEntryId。deleteDir:renderer 零调用。deleteExternalFile/deleteExternalDir:仅NotesService.ts使用,传entry.externalPath。v2 笔记纳入条目系统后统一走permanentDelete(entryId)。clear:renderer 零调用,仅在 preload/ipc.ts 注册。安全移除。
E. 文件移动 / 重命名
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
move |
按路径移动文件 | 🔀 move |
统一用 FileEntryId,不区分 file/dir |
moveDir |
按路径移动目录 | 🔀 move |
合并 |
rename |
按路径重命名文件(加 .md) | 🔀 move |
rename = 同目录 move + newName |
renameDir |
按路径重命名目录 | 🔀 move |
合并 |
v1 签名:
move(path: string, newPath: string): Promise<void>
moveDir(dirPath: string, newDirPath: string): Promise<void>
rename(path: string, newName: string): Promise<void>
renameDir(dirPath: string, newName: string): Promise<void>
v2 签名:
// move + rename 合并:newName 可选,省略则保持原名
move(params: { id: FileEntryId; targetParentId: FileEntryId; newName?: string }): Promise<FileEntry>
batchMove(params: { ids: FileEntryId[]; targetParentId: FileEntryId }): Promise<BatchOperationResult>
v1 兼容性审查:
move/moveDir:仅NotesPage.tsx使用,按entry.type分别调用,传externalPath。v2 统一move(entryId, targetParentId)。rename/renameDir:仅NotesService.ts使用,按isFile分别调用,传externalPath+safeName。v2 统一move(entryId, 原parentId, newName)。
F. 底层 FS 操作
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
write |
按外部路径写入 bytes/string | ✅ write |
笔记保存、导出等场景仍需直接写外部路径,不经过条目系统 |
writeWithId |
按 fileId 写入 storage | 🔀 write |
合并到 write,传 FileEntryId 或 FilePath |
mkdir |
创建目录 | 🔀 createEntry |
v2 创建目录走 createEntry({ type: 'dir' }) |
copy |
从 storage 复制到外部路径 | ✅ copy |
当前零调用,但文件管理器基本操作,提前设计 |
createTempFile |
生成临时文件路径(不创建文件) | ❌ | 粘贴场景被 createEntry({ content: Uint8Array }) 替代 |
v1 签名:
write(filePath: string, data: Uint8Array | string): Promise<void>
writeWithId(id: string, content: string): Promise<void>
mkdir(dirPath: string): Promise<string>
copy(fileId: string, destPath: string): Promise<void>
createTempFile(fileName: string): Promise<string>
v2 签名:
// 写内容到指定目标(条目或外部路径),不创建新条目
write(target: FileEntryId | FilePath, data: string | Uint8Array): Promise<void>
// 树内复制(创建新条目 + 物理复制)
copy(params: { id: FileEntryId; targetParentId: FileEntryId; newName?: string }): Promise<FileEntry>
// 导出到外部路径(不创建新条目)
copy(params: { id: FileEntryId; destPath: FilePath }): Promise<void>
v1 兼容性审查:
write:PasteService(落盘粘贴数据,v2 被createEntry替代)、NotesService/NotesPage(写笔记内容)、export.ts(导出 markdown)、exportExcel.ts(写 Excel)、HtmlArtifactsCard(临时 HTML)。笔记和导出场景仍需write。writeWithId:仅 minapps 配置文件读写(custom-minapps.json)。v2 用write(FileEntryId | FilePath, ...)。mkdir:仅 NotesService 创建笔记子目录。v2 走createEntry({ type: 'dir' })。copy:renderer 零调用,安全移除。createTempFile:粘贴场景被createEntry({ parentId: 'mount_temp', content })替代。 临时文件纳入条目系统,粘贴时创建临时 FileRef(sourceType: 'temp_session'),mount_temp兼作临时文件和缓存。ref 由调用方显式管理(发送时删临时 ref + 创建正式 ref + move, 取消时删 ref)。清理器只自动删除无 ref 的条目(启动时 + 定期),绝不删 ref。 用户通过删 ref 主动释放不需要的缓存。 HTML 预览可用write写 temp 路径。(?)
G. 文件检测 / 校验
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
isTextFile |
检测是否文本文件 | 🔀 getMetadata |
metadata.type === 'text' 判断,不单独保留 |
isDirectory |
检测是否目录 | 🔀 getMetadata |
条目用 entry.type,外部路径用 getMetadata 判断 |
checkFileName |
文件名消毒(sanitize)+ 目标路径冲突检测 | ❌ (拆分) | sanitize 提取为 shared 纯函数(不需要 IPC),冲突检测由 createEntry/move 内部处理 |
validateNotesDirectory |
校验笔记目录合法性 | ✅ validateNotesPath |
notes 专用,暂不泛化。app 内部只允许 Data/files/notes/ |
v1 签名:
isTextFile(filePath: string): Promise<boolean>
isDirectory(filePath: string): Promise<boolean>
checkFileName(dirPath: string, fileName: string, isFile: boolean): Promise<{ safeName: string; exists: boolean }>
validateNotesDirectory(dirPath: string): Promise<boolean>
v2 签名:
// 已在 C 组定义
getMetadata(target: FileEntryId | FilePath): Promise<FileMetadata>
// 验证路径是否适合作为 notes 目录
// notes 专用:app 内部只允许 Data/files/notes/,禁止指向其他 mount 目录
validateNotesPath(dirPath: FilePath): Promise<boolean>
v1 兼容性审查:
isTextFile:utils/file.ts和AttachmentPreview.tsx使用。v2 用getMetadata(path).type === 'text'替代。isDirectory:仅SkillsSettings.tsx拖拽安装判断。v2 用getMetadata(path)判断。checkFileName:仅NotesService.ts/NotesPage.tsx使用(4 处),用于创建/重命名前校验。v2 由createEntry/move的 service 内部校验,冲突时抛错误,renderer 捕获并提示用户。validateNotesDirectory:NotesService.ts/NotesSettings.tsx使用。v2 改为validateNotesPath, 主要改动:将硬编码受限路径(filesDir/appDataPath)替换为"app 内部只允许Data/files/notes/", 新增禁止指向其他 mount basePath(managed/、temp/等)。其余检查(存在、可写、非系统根、非当前路径)不变。 |validateNotesDirectory| 校验笔记目录合法性 | ❓ | |
H. 系统操作
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
openPath |
用系统默认程序打开文件/目录 | ✅ open |
接收 FileEntryId | FilePath,service resolve 物理路径 |
openFileWithRelativePath |
用相对路径打开 storage 文件 | 🔀 open |
合并,v2 传 FileEntryId |
showInFolder |
在文件管理器中显示 | ✅ showInFolder |
接收 FileEntryId | FilePath |
getPathForFile |
webUtils.getPathForFile(preload 直接调) |
✅ 移出 | 非 IPC,通过 contextBridge 暴露的同步工具方法,不属于 FileManager |
v1 签名:
openPath(path: string): Promise<void>
openFileWithRelativePath(file: FileMetadata): Promise<void>
showInFolder(path: string): Promise<void>
getPathForFile(file: File): string
v2 签名:
// 用系统默认程序打开文件/目录
open(target: FileEntryId | FilePath): Promise<void>
// 在系统文件管理器中显示
showInFolder(target: FileEntryId | FilePath): Promise<void>
// 移至 preload utils,不属于 FileManager IPC
// getPathForFile(file: File): string
v1 兼容性审查:
openPath:多处使用(知识库目录、文件列表、引用链接、agent 工具路径),传外部路径。v2open支持FilePath覆盖。openFileWithRelativePath:仅知识库文件/视频使用,传FileMetadata(内部拼 storage 路径)。v2 传FileEntryId,service resolve 物理路径。showInFolder:仅ClickableFilePath.tsx使用,传路径。v2 支持FileEntryId | FilePath。getPathForFile:多处使用(PasteService、拖拽、知识库文件),preload 直接调webUtils,不变。
I. 目录扫描
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
getDirectoryStructure |
递归扫描目录树 | ❌ | v2 笔记纳入条目系统,用 DataApi children 查询替代 |
listDirectory |
ripgrep 搜索目录内容 | ✅ listDirectory |
agent 工具面板列出外部目录文件,非条目系统管理,仍需保留 |
v1 签名:
getDirectoryStructure(dirPath: string): Promise<NotesTreeNode[]>
listDirectory(dirPath: string, options?: DirectoryListOptions): Promise<string[]>
v2 签名:
// 列出外部目录内容(非条目系统管理的目录)
listDirectory(dirPath: FilePath, options?: DirectoryListOptions): Promise<string[]> // DirectoryListOptions 维持原样
v1 兼容性审查:
getDirectoryStructure:仅 Notes 使用(加载树、检查目录内容)。v2 笔记纳入条目系统后,用GET /files/entries/:id/children替代。listDirectory:仅useResourcePanel.tsx使用,列出 agent 可访问目录的文件。传外部路径 + options。v2 保留,签名基本不变。
J. File Watcher
| v1 方法 | 功能 | v2 方案 | 说明 |
|---|---|---|---|
startFileWatcher |
启动 chokidar 监听 | ❌ | v2 由 FileManager service 内部管理 local_external mount 的 watcher |
stopFileWatcher |
停止监听 | ❌ | 同上,service 跟随 mount 生命周期自动管理 |
pauseFileWatcher |
暂停监听(批量操作时) | ❌ | 同上,service 在批量操作时内部暂停 |
resumeFileWatcher |
恢复监听 | ❌ | 同上 |
onFileChange |
renderer 监听变更事件 | ❌ | v2 renderer 通过 DataApi 数据订阅感知变更,不直接监听 FS 事件 |
v1 签名:
startFileWatcher(dirPath: string, config?: any): Promise<void>
stopFileWatcher(): Promise<void>
pauseFileWatcher(): Promise<void>
resumeFileWatcher(): Promise<void>
onFileChange(callback: (data: FileChangeEvent) => void): () => void
v2 签名:
无。Watcher 由 FileManager service 内部管理,不暴露 IPC。
v1 兼容性审查:
- 全部仅 Notes 使用(
NotesPage.tsx、NotesService.ts)。- v2 笔记纳入条目系统后,
local_externalmount 的 watcher 由 FileManager service 内部管理: FS 变更 → service 自动同步到 DB → renderer 通过 DataApi 数据订阅感知。- 批量操作时的 pause/resume 也由 service 内部协调,renderer 无需关心。
v2 FileManager IPC 完整方法列表
v1 44 个方法 → v2 19 个方法(含 1 个 preload 工具方法)。
类型定义
type FilePath = `/${string}` | `${string}:${string}` | `file://${string}`;
type Base64String = `data:${string};base64,${string}`;
type URLString = `http://${string}` | `https://${string}`;
type FileContent = FilePath | Base64String | URLString | Uint8Array;
type CreateEntryParams =
| { type: "file"; parentId: FileEntryId; name: string; content: FileContent }
| { type: "dir"; parentId: FileEntryId; name: string };
type MetadataBase = { size: number; createdAt: number; modifiedAt: number };
type DirectoryMetadata = MetadataBase & { kind: "directory" };
type FileMetadataCommon = MetadataBase & { kind: "file"; mime: string };
type ImageFileMetadata = FileMetadataCommon & {
type: "image";
width: number;
height: number;
};
type PdfFileMetadata = FileMetadataCommon & { type: "pdf"; pageCount: number };
type TextFileMetadata = FileMetadataCommon & { type: "text"; encoding: string };
type GenericFileMetadata = FileMetadataCommon & { type: "other" };
type FileKindMetadata =
| ImageFileMetadata
| PdfFileMetadata
| TextFileMetadata
| GenericFileMetadata;
type FileMetadata = DirectoryMetadata | FileKindMetadata;
type BatchOperationResult = {
succeeded: FileEntryId[];
failed: Array<{ id: FileEntryId; error: string }>;
};
// 图片读取时可选变换(#14062),非图片文件传入时静默忽略,具体字段待调研 sharp API
type ImageTransform = {
maxDimension?: number;
quality?: number;
format?: string;
};
方法签名
// ─── A. 文件选择 / 对话框 ───
select(options: { directory?: never; multiple?: false; filters?: FileFilter[]; title?: string }): Promise<string | null>
select(options: { directory?: never; multiple: true; filters?: FileFilter[]; title?: string }): Promise<string[]>
select(options: { directory: true; title?: string }): Promise<string | null>
save(options: { content: string | Uint8Array; defaultPath?: string; filters?: FileFilter[] }): Promise<string | null>
// ─── B. 条目创建 ───
createEntry(params: CreateEntryParams): Promise<FileEntry>
batchCreateEntries(params: { parentId: FileEntryId; items: Array<{ name: string; content: FileContent }> }): Promise<BatchOperationResult>
// ─── C. 文件读取 / 元信息 ───
read(target: FileEntryId | FilePath, options?: { encoding?: 'text'; detectEncoding?: boolean }): Promise<string>
read(target: FileEntryId | FilePath, options: { encoding: 'base64'; imageTransform?: ImageTransform }): Promise<{ data: string; mime: string }>
read(target: FileEntryId | FilePath, options: { encoding: 'binary'; imageTransform?: ImageTransform }): Promise<{ data: Uint8Array; mime: string }>
getMetadata(target: FileEntryId | FilePath): Promise<FileMetadata>
// ─── D. 条目删除 ───
trash(params: { id: FileEntryId }): Promise<void>
restore(params: { id: FileEntryId }): Promise<FileEntry>
permanentDelete(params: { id: FileEntryId }): Promise<void>
batchTrash(params: { ids: FileEntryId[] }): Promise<BatchOperationResult>
batchRestore(params: { ids: FileEntryId[] }): Promise<BatchOperationResult>
batchPermanentDelete(params: { ids: FileEntryId[] }): Promise<BatchOperationResult>
// ─── E. 条目移动(含重命名) ───
move(params: { id: FileEntryId; targetParentId: FileEntryId; newName?: string }): Promise<FileEntry>
batchMove(params: { ids: FileEntryId[]; targetParentId: FileEntryId }): Promise<BatchOperationResult>
// ─── F. 文件写入 / 复制 ───
write(target: FileEntryId | FilePath, data: string | Uint8Array): Promise<void>
copy(params: { id: FileEntryId; targetParentId: FileEntryId; newName?: string }): Promise<FileEntry>
copy(params: { id: FileEntryId; destPath: FilePath }): Promise<void>
// ─── G. 校验 / 路径工具 ───
validateNotesPath(dirPath: FilePath): Promise<boolean>
canWrite(dirPath: FilePath): Promise<boolean>
resolvePath(filePath: string): Promise<string>
isPathInside(childPath: string, parentPath: string): Promise<boolean>
isNotEmptyDir(dirPath: FilePath): Promise<boolean>
// ─── H. 系统操作 ───
open(target: FileEntryId | FilePath): Promise<void>
showInFolder(target: FileEntryId | FilePath): Promise<void>
// ─── I. 目录扫描 ───
listDirectory(dirPath: FilePath, options?: DirectoryListOptions): Promise<string[]> // 维持原样
不在 FileManager IPC 中的方法:
getPathForFile(file: File): string— preload 通过 contextBridge 暴露的同步工具方法,不属于 FileManager- File Watcher(start/stop/pause/resume/onFileChange)— v2 由 FileManager service 内部管理,不暴露 IPC
getDirectoryStructure— v2 用 DataApiGET /files/entries/:id/children替代checkFileName— sanitize 提取为 shared 纯函数,冲突检测由 service 内部处理
非 File_ 前缀的文件相关 IPC
v1 还有一些散落在其他命名空间下的文件相关 IPC,需要统一分析归属。
已合并到 File Module IPC
| v1 IPC | v1 实现 | v2 方案 | 说明 |
|---|---|---|---|
Fs_Read |
FileService.readFile |
🔀 read(FilePath) |
→ ops.read(双态方法的 FilePath 路径) |
Fs_ReadText |
FileService.readTextFileWithAutoEncoding |
🔀 read(FilePath, { encoding: 'text' }) |
同上 |
Open_Path |
shell.openPath(path) |
🔀 open(FilePath) |
与 File_OpenPath 完全重复,→ ops.open |
App_HasWritePermission |
hasWritePermission(filePath) |
🔀 canWrite(FilePath) |
→ ops.canWrite |
App_ResolvePath |
path.resolve(untildify(filePath)) |
🔀 resolvePath(FilePath) |
→ ops.resolvePath |
App_IsPathInside |
isPathInside(childPath, parentPath) |
🔀 isPathInside(child, parent) |
→ ops.isPathInside |
App_IsNotEmptyDir |
fs.readdirSync(path).length > 0 |
🔀 isNotEmptyDir(FilePath) |
→ ops.isNotEmptyDir |
v1 兼容性审查:
Fs_Read:aiCore 和 renderer 中用于读取外部文件(URL 或本地路径),v2read(FilePath)覆盖。Fs_ReadText:renderer 中用于读取文本文件并自动检测编码,v2read(FilePath, { encoding: 'text', detectEncoding: true })覆盖。Open_Path:多处使用(知识库、导出结果等),与File_OpenPath实现完全相同(均调用shell.openPath),v2 统一为open(FilePath)。App_HasWritePermission:数据迁移选择目录时校验权限。通用能力,validateNotesPath内部也需要。App_ResolvePath/App_IsPathInside:纯路径计算,无 FS I/O。renderer 无node:path,仍需 IPC。App_IsNotEmptyDir:数据迁移校验目录。通用能力,归入 ops。
保持独立(不属于 File Module)
| v1 IPC | v1 实现 | v2 方案 | 说明 |
|---|---|---|---|
Pdf_ExtractText |
extractPdfText(data: Uint8Array | ArrayBuffer | string) |
✅ 保持独立 | 纯内容处理(传 buffer),不依赖文件系统或 entry 系统 |
App_Copy |
fs.promises.cp 递归复制 |
✅ 数据迁移模块 | userData 递归复制 + occupiedDirs 排除,专用场景 |
不属于 FileManager(各自业务模块)
| v1 IPC | 说明 | v2 归属 |
|---|---|---|
Open_Website |
shell.openExternal(url) — URL 不是文件操作 |
App 层 |
FileService_Upload/List/Delete/Retrieve |
AI Provider 远程文件 API(Gemini 等) | Provider 模块 |
Gemini_UploadFile/Base64File/RetrieveFile/ListFiles/DeleteFile |
Gemini 专用文件操作 | Provider 模块 |
Export_Word |
Word 导出 | Export 模块 |
Zip_Compress/Decompress |
压缩解压 | Backup 模块 |
Webview_PrintToPDF/SaveAsHTML |
Webview 输出 | Webview 模块 |
Skill_ReadFile/ListFiles |
Skill 文件读取 | Skill 模块 |