# 4-22 [done] Local Folder / Convex 统一树源顺序执行清单 v1

> 更新时间：2026-05-06
>
> 来源：
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-20-vscode-explorer-file-tree-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-2-sidebar-pagetree-filetree-product-interaction-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-4-tree-projection-protocol-contract-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-7-tree-shell-ui-state-boundary-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/vscode`
>
> 状态说明：本清单对应的 Local Folder / Convex 统一树源主链已进入已完成基线；后续 Markdown parser、Page Aggregate Rust-first、tree live stream 与设计状态漂移由后续稿继续跟踪。

## 0. 执行原则

本清单按顺序执行，不跳阶段。

总目标：

> **只维护一套 tree projection、tree command、preflight、runtime state 和 UI；Local Folder 与 Convex 只作为不同 `WorkspaceSource` executor 存在。**

硬约束：

- [x] 不让本地文件夹先导入 Convex 再渲染。
- [x] 不在 UI 层用本地 path 或 Convex record 拼第二套树。
- [x] 不新增 local 专用文件树 UI。
- [x] 不绕过 Rust kernel 写页面树、文件树、排序或权限。
- [x] 不把 `expanded/selected/focus/drag/drop/contextMenu` 写进 projection。
- [x] 所有写操作先 preflight，再 execute，再 projection resync。

**Done 条件：**

- 本清单对应实现中，Local 和 Convex 都通过同一 command envelope 进入 Rust command router。
- `file_tree/page_tree/page_aggregate` 的 UI 消费路径不因 source 分叉。
- 本地文件夹与 Convex 的差异只通过 capability / preflight / executor 表达。

## 1. Phase A：冻结 Source 与协议边界

### A1. 定义 `WorkspaceSource` 合同

- [x] 在 Rust 侧定义 `WorkspaceSource` trait / interface 草案。
- [x] 字段至少覆盖 `sourceKind`、`rootUri`、`workspaceId`、`capabilities`。
- [x] 方法至少覆盖 `loadSnapshot`、`watch`、`preflightCommand`、`executeCommand`、`resolvePageAggregate`。
- [x] 明确 `local_folder` 和 `convex_workspace` 两个 source kind。
- [x] 写 contract test，验证 source kind 和 root uri 会进入 command envelope。

**Done 条件：**

- Rust 代码中存在统一 `WorkspaceSource` 抽象。
- 至少有 mock source 能输出 `file_tree/page_tree` snapshot。
- 前端没有新增 source-specific tree component。

### A2. 扩展 projection source metadata

- [x] 为 projection item 补 source metadata 方案。
- [x] 字段候选固定为 `sourceKind`、`sourceUri`、`relativePath`、`storageIdentity`、`operationProfile`。
- [x] 优先放入 `resourceMeta.extra`，除非需要正式扩展 `core-protocol`。
- [x] 更新 TS 类型映射。
- [x] 补 fixture：local folder item、convex item 同时存在。

**Done 条件：**

- `file_tree` item 可以区分来自 local 还是 convex。
- UI 仍只读 projection 字段，不直接访问 source adapter。
- fixture 覆盖 `directory/md file/normal file/document/index/asset`。

### A3. 统一 command envelope

- [x] 定义 `TreeCommandEnvelope` 的 source 字段。
- [x] envelope 至少包含 `sourceKind`、`workspaceId`、`rootUri`、`targetNodeId`、`targetResourceMeta`、`selection`、`operation`。
- [x] 现有 `tree.node.*`、`tree.subtree.*`、`tree.resource.*` 进入统一 router。
- [x] router 按 source kind 分发到 executor。
- [x] 对 unknown source / capability missing 返回可解释错误。

**Done 条件：**

- 同一个 UI action 可以构造同一种 envelope。
- local / convex 的 executor 可以在测试中分别接收 envelope。
- 没有新增 `/api/local-filetree/*` 这类平行命令入口。

## 2. Phase B：Local Folder 只读主链

### B1. 打开本地文件夹入口

- [x] 增加选择本地文件夹的入口。
- [x] 生成 `SpaceRoot=file://...`。
- [x] 记录最近打开的 local root。
- [x] 禁止 root 外路径访问。
- [x] 对无权限目录返回明确错误。

**Done 条件：**

- 用户能选择一个本地文件夹并进入 local workspace。
- 3000 `desktop:hot` 主入口不能跳到 `/tree` debug shell，也不能落入 `legacy_next_compat_disabled`。
- Convex workspace 入口不受影响。
- smoke 能断言当前 source kind 为 `local_folder`。

### B2. LocalFolderSource 只读扫描

- [x] 扫描目录。
- [x] 识别 directory。
- [x] 识别 `.md` 文件。
- [x] 识别普通文件。
- [x] 默认忽略 `.git`、`node_modules`、`.mnote/trash` 等高噪音目录。
- [x] 处理 symlink：P0 只显示，不默认跟随写入。

**Done 条件：**

- 本地文件夹能输出 read-only snapshot。
- snapshot 不越过 root。
- 扫描失败不会让整个 app 崩溃。

### B3. 输出 local `file_tree`

- [x] directory 映射为 folder row。
- [x] `.md` 映射为 markdown file row。
- [x] 普通文件映射为 asset row。
- [x] iconHint 覆盖 markdown/image/video/audio/pdf/book/table/unknown。
- [x] childCount、depth、parentNodeId、position 由 kernel 生成。
- [x] 排序规则先固定为 VSCode default：folder first，name sort。

**Done 条件：**

- `kernel.project_view(file_tree)` 可以返回 local folder projection。
- UI 使用同一 Explorer surface 渲染 local projection。
- 浏览器 smoke 可看到真实目录、`.md`、普通文件。

### B4. 输出 md-only `page_tree`

- [x] 只把 `.md` 文件纳入 page tree。
- [x] 目录只作为页面树分组/父级，不作为正文页面打开。
- [x] 页面标题优先级固定为 frontmatter `title` > 第一行 H1 > 文件名。
- [x] parent/child 来自目录层级。
- [x] 同名 md 文件必须通过 path / page id 区分。

**Done 条件：**

- 同一个 local root 下，Explorer 显示所有文件，页面树只显示 `.md`。
- 点击页面树 md 节点能定位到同一个 `.md` resource。
- 不因过滤普通文件影响 page tree 层级。

### B5. `.md` 只读 Page Aggregate

- [x] 读取 `.md` 文件。
- [x] 解析 frontmatter。
- [x] 解析标题。
- [x] 生成只读 `page_aggregate`。
- [x] 文档页用现有 `DocumentShell / DocumentContent` 消费。
- [x] 文件读取失败显示错误页，不清空 Explorer。

**Done 条件：**

- 点击本地 `.md` 文件能打开文档阅读态。
- 文档页不关心内容来自 local 还是 Convex。
- `page_aggregate` 字段满足 `5-5-1` 最小契约。

## 3. Phase C：页面身份与本地元数据

### C1. 本地 page id 策略

- [x] 支持 frontmatter `mnote_id`。
- [x] 支持 `.mnote/page-ids.json`。
- [x] 支持 path-derived 临时 id，但仅用于未初始化只读模式。
- [x] page id 生成必须稳定且不与 Convex document id 冲突。
- [x] 写测试覆盖 rename/move 后 id 保持。

**Done 条件：**

- 重命名或移动 `.md` 后，page id 不变。
- 没有 id 的文件能被安全初始化。
- 同名不同路径 `.md` 不冲突。

### C2. 本地 `.mnote` 元数据目录

- [x] 定义 `.mnote/page-ids.json`。
- [x] 定义 `.mnote/page-options.json`。
- [x] 定义 `.mnote/trash` 或系统 trash 策略。
- [x] `.mnote` 不出现在默认 Explorer 展示中，除非开启 debug。
- [x] 元数据写入必须原子化或有失败回滚策略。

**Done 条件：**

- `.mnote` 元数据能支持 page id 和页面设置。
- 默认用户视图不被 `.mnote` 噪音污染。
- 元数据损坏时有可解释错误和恢复建议。

## 4. Phase D：统一 Explorer P0 交互

### D1. Active / Selection / Focus 分离

- [x] 统一 row state：`activeRowId`、`selectedRowIds`、`focusedRowId`。
- [x] active 表示右侧打开资源。
- [x] selection 表示 Explorer 操作集合。
- [x] focus 表示键盘焦点。
- [x] local 和 convex 共用 reducer。
- [x] 多选时 selection 视觉优先于 active。

**Done 条件：**

- 截图中的 `index.md` active/focus/selection 歧义被测试覆盖。
- local `.md` 和 Convex `index.md` 使用同一视觉规则。
- selection 不回写 projection。

### D2. 键盘导航

- [x] `ArrowUp/ArrowDown` 移动 focus。
- [x] `ArrowLeft/ArrowRight` 折叠 / 展开 / 父子导航。
- [x] `Home/End` 跳首尾。
- [x] `Enter` 打开 focused row。
- [x] `F2` 进入 inline rename。
- [x] `Delete/Backspace` 触发删除 preflight。
- [x] `Ctrl/Cmd+C` copy。
- [x] `Ctrl/Cmd+X` cut。
- [x] `Ctrl/Cmd+V` paste。
- [x] `Esc` 取消 rename / menu / drag feedback。

**Done 条件：**

- 键盘能力在 runtime contract 中测试，不只散落在 React handler。
- local 和 convex 使用同一组按键浏览器 smoke。
- `cut` 不再缺失。

### D3. 选择与右键

- [x] 单击单选并设置 anchor/focus。
- [x] `Ctrl/Cmd + Click` 切换选中。
- [x] `Shift + Click` 范围选。
- [x] 右键已选区内 row 保留选区。
- [x] 右键未选 row 切换到该 row。
- [x] 空白区点击清空 selection/focus。

**Done 条件：**

- 多选 context menu 不会被右键误清空。
- local/convex 的 selection reducer 共用。
- 过滤可见行变化后 selection normalize 正确。
- 已选区内右键、未选项右键、空白区点击三条路径都必须在真实浏览器中验证。

### D4. Row grid 与 root toolbar

- [x] 固定 indent / twistie / icon / label / decoration / actions 六列。
- [x] section header 使用 `EXPLORER`。
- [x] root row 明确显示 local folder 或 cloud workspace。
- [x] toolbar 至少有新建、上传/导入、刷新、全部折叠、更多。
- [x] toolbar action 由 capability 控制可用性。

**Done 条件：**

- hover action 不改变行高和缩进。
- local root 与 Convex root 在同一 Explorer 框架下展示。
- toolbar 不创建 source-specific UI 分支。
- hover / focus 显示 actions 时，row height、indent、twistie/icon/label 列位置必须有浏览器截图或 DOM 断言。

## 5. Phase E：统一 Preflight 与 Context Menu

### E1. Context menu profile

- [x] root 菜单：新建页面、新建文件夹、上传/导入、刷新、全部折叠。
- [x] md/page 菜单：打开、重命名、复制、剪切、粘贴、移动到、删除、Reveal。
- [x] folder 菜单：新建页面、新建文件夹、粘贴、重命名、删除。
- [x] asset 菜单：打开、重命名、复制、剪切、删除、下载、移动到。
- [x] 多选菜单：复制、剪切、删除、移动到。
- [x] share/publish 只在 Convex capability 存在时出现。

**Done 条件：**

- 菜单项由 rowKind/resourceKind/capability/selection 共同裁剪。
- 不支持的能力有禁用原因或隐藏规则。
- mindmap/table 的“不支持”状态被 capability 表达，而不是散落提示。

### E2. Preflight result UI

- [x] delete preflight 展示影响集合。
- [x] move preflight 展示目标和冲突。
- [x] paste preflight 展示命名冲突策略。
- [x] upload/drop preflight 展示目标是否可写。
- [x] readonly / permission denied / root escape 返回统一错误结构。

**Done 条件：**

- local 和 convex 的 preflight 使用同一 UI 组件展示。
- 失败时不发 execute。
- 危险动作不会静默执行。

## 6. Phase F：Local 写操作 P1

### F1. 新建

- [x] `tree.node.create` 在 local source 创建 `.md`。
- [x] `tree.folder.create` 或等价命令创建目录。
- [x] 新建后进入 inline rename。
- [x] 命名冲突走 incremental naming 或 preflight 提示。
- [x] Convex 对 `New Folder` 按 capability 禁用，除非已有 folder resource。

**Done 条件：**

- 同一个新建入口可在 local 创建文件，在 Convex 创建 document。
- Convex 不支持 folder 时不会显示假入口。
- 新建结果通过 projection resync 出现。

### F2. Inline rename

- [x] 本地文件 / 目录 rename 写入文件系统。
- [x] `.md` rename 保持 page id。
- [x] Convex document rename 继续走 `tree.node.rename`。
- [x] asset rename 收口到 `tree.resource.rename` 或等价正式命令。
- [x] `Enter` 提交，`Esc` 取消。
- [x] 文件型资源默认选中文件名 stem。

**Done 条件：**

- 不再使用 `prompt` 做重命名。
- local 和 convex 的 rename UI 完全一致。
- rename 失败不破坏 selection/focus。

### F3. Move / Copy / Cut / Paste

- [x] local move/copy 使用文件系统操作。
- [x] Convex move/copy 使用 existing tree/resource command。
- [x] cut 状态有 decoration。
- [x] paste 成功后清空 cut。
- [x] 多选时过滤父子重复选择。
- [x] 禁止 move 到自身或后代。

**Done 条件：**

- local/convex 都通过同一 clipboard payload。
- cut/copy/paste 浏览器 smoke 覆盖两个 source。
- projection resync 后 selection/focus 合理保留。

### F4. Delete / Restore / Purge

- [x] local delete 进入 `.mnote/trash` 或系统 trash，策略需冻结。
- [x] local restore 从 trash 恢复。
- [x] local purge 永久删除。
- [x] Convex delete/restore/purge 继续走 `tree.node.*` / `tree.resource.*`。
- [x] asset delete/restore 从 legacy `/api/media/batch` 收口。

**Done 条件：**

- 删除不是直接 `rm` 静默执行。
- restore 在 local 和 Convex 都有统一菜单语义。
- purge 有更强确认。

### F5. Drop 外部文件

- [x] local drop 文件复制进目标目录。
- [x] Convex drop 文件上传对象存储。
- [x] drop 到 readonly 目标被拒绝。
- [x] drop 到 md 文件时目标归属规则明确。
- [x] before/inside/after indicator 覆盖本地目录和 Convex document/asset-folder。

**Done 条件：**

- 外部拖入在 local 和 convex 使用同一 drop preflight UI。
- 非法目标不执行写入。
- 文件写入后 projection 自动刷新。
- 外部文件 drop、内部拖拽、before/inside/after indicator、hover auto expand 都必须在真实浏览器中验收，不能只靠 contract test 或 jsdom。
- 至少覆盖自拖自身、拖到后代、跨 workspace、只读目标四类非法拖拽场景。

**验收记录：**

- local 外部 drop：`scripts/task163-local-folder-unified-tree-browser-smoke.js` 使用真实 Chromium `DataTransfer/File`，覆盖取消 preflight 不写入、确认后复制进目录、drop 到 `.md` 时写入父目录、readonly 目标拒绝且不发 execute。
- Convex 外部 drop：同一 smoke 通过 Rust DOM host 监听 `tree.filetree.external-drop`，覆盖 Convex document 与 asset-folder 两类目标；宿主侧继续由 `tree-shell-dom-host.tsx -> Sidebar.handleResourcePaneDropFiles -> uploadFileTreeResourceAsset -> /api/media/upload` 上传对象存储。历史 `tree-shell-iframe-host.tsx` 已于 2026-05-16 移入 `recycle/`，不再作为 active 路径。
- 对象存储链路：`resource-command-client.test.ts` 覆盖 `uploadFileTreeResourceAsset`，`app/api/media/upload/route.test.ts` 覆盖 `tree.resource.upload` plan、Convex upload URL、`mediaAssets:createWithStorage` 与资源 delta artifact。
- indicator：同一 smoke 覆盖本地目录 before/inside/after、hover auto expand，以及 Convex document before/inside/after 与 asset-folder inside。

## 7. Phase G：Watcher / Realtime / Refresh

### G1. Local file watcher

- [x] 监听 root 下文件新增、删除、重命名、修改。
- [x] 忽略 `.git/node_modules/.mnote/trash` 等目录。
- [x] 防抖合并事件。
- [x] source 输出 tree delta 或触发 snapshot refresh。
- [x] 写操作自身产生的 watcher 事件不造成重复刷新风暴。

**Done 条件：**

- 外部编辑器新增 `.md` 后 page_tree 自动更新。
- 外部删除文件后 Explorer 自动更新。
- watcher 不越过 root。

**验收记录：**

- Rust 侧新增 `/api/tree/local-folder-watch`，由 `LocalFolderSource` 计算 root revision，只在可见文件/目录变化时变更 revision，并复用 root escape 与 ignore 规则。
- 浏览器端 local source 不再固定 2.5 秒 reload，而是轮询 revision，变化后防抖触发 snapshot refresh。
- `cargo test -p mnote-web local_ -- --nocapture` 覆盖 `.mnote` 噪音不改变 revision、可见文件内容变更会改变 revision、root escape 防护。
- `scripts/task163-local-folder-unified-tree-browser-smoke.js` 覆盖外部新增 `.md` 后 page_tree 自动更新，以及外部新增/删除普通文件后 Explorer 自动更新。

### G2. Convex realtime parity

- [x] Convex 继续使用现有 tree stream。
- [x] preferred snapshot 选择不因 local source 破坏。
- [x] local source 和 Convex source 都能进入统一 tree event consumer。
- [x] source 切换时清理上一 source 的 watcher / stream。

**Done 条件：**

- local 和 convex 的实时刷新状态都可观测。
- 切换 workspace 不出现旧 source 事件污染新 workspace。
- 当前 Convex smoke 不回退。

**验收记录：**

- Convex 侧继续由 `useSidebarTreeStream` / tree stream server 处理 realtime，`pnpm vitest run src/lib/tree-stream/use-sidebar-tree-stream.test.tsx src/components/sidebar/use-preferred-sidebar-snapshot.test.tsx src/lib/tree-stream/server.test.ts` 通过。
- local 侧通过同一 tree shell source metadata 和 revision watcher 触发 snapshot refresh，不引入第二套 UI。
- 真实浏览器 smoke 覆盖 local -> Convex source 切换，断言旧 local watcher 内容和 selection 不污染 Convex file tree。

## 8. Phase H：Markdown 编辑与写回 P2

### H1. Markdown AST 到编辑器文档

- [x] Markdown parse 成 editor document。
- [x] frontmatter 保留。
- [x] 基础块覆盖 paragraph / heading / list / quote / code / divider。
- [x] 附件链接解析为 resource reference。
- [x] 不支持块有降级显示。

**Done 条件：**

- 本地 `.md` 可进入可编辑态。
- 基础 Markdown 往返不丢主体内容。
- 不支持格式不会导致保存破坏原文件。

**验收记录：**

- `LocalFolderSource` 的 Markdown 读取侧已覆盖 heading 1-6、paragraph、bullet / numbered list、quote、fenced code、divider、相对附件链接 `media` reference；未知/不支持语法降级为 paragraph。
- `cargo test -p mnote-web local_markdown -- --nocapture` 覆盖基础块解析、附件链接解析、unsupported table 降级显示。
- 真实浏览器 smoke 打开 `docs/blocks.md`，断言 frontmatter title、列表、quote、code 与附件链接文本均可在文档页读出。

### H2. 编辑器文档写回 Markdown

- [x] 保存正文写回 `.md`。
- [x] 标题编辑规则冻结：文件名 / H1 / frontmatter 如何同步。
- [x] 页面设置写入 frontmatter 或 `.mnote/page-options.json`。
- [x] 保存失败保留编辑器状态并显示错误。
- [x] watcher 与保存事件去重。

**Done 条件：**

- 修改本地 `.md` 后刷新仍能看到新内容。
- 文件名、H1、frontmatter title 不互相打架。
- Convex 编辑链路不受本地写回影响。

**验收记录：**

- 正文保存通过 `/api/documents/save` 写回 `.md`，标题编辑通过 `/api/documents/title` 只写 frontmatter `title`，文件名变更仍由 Explorer rename 负责；三者职责分离。
- 页面设置写入 `.mnote/page-options.json`，不混入正文。
- `cargo test -p mnote-web local_ -- --nocapture` 覆盖本地正文、标题和页面设置写回；`local_markdown_save_preserves_frontmatter_and_writes_basic_blocks` 覆盖 frontmatter 保留与基础块 Markdown 输出。
- 真实浏览器 smoke 通过 document APIs 保存本地 Markdown，刷新后仍能看到新标题和正文；revision watcher 只在本地 root revision 变化后防抖刷新，避免固定刷新风暴。

## 9. Phase I：Local / Cloud 显式桥接 P3

### I1. Import / Export

- [x] local folder import 到 cloud workspace。
- [x] cloud workspace export 到 local folder。
- [x] 冲突策略明确。
- [x] page id 映射保留。
- [x] 附件路径和对象存储映射保留。

**Done 条件：**

- import/export 是显式动作，不是 local 打开时自动发生。
- 用户可预览影响集合。
- 失败可重试或回滚。

**验收记录：**

- P3 候选合同已冻结在 `4-23-local-cloud-explicit-bridge-p3-candidate-v1.md`。
- import/export 均被定义为 `preview -> execute` 显式动作，不能在打开 local folder、保存 local `.md`、watcher 或普通树操作中隐式发生。
- 冲突策略固定为 preview 输出冲突集合，execute 默认 incremental naming，不静默覆盖。
- page id 映射分别通过 `mnote_id` / `.mnote/page-ids.json`、`cloud_document_id`、`.mnote/cloud-export-map.json` 保留。
- 附件路径和对象存储 id 必须进入 export/import map；execute 失败必须返回 retry cursor 和 rollback plan。

### I2. Publish / Sync 候选

- [x] local page publish 到 Convex public page。
- [x] 手动 sync 候选设计。
- [x] 不在 P0/P1 引入隐式双向同步。
- [x] 权限模型不混淆：local OS 权限不同于 Convex ACL。

**Done 条件：**

- 本地 workspace 不依赖 Convex 才能工作。
- Cloud 功能通过显式桥接启用。
- 没有自动双真相。

**验收记录：**

- `4-23` 固定 publish 为 `tree.bridge.publish.preview/execute`，只发布显式选择的单页或子树。
- sync 只进入 `tree.bridge.sync.preview` 候选，不提供默认 `sync.execute`，避免形成自动双真相。
- local OS 权限与 Convex ACL 在 preview 中必须单独展示，不互相推导。
- 当前实现中的 local folder 读写、watcher、Markdown 保存均不依赖 Convex；Convex 能力只通过显式 bridge 候选启用。

## 10. 全局验收矩阵

### 10.1 Contract tests

- [x] `WorkspaceSource` mock local / mock convex。
- [x] command envelope source routing。
- [x] projection source metadata。
- [x] capability-driven menu profile。
- [x] preflight result schema。
- [x] active / selection / focus reducer。
- [x] cut / copy / paste reducer。

**Done 条件：**

- 不启动浏览器也能验证核心协议。
- local/convex 至少各有一套同命令不同 executor 的测试。
- Contract tests 只负责协议、schema、reducer、payload 与 executor 分发，不替代拖拽、右键、多选、外部 drop、source 切换的真实浏览器验收。

### 10.2 Rust tests

- [x] local folder scan。
- [x] md-only page_tree。
- [x] file_tree folder first sorting。
- [x] page id persistence。
- [x] root escape 防护。
- [x] local write executor。
- [x] Convex executor regression。

**Done 条件：**

- Rust 测试覆盖 source adapter 主路径。
- 安全边界有失败用例。
- Convex 旧路径没有被本地 source 破坏。

### 10.3 Browser smoke

- [x] 打开 local folder。
- [x] 3000 `desktop:hot` 主入口覆盖打开本地文件夹：不触发原生 prompt，不跳 `/tree`，不落入 `legacy_next_compat_disabled`。
- [x] Explorer 显示目录、md、普通文件。
- [x] 页面树只显示 md。
- [x] 打开 md 到文档页。
- [x] 同一套右键菜单在 local / convex 按 capability 裁剪。
- [x] 已选区内右键保留多选，未选项右键切换 selection 后开菜单，空白区点击清空 selection / focus。
- [x] `Ctrl/Cmd+Click`、`Shift+Click`、`Ctrl/Cmd+A` 多选链路。
- [x] 3000 主 Sidebar file tree 的 `Shift+Click` 范围多选由 `scripts/task164-desktop-hot-local-folder-main-entry-smoke.js` 验收。
- [x] inline rename。
- [x] `Ctrl/Cmd+C/X/V` 与菜单 Copy / Cut / Paste 都覆盖，cut decoration 出现，paste 成功后消失。
- [x] 内部拖拽覆盖单选、多选、move / copy 修饰键切换。
- [x] 内部拖拽覆盖 before / inside / after 三态 indicator。
- [x] 内部拖拽覆盖 hover auto expand。
- [x] 内部拖拽覆盖自拖自身、拖到后代、跨 workspace、只读目标四类非法场景，并断言不会发 execute。
- [x] 外部文件 drop 使用真实 `DataTransfer/File` 或等价 Playwright 文件拖入，覆盖 local folder、Convex document/asset-folder、readonly 目标、md 文件目标。
- [x] 外部文件 drop 覆盖 preflight UI、写入/上传结果、projection refresh。
- [x] watcher 外部修改刷新。
- [x] 切回 Convex workspace 后原有页面树正常。
- [x] source 切换后旧 watcher / stream 已清理，selection / focus / active 不被旧 source 污染。
- [x] local 与 Convex 上同一动作保持同一 UI 和同一命令名，但 executor 行为可不同。
- [x] 组合场景覆盖多同名 `新页面`、每页下 `index.md`、多选、右键、`F2`、`Ctrl/Cmd+X/V`、外部文件拖入、切回 Convex workspace。

**Done 条件：**

- smoke 覆盖 source 切换。
- smoke 覆盖 3000 主入口本地文件夹链路；不能只用 `/tree` debug shell 作为完成证据。
- smoke 覆盖至少一个同 UI、同命令、不同 executor 的动作。
- 截图中 Explorer root / active / selection / focus 可解释。
- 浏览器 smoke 必须是真实浏览器自动化，不接受纯 jsdom、DOM 单测或只跑 reducer 的结果。
- 拖拽、外部 drop、上下文菜单、hover 和键盘导航必须在浏览器里触发真实事件链，不接受手工只看截图的验收。

## 11. 最终 done 条件

只有同时满足以下条件，`4-20/4-21/4-22` 才能整体移动到 `done/`：

- [x] `WorkspaceSource` 抽象落地。
- [x] `LocalFolderSource` 支持本地只读 `file_tree/page_tree/page_aggregate`。
- [x] `ConvexWorkspaceSource` 继续通过同一 projection/command 消费路径工作。
- [x] Explorer P0 交互完成：active/selection/focus、keyboard、context menu、inline rename、cut/copy/paste、drag/drop。
- [x] 至少一组写操作在 local 和 convex 上共用 UI 和 command envelope。
- [x] 本地文件夹不经过 Convex 中介。
- [x] page id 不因本地 rename/move 断裂。
- [x] UI 没有 source-specific tree fork。
- [x] 所有危险写操作都有 preflight。
- [x] 浏览器 smoke 和 Rust/TS contract tests 通过。
- [x] 3000 `desktop:hot` 主入口 smoke 通过，覆盖本地文件夹入口、legacy fallback 拒绝和主 Sidebar `Shift+Click` 多选。
- [x] 拖拽、外部 drop、右键、多选、键盘、source 切换的关键路径在真实浏览器中逐项验收通过；这些 hard gate 未通过时，即使 contract/Rust 测试通过也不得移动到 `done/`。

## 12. 明确非目标

- [x] 不在 P0 实现本地和云端自动同步。
- [x] 不在 P0 承诺 Markdown 与编辑器完全无损互转。
- [x] 不用本地 per-user 文件夹替代 Convex 协作权限。
- [x] 不把 VSCode 源码作为直接复制对象；只参考 Explorer 行为模型。
- [x] 不把 Convex 虚拟空间强行伪装成真实磁盘目录。
- [x] 不把 Local Folder 模式做成必须登录才能使用。
