docs: add working directory feature implementation plan

Design document for per-directory state switching (providers, MCP,
skills, prompts). Not yet implemented - saved for future reference.
This commit is contained in:
Jason
2026-04-10 08:33:13 +08:00
Unverified
parent f9fb9085ac
commit cc1530f997
+597
View File
@@ -0,0 +1,597 @@
# 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<Vec<WorkingDirectory>, 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<Option<WorkingDirectory>, 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<bool, AppError>
fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
```
**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) = &current {
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<T, String>` + `.map_err(|e| e.to_string())`
```rust
#[tauri::command]
pub fn list_working_directories(state: State<'_, AppState>) -> Result<Vec<WorkingDirectory>, String>
#[tauri::command]
pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option<String>) -> Result<WorkingDirectory, String>
#[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<Option<WorkingDirectory>, 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<WorkingDirectory[]>("list_working_directories"),
add: (path: string, name?: string) =>
invoke<WorkingDirectory>("add_working_directory", { path, name }),
delete: (id: string) => invoke<void>("delete_working_directory", { id }),
rename: (id: string, name: string) =>
invoke<void>("rename_working_directory", { id, name }),
switch: (id: string) => invoke<void>("switch_working_directory", { id }),
getCurrent: () =>
invoke<WorkingDirectory | null>("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 后数据刷新