docs/working-directory-plan.md
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 清除。
-- 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 列Prompt 只存 (dir_id, app_type, prompt_id):
Provider 合并 is_current + in_failover_queue:
(app_type, provider_id) 的状态在 schema.rs 中:
create_tables_on_conn() 添加 5 个 CREATE TABLEmigrate_v8_to_v9(conn): 创建 5 张表 + 插入 __default__ 行SCHEMA_VERSION 升至 97 => ... 后加 8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }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(())
}
src-tauri/src/database/dao/working_dir.rs所有方法都是 impl Database 块,遵循现有 DAO 模式。
关键方法签名(需要 _on_conn 变体支持事务):
// ═══ 工作目录 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 实现思路:
-- 先清除旧快照
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 实现思路:
-- 检查是否有快照
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 列):
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 实现思路:
-- 先全部禁用
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);
src-tauri/src/services/working_dir.rsuse 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(())
}
}
src-tauri/src/commands/working_dir.rs遵循现有模式:State<'_, AppState> + Result<T, String> + .map_err(|e| e.to_string())。
#[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>
| 文件 | 修改内容 |
|---|---|
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 个新命令 |
src-tauri/src/database/dao/failover.rs:
/// 清除所有 provider_health 记录(切换目录时调用)
pub fn clear_all_provider_health(&self) -> Result<(), AppError>
src/lib/api/workingDir.tsimport { 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"),
};
src/components/WorkingDirSwitcher.tsx位置:Header toolbar,靠近 AppSwitcher。
功能:
切换后的 Query Invalidation:
// 需要验证实际的 queryKey 名称
queryClient.invalidateQueries({ queryKey: ["providers"] });
queryClient.invalidateQueries({ queryKey: ["mcp-servers"] });
queryClient.invalidateQueries({ queryKey: ["installed-skills"] });
queryClient.invalidateQueries({ queryKey: ["prompts"] });
queryClient.invalidateQueries({ queryKey: ["workingDirectories"] });
三个文件都需更新:
src/i18n/locales/zh.jsonsrc/i18n/locales/en.jsonsrc/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,保护手动修改。 |
database/schema.rs — 5 个 CREATE TABLE + migrate_v8_to_v9()database/mod.rs — SCHEMA_VERSION = 9 + 迁移分支database/dao/working_dir.rs — 全部 DAO 方法(_on_conn 变体)database/dao/failover.rs — 新增 clear_all_provider_health()database/dao/mod.rs — 注册模块services/working_dir.rs — WorkingDirService::switch() 等commands/working_dir.rs — 6 个 Tauri 命令services/mod.rs — 注册模块commands/mod.rs — 注册模块lib.rs — invoke_handler 注册src/lib/api/workingDir.ts — API 封装src/types.ts — WorkingDirectory 类型src/components/WorkingDirSwitcher.tsx — UI 组件src/App.tsx — 集成到 header toolbarsrc/i18n/locales/{zh,en,ja}.json — 国际化src-tauri/src/database/dao/working_dir.rssrc-tauri/src/services/working_dir.rssrc-tauri/src/commands/working_dir.rssrc/lib/api/workingDir.tssrc/components/WorkingDirSwitcher.tsxsrc-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_healthsrc-tauri/src/services/mod.rs — 模块注册src-tauri/src/commands/mod.rs — 模块注册src-tauri/src/lib.rs — invoke_handlersrc/App.tsx — 集成 WorkingDirSwitchersrc/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)cargo test — DAO 层单元测试(使用 Database::memory())
__default__ 全局状态保护pnpm typecheck — TypeScript 类型检查pnpm lint — ESLint 检查