# CC-Switch "工作目录" 功能 — 实施方案 ## Context CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。 本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。 --- ## 一、需要按目录区分的实体(完整清单) | 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 | |------|-------------|---------|-----------|------| | **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 | | **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 | | **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 | | **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills | | **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 | | Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 | | Settings | key-value | flat table | NO | 全局用户偏好 | | Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 | | Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 | | Usage/Logs | historical | various tables | NO | 历史数据,不应分区 | > 原计划遗漏了 **Failover Queue** 和 **Provider Health 清除**。 --- ## 二、数据库变更(Schema v8 → v9) ### 新增 5 张表 ```sql -- 1. 工作目录注册表 CREATE TABLE IF NOT EXISTS working_directories ( id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, name TEXT, is_current BOOLEAN NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0 ); -- 2. Provider 状态快照 (is_current + in_failover_queue) -- 每个目录保存所有 provider 的两个状态标志 CREATE TABLE IF NOT EXISTS dir_provider_state ( dir_id TEXT NOT NULL, app_type TEXT NOT NULL, provider_id TEXT NOT NULL, is_current BOOLEAN NOT NULL DEFAULT 0, in_failover_queue BOOLEAN NOT NULL DEFAULT 0, PRIMARY KEY (dir_id, app_type, provider_id) ); -- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开) CREATE TABLE IF NOT EXISTS dir_mcp_state ( dir_id TEXT NOT NULL, mcp_id TEXT NOT NULL, enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, PRIMARY KEY (dir_id, mcp_id) ); -- 4. Skill 启用状态快照 (直接镜像 4 列) CREATE TABLE IF NOT EXISTS dir_skill_state ( dir_id TEXT NOT NULL, skill_id TEXT NOT NULL, enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0, enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, PRIMARY KEY (dir_id, skill_id) ); -- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id) CREATE TABLE IF NOT EXISTS dir_prompt_state ( dir_id TEXT NOT NULL, app_type TEXT NOT NULL, prompt_id TEXT NOT NULL, PRIMARY KEY (dir_id, app_type) ); ``` ### 设计决策说明 **MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**: - 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列 - 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行) - 未来增加新 app 时,两边同步加列即可 **Prompt 只存 `(dir_id, app_type, prompt_id)`**: - 每个 app_type 最多一个 enabled prompt,不需要存 boolean - 无记录 = 该 app 无激活 prompt **Provider 合并 `is_current` + `in_failover_queue`**: - 两个标志都是 per `(app_type, provider_id)` 的状态 - 存在同一表中避免多表 JOIN ### 迁移脚本 在 `schema.rs` 中: - `create_tables_on_conn()` 添加 5 个 CREATE TABLE - 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__` 行 - `SCHEMA_VERSION` 升至 9 - 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }` ```rust fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> { // 创建 5 张表(使用 IF NOT EXISTS,幂等) // ... // 插入 __default__ 虚拟目录,代表"全局默认"状态 conn.execute( "INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \ VALUES ('__default__', '__default__', NULL, 0, ?1)", [crate::database::get_unix_timestamp()?], )?; Ok(()) } ``` --- ## 三、后端实现 ### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs` 所有方法都是 `impl Database` 块,遵循现有 DAO 模式。 **关键方法签名**(需要 `_on_conn` 变体支持事务): ```rust // ═══ 工作目录 CRUD ═══ pub fn list_working_directories(&self) -> Result, AppError> pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError> pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError> pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError> pub fn get_current_working_directory(&self) -> Result, AppError> // 使用 _on_conn 变体,在 Service 层的事务中调用 fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError> // ═══ 快照写入 ═══ (都有 _on_conn 变体) fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError> // ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照) fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result ``` **snapshot_providers 实现思路**: ```sql -- 先清除旧快照 DELETE FROM dir_provider_state WHERE dir_id = ?1; -- 从主表复制当前状态 INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue) SELECT ?1, app_type, id, is_current, in_failover_queue FROM providers WHERE is_current = 1 OR in_failover_queue = 1; ``` **apply_provider_snapshot 实现思路**: ```sql -- 检查是否有快照 SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false -- 在事务中:先清除主表所有 is_current 和 in_failover_queue UPDATE providers SET is_current = 0; UPDATE providers SET in_failover_queue = 0; -- 从快照恢复 UPDATE providers SET is_current = 1 WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1); UPDATE providers SET in_failover_queue = 1 WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1); ``` **snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列): ```sql DELETE FROM dir_mcp_state WHERE dir_id = ?1; INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode) SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode FROM mcp_servers; ``` **apply_mcp_snapshot 实现思路**: ```sql -- 先全部禁用 UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0; -- 从快照恢复 UPDATE mcp_servers SET enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id), enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id) WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1); ``` ### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs` ```rust use crate::store::AppState; use crate::error::AppError; use crate::database::lock_conn; use crate::app_config::AppType; use crate::services::{McpService, ProviderService, SkillService}; use crate::config::write_text_file; use crate::prompt_files::prompt_file_path; pub struct WorkingDirService; impl WorkingDirService { /// 核心切换逻辑 pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> { // ═══ 前置检查 ═══ // 1. 检查代理接管状态,若活跃则拒绝切换 // 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active // (因为 ProxyService::is_running() 是 async,而此函数是 sync) Self::check_proxy_not_active(state)?; // ═══ Phase 1: 回填 Prompt ═══ // 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt // 这样即使用户手动编辑了 live 文件,内容也不会丢失 Self::backfill_prompt_content(state)?; // ═══ Phase 2: 数据库操作(事务) ═══ { let conn = lock_conn!(state.db.conn); conn.execute("BEGIN IMMEDIATE", [])?; let result = (|| -> Result<(), AppError> { // 获取当前工作目录 let current = Self::get_current_dir_id_on_conn(&conn)?; // 保存当前状态到旧目录 if let Some(old_id) = ¤t { Database::snapshot_providers_on_conn(&conn, old_id)?; Database::snapshot_mcp_on_conn(&conn, old_id)?; Database::snapshot_skills_on_conn(&conn, old_id)?; Database::snapshot_prompts_on_conn(&conn, old_id)?; } else { // 无当前目录 = 全局模式,保存到 __default__ Database::snapshot_providers_on_conn(&conn, "__default__")?; Database::snapshot_mcp_on_conn(&conn, "__default__")?; Database::snapshot_skills_on_conn(&conn, "__default__")?; Database::snapshot_prompts_on_conn(&conn, "__default__")?; } // 加载目标目录快照(如果有的话) // 如果无快照(首次进入),保持主表不变 Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?; Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?; Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?; Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?; // 更新 is_current 标记 Database::set_current_working_directory_on_conn(&conn, target_dir_id)?; Ok(()) })(); match result { Ok(()) => conn.execute("COMMIT", [])?, Err(e) => { let _ = conn.execute("ROLLBACK", []); return Err(e); } }; } // conn 锁在此处释放 // ═══ Phase 3: 同步 live 配置文件 ═══ Self::sync_all_live(state)?; // ═══ Phase 4: 清除 Provider Health ═══ state.db.clear_all_provider_health()?; Ok(()) } /// 回填 live prompt 文件内容到 DB(切换前调用) fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> { for app in AppType::all() { let path = prompt_file_path(&app)?; if !path.exists() { continue; } let live_content = std::fs::read_to_string(&path).unwrap_or_default(); if live_content.trim().is_empty() { continue; } let mut prompts = state.db.get_prompts(app.as_str())?; if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) { prompt.content = live_content; prompt.updated_at = Some(get_unix_timestamp()?); state.db.save_prompt(app.as_str(), prompt)?; } } Ok(()) } /// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用) /// 注意:不做回填!只写入。区别于 PromptService::enable_prompt() fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> { for app in AppType::all() { let path = prompt_file_path(&app)?; let prompts = state.db.get_prompts(app.as_str())?; if let Some(prompt) = prompts.values().find(|p| p.enabled) { write_text_file(&path, &prompt.content)?; } // 无 enabled prompt 时不清空文件(保留现状) } Ok(()) } /// 同步所有 live 配置(Provider + MCP + Skill + Prompt) fn sync_all_live(state: &AppState) -> Result<(), AppError> { // 1. Provider → live files ProviderService::sync_current_to_live(state)?; // sync_current_to_live 内部已调用 McpService::sync_all_enabled() // 2. Skills → app dirs (循环每个 app) for app in AppType::all() { let _ = SkillService::sync_to_app(&state.db, &app); } // 3. Prompts → live files Self::write_prompts_to_live(state)?; Ok(()) } /// 检查代理是否活跃(同步检查数据库标志) fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> { // 检查 proxy_config 表中 live_takeover_active 列 // 如果有任何 app 的 live_takeover_active = 1,拒绝切换 let conn = lock_conn!(state.db.conn); let active: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)", [], |r| r.get(0) ).unwrap_or(false); if active { return Err(AppError::Message( "代理接管模式运行中,请先停止代理再切换工作目录".into() )); } Ok(()) } } ``` ### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs` 遵循现有模式:`State<'_, AppState>` + `Result` + `.map_err(|e| e.to_string())`。 ```rust #[tauri::command] pub fn list_working_directories(state: State<'_, AppState>) -> Result, String> #[tauri::command] pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option) -> Result #[tauri::command] pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> #[tauri::command] pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String> #[tauri::command] pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String> // 调用 WorkingDirService::switch() #[tauri::command] pub fn get_current_working_directory(state: State<'_, AppState>) -> Result, String> ``` ### 3.4 需修改的现有文件 | 文件 | 修改内容 | |------|---------| | `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` | | `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao | | `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` | | `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` | | `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` | | `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 | ### 3.5 可能需要新增的 DAO 辅助方法 `src-tauri/src/database/dao/failover.rs`: ```rust /// 清除所有 provider_health 记录(切换目录时调用) pub fn clear_all_provider_health(&self) -> Result<(), AppError> ``` --- ## 四、前端实现 ### 4.1 API — `src/lib/api/workingDir.ts` ```typescript import { invoke } from "@tauri-apps/api/core"; export interface WorkingDirectory { id: string; path: string; name?: string; isCurrent: boolean; createdAt: number; } export const workingDirApi = { list: () => invoke("list_working_directories"), add: (path: string, name?: string) => invoke("add_working_directory", { path, name }), delete: (id: string) => invoke("delete_working_directory", { id }), rename: (id: string, name: string) => invoke("rename_working_directory", { id, name }), switch: (id: string) => invoke("switch_working_directory", { id }), getCurrent: () => invoke("get_current_working_directory"), }; ``` ### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx` **位置**:Header toolbar,靠近 AppSwitcher。 **功能**: - 下拉菜单显示已注册目录列表 - 当前目录高亮 - "浏览…" 按钮调用 Tauri 文件夹选择对话框 - 右键菜单:重命名、删除 - "__default__(全局)" 选项恢复到全局状态 - 切换后 invalidate 所有相关 React Query **切换后的 Query Invalidation**: ```typescript // 需要验证实际的 queryKey 名称 queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["mcp-servers"] }); queryClient.invalidateQueries({ queryKey: ["installed-skills"] }); queryClient.invalidateQueries({ queryKey: ["prompts"] }); queryClient.invalidateQueries({ queryKey: ["workingDirectories"] }); ``` ### 4.3 i18n 三个文件都需更新: - `src/i18n/locales/zh.json` - `src/i18n/locales/en.json` - `src/i18n/locales/ja.json` --- ## 五、切换流程时序 ``` 用户选择目录 B │ ├── 1. check_proxy_not_active() │ → 如果代理接管中,返回错误,终止 │ ├── 2. backfill_prompt_content() │ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content │ → 保护用户手动编辑的 prompt 不丢失 │ ├── 3. BEGIN TRANSACTION │ ├── snapshot(old_dir / __default__) │ │ ├── providers → dir_provider_state (is_current + in_failover_queue) │ │ ├── mcp_servers → dir_mcp_state (4 列直接复制) │ │ ├── skills → dir_skill_state (4 列直接复制) │ │ └── prompts → dir_prompt_state (enabled prompt_id) │ │ │ ├── apply(target_dir) │ │ ├── dir_provider_state → providers │ │ ├── dir_mcp_state → mcp_servers │ │ ├── dir_skill_state → skills │ │ └── dir_prompt_state → prompts │ │ │ └── set_current_working_directory(target_dir) │ ├── COMMIT │ ├── 4. sync_all_live() │ ├── ProviderService::sync_current_to_live(state) │ │ └── 内部已调用 McpService::sync_all_enabled() │ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) } │ └── write_prompts_to_live() ← 无回填,直接写 │ └── 5. clear_all_provider_health() → 清除运行时熔断器状态 ``` --- ## 六、边界情况处理 | 场景 | 处理方式 | |------|---------| | **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 | | **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 | | **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 | | **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 | | **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 | | **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 | | **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 | | **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 | | **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 | --- ## 七、实施顺序 ### Phase 1: 数据库 1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()` 2. `database/mod.rs` — `SCHEMA_VERSION = 9` + 迁移分支 3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体) 4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()` 5. `database/dao/mod.rs` — 注册模块 ### Phase 2: 服务 + 命令 6. `services/working_dir.rs` — `WorkingDirService::switch()` 等 7. `commands/working_dir.rs` — 6 个 Tauri 命令 8. `services/mod.rs` — 注册模块 9. `commands/mod.rs` — 注册模块 10. `lib.rs` — invoke_handler 注册 ### Phase 3: 前端 11. `src/lib/api/workingDir.ts` — API 封装 12. `src/types.ts` — WorkingDirectory 类型 13. `src/components/WorkingDirSwitcher.tsx` — UI 组件 14. `src/App.tsx` — 集成到 header toolbar 15. `src/i18n/locales/{zh,en,ja}.json` — 国际化 ### Phase 4: 优化(可选) 16. 启动恢复检查(DB 状态 vs live 文件一致性) 17. 托盘菜单显示当前工作目录 --- ## 八、关键文件索引 ### 新增文件(5 个) - `src-tauri/src/database/dao/working_dir.rs` - `src-tauri/src/services/working_dir.rs` - `src-tauri/src/commands/working_dir.rs` - `src/lib/api/workingDir.ts` - `src/components/WorkingDirSwitcher.tsx` ### 必须修改的文件(7 个) - `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移 - `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环 - `src-tauri/src/database/dao/mod.rs` — 模块注册 - `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health - `src-tauri/src/services/mod.rs` — 模块注册 - `src-tauri/src/commands/mod.rs` — 模块注册 - `src-tauri/src/lib.rs` — invoke_handler ### 必须修改的前端文件(4 个) - `src/App.tsx` — 集成 WorkingDirSwitcher - `src/types.ts` — WorkingDirectory 接口 - `src/i18n/locales/zh.json` — 中文 - `src/i18n/locales/en.json` — 英文 - `src/i18n/locales/ja.json` — 日文 ### 参考文件(理解现有模式) - `src-tauri/src/services/mcp.rs` — `sync_all_enabled()` (line 165) - `src-tauri/src/services/skill.rs` — `sync_to_app()` (line 1707) - `src-tauri/src/services/provider/mod.rs` — `sync_current_to_live()` (line 1552) - `src-tauri/src/services/prompt.rs` — `enable_prompt()` (line 73) — 理解回填逻辑 - `src-tauri/src/prompt_files.rs` — prompt 文件路径 - `src-tauri/src/config.rs` — `write_text_file()` (line 176) --- ## 九、验证计划 ### 后端验证 1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`) - 快照/恢复往返一致性 - 新增/删除实体后的 apply 行为 - `__default__` 全局状态保护 - 事务回滚测试 2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化 ### 前端验证 1. `pnpm typecheck` — TypeScript 类型检查 2. `pnpm lint` — ESLint 检查 3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新