CC Switch 恢复提示词后,没被管理的文件不见了

2026-08-31

丢数据的方式有两种。一种是删除,文件没了,你一眼就知道出事了;另一种是写空,文件还在,大小变成 0,你打开一看空空如也,第一反应往往是”是不是没同步下来”,而不是”是不是被谁清了”。

CC Switch 在 v3.20.1 修掉的 #6810 就属于第二种。症状是:做了一次 WebDAV / S3 下载,或者导入了一份备份,之后发现本机的提示词文件——比如 ~/.config/opencode/AGENTS.md——内容没了,文件本身还在。而这个文件里的内容,你自己写的那些,压根就没上传过。

先说清楚这篇的依据

下面所有结论都来自 CC Switch 官方仓库的静态阅读:v3.19.2..v3.20.1 区间的 git 提交与 diff、src-tauri/ 下的 Rust 源码、以及 docs/ 里的用户手册和发布说明。核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。

我们没有安装、编译或运行过这个桌面应用,所以这篇文章不会告诉你”点哪里会看到什么”,只会告诉你哪个文件的哪一行写了什么、哪个测试断言了什么。判定动作也都设计成可以在你自己的机器上、对着文件系统和仓库源码去核,而不是靠截图比对。

那一行被删掉的代码

投影逻辑在 src-tauri/src/services/prompt.rs:22project_prompt_set_to_path。它的职责是把数据库里某个应用的提示词集合,投影成本地的那一个文件。

修复提交 c911c7e3(标题 fix(prompts): keep unmanaged prompt files intact when a restore enables none (#6810),body 里写着 Fixes #6778,提交日 2026-08-26,单文件 +16/−9)删掉的,就是这个函数里的 else 分支:

} else if target_path.exists() {
    // Match the existing "disable the last prompt" behavior without
    // creating an otherwise unused application config directory.
    write_text_file(target_path, "")?;
}

原注释交代得很清楚:这样写是为了跟”停用最后一个提示词”的既有行为保持一致,同时避免凭空创建一个用不上的应用配置目录。逻辑上没毛病——如果一个提示词都没启用,那这个被托管的文件理应是空的。

问题出在调用它的是谁。commit body 原话是:WebDAV / S3 下载(或备份导入)会先还原 db.sql,再把导入进来的提示词行投影回本地文件。恢复进来的那份数据库里,如果某个应用一条 enabled 的提示词都没有,这个 else 分支就会把本地文件写成空字符串——而这些文件根本不在同步载荷里,从来没被上传过。

于是,一个在界面路径上完全正确的”清理”动作,在恢复路径上变成了数据毁损。

同一个函数,两条调用路径,语义相反

这是这次修复最值得记的一点:判断”该不该清空”的依据不在函数内部,而在调用方是谁

  • 从界面停用最后一个提示词:数据库里”没有 enabled”这件事,确实等于”用户想让这个文件空着”。
  • 从数据库恢复:快照里”没有 enabled”只说明那份快照里没有这条记录,它对本地文件当前是什么内容不发表任何意见。

v3.20.1 的处置是把清空职责收回到 PromptService::upsert_prompt 一条路径上,投影这条路径改成什么都不做。删掉的分支原地换成一段注释(src-tauri/src/services/prompt.rs// With nothing enabled, leave the target file untouched. 开头那段),把上面这层理由写进了源码。

最硬的证据是测试期望值的翻转。同一个函数、结构相同的输入,测试名从 restored_prompt_projection_clears_a_stale_file_when_none_are_enabled(断言读回来是 "")改成了 restored_prompt_projection_preserves_the_live_file_when_none_are_enabled:527,断言读回来还是 "local content")。期望值从”空”直接翻到”原样”——同一份代码在同一份输入下,正确答案改了。

怎么确认你遇到的就是这个

四步,都能自己做:

  1. 确认丢内容的文件名。 发布说明 docs/release-notes/v3.20.1-zh.md:15 把受影响的文件点名为 CLAUDE.md / AGENTS.md / GEMINI.md / SOUL.md。这四个名字是这个工具托管提示词时会去写的目标文件名。不在这个范围里的文件,基本可以先排除。
  2. 确认是”空”不是”没了”。 那个分支写的是空字符串,不是删除,而且外面还包着 target_path.exists() 守卫——本来不存在的文件不会被凭空创建。所以如果你的文件是整个消失了,方向就错了,该去查别的东西。
  3. 确认触发动作是”恢复”而不是”操作”。 这条投影只从恢复类入口进来:云端下载、备份导入。如果你在文件变空之前做的是”在应用里停用了最后一个提示词”,那走的是另一条路——清空职责留在 PromptService::upsert_prompt 这条路径上,是设计内的行为,不是 bug。
  4. 确认恢复进来的那份数据里确实没有启用项。 触发条件是”该应用在恢复后的数据库里一条 enabled 提示词都没有”。如果恢复后你在这个应用下能看到有启用中的提示词,那么走的是 if let Some(...) 那个分支——写进去的是提示词内容,不是空串。

手册在这里帮不上忙

有个坑要提前说:如果你打算按用户手册去核对”哪些应用会被同步到哪个文件”,会漏掉一半。

docs/user-manual/zh/ 下的 3.2-prompts.md:79-84 那张同步目标表只有四行——Claude、Codex、Gemini、OpenCode。而前端 src/components/prompts/PromptFormPanel.tsx:28-38 里的 filenameMap 是一个 Record<AppId, string>:键是应用 id,值是该应用对应的提示词文件名。这个 map 的键随托管范围增删,v3.20.1 这一版里它覆盖的应用明显多于手册那张表,映射到的文件名里还有手册完全没提的 SOUL.md

也就是说,手册那张表明显不全。两处不一致,以我们实读到的仓库状态为准。补充一句边界:这个 filenameMap 只驱动表单里的 placeholder 文案(:146),它不是写盘路径本身,所以别拿它当”文件一定落在哪”的依据——它只是帮你把”我这个应用是不是也在托管范围内”这件事看全。

处置与验证

处置是升级到 v3.20.1。v3.20.1 之前的实现里,投影遇到”没有启用项”会把目标文件写空;v3.20.1 已改为保持目标文件原样。

已经被写空的内容,这个工具不负责帮你找回来——理由恰恰就是这次修复的立论:这些文件从来不在同步载荷里,云端那份快照里根本没有它们。能救回来的只有你自己的本地备份、编辑器的历史版本、或者这个目录本身在版本控制里的记录。

验证不用去点界面,在仓库里就能做两件事:

  • 打开 src-tauri/src/services/prompt.rs,看 project_prompt_set_to_pathif let Some((_, prompt)) = enabled.first() 这个块后面——如果跟着的是 else if target_path.exists() 加一句 write_text_file(target_path, ""),那就是修复前的形态;如果跟着的是一段以 // With nothing enabled 开头的注释,那就是修复后的形态。
  • 看同文件测试块里那个用例叫 preserves_the_live_file 还是 clears_a_stale_file。名字本身就是版本指纹。

顺带一提,这条修复的提交带了 Co-authored-by 署名,是社区报障加社区修的一条。

什么情况说明不是这个原因

排障文章最容易省掉的一步,恰恰是最有用的一步。以下五种情况,根因都不在 #6810

  • 你在应用里停用了最后一个提示词,然后文件变空了。 这是设计内行为,走的是 PromptService::upsert_prompt——被删掉的那个投影分支,原注释里说的「跟『停用最后一个提示词』的既有行为保持一致」,对齐的正是它。这条路径 v3.20.1 没动:修复只改了投影函数与它对应的那条测试。
  • 文件被删掉了,不是被写空。 上面说过,那个分支只写空串。
  • 文件内容不是空的,而是变成了另一段你没写过的内容。 那说明走的是”有启用项”的分支,投影写进去的是数据库里那条提示词的正文。这是恢复应有的结果,不是丢数据——如果你不想要它,问题在于恢复的那份快照里存了什么。
  • 你压根没做过恢复、下载或导入。 这条投影只从恢复类入口触发,没有恢复动作就没有这条调用。
  • 你遇到的是”删除提示词报错”而不是文件被清空。 那是另一码事:后端在 src-tauri/src/services/prompt.rs 的删除逻辑里有一条硬拦截,prompt.enabled 为真时直接返回 InvalidInput("无法删除已启用的提示词")。这条规则的前端落点还不统一——Pi 面板在按钮层就做了拦截(PiPromptPanel.tsx:191isDeleteDisabled={(_id, prompt) => prompt.enabled}),标准面板的 handleDeletePromptPanel.tsx:184-211)没有 enabled 判断,会照常发起删除再被后端拒掉。两处的表现不同,但都不是 #6810

一句话拿走

一个写空动作,正确与否取决于调用它的是”用户的意图”还是”一份不完整的快照”。这类 bug 的共同形状是:函数内部逻辑自洽,缺的是对调用方语境的区分。下次你在做”同步”或”恢复”的时候,值得先问一句——这次要投影的数据里,缺失到底代表”清空”,还是仅仅代表”我这里没有”


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、 路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理, 核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。