From 53cd7d3415e14810a11b23afbb07a1697cbff525 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 5 Jul 2026 21:55:36 +0800 Subject: [PATCH 01/22] =?UTF-8?q?docs(skill):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E5=8F=91=E7=89=88=E6=8A=80=E8=83=BD=EF=BC=8C=E6=B6=88=E9=87=8D?= =?UTF-8?q?=E5=B9=B6=E5=B0=B1=E8=BF=91=E5=BD=92=E7=BD=AE=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .claude/skills/release-new-version/SKILL.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.claude/skills/release-new-version/SKILL.md b/.claude/skills/release-new-version/SKILL.md index b99edbc..b82bd71 100644 --- a/.claude/skills/release-new-version/SKILL.md +++ b/.claude/skills/release-new-version/SKILL.md @@ -1,6 +1,6 @@ --- name: release-new-version -description: 手动触发的发版技能。仅在用户主动输入 `/release-new-version` 命令时调用,模型不得自动调用此技能。 +description: 手动触发的发版流程:更新版本文件 → 提交 → 生成 release notes → 打 annotated tag → push。 disable-model-invocation: true --- @@ -115,6 +115,8 @@ git log --pretty=format:'%h %s' {BASE_TAG}..HEAD 若基准为 `INITIAL`,改用 `git log --pretty=format:'%h %s'`(取全部历史)。 +若 `{BASE_TAG}..HEAD` 区间无 commit(少见,如重打 tag),release notes 为空,需提示用户确认是否继续。 + #### 6.2 过滤噪音 commit 移除匹配 `chore(release): bump version to` 的行(版本升级 commit 本身)。 @@ -197,6 +199,8 @@ git push origin v{VERSION} rm -f "$NOTES_FILE" ``` +tag 必须用 annotated(`-a`):lightweight tag 不保存 message,`git show v{VERSION}` 将看不到变更内容。 + ### 步骤 8:完成确认 - 汇报已完成的步骤,展示生成的 release notes 内容(供用户确认)。 @@ -206,12 +210,8 @@ rm -f "$NOTES_FILE" ## 注意事项 -- **发布分支固定为 `main`**:版本 bump 提交与 tag 都落在 main(见步骤 0);发布内容先经 PR 合入 main,不在 dev/功能分支上直接发版。 +跨步骤的约定与环境依赖(单步内的规则已就近写在对应步骤里): + - 版本文件中始终使用纯 semver(如 `0.17.0`),只有 git tag 才加 `v` 前缀(`v0.17.0`)。 -- **tag 必须用 annotated(`-a`)**,lightweight tag 不会保存 message,`git show v{VERSION}` 将看不到变更内容。 -- 对比基准 tag 必须真实存在(通过 `git rev-parse` 校验),不存在则中止并询问用户确认。 -- 当 `{BASE_TAG}..HEAD` 区间无 commit 时(少见,如重打 tag),release notes 为空,需提示用户确认是否继续。 -- 执行前检查工作区是否有未提交的无关变更,若有则先提示用户。 -- 不手动修改 `pnpm-lock.yaml`,不手动编辑 `Cargo.lock`。 +- 不手动修改 `pnpm-lock.yaml`,不手动编辑 `Cargo.lock`(步骤 3 用 `cargo update` 同步)。 - **应用自更新依赖签名与发布**:release workflow 需配置 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 两个 GitHub secret,否则不会生成 `.sig` 与 `latest.json`,自更新不可用;`src-tauri/tauri.conf.json` 的 `plugins.updater.pubkey` 必须是对应公钥,不能留占位符。 -- **草稿 Release 必须手动发布**:workflow 以 `releaseDraft: true` 创建草稿,updater 的 endpoint(`releases/latest/download/latest.json`)只解析到已发布的正式 Release;发布草稿后自更新与 Homebrew Cask 更新才会触发。 From bfa11fe347e14417dcaa45e7bf0ffa4b406e0f3e Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 5 Jul 2026 22:02:24 +0800 Subject: [PATCH 02/22] =?UTF-8?q?docs(skill):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=8D=87=E7=BA=A7=E4=B8=8E=20schema=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=8A=80=E8=83=BD=EF=BC=8C=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E8=AF=8D=E5=B9=B6=E5=85=A5=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .claude/skills/update-claude-code-schema/SKILL.md | 10 +--------- .claude/skills/upgrade-dependencies/SKILL.md | 10 ++-------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/.claude/skills/update-claude-code-schema/SKILL.md b/.claude/skills/update-claude-code-schema/SKILL.md index ffc26f0..b59aacb 100644 --- a/.claude/skills/update-claude-code-schema/SKILL.md +++ b/.claude/skills/update-claude-code-schema/SKILL.md @@ -1,19 +1,12 @@ --- name: update-claude-code-schema -description: Code Manager 仓库的 Claude Code settings schema 同步技能。涉及 src/schemas/claude-settings.schema.json 升级、SchemaStore 最新定义同步、Claude Code 新增 settings 字段(hooks/env/worktree/permissionRule 等)、或后端 Rust 配置校验兼容性检查时使用:以官方 SchemaStore 为唯一事实源,按"下载 → 整体替换 → 语义比对 → Rust 校验"四步走。 +description: Code Manager 仓库的 Claude Code settings schema 同步技能。同步 SchemaStore 最新定义、Claude Code 新增 settings 字段(hooks/env/worktree/permissionRule 等)、`validate_settings_document` 或 Rust 配置校验红,或复盘 schema 变化对前端编辑器/表单/类型契约的影响,凡涉及 src/schemas/claude-settings.schema.json 都使用本技能。 --- # Update Claude Code Schema 为 Code Manager 同步 Claude Code settings 的 SchemaStore 最新定义。**事实源永远是当前下载的 `https://www.schemastore.org/claude-code-settings.json`**——不要凭记忆、不要从网页片段拼补、不要只 diff 局部字段。 -## 触发场景 - -- 同步 SchemaStore 最新 settings schema。 -- Claude Code 发布新 settings 字段(hooks 子字段、permissionRule 模式、env 结构、worktree 选项等)。 -- `validate_settings_document` 测试失败或 Rust 配置校验红。 -- 复盘 schema 变化对前端编辑器、表单或类型契约的影响。 - ## 工作流速览 1. **前置检查**:工具 + 规则 + 工作区状态。 @@ -39,7 +32,6 @@ curl -fsSL https://www.schemastore.org/claude-code-settings.json \ - **为什么下载到临时文件**:留一份原始字节用于后续语义比对;本地文件可能已带格式化痕迹,直接覆盖会丢掉对照基线。 - sandbox 下网络/代理可能挡掉 curl,按权限流程提权重跑同一命令,不要绕过。 -- **不**从网页片段、旧 PR diff 或记忆里手工拼补字段——SchemaStore 是 single source of truth,手工拼补必然遗漏上游的"删除字段"。 ## 整体替换 + 单文件格式化 diff --git a/.claude/skills/upgrade-dependencies/SKILL.md b/.claude/skills/upgrade-dependencies/SKILL.md index 583e2c9..a7d5950 100644 --- a/.claude/skills/upgrade-dependencies/SKILL.md +++ b/.claude/skills/upgrade-dependencies/SKILL.md @@ -1,18 +1,12 @@ --- name: upgrade-dependencies -description: Code Manager 仓库 pnpm/Cargo 依赖升级技能。无论是依赖巡检、单包升级、Tauri 栈对齐、安全补丁修复,还是评估 Vite/TypeScript/Rust 主版本 breaking change,只要涉及 package.json、Cargo.toml、lock 文件或要跑 pnpm audit/cargo update,都使用本技能:按"安全→Tauri→前端→breaking"分批推进,每批跑对应验证。 +description: Code Manager 仓库 pnpm/Cargo 依赖升级技能。依赖巡检、单包升级、Tauri 栈对齐、安全补丁修复、评估 Vite/TypeScript/Rust 主版本 breaking change,或复盘升级失败(构建/测试挂、锁文件漂移、版本回滚),只要涉及 package.json、Cargo.toml、lock 文件或要跑 pnpm audit/cargo update,都使用本技能。 --- # Upgrade Dependencies 为 Code Manager 仓库执行依赖检查、升级规划、分批实施和复盘。**事实源永远是当前命令输出**——本文档列出的历史版本号会过时,不能当作未来升级的依据。 -## 触发场景 - -- 检查当前可升级依赖、漏洞或 `cargo update --dry-run` 结果。 -- 规划或实施升级(含 patch/minor、Tauri 栈、breaking 主版本)。 -- 复盘升级失败:构建挂、测试挂、锁文件漂移、版本回滚。 - ## 工作流速览 1. **前置检查**:工作区状态 + 工具链版本 + 命中规则。 @@ -122,7 +116,7 @@ Vite、TypeScript、pnpm、Rust 主版本依赖逐项独立评估。 下列是过往升级踩过的具体事实。**版本号会过时,再次升级前必须用"事实源"小节的命令重新查询。** 若历史与当前输出冲突,信任当前输出并就地更新或删除条目。 -- `pnpm@11.x` 在本仓库可用;安装走 `CI=true pnpm install` 或 `CI=true pnpm install --no-frozen-lockfile`。 +- `pnpm@11.x` 在本仓库可用(安装命令见"执行约束")。 - Vite 8 + `@vitejs/plugin-react` 6 + TypeScript 6 可通过本仓库门禁;TS6 下需显式 Node types,并移除已弃用的 `baseUrl`,仅保留 `paths` alias。 - `schemars 1.x` 弃用了 `RootSchema.schema.object` 访问路径;schema 契约测试改为 `serde_json::to_value(schema_for!(...))` 后读取 `properties` / `required`。 - `reqwest 0.13` 使用 `default-features = false` 时,Rustls feature 是 `rustls`,不是旧的 `rustls-tls`。 From 33cbec3fc119dfc2f52cd437d45e30f6317cdd44 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 5 Jul 2026 23:18:09 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat(tray):=20=E6=96=B0=E5=A2=9E=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E4=BC=91=E7=9C=A0=E4=B8=89=E6=80=81=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=8C=E4=BC=9A=E8=AF=9D=E8=BF=90=E8=A1=8C=E6=97=B6=E9=98=BB?= =?UTF-8?q?=E6=AD=A2=20macOS=20=E7=A9=BA=E9=97=B2=E4=BC=91=E7=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三态模式 off/whileActive/always 持久化在 AppPreferences;whileActive 依据 running 类会话数(waiting 不计入)自动 acquire/release IOKit 电源断言,仅阻止 系统空闲休眠、不提权、不处理合盖。Settings 分段控件与主托盘子菜单均只在 macOS 渲染,退出时释放断言。 Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 29 ++ ...sleep-prevention-idle-only-no-privilege.md | 14 + src-tauri/src/config.rs | 23 ++ src-tauri/src/lib.rs | 6 + src-tauri/src/sleep.rs | 262 ++++++++++++++++++ src-tauri/src/tray.rs | 90 ++++++ .../fixtures/config-registry.example.json | 3 +- src/bindings.ts | 12 + src/components/SettingsDrawer.tsx | 38 +++ src/i18n.ts | 15 + src/types.ts | 5 + 11 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-sleep-prevention-idle-only-no-privilege.md create mode 100644 src-tauri/src/sleep.rs diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..97d5a42 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,29 @@ +# Code Manager + +Code Manager 的领域术语表(glossary),只记录本项目语境下需要统一口径的词汇,不记录实现细节。 + +## Language + +### 防止休眠(Sleep Prevention) + +**防止休眠(Sleep Prevention)**: +应用阻止操作系统进入空闲休眠的能力,用于保证 Claude Code 会话在无人值守时不被系统休眠打断。 +_Avoid_: 保持唤醒(keep awake)、caffeine、防睡眠 + +**防止休眠模式(Sleep Prevention Mode)**: +一个三态互斥的用户偏好,决定防止休眠何时生效。取值:关闭(Off)、始终(Always)、仅活动时(While Active)。 +_Avoid_: 防止休眠开关(它不是布尔开关,是三态) + +**始终(Always)**: +防止休眠模式的一种取值。无条件保持电脑唤醒,与会话状态无关,直到用户切换到其它模式。 +_Avoid_: 手动模式、常开 + +**仅活动时(While Active)**: +防止休眠模式的一种取值。仅当存在[活动会话](#活动会话active-session)时保持唤醒,会话结束后自动释放。 +_Avoid_: 自动模式 + +### 活动会话(Active Session) + +**活动会话(Active Session)**: +一个处于 running 类状态(`running / busy / active / starting`)的 Claude Code 会话。**waiting(等待用户操作)不计入**——那时 Claude 卡在等人,机器休眠也不会杀死会话。"仅活动时"模式据此判断是否保持唤醒。 +_Avoid_: 运行中会话(running session,只是其中一个具体状态) diff --git a/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md b/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md new file mode 100644 index 0000000..65fe675 --- /dev/null +++ b/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md @@ -0,0 +1,14 @@ +# 防止休眠只拦截系统空闲休眠,不提权、不处理合盖 + +## Context + +用户希望在 Claude Code 会话运行时阻止 macOS 进入休眠,避免长任务被系统休眠打断。macOS 上可选手段:`caffeinate -i` / `NSProcessInfo.beginActivityWithOptions`(阻止空闲休眠,盖子开着可靠、无需权限)、`caffeinate -s`(阻止系统休眠,仅 AC 供电、合盖不可靠且积热)、`sudo pmset disablesleep`(能可靠合盖不睡,但需要 root/提权)、外接屏 clamshell(需硬件条件,系统自带)。 + +## Decision + +只做**阻止系统空闲休眠**,在 macOS 上通过进程内 `NSProcessInfo.beginActivityWithOptions(.idleSystemSleepDisabled)` 断言实现(objc2,`#[cfg(target_os = "macos")]` 门控,贴合 `led.rs` / `macos_notifications.rs` 既有原生模式);Windows / Linux 先做 no-op。**不阻止屏幕熄灭**,**不引入任何提权 / sudo / `pmset`**,因此**合盖休眠不在能力范围内**。 + +## Consequences + +- 该功能只在**盖子开着**时有效;合盖(无外接屏)仍会休眠,这是刻意的边界,不是缺陷。未来若要支持合盖,需要单独评估提权方案(密码弹窗、安全面扩大),属于另一个决策。 +- 选择进程内原生断言而非 `caffeinate` 子进程:避免唯一一处常驻辅助进程、避免会话状态抖动时反复起/杀进程,崩溃时 OS 自动回收断言。 diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d96db56..072aec9 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -126,6 +126,9 @@ pub struct AppPreferences { /// 等待提示音效,默认 Glass。 #[serde(default)] pub waiting_sound: WaitingSound, + /// 防止休眠模式:off 不干预 / whileActive 仅 running 类会话运行时 / always 无条件(默认 off,仅 macOS 生效)。 + #[serde(default)] + pub sleep_prevention: crate::sleep::SleepPreventionMode, } impl Default for AppPreferences { @@ -149,6 +152,7 @@ impl Default for AppPreferences { floating_widget_opacity: default_floating_widget_opacity(), waiting_sound_enabled: false, waiting_sound: WaitingSound::default(), + sleep_prevention: crate::sleep::SleepPreventionMode::default(), } } } @@ -386,6 +390,8 @@ pub struct AppPreferencesInput { pub waiting_sound_enabled: bool, #[serde(default)] pub waiting_sound: WaitingSound, + #[serde(default)] + pub sleep_prevention: crate::sleep::SleepPreventionMode, } #[derive(Debug, Clone, Deserialize, specta::Type)] @@ -1034,6 +1040,7 @@ fn normalize_app_preferences(input: AppPreferencesInput) -> Result Result Result { + let _lock = crate::utils::lock_config()?; + let mut registry = load_registry()?; + registry.app.sleep_prevention = mode; + save_registry(®istry)?; + Ok(registry) +} + #[tauri::command] #[specta::specta] pub fn set_app_preferences( @@ -2845,6 +2864,8 @@ pub fn set_app_preferences( crate::tray::apply_focus_session_shortcut(&app_handle); // 按最新偏好同步桌面用量浮窗的显隐(启用则创建/显示,关闭则隐藏) crate::widget::sync_widget_visibility(&app_handle, preferences.floating_widget_enabled); + // 按最新防止休眠偏好重新评估(切到 off 立即释放,切到 always/whileActive 按会话状态处理) + crate::sleep::apply_sleep_preference(&app_handle); let _ = app_handle.emit("config-workspace-changed", ()); let _ = app_handle.emit("project-launcher-settings-changed", ()); if previous_third_party_pricing != preferences.third_party_provider_pricing_enabled { @@ -2952,6 +2973,7 @@ mod tests { floating_widget_opacity: default_floating_widget_opacity(), waiting_sound_enabled: true, waiting_sound: WaitingSound::Submarine, + sleep_prevention: crate::sleep::SleepPreventionMode::default(), }; let normalized = normalize_app_preferences(input).expect("normalize 应成功"); @@ -3965,6 +3987,7 @@ mod tests { floating_widget_opacity: default_floating_widget_opacity(), waiting_sound_enabled: false, waiting_sound: WaitingSound::default(), + sleep_prevention: crate::sleep::SleepPreventionMode::default(), }, profiles: vec![ConfigProfile { id: "user-deepseek".to_string(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d06ed52..1eba24c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod native_open; mod plugins; mod project; mod skills; +mod sleep; mod sound; mod stats; mod terminal_focus; @@ -293,6 +294,8 @@ pub fn run() { usage::start_usage_runtime(app).map_err(std::io::Error::other)?; // 启动 LED 灯效运行时(独立 worker 线程驱动设备,按当前会话状态点亮一次) led::start_led_runtime(app); + // 启动防止休眠运行时(按当前偏好 + 会话状态决定是否阻止系统空闲休眠,仅 macOS 生效) + sleep::start_sleep_runtime(app); // 按当前偏好同步桌面用量浮窗显隐(启用则创建置顶小窗) widget::sync_widget_visibility( app.handle(), @@ -322,12 +325,15 @@ pub fn run() { // 真正生效。Cmd+Q 不会触发这个事件,不能只依赖这里。 tauri::RunEvent::ExitRequested { .. } => { tray::remove_trays(app_handle); + // 释放防止休眠断言,避免退出后系统仍被我们的断言挡着不休眠 + sleep::release_on_exit(app_handle); } // 所有退出路径最终都汇聚到这里,是 Cmd+Q(原生 [NSApp terminate:],AppKit // 自己的终止流程,不会触发 ExitRequested)唯一能拿到的收尾时机。对已在 // ExitRequested 移除过的托盘再次调用是无副作用的空操作,兜底覆盖 Cmd+Q。 tauri::RunEvent::Exit => { tray::remove_trays(app_handle); + sleep::release_on_exit(app_handle); #[cfg(target_os = "macos")] std::thread::sleep(std::time::Duration::from_millis(TRAY_EXIT_GRACE_MS)); } diff --git a/src-tauri/src/sleep.rs b/src-tauri/src/sleep.rs new file mode 100644 index 0000000..f79ee6d --- /dev/null +++ b/src-tauri/src/sleep.rs @@ -0,0 +1,262 @@ +//! 防止系统空闲休眠:Claude Code 会话运行时阻止 macOS 进入空闲休眠,避免长任务被系统休眠打断。 +//! +//! 边界(见 `docs/adr/0001-sleep-prevention-idle-only-no-privilege.md`):只阻止「系统空闲休眠」 +//! (`PreventUserIdleSystemSleep`),不阻止屏幕熄灭、不提权、不处理合盖,因此只在盖子开着时有效。 +//! +//! 分层: +//! - 判定层 `should_stay_awake`:纯逻辑,跨平台,有单测。 +//! - 设备层(`#[cfg(target_os = "macos")]`):IOKit 电源断言 `IOPMAssertion`,句柄是整型 assertion id, +//! 天然 `Send + Sync`,进程内持有;非 macOS 全部 no-op。 +//! - 运行时 `reconcile`:读最新偏好 + running 会话数,acquire/release 使实际持有与期望一致(幂等)。 +//! - 驱动入口 `on_session_state_changed`:托盘每次复算会话状态时调用。 + +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +/// 防止休眠模式,作为 `AppPreferences.sleep_prevention` 持久化。三态互斥。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum SleepPreventionMode { + /// 不干预,系统正常休眠(默认)。 + #[default] + Off, + /// 仅存在 running 类会话(running/busy/active/starting,waiting 不计入)时保持唤醒。 + WhileActive, + /// 无条件保持唤醒。 + Always, +} + +/// 防止休眠运行时状态:当前持有的电源断言句柄;`None` 表示未持有。 +/// 句柄是 IOKit 的整型 `IOPMAssertionID`,因此本结构天然 `Send + Sync`,可直接交 Tauri 托管。 +#[derive(Default)] +pub struct SleepState { + assertion: Mutex>, +} + +/// 判定给定模式 + running 会话数下是否应保持唤醒。纯函数,单测锚点。 +fn should_stay_awake(mode: SleepPreventionMode, running_count: usize) -> bool { + match mode { + SleepPreventionMode::Off => false, + SleepPreventionMode::Always => true, + SleepPreventionMode::WhileActive => running_count > 0, + } +} + +/// 启动防止休眠运行时:注册 `SleepState`,并按当前偏好 + 会话状态评估一次。 +/// 在 `lib.rs::setup` 中 LED runtime 之后调用一次。 +pub fn start_sleep_runtime(app: &tauri::App) { + app.manage(SleepState::default()); + reconcile(app.handle(), crate::tray::current_running_session_count()); +} + +/// 会话聚合状态变化时调用(托盘 `rebuild_sessions_tray`)。`running_count` 为 running 类会话数。 +pub fn on_session_state_changed(app: &AppHandle, running_count: usize) { + reconcile(app, running_count); +} + +/// 偏好变更时调用(`set_app_preferences` / 托盘切换)。按当前会话状态重新评估。 +pub fn apply_sleep_preference(app: &AppHandle) { + reconcile(app, crate::tray::current_running_session_count()); +} + +/// 应用退出时释放断言(`lib.rs` 的 `RunEvent::ExitRequested` / `RunEvent::Exit`)。幂等。 +pub fn release_on_exit(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let state = state.inner(); + if let Ok(mut held) = state.assertion.lock() { + if let Some(id) = held.take() { + release_assertion(id); + } + } +} + +/// 读最新偏好 + running 数,acquire/release 使实际持有与期望一致(幂等,重复调用无副作用)。 +fn reconcile(app: &AppHandle, running_count: usize) { + let Some(state) = app.try_state::() else { + return; + }; + let state = state.inner(); + let mode = crate::config::load_app_preferences().sleep_prevention; + let desired = should_stay_awake(mode, running_count); + + let Ok(mut held) = state.assertion.lock() else { + return; + }; + match (desired, held.is_some()) { + (true, false) => match acquire_assertion() { + Some(id) => { + *held = Some(id); + log::info!("event=sleep.assert status=ok mode={mode:?} running={running_count}"); + } + None => log::warn!("event=sleep.assert status=err mode={mode:?}"), + }, + (false, true) => { + if let Some(id) = held.take() { + release_assertion(id); + log::info!("event=sleep.release status=ok"); + } + } + _ => {} + } +} + +// ── 设备层:macOS 走 IOKit 电源断言,其它平台 no-op ── + +#[cfg(target_os = "macos")] +mod ffi { + use std::os::raw::{c_char, c_void}; + + pub type CFTypeRef = *const c_void; + pub type CFStringRef = *const c_void; + pub type CFAllocatorRef = *const c_void; + pub type IOPMAssertionID = u32; + pub type IOReturn = i32; + pub type IOPMAssertionLevel = u32; + + /// 断言开启电平。 + pub const IOPM_ASSERTION_LEVEL_ON: IOPMAssertionLevel = 255; + /// CFString UTF-8 编码常量。 + pub const CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + /// `kIOReturnSuccess`。 + pub const IO_RETURN_SUCCESS: IOReturn = 0; + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + pub fn CFStringCreateWithCString( + alloc: CFAllocatorRef, + c_str: *const c_char, + encoding: u32, + ) -> CFStringRef; + pub fn CFRelease(cf: CFTypeRef); + } + + #[link(name = "IOKit", kind = "framework")] + extern "C" { + pub fn IOPMAssertionCreateWithName( + assertion_type: CFStringRef, + assertion_level: IOPMAssertionLevel, + assertion_name: CFStringRef, + assertion_id: *mut IOPMAssertionID, + ) -> IOReturn; + pub fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn; + } +} + +/// 创建一个「阻止系统空闲休眠」的电源断言,返回其整型句柄。失败返回 `None`。 +#[cfg(target_os = "macos")] +fn acquire_assertion() -> Option { + use std::ffi::CString; + use std::ptr; + + let type_c = CString::new("PreventUserIdleSystemSleep").ok()?; + let name_c = CString::new("Code Manager keeping Claude Code sessions awake").ok()?; + + // SAFETY: 两个入参 CFString 由 CFStringCreateWithCString 从合法 nul 结尾字符串创建;分配器传 NULL + // 表示默认分配器(CF 约定)。创建断言成功后 IOKit 内部保留所需引用,故这里立即 CFRelease 两个 + // CFString,无泄漏。返回的整型句柄由 reconcile/release_on_exit 恰好释放一次。 + unsafe { + let type_cf = ffi::CFStringCreateWithCString( + ptr::null(), + type_c.as_ptr(), + ffi::CF_STRING_ENCODING_UTF8, + ); + let name_cf = ffi::CFStringCreateWithCString( + ptr::null(), + name_c.as_ptr(), + ffi::CF_STRING_ENCODING_UTF8, + ); + if type_cf.is_null() || name_cf.is_null() { + if !type_cf.is_null() { + ffi::CFRelease(type_cf); + } + if !name_cf.is_null() { + ffi::CFRelease(name_cf); + } + return None; + } + + let mut id: u32 = 0; + let ret = ffi::IOPMAssertionCreateWithName( + type_cf, + ffi::IOPM_ASSERTION_LEVEL_ON, + name_cf, + &mut id, + ); + ffi::CFRelease(type_cf); + ffi::CFRelease(name_cf); + + if ret == ffi::IO_RETURN_SUCCESS { + Some(id) + } else { + None + } + } +} + +/// 释放电源断言。 +#[cfg(target_os = "macos")] +fn release_assertion(id: u32) { + // SAFETY: id 来自成功的 IOPMAssertionCreateWithName;reconcile 用 take() 保证同一句柄只释放一次。 + unsafe { + let _ = ffi::IOPMAssertionRelease(id); + } +} + +/// 非 macOS 暂不支持阻止休眠:返回占位句柄让状态机正常流转(避免每轮重试),但不产生实际副作用。 +#[cfg(not(target_os = "macos"))] +fn acquire_assertion() -> Option { + log::info!("event=sleep.assert status=skip reason=unsupported_platform"); + Some(0) +} + +#[cfg(not(target_os = "macos"))] +fn release_assertion(_id: u32) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn off_never_stays_awake() { + assert!(!should_stay_awake(SleepPreventionMode::Off, 0)); + assert!(!should_stay_awake(SleepPreventionMode::Off, 3)); + } + + #[test] + fn always_always_stays_awake() { + assert!(should_stay_awake(SleepPreventionMode::Always, 0)); + assert!(should_stay_awake(SleepPreventionMode::Always, 5)); + } + + #[test] + fn while_active_tracks_running_count() { + assert!(!should_stay_awake(SleepPreventionMode::WhileActive, 0)); + assert!(should_stay_awake(SleepPreventionMode::WhileActive, 1)); + assert!(should_stay_awake(SleepPreventionMode::WhileActive, 9)); + } + + #[test] + fn default_mode_is_off() { + assert_eq!(SleepPreventionMode::default(), SleepPreventionMode::Off); + } + + #[test] + fn mode_serializes_camel_case() { + assert_eq!( + serde_json::to_string(&SleepPreventionMode::WhileActive).unwrap(), + "\"whileActive\"" + ); + assert_eq!( + serde_json::to_string(&SleepPreventionMode::Off).unwrap(), + "\"off\"" + ); + assert_eq!( + serde_json::to_string(&SleepPreventionMode::Always).unwrap(), + "\"always\"" + ); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 2f45199..e537588 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -720,6 +720,14 @@ fn build_tray_menu(app: &AppHandle, state: &ConfigRegistry) -> tauri::Result tauri::Result Option { + use crate::sleep::SleepPreventionMode as M; + match key { + "off" => Some(M::Off), + "while_active" => Some(M::WhileActive), + "always" => Some(M::Always), + _ => None, + } +} + +/// 防止休眠三态在托盘菜单中的显示标签(按语言)。 +#[cfg(target_os = "macos")] +fn sleep_mode_label(mode: crate::sleep::SleepPreventionMode, language: &str) -> &'static str { + use crate::sleep::SleepPreventionMode as M; + match (language, mode) { + ("en", M::Off) => "Off", + ("en", M::WhileActive) => "While Active", + ("en", M::Always) => "Always", + (_, M::Off) => "关闭", + (_, M::WhileActive) => "仅活动时", + (_, M::Always) => "始终", + } +} + +/// 构建「防止休眠」子菜单:父项标题带当前模式,hover 展开三选一,当前项以 `✓` 标记(沿用配置列表约定)。 +/// 仅 macOS 有实际效果,故整个子菜单只在 macOS 渲染。 +#[cfg(target_os = "macos")] +fn build_sleep_submenu( + app: &AppHandle, + state: &ConfigRegistry, +) -> tauri::Result> { + use crate::sleep::SleepPreventionMode as M; + use tauri::menu::SubmenuBuilder; + + let language = state.app.ui_language.as_str(); + let current = state.app.sleep_prevention; + let title_prefix = if language == "en" { + "Prevent Sleep" + } else { + "防止休眠" + }; + let title = format!("{title_prefix}: {}", sleep_mode_label(current, language)); + + let mut builder = SubmenuBuilder::new(app, title); + for mode in [M::Off, M::WhileActive, M::Always] { + let id = match mode { + M::Off => "sleep_off", + M::WhileActive => "sleep_while_active", + M::Always => "sleep_always", + }; + // 与配置列表一致:激活项 "✓ " 前缀,未激活项等宽留白对齐 + let marker = if mode == current { "✓ " } else { " " }; + builder = builder.text(id, format!("{marker}{}", sleep_mode_label(mode, language))); + } + builder.build() +} + fn build_sessions_tray_menu( app: &AppHandle, state: &ConfigRegistry, @@ -837,6 +903,12 @@ pub(crate) fn current_session_led_state() -> crate::led::SessionLedState { crate::led::SessionLedState::from_counts(waiting, running, other) } +/// 读取当前会话并返回 running 类(running/busy/active/starting)会话数,供防止休眠运行时启动/评估时使用。 +pub(crate) fn current_running_session_count() -> usize { + let sessions = load_tray_sessions(); + count_session_states(&sessions).1 +} + fn rebuild_sessions_tray(app_handle: &AppHandle, state: &ConfigRegistry) { let sessions = load_tray_sessions(); handle_pending_session_notifications(app_handle, state, &sessions); @@ -847,6 +919,8 @@ fn rebuild_sessions_tray(app_handle: &AppHandle, state: &ConfigRegistry) { app_handle, crate::led::SessionLedState::from_counts(waiting, running, other), ); + // 防止休眠:running 类会话数决定「仅活动时」模式是否保持唤醒(同 LED,独立于会话托盘可见性)。 + crate::sleep::on_session_state_changed(app_handle, running); let Some(tray) = app_handle.tray_by_id(SESSIONS_TRAY_ID) else { return; @@ -1248,6 +1322,21 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { // 页面导航 show_main_window(app); let _ = app.emit("navigate-to-tab", tab.to_string()); + } else if let Some(mode_key) = id.strip_prefix("sleep_") { + // 防止休眠三态快捷切换:落盘后重建菜单(刷新 ✓ 与父项标题)、按新模式重新评估断言、通知前端 + if let Some(mode) = parse_sleep_prevention_key(mode_key) { + match crate::config::set_sleep_prevention_mode(mode) { + Ok(state) => { + log::info!("event=tray.sleep_prevention status=ok mode={mode_key}"); + rebuild_tray_menu(app, Some(&state)); + crate::sleep::apply_sleep_preference(app); + let _ = app.emit("config-workspace-changed", ()); + } + Err(e) => { + crate::logging::log_command_error("tray.sleep_prevention", &e); + } + } + } } else { match id { "show_window" => { @@ -1357,6 +1446,7 @@ mod tests { floating_widget_opacity: 92, waiting_sound_enabled: false, waiting_sound: crate::config::WaitingSound::default(), + sleep_prevention: crate::sleep::SleepPreventionMode::default(), } } diff --git a/src-tauri/tests/fixtures/config-registry.example.json b/src-tauri/tests/fixtures/config-registry.example.json index 563b72d..5e3e065 100644 --- a/src-tauri/tests/fixtures/config-registry.example.json +++ b/src-tauri/tests/fixtures/config-registry.example.json @@ -28,7 +28,8 @@ ], "floatingWidgetOpacity": 92, "waitingSoundEnabled": false, - "waitingSound": "glass" + "waitingSound": "glass", + "sleepPrevention": "off" }, "profiles": [ { diff --git a/src/bindings.ts b/src/bindings.ts index 67dedc5..b8a0226 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -157,6 +157,8 @@ export type AppPreferences = { waitingSoundEnabled?: boolean, /** 等待提示音效,默认 Glass。 */ waitingSound?: WaitingSound, + /** 防止休眠模式:off 不干预 / whileActive 仅 running 类会话运行时 / always 无条件(默认 off,仅 macOS 生效)。 */ + sleepPrevention?: SleepPreventionMode, }; export type AppPreferencesInput = { @@ -178,6 +180,7 @@ export type AppPreferencesInput = { floatingWidgetOpacity?: number, waitingSoundEnabled?: boolean, waitingSound?: WaitingSound, + sleepPrevention?: SleepPreventionMode, }; export type BindingState = BindingState_Serialize | BindingState_Deserialize; @@ -1046,6 +1049,15 @@ export type SkillFileTreeEntry = { /** 支持文件树条目(SKILL.md 以外的文件和目录) */ export type SkillFileTreeEntryKind = "file" | "directory"; +/** 防止休眠模式,作为 `AppPreferences.sleep_prevention` 持久化。三态互斥。 */ +export type SleepPreventionMode = +/** 不干预,系统正常休眠(默认)。 */ +"off" | +/** 仅存在 running 类会话(running/busy/active/starting,waiting 不计入)时保持唤醒。 */ +"whileActive" | +/** 无条件保持唤醒。 */ +"always"; + export type StatusLinePresetInstallResult = { presetId: string, targetPath: string, diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 3c23126..5622805 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -64,6 +64,7 @@ import { PopoverTrigger, } from "./ui/popover"; import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; +import { SegmentedControl } from "./ui/segmented-control"; import { Select, SelectContent, @@ -144,6 +145,16 @@ const waitingSoundOptions: { { value: "tink", labelKey: "settings.waitingSoundTink" }, ]; +// 防止休眠三态选项(分段控件顺序) +const sleepPreventionOptions: { + value: NonNullable; + labelKey: TranslationKey; +}[] = [ + { value: "off", labelKey: "settings.sleepPreventionOff" }, + { value: "whileActive", labelKey: "settings.sleepPreventionWhileActive" }, + { value: "always", labelKey: "settings.sleepPreventionAlways" }, +]; + // 浮窗可选指标及其文案 key,顺序即设置面板与浮窗的默认展示顺序 const floatingWidgetMetricOptions: { value: WidgetMetric; labelKey: TranslationKey }[] = [ { value: "cost", labelKey: "widget.metric.cost" }, @@ -1317,6 +1328,33 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { /> )} + {platformName === "macos" && ( + + + + ({ + value: option.value, + label: t(option.labelKey), + }))} + onValueChange={(next) => { + void persistPreferences( + { ...nextPreferences, sleepPrevention: next }, + nextPreferences, + ); + }} + /> + {t("settings.sleepPreventionHint")} + + + + )} + Date: Sun, 5 Jul 2026 23:36:42 +0800 Subject: [PATCH 04/22] =?UTF-8?q?feat(settings):=20=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E4=BC=91=E7=9C=A0=E5=8A=A0=E5=AE=9E=E6=97=B6=E7=94=9F=E6=95=88?= =?UTF-8?q?=E5=BE=BD=E6=A0=87=E4=B8=8E=E5=88=87=E6=8D=A2=20Toast=20?= =?UTF-8?q?=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新增 get_sleep_prevention_status 命令与 sleep-prevention-changed 事件, active 翻转(会话起止/模式切换致断言 acquire/release)时广播;设置页挂载拉一次 + 订阅事件,实时展示「正在保持唤醒/空闲可休眠」徽标,切换成功补 Toast 反馈。 Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 2 + src-tauri/src/sleep.rs | 49 +++++++++++++++++++- src/bindings.ts | 10 ++++ src/components/SettingsDrawer.tsx | 77 +++++++++++++++++++++++++++++-- src/i18n.ts | 6 +++ src/ipc.ts | 1 + src/types.ts | 6 +++ 7 files changed, 145 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1eba24c..60949ec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,6 +69,7 @@ use skills::{ import_skills_from_directory, open_skill_in_editor, sync_skill_to_codex, toggle_skill, update_skill, }; +use sleep::get_sleep_prevention_status; use sound::preview_waiting_sound; use stats::{get_stats, open_claude_json_in_editor}; use tauri::Manager; @@ -170,6 +171,7 @@ fn build_specta_builder() -> tauri_specta::Builder { refresh_plugin_install_counts, led_probe_status, led_test_mode, + get_sleep_prevention_status, preview_waiting_sound, ]) .dangerously_cast_bigints_to_number() diff --git a/src-tauri/src/sleep.rs b/src-tauri/src/sleep.rs index f79ee6d..d151a84 100644 --- a/src-tauri/src/sleep.rs +++ b/src-tauri/src/sleep.rs @@ -13,7 +13,10 @@ use std::sync::Mutex; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; + +/// 防止休眠状态变化事件名:`active` 翻转(开始/停止保持唤醒)时广播,供设置页实时刷新徽标。 +const SLEEP_PREVENTION_CHANGED_EVENT: &str = "sleep-prevention-changed"; /// 防止休眠模式,作为 `AppPreferences.sleep_prevention` 持久化。三态互斥。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, specta::Type)] @@ -35,6 +38,40 @@ pub struct SleepState { assertion: Mutex>, } +/// 防止休眠对外状态快照:当前模式 + 此刻是否正持有断言(正在保持唤醒)。供设置页展示。 +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct SleepPreventionStatus { + /// 当前防止休眠模式。 + pub mode: SleepPreventionMode, + /// 此刻是否正持有电源断言(true=正在保持唤醒,false=空闲可休眠)。 + pub active: bool, +} + +/// 读取当前防止休眠状态(模式 + 是否正在保持唤醒)。设置页挂载时拉一次,之后靠事件增量刷新。 +#[tauri::command] +#[specta::specta] +pub fn get_sleep_prevention_status(app: AppHandle) -> SleepPreventionStatus { + current_status(&app) +} + +/// 组装当前状态快照:模式取自偏好,active 取自是否持有断言。 +fn current_status(app: &AppHandle) -> SleepPreventionStatus { + let mode = crate::config::load_app_preferences().sleep_prevention; + let active = app + .try_state::() + .and_then(|state| { + state + .inner() + .assertion + .lock() + .ok() + .map(|held| held.is_some()) + }) + .unwrap_or(false); + SleepPreventionStatus { mode, active } +} + /// 判定给定模式 + running 会话数下是否应保持唤醒。纯函数,单测锚点。 fn should_stay_awake(mode: SleepPreventionMode, running_count: usize) -> bool { match mode { @@ -91,6 +128,7 @@ fn reconcile(app: &AppHandle, running_count: usize) { Some(id) => { *held = Some(id); log::info!("event=sleep.assert status=ok mode={mode:?} running={running_count}"); + emit_status(app, mode, true); } None => log::warn!("event=sleep.assert status=err mode={mode:?}"), }, @@ -98,12 +136,21 @@ fn reconcile(app: &AppHandle, running_count: usize) { if let Some(id) = held.take() { release_assertion(id); log::info!("event=sleep.release status=ok"); + emit_status(app, mode, false); } } _ => {} } } +/// 广播状态变化事件,供设置页实时刷新徽标(active 翻转时调用)。 +fn emit_status(app: &AppHandle, mode: SleepPreventionMode, active: bool) { + let _ = app.emit( + SLEEP_PREVENTION_CHANGED_EVENT, + SleepPreventionStatus { mode, active }, + ); +} + // ── 设备层:macOS 走 IOKit 电源断言,其它平台 no-op ── #[cfg(target_os = "macos")] diff --git a/src/bindings.ts b/src/bindings.ts index b8a0226..a86ae7f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -119,6 +119,8 @@ export const commands = { ledProbeStatus: () => __TAURI_INVOKE("led_probe_status"), /** 测试某个灯效(设置页「测试」按钮 / 真机验证门)。立即下发,不受 enabled 影响。 */ ledTestMode: (mode: number) => typedError(__TAURI_INVOKE("led_test_mode", { mode })), + /** 读取当前防止休眠状态(模式 + 是否正在保持唤醒)。设置页挂载时拉一次,之后靠事件增量刷新。 */ + getSleepPreventionStatus: () => __TAURI_INVOKE("get_sleep_prevention_status"), /** 设置页"试听"入口:立即播放一次选中音效。 */ previewWaitingSound: (sound: WaitingSound) => typedError(__TAURI_INVOKE("preview_waiting_sound", { sound })), }; @@ -1058,6 +1060,14 @@ export type SleepPreventionMode = /** 无条件保持唤醒。 */ "always"; +/** 防止休眠对外状态快照:当前模式 + 此刻是否正持有断言(正在保持唤醒)。供设置页展示。 */ +export type SleepPreventionStatus = { + /** 当前防止休眠模式。 */ + mode: SleepPreventionMode, + /** 此刻是否正持有电源断言(true=正在保持唤醒,false=空闲可休眠)。 */ + active: boolean, +}; + export type StatusLinePresetInstallResult = { presetId: string, targetPath: string, diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 5622805..4443497 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import { showOperationError } from "@/lib/user-facing-error"; +import useTauriEvent from "../hooks/useTauriEvent"; import { useToast } from "../hooks/useToast"; import { type Language, type TranslationKey, useI18n } from "../i18n"; import { ipc } from "../ipc"; @@ -33,6 +34,7 @@ import type { NativeOpenAppOptions, NativeOpenPlatform, SessionTrayCountStyle, + SleepPreventionStatus, WidgetMetric, } from "../types"; import LogViewer from "./LogViewer"; @@ -756,6 +758,8 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { const [isSystemInfoOpen, setIsSystemInfoOpen] = useState(false); const [launchAtLogin, setLaunchAtLogin] = useState(false); const [nativeOpenOptions, setNativeOpenOptions] = useState(null); + // 防止休眠运行时状态:此刻是否正持有断言(正在保持唤醒),挂载拉一次 + 事件增量刷新 + const [sleepStatus, setSleepStatus] = useState(null); useEffect(() => { ipc @@ -799,6 +803,31 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { }; }, []); + // 挂载时拉一次防止休眠运行时状态(模式 + 是否正在保持唤醒) + useEffect(() => { + let cancelled = false; + ipc + .getSleepPreventionStatus() + .then((status) => { + if (!cancelled) { + setSleepStatus(status); + } + }) + .catch(() => { + if (!cancelled) { + setSleepStatus(null); + } + }); + return () => { + cancelled = true; + }; + }, []); + + // 会话开始/结束或模式切换导致 active 翻转时,后端广播事件,实时刷新徽标 + useTauriEvent("sleep-prevention-changed", (status) => { + setSleepStatus(status); + }); + const showTrayTitle = preferences.showTrayTitle; const trayTitleMaxChars = preferences.trayTitleMaxChars; // Slider 位置:未开启时为 0,有字数限制时取限制值,无限制时取最大值 @@ -861,16 +890,21 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { [language, preferences], ); - async function persistPreferences(next: AppPreferences, rollback: AppPreferences) { + async function persistPreferences( + next: AppPreferences, + rollback: AppPreferences, + ): Promise { setPreferences(next); try { await ipc.setAppPreferences(next); + return true; } catch (err) { setPreferences(rollback); if (rollback.uiLanguage !== language) { setLanguage(rollback.uiLanguage as Language); } showOperationError(showToast, t("toast.configSaveError"), err); + return false; } } @@ -1332,6 +1366,30 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { + + + {sleepStatus?.active + ? t("settings.sleepPreventionActive") + : t("settings.sleepPreventionIdle")} + + + ) + } > @@ -1343,10 +1401,19 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { label: t(option.labelKey), }))} onValueChange={(next) => { - void persistPreferences( - { ...nextPreferences, sleepPrevention: next }, - nextPreferences, - ); + void (async () => { + const ok = await persistPreferences( + { ...nextPreferences, sleepPrevention: next }, + nextPreferences, + ); + if (ok) { + const label = t( + sleepPreventionOptions.find((option) => option.value === next) + ?.labelKey ?? "settings.sleepPreventionOff", + ); + showToast(`${t("toast.sleepPreventionSwitched")}: ${label}`, "success"); + } + })(); }} /> {t("settings.sleepPreventionHint")} diff --git a/src/i18n.ts b/src/i18n.ts index 55ba8e5..9ac8592 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1324,6 +1324,8 @@ const translations = { "settings.sleepPreventionOff": "关闭", "settings.sleepPreventionWhileActive": "仅活动时", "settings.sleepPreventionAlways": "始终", + "settings.sleepPreventionActive": "正在保持唤醒", + "settings.sleepPreventionIdle": "空闲,可休眠", "settings.sleepPreventionHint": "仅活动时:有会话正在运行才保持唤醒。仅阻止系统空闲休眠,不影响屏幕熄灭;合盖仍会休眠。", "settings.thirdPartyProviderPricing": "第三方模型计价", @@ -1464,6 +1466,7 @@ const translations = { // 操作通知(Toast) "toast.configLoadError": "加载配置失败", "toast.configSaveError": "保存配置失败", + "toast.sleepPreventionSwitched": "已切换防止休眠", "toast.autostartQueryError": "读取自启动状态失败", "toast.autostartSaveError": "保存自启动设置失败", "toast.ledTestError": "测试 LED 灯效失败", @@ -3057,6 +3060,8 @@ const translations = { "settings.sleepPreventionOff": "Off", "settings.sleepPreventionWhileActive": "While active", "settings.sleepPreventionAlways": "Always", + "settings.sleepPreventionActive": "Keeping awake", + "settings.sleepPreventionIdle": "Idle, can sleep", "settings.sleepPreventionHint": "While active: stays awake only when a session is running. Prevents system idle sleep only — the display can still sleep, and closing the lid still sleeps.", "settings.thirdPartyProviderPricing": "Third-party model pricing", @@ -3203,6 +3208,7 @@ const translations = { // 操作通知(Toast) "toast.configLoadError": "Failed to load configs", "toast.configSaveError": "Failed to save config", + "toast.sleepPreventionSwitched": "Prevent sleep switched", "toast.autostartQueryError": "Failed to read auto-start status", "toast.autostartSaveError": "Failed to save auto-start setting", "toast.ledTestError": "Failed to test LED light", diff --git a/src/ipc.ts b/src/ipc.ts index ecff520..5580253 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -166,6 +166,7 @@ type CompatibleIpcOverrides = { reorderProfiles(ids: string[]): Promise; rescanUsage(): Promise; previewWaitingSound(sound: AppTypes.AppPreferences["waitingSound"]): Promise; + getSleepPreventionStatus(): Promise; setAppPreferences(data: AppTypes.AppPreferences): Promise; syncSharedProfileSettings( sourceId: string, diff --git a/src/types.ts b/src/types.ts index 4c2ad85..0185d60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,12 @@ export interface AppPreferences { /** 防止休眠三态:off 关闭 / whileActive 仅活动会话运行时 / always 始终。 */ export type SleepPreventionMode = "off" | "whileActive" | "always"; +/** 防止休眠运行时状态:当前模式 + 此刻是否正持有断言(正在保持唤醒)。 */ +export interface SleepPreventionStatus { + mode: SleepPreventionMode; + active: boolean; +} + export interface LocalizedText { zh: string; en: string; From 3e8c55d81805068ca6a7d56e1e93ef065b8df646 Mon Sep 17 00:00:00 2001 From: maguowei Date: Mon, 6 Jul 2026 21:43:56 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat(sleep):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E5=B1=8F=E5=B9=95=E5=B8=B8=E4=BA=AE=EF=BC=8C?= =?UTF-8?q?=E6=89=98=E7=9B=98=E4=B8=8E=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话保持唤醒时可连显示器一起不熄:正交布尔 keepDisplayAwake, 关(默认)用 PreventUserIdleSystemSleep 只挡系统、放任屏幕熄灭, 开时改用 PreventUserIdleDisplaySleep。托盘子菜单加可勾选项、 设置页加 Switch 与「含屏幕」徽标,并订阅 config-workspace-changed 让托盘改动实时刷新面板。 Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 6 +- ...sleep-prevention-idle-only-no-privilege.md | 7 + src-tauri/src/config.rs | 18 ++ src-tauri/src/sleep.rs | 180 +++++++++++++++--- src-tauri/src/tray.rs | 38 ++++ .../fixtures/config-registry.example.json | 3 +- src/bindings.ts | 3 + src/components/SettingsDrawer.tsx | 72 +++++-- src/i18n.ts | 16 +- src/types.ts | 2 + 10 files changed, 308 insertions(+), 37 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 97d5a42..389cfe3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,9 +11,13 @@ Code Manager 的领域术语表(glossary),只记录本项目语境下需要统 _Avoid_: 保持唤醒(keep awake)、caffeine、防睡眠 **防止休眠模式(Sleep Prevention Mode)**: -一个三态互斥的用户偏好,决定防止休眠何时生效。取值:关闭(Off)、始终(Always)、仅活动时(While Active)。 +一个三态互斥的用户偏好,决定防止休眠**何时**生效。取值:关闭(Off)、始终(Always)、仅活动时(While Active)。与[屏幕常亮](#屏幕常亮keep-display-awake)正交:模式管"何时",屏幕常亮管"保持什么"。 _Avoid_: 防止休眠开关(它不是布尔开关,是三态) +**屏幕常亮(Keep Display Awake)**: +一个与[防止休眠模式](#防止休眠模式sleep-prevention-mode)正交的布尔偏好,决定保持唤醒时**连显示器一起不熄**(阻止显示器空闲休眠,连带系统)还是**只挡系统空闲休眠、放任屏幕熄灭**。仅在模式非关闭时有意义;关闭时无效。默认关(只挡系统)。 +_Avoid_: 防止黑屏、屏幕常显、display sleep + **始终(Always)**: 防止休眠模式的一种取值。无条件保持电脑唤醒,与会话状态无关,直到用户切换到其它模式。 _Avoid_: 手动模式、常开 diff --git a/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md b/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md index 65fe675..9a22e78 100644 --- a/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md +++ b/docs/adr/0001-sleep-prevention-idle-only-no-privilege.md @@ -12,3 +12,10 @@ - 该功能只在**盖子开着**时有效;合盖(无外接屏)仍会休眠,这是刻意的边界,不是缺陷。未来若要支持合盖,需要单独评估提权方案(密码弹窗、安全面扩大),属于另一个决策。 - 选择进程内原生断言而非 `caffeinate` 子进程:避免唯一一处常驻辅助进程、避免会话状态抖动时反复起/杀进程,崩溃时 OS 自动回收断言。 + +## Update (2026-07-06):屏幕熄灭从"永不阻止"改为可选 + +- 实现落地时用的是 IOKit `IOPMAssertionCreateWithName`(整型句柄,天然 `Send + Sync`),而非本文 Decision 段最初写的 `NSProcessInfo`;两者语义等价,IOKit 版更便于进程内持有整型句柄。 +- 新增正交布尔偏好 `keep_display_awake`:关(默认)时断言类型为 `PreventUserIdleSystemSleep`(只挡系统、放任屏幕熄灭,即原始决策);开时改用 `PreventUserIdleDisplaySleep`(连显示器一起不熄,连带系统)。 +- **默认仍为"仅系统"**:保持屏幕常亮更耗电、有烧屏风险,且无人值守长任务不需要亮屏,故设为 opt-in。 +- **不改变合盖边界**:阻止显示器休眠同样盖不住合盖 sleep,提权/合盖仍不在范围内。 diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 072aec9..9778ad5 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -129,6 +129,9 @@ pub struct AppPreferences { /// 防止休眠模式:off 不干预 / whileActive 仅 running 类会话运行时 / always 无条件(默认 off,仅 macOS 生效)。 #[serde(default)] pub sleep_prevention: crate::sleep::SleepPreventionMode, + /// 保持唤醒时是否连显示器一起不熄(默认 false=仅系统;true 时改用 PreventUserIdleDisplaySleep)。仅 macOS 生效。 + #[serde(default)] + pub keep_display_awake: bool, } impl Default for AppPreferences { @@ -153,6 +156,7 @@ impl Default for AppPreferences { waiting_sound_enabled: false, waiting_sound: WaitingSound::default(), sleep_prevention: crate::sleep::SleepPreventionMode::default(), + keep_display_awake: false, } } } @@ -392,6 +396,8 @@ pub struct AppPreferencesInput { pub waiting_sound: WaitingSound, #[serde(default)] pub sleep_prevention: crate::sleep::SleepPreventionMode, + #[serde(default)] + pub keep_display_awake: bool, } #[derive(Debug, Clone, Deserialize, specta::Type)] @@ -1041,6 +1047,7 @@ fn normalize_app_preferences(input: AppPreferencesInput) -> Result Result { + let _lock = crate::utils::lock_config()?; + let mut registry = load_registry()?; + registry.app.keep_display_awake = !registry.app.keep_display_awake; + save_registry(®istry)?; + Ok(registry) +} + #[tauri::command] #[specta::specta] pub fn set_app_preferences( @@ -2974,6 +2990,7 @@ mod tests { waiting_sound_enabled: true, waiting_sound: WaitingSound::Submarine, sleep_prevention: crate::sleep::SleepPreventionMode::default(), + keep_display_awake: false, }; let normalized = normalize_app_preferences(input).expect("normalize 应成功"); @@ -3988,6 +4005,7 @@ mod tests { waiting_sound_enabled: false, waiting_sound: WaitingSound::default(), sleep_prevention: crate::sleep::SleepPreventionMode::default(), + keep_display_awake: false, }, profiles: vec![ConfigProfile { id: "user-deepseek".to_string(), diff --git a/src-tauri/src/sleep.rs b/src-tauri/src/sleep.rs index d151a84..a91ad1d 100644 --- a/src-tauri/src/sleep.rs +++ b/src-tauri/src/sleep.rs @@ -1,13 +1,18 @@ -//! 防止系统空闲休眠:Claude Code 会话运行时阻止 macOS 进入空闲休眠,避免长任务被系统休眠打断。 +//! 防止空闲休眠:Claude Code 会话运行时阻止 macOS 进入空闲休眠,避免长任务被系统休眠打断。 //! -//! 边界(见 `docs/adr/0001-sleep-prevention-idle-only-no-privilege.md`):只阻止「系统空闲休眠」 -//! (`PreventUserIdleSystemSleep`),不阻止屏幕熄灭、不提权、不处理合盖,因此只在盖子开着时有效。 +//! 边界(见 `docs/adr/0001-sleep-prevention-idle-only-no-privilege.md`):默认只阻止「系统空闲休眠」 +//! (`PreventUserIdleSystemSleep`,放任屏幕熄灭);用户可选「同时保持屏幕常亮」改用 +//! (`PreventUserIdleDisplaySleep`,屏幕连带系统都不熄)。均不提权、不处理合盖,只在盖子开着时有效。 +//! +//! 两个正交维度: +//! - 何时(`should_stay_awake`):由三态模式 + running 会话数决定是否该保持唤醒。 +//! - 保持什么(`desired_assertion_kind`):由 `keep_display_awake` 决定断言类型(仅系统 / 系统+屏幕)。 //! //! 分层: -//! - 判定层 `should_stay_awake`:纯逻辑,跨平台,有单测。 +//! - 判定层 `should_stay_awake` / `reconcile_action`:纯逻辑,跨平台,有单测。 //! - 设备层(`#[cfg(target_os = "macos")]`):IOKit 电源断言 `IOPMAssertion`,句柄是整型 assertion id, //! 天然 `Send + Sync`,进程内持有;非 macOS 全部 no-op。 -//! - 运行时 `reconcile`:读最新偏好 + running 会话数,acquire/release 使实际持有与期望一致(幂等)。 +//! - 运行时 `reconcile`:读最新偏好 + running 会话数,acquire/release/reacquire 使实际持有与期望一致(幂等)。 //! - 驱动入口 `on_session_state_changed`:托盘每次复算会话状态时调用。 use std::sync::Mutex; @@ -31,11 +36,40 @@ pub enum SleepPreventionMode { Always, } -/// 防止休眠运行时状态:当前持有的电源断言句柄;`None` 表示未持有。 +/// 电源断言范围:仅阻止系统空闲休眠 / 连显示器一起不熄。由 `keep_display_awake` 决定。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AssertionKind { + /// 仅阻止系统空闲休眠,放任屏幕熄灭(默认)。 + System, + /// 阻止显示器空闲休眠,连带系统(屏幕常亮)。 + Display, +} + +impl AssertionKind { + /// 对应的 IOKit 断言类型字符串。 + #[cfg(target_os = "macos")] + fn assertion_type(self) -> &'static str { + match self { + AssertionKind::System => "PreventUserIdleSystemSleep", + AssertionKind::Display => "PreventUserIdleDisplaySleep", + } + } +} + +/// 由「屏幕常亮」偏好解析目标断言范围。 +fn desired_assertion_kind(keep_display_awake: bool) -> AssertionKind { + if keep_display_awake { + AssertionKind::Display + } else { + AssertionKind::System + } +} + +/// 防止休眠运行时状态:当前持有的电源断言(句柄 + 范围);`None` 表示未持有。 /// 句柄是 IOKit 的整型 `IOPMAssertionID`,因此本结构天然 `Send + Sync`,可直接交 Tauri 托管。 #[derive(Default)] pub struct SleepState { - assertion: Mutex>, + assertion: Mutex>, } /// 防止休眠对外状态快照:当前模式 + 此刻是否正持有断言(正在保持唤醒)。供设置页展示。 @@ -81,6 +115,34 @@ fn should_stay_awake(mode: SleepPreventionMode, running_count: usize) -> bool { } } +/// reconcile 的纯决策:给定「是否该唤醒 + 当前持有范围 + 目标范围」,算出该执行的动作。 +#[derive(Debug, PartialEq, Eq)] +enum ReconcileAction { + /// 无需动作(已一致)。 + None, + /// 未持有,需按目标范围获取。 + Acquire(AssertionKind), + /// 已持有但不再需要,释放。 + Release, + /// 已持有但范围变了,释放旧的再按新范围获取。 + Reacquire(AssertionKind), +} + +/// 使实际持有与期望一致的决策函数。纯逻辑,单测锚点。 +fn reconcile_action( + awake: bool, + held: Option, + desired_kind: AssertionKind, +) -> ReconcileAction { + match (awake, held) { + (false, Some(_)) => ReconcileAction::Release, + (false, None) => ReconcileAction::None, + (true, None) => ReconcileAction::Acquire(desired_kind), + (true, Some(kind)) if kind == desired_kind => ReconcileAction::None, + (true, Some(_)) => ReconcileAction::Reacquire(desired_kind), + } +} + /// 启动防止休眠运行时:注册 `SleepState`,并按当前偏好 + 会话状态评估一次。 /// 在 `lib.rs::setup` 中 LED runtime 之后调用一次。 pub fn start_sleep_runtime(app: &tauri::App) { @@ -105,7 +167,7 @@ pub fn release_on_exit(app: &AppHandle) { }; let state = state.inner(); if let Ok(mut held) = state.assertion.lock() { - if let Some(id) = held.take() { + if let Some((id, _)) = held.take() { release_assertion(id); } } @@ -117,29 +179,52 @@ fn reconcile(app: &AppHandle, running_count: usize) { return; }; let state = state.inner(); - let mode = crate::config::load_app_preferences().sleep_prevention; - let desired = should_stay_awake(mode, running_count); + let prefs = crate::config::load_app_preferences(); + let mode = prefs.sleep_prevention; + let awake = should_stay_awake(mode, running_count); + let desired_kind = desired_assertion_kind(prefs.keep_display_awake); let Ok(mut held) = state.assertion.lock() else { return; }; - match (desired, held.is_some()) { - (true, false) => match acquire_assertion() { + let held_kind = held.as_ref().map(|&(_, kind)| kind); + match reconcile_action(awake, held_kind, desired_kind) { + ReconcileAction::None => {} + ReconcileAction::Acquire(kind) => match acquire_assertion(kind) { Some(id) => { - *held = Some(id); - log::info!("event=sleep.assert status=ok mode={mode:?} running={running_count}"); + *held = Some((id, kind)); + log::info!( + "event=sleep.assert status=ok mode={mode:?} kind={kind:?} running={running_count}" + ); emit_status(app, mode, true); } - None => log::warn!("event=sleep.assert status=err mode={mode:?}"), + None => log::warn!("event=sleep.assert status=err mode={mode:?} kind={kind:?}"), }, - (false, true) => { - if let Some(id) = held.take() { + ReconcileAction::Release => { + if let Some((id, _)) = held.take() { release_assertion(id); log::info!("event=sleep.release status=ok"); emit_status(app, mode, false); } } - _ => {} + ReconcileAction::Reacquire(kind) => { + // 范围变化(如切换屏幕常亮):释放旧断言、按新范围重取。active 仍为 true, + // 徽标范围由前端偏好驱动,无需重复发 active 事件。 + if let Some((old_id, _)) = held.take() { + release_assertion(old_id); + } + match acquire_assertion(kind) { + Some(id) => { + *held = Some((id, kind)); + log::info!("event=sleep.reacquire status=ok mode={mode:?} kind={kind:?}"); + } + None => { + // 重取失败:旧断言已释放,实际已不再保持唤醒,据实广播 active=false + log::warn!("event=sleep.reacquire status=err mode={mode:?} kind={kind:?}"); + emit_status(app, mode, false); + } + } + } } } @@ -193,13 +278,13 @@ mod ffi { } } -/// 创建一个「阻止系统空闲休眠」的电源断言,返回其整型句柄。失败返回 `None`。 +/// 按范围创建一个电源断言,返回其整型句柄。失败返回 `None`。 #[cfg(target_os = "macos")] -fn acquire_assertion() -> Option { +fn acquire_assertion(kind: AssertionKind) -> Option { use std::ffi::CString; use std::ptr; - let type_c = CString::new("PreventUserIdleSystemSleep").ok()?; + let type_c = CString::new(kind.assertion_type()).ok()?; let name_c = CString::new("Code Manager keeping Claude Code sessions awake").ok()?; // SAFETY: 两个入参 CFString 由 CFStringCreateWithCString 从合法 nul 结尾字符串创建;分配器传 NULL @@ -255,7 +340,7 @@ fn release_assertion(id: u32) { /// 非 macOS 暂不支持阻止休眠:返回占位句柄让状态机正常流转(避免每轮重试),但不产生实际副作用。 #[cfg(not(target_os = "macos"))] -fn acquire_assertion() -> Option { +fn acquire_assertion(_kind: AssertionKind) -> Option { log::info!("event=sleep.assert status=skip reason=unsupported_platform"); Some(0) } @@ -291,6 +376,57 @@ mod tests { assert_eq!(SleepPreventionMode::default(), SleepPreventionMode::Off); } + #[test] + fn desired_kind_follows_keep_display_awake() { + assert_eq!(desired_assertion_kind(false), AssertionKind::System); + assert_eq!(desired_assertion_kind(true), AssertionKind::Display); + } + + #[test] + fn reconcile_release_when_not_awake_but_held() { + assert_eq!( + reconcile_action(false, Some(AssertionKind::System), AssertionKind::System), + ReconcileAction::Release + ); + } + + #[test] + fn reconcile_noop_when_not_awake_and_unheld() { + assert_eq!( + reconcile_action(false, None, AssertionKind::Display), + ReconcileAction::None + ); + } + + #[test] + fn reconcile_acquire_when_awake_and_unheld() { + assert_eq!( + reconcile_action(true, None, AssertionKind::Display), + ReconcileAction::Acquire(AssertionKind::Display) + ); + } + + #[test] + fn reconcile_noop_when_held_kind_matches() { + assert_eq!( + reconcile_action(true, Some(AssertionKind::System), AssertionKind::System), + ReconcileAction::None + ); + } + + #[test] + fn reconcile_reacquire_when_scope_changes_while_awake() { + // 会话仍在跑但用户切换了屏幕常亮:需换断言类型 + assert_eq!( + reconcile_action(true, Some(AssertionKind::System), AssertionKind::Display), + ReconcileAction::Reacquire(AssertionKind::Display) + ); + assert_eq!( + reconcile_action(true, Some(AssertionKind::Display), AssertionKind::System), + ReconcileAction::Reacquire(AssertionKind::System) + ); + } + #[test] fn mode_serializes_camel_case() { assert_eq!( diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index e537588..082878e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -795,6 +795,27 @@ fn build_sleep_submenu( let marker = if mode == current { "✓ " } else { " " }; builder = builder.text(id, format!("{marker}{}", sleep_mode_label(mode, language))); } + + // 屏幕常亮是与模式正交的可勾选项:勾上则连显示器一起不熄。关闭模式时无意义,置灰。 + builder = builder.separator(); + let display_label = if language == "en" { + "Keep Display Awake" + } else { + "含屏幕(显示器同时常亮)" + }; + let display_marker = if state.app.keep_display_awake { + "✓ " + } else { + " " + }; + let display_item = MenuItemBuilder::with_id( + "sleep_display_toggle", + format!("{display_marker}{display_label}"), + ) + .enabled(current != M::Off) + .build(app)?; + builder = builder.item(&display_item); + builder.build() } @@ -1322,6 +1343,22 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { // 页面导航 show_main_window(app); let _ = app.emit("navigate-to-tab", tab.to_string()); + } else if id == "sleep_display_toggle" { + // 屏幕常亮勾选切换:锁内取反落盘,重建菜单刷新 ✓、重新评估断言类型、通知前端 + match crate::config::toggle_keep_display_awake() { + Ok(state) => { + log::info!( + "event=tray.keep_display_awake status=ok value={}", + state.app.keep_display_awake + ); + rebuild_tray_menu(app, Some(&state)); + crate::sleep::apply_sleep_preference(app); + let _ = app.emit("config-workspace-changed", ()); + } + Err(e) => { + crate::logging::log_command_error("tray.keep_display_awake", &e); + } + } } else if let Some(mode_key) = id.strip_prefix("sleep_") { // 防止休眠三态快捷切换:落盘后重建菜单(刷新 ✓ 与父项标题)、按新模式重新评估断言、通知前端 if let Some(mode) = parse_sleep_prevention_key(mode_key) { @@ -1447,6 +1484,7 @@ mod tests { waiting_sound_enabled: false, waiting_sound: crate::config::WaitingSound::default(), sleep_prevention: crate::sleep::SleepPreventionMode::default(), + keep_display_awake: false, } } diff --git a/src-tauri/tests/fixtures/config-registry.example.json b/src-tauri/tests/fixtures/config-registry.example.json index 5e3e065..58acb65 100644 --- a/src-tauri/tests/fixtures/config-registry.example.json +++ b/src-tauri/tests/fixtures/config-registry.example.json @@ -29,7 +29,8 @@ "floatingWidgetOpacity": 92, "waitingSoundEnabled": false, "waitingSound": "glass", - "sleepPrevention": "off" + "sleepPrevention": "off", + "keepDisplayAwake": false }, "profiles": [ { diff --git a/src/bindings.ts b/src/bindings.ts index a86ae7f..14a8032 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -161,6 +161,8 @@ export type AppPreferences = { waitingSound?: WaitingSound, /** 防止休眠模式:off 不干预 / whileActive 仅 running 类会话运行时 / always 无条件(默认 off,仅 macOS 生效)。 */ sleepPrevention?: SleepPreventionMode, + /** 保持唤醒时是否连显示器一起不熄(默认 false=仅系统;true 时改用 PreventUserIdleDisplaySleep)。仅 macOS 生效。 */ + keepDisplayAwake?: boolean, }; export type AppPreferencesInput = { @@ -183,6 +185,7 @@ export type AppPreferencesInput = { waitingSoundEnabled?: boolean, waitingSound?: WaitingSound, sleepPrevention?: SleepPreventionMode, + keepDisplayAwake?: boolean, }; export type BindingState = BindingState_Serialize | BindingState_Deserialize; diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 4443497..4058c0f 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -19,7 +19,7 @@ import { Sun, Terminal as TerminalIcon, } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { showOperationError } from "@/lib/user-facing-error"; import useTauriEvent from "../hooks/useTauriEvent"; import { useToast } from "../hooks/useToast"; @@ -28,6 +28,7 @@ import { ipc } from "../ipc"; import { cn } from "../lib/utils"; import type { AppPreferences, + ConfigWorkspace, DefaultEditorApp, DefaultTerminalApp, LedControlPreferences, @@ -761,19 +762,25 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { // 防止休眠运行时状态:此刻是否正持有断言(正在保持唤醒),挂载拉一次 + 事件增量刷新 const [sleepStatus, setSleepStatus] = useState(null); + // 应用后端工作区快照到本地偏好态,并同步 UI 语言。挂载加载与事件刷新共用,避免逻辑漂移。 + const applyWorkspace = useCallback( + (workspace: ConfigWorkspace) => { + setPreferences(workspace.app); + if (workspace.app.uiLanguage !== language) { + setLanguage(workspace.app.uiLanguage as Language); + } + }, + [language, setLanguage], + ); + useEffect(() => { ipc .getConfigWorkspace() - .then((workspace) => { - setPreferences(workspace.app); - if (workspace.app.uiLanguage !== language) { - setLanguage(workspace.app.uiLanguage as Language); - } - }) + .then(applyWorkspace) .catch((err) => { showOperationError(showToast, t("toast.configLoadError"), err); }); - }, [language, setLanguage, showToast, t]); + }, [applyWorkspace, showToast, t]); // 自启动真实状态由系统持久化(LaunchAgent / 注册表 / .desktop),打开抽屉时主动同步 useEffect(() => { @@ -828,6 +835,16 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { setSleepStatus(status); }); + // 托盘等其它入口改动偏好后广播 config-workspace-changed,重新拉取让设置面板实时同步 + useTauriEvent("config-workspace-changed", () => { + ipc + .getConfigWorkspace() + .then(applyWorkspace) + .catch(() => { + // 静默失败:偏好已由触发方落盘,仅面板未即时刷新,不打扰用户 + }); + }); + const showTrayTitle = preferences.showTrayTitle; const trayTitleMaxChars = preferences.trayTitleMaxChars; // Slider 位置:未开启时为 0,有字数限制时取限制值,无限制时取最大值 @@ -1383,9 +1400,13 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { sleepStatus?.active ? "text-foreground" : "text-muted-foreground", )} > - {sleepStatus?.active - ? t("settings.sleepPreventionActive") - : t("settings.sleepPreventionIdle")} + {t( + !sleepStatus?.active + ? "settings.sleepPreventionIdle" + : preferences.keepDisplayAwake + ? "settings.sleepPreventionActiveWithDisplay" + : "settings.sleepPreventionActive", + )} ) @@ -1418,6 +1439,35 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { /> {t("settings.sleepPreventionHint")} + + + + + {t("settings.keepDisplayAwake")} + + {t("settings.keepDisplayAwakeHint")} + + { + void (async () => { + const ok = await persistPreferences( + { ...nextPreferences, keepDisplayAwake: checked }, + nextPreferences, + ); + if (ok) { + showToast( + t(checked ? "toast.keepDisplayAwakeOn" : "toast.keepDisplayAwakeOff"), + "success", + ); + } + })(); + }} + aria-label={t("settings.keepDisplayAwake")} + /> + )} diff --git a/src/i18n.ts b/src/i18n.ts index 9ac8592..7e635e0 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1325,9 +1325,13 @@ const translations = { "settings.sleepPreventionWhileActive": "仅活动时", "settings.sleepPreventionAlways": "始终", "settings.sleepPreventionActive": "正在保持唤醒", + "settings.sleepPreventionActiveWithDisplay": "正在保持唤醒(含屏幕)", "settings.sleepPreventionIdle": "空闲,可休眠", "settings.sleepPreventionHint": - "仅活动时:有会话正在运行才保持唤醒。仅阻止系统空闲休眠,不影响屏幕熄灭;合盖仍会休眠。", + "仅活动时:有会话正在运行才保持唤醒。默认只阻止系统空闲休眠、放任屏幕熄灭;合盖仍会休眠。", + "settings.keepDisplayAwake": "同时保持屏幕常亮", + "settings.keepDisplayAwakeHint": + "开启后连显示器一起不熄,否则只挡系统、放任屏幕熄灭。更耗电、有烧屏风险,按需开启;防止休眠关闭时不可用。", "settings.thirdPartyProviderPricing": "第三方模型计价", "settings.thirdPartyProviderPricingDesc": "使用 models.dev 为 Kimi、MiMo、GLM、MiniMax、DeepSeek 系列模型估算费用,关闭后这些模型按 $0 计入。", @@ -1467,6 +1471,8 @@ const translations = { "toast.configLoadError": "加载配置失败", "toast.configSaveError": "保存配置失败", "toast.sleepPreventionSwitched": "已切换防止休眠", + "toast.keepDisplayAwakeOn": "已开启屏幕常亮", + "toast.keepDisplayAwakeOff": "已关闭屏幕常亮,屏幕可正常熄灭", "toast.autostartQueryError": "读取自启动状态失败", "toast.autostartSaveError": "保存自启动设置失败", "toast.ledTestError": "测试 LED 灯效失败", @@ -3061,9 +3067,13 @@ const translations = { "settings.sleepPreventionWhileActive": "While active", "settings.sleepPreventionAlways": "Always", "settings.sleepPreventionActive": "Keeping awake", + "settings.sleepPreventionActiveWithDisplay": "Keeping awake (incl. display)", "settings.sleepPreventionIdle": "Idle, can sleep", "settings.sleepPreventionHint": - "While active: stays awake only when a session is running. Prevents system idle sleep only — the display can still sleep, and closing the lid still sleeps.", + "While active: stays awake only when a session is running. By default prevents system idle sleep only — the display can still sleep, and closing the lid still sleeps.", + "settings.keepDisplayAwake": "Also keep the display awake", + "settings.keepDisplayAwakeHint": + "When on, the display stays on too; otherwise only the system is kept awake and the screen may sleep. Uses more power with burn-in risk — enable as needed; unavailable when Prevent Sleep is off.", "settings.thirdPartyProviderPricing": "Third-party model pricing", "settings.thirdPartyProviderPricingDesc": "Use models.dev to estimate costs for Kimi, MiMo, GLM, MiniMax, and DeepSeek models. When disabled, these models count as $0.", @@ -3209,6 +3219,8 @@ const translations = { "toast.configLoadError": "Failed to load configs", "toast.configSaveError": "Failed to save config", "toast.sleepPreventionSwitched": "Prevent sleep switched", + "toast.keepDisplayAwakeOn": "Display will stay awake", + "toast.keepDisplayAwakeOff": "Display no longer forced on; screen can sleep", "toast.autostartQueryError": "Failed to read auto-start status", "toast.autostartSaveError": "Failed to save auto-start setting", "toast.ledTestError": "Failed to test LED light", diff --git a/src/types.ts b/src/types.ts index 0185d60..3a3a41d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,8 @@ export interface AppPreferences { waitingSound: "glass" | "submarine" | "hero" | "ping" | "sosumi" | "tink"; /** 防止休眠模式(off 不干预 / whileActive 仅活动会话运行时 / always 无条件,仅 macOS 生效)。 */ sleepPrevention?: SleepPreventionMode; + /** 屏幕常亮:与模式正交,开启后连显示器一起不熄;默认关(只挡系统空闲休眠)。仅 macOS 生效。 */ + keepDisplayAwake?: boolean; } /** 防止休眠三态:off 关闭 / whileActive 仅活动会话运行时 / always 始终。 */ From b647a41363e3656acc7e57fc35e74786d189d20c Mon Sep 17 00:00:00 2001 From: maguowei Date: Mon, 6 Jul 2026 22:01:13 +0800 Subject: [PATCH 06/22] =?UTF-8?q?refactor(settings):=20=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E5=80=BC=E6=8F=90=E5=8D=87=E5=88=B0=20App?= =?UTF-8?q?=EF=BC=8C=E6=8A=BD=E5=B1=89=E6=94=B9=E4=B8=BA=20prop=20?= =?UTF-8?q?=E4=B8=8B=E5=8F=91=E7=9A=84=E7=BC=96=E8=BE=91=E8=8D=89=E7=A8=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 18 ++++-- src/components/SettingsDrawer.tsx | 59 +++---------------- .../__tests__/SettingsDrawer.test.tsx | 37 +++++------- src/i18n.ts | 6 +- 4 files changed, 43 insertions(+), 77 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f481de3..070ed7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,7 +76,7 @@ function PageLoadingFallback() { } function App() { - const { t } = useI18n(); + const { t, setLanguage } = useI18n(); const { showToast } = useToast(); const [workspace, setWorkspace] = useState(EMPTY_WORKSPACE); const [activeTab, setActiveTab] = useState("configs"); @@ -129,6 +129,15 @@ function App() { }; }, [workspace.app.collapseSidebarByDefault]); + // 后端偏好是 UI 语言的权威值:工作区刷新后同步 i18n(setLanguage 同值幂等)。 + // 首屏加载完成前不动本地缓存语言,避免 EMPTY_WORKSPACE 的 zh 兜底闪切。 + useEffect(() => { + if (!isTauri() || loading) { + return; + } + setLanguage(workspace.app.uiLanguage === "en" ? "en" : "zh"); + }, [loading, workspace.app.uiLanguage, setLanguage]); + useTauriEvent("config-workspace-changed", () => { void loadWorkspace(); }); @@ -177,10 +186,11 @@ function App() { runWithEditorExitGuard(() => activateTab(nextTab)); }); + // 抽屉内所有落盘操作都经 set_app_preferences 广播 config-workspace-changed, + // App 已订阅并即时刷新,关闭时无需再兜底重拉。 const closeSettingsDrawer = useCallback(() => { setIsSettingsOpen(false); - void loadWorkspace(); - }, [loadWorkspace]); + }, []); const handleSettingsClick = useCallback(() => { const toggleSettingsDrawer = () => { @@ -350,7 +360,7 @@ function App() { {isSettingsOpen && ( - + )} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 4058c0f..e4dfa3f 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -19,7 +19,7 @@ import { Sun, Terminal as TerminalIcon, } from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { showOperationError } from "@/lib/user-facing-error"; import useTauriEvent from "../hooks/useTauriEvent"; import { useToast } from "../hooks/useToast"; @@ -28,7 +28,6 @@ import { ipc } from "../ipc"; import { cn } from "../lib/utils"; import type { AppPreferences, - ConfigWorkspace, DefaultEditorApp, DefaultTerminalApp, LedControlPreferences, @@ -82,6 +81,8 @@ import { Switch } from "./ui/switch"; interface SettingsDrawerProps { onClose: () => void; + /** App 下发的权威偏好快照(workspace.app);托盘等外部入口改动后随 App 刷新流入 */ + preferences: AppPreferences; } interface SettingsSectionCardProps { @@ -732,29 +733,12 @@ function SystemNotificationsHelpButton() { ); } -function SettingsDrawer({ onClose }: SettingsDrawerProps) { +function SettingsDrawer({ onClose, preferences: appPreferences }: SettingsDrawerProps) { const { t, language, setLanguage } = useI18n(); const { theme, setTheme } = useTheme(); const { showToast } = useToast(); - const [preferences, setPreferences] = useState({ - showTrayTitle: true, - showTraySessions: true, - systemNotificationsEnabled: false, - collapseSidebarByDefault: false, - thirdPartyProviderPricingEnabled: true, - uiLanguage: "zh", - defaultTerminalApp: "terminal", - defaultEditorApp: null, - trayTitleMaxChars: null, - sessionTrayCountStyle: "superscriptCompact", - trayPulseWaiting: true, - focusSessionShortcut: DEFAULT_FOCUS_SESSION_SHORTCUT, - floatingWidgetEnabled: false, - floatingWidgetMetrics: ["cost", "totalTokens", "cacheHitRate"], - floatingWidgetOpacity: 92, - waitingSoundEnabled: false, - waitingSound: "glass", - }); + // 本地可编辑草稿:承担乐观更新与保存失败回滚;权威值变化时由下方 effect 覆盖 + const [preferences, setPreferences] = useState(appPreferences); const [isLogViewerOpen, setIsLogViewerOpen] = useState(false); const [isSystemInfoOpen, setIsSystemInfoOpen] = useState(false); const [launchAtLogin, setLaunchAtLogin] = useState(false); @@ -762,25 +746,10 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { // 防止休眠运行时状态:此刻是否正持有断言(正在保持唤醒),挂载拉一次 + 事件增量刷新 const [sleepStatus, setSleepStatus] = useState(null); - // 应用后端工作区快照到本地偏好态,并同步 UI 语言。挂载加载与事件刷新共用,避免逻辑漂移。 - const applyWorkspace = useCallback( - (workspace: ConfigWorkspace) => { - setPreferences(workspace.app); - if (workspace.app.uiLanguage !== language) { - setLanguage(workspace.app.uiLanguage as Language); - } - }, - [language, setLanguage], - ); - + // 权威值随 App 的工作区刷新流入(托盘等外部入口改动 → 后端广播 → App 重拉 → prop 更新),覆盖本地草稿 useEffect(() => { - ipc - .getConfigWorkspace() - .then(applyWorkspace) - .catch((err) => { - showOperationError(showToast, t("toast.configLoadError"), err); - }); - }, [applyWorkspace, showToast, t]); + setPreferences(appPreferences); + }, [appPreferences]); // 自启动真实状态由系统持久化(LaunchAgent / 注册表 / .desktop),打开抽屉时主动同步 useEffect(() => { @@ -835,16 +804,6 @@ function SettingsDrawer({ onClose }: SettingsDrawerProps) { setSleepStatus(status); }); - // 托盘等其它入口改动偏好后广播 config-workspace-changed,重新拉取让设置面板实时同步 - useTauriEvent("config-workspace-changed", () => { - ipc - .getConfigWorkspace() - .then(applyWorkspace) - .catch(() => { - // 静默失败:偏好已由触发方落盘,仅面板未即时刷新,不打扰用户 - }); - }); - const showTrayTitle = preferences.showTrayTitle; const trayTitleMaxChars = preferences.trayTitleMaxChars; // Slider 位置:未开启时为 0,有字数限制时取限制值,无限制时取最大值 diff --git a/src/components/__tests__/SettingsDrawer.test.tsx b/src/components/__tests__/SettingsDrawer.test.tsx index ca07bc4..e845911 100644 --- a/src/components/__tests__/SettingsDrawer.test.tsx +++ b/src/components/__tests__/SettingsDrawer.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "../../i18n"; -import type { ConfigWorkspace } from "../../types"; +import type { AppPreferences, ConfigWorkspace } from "../../types"; import SettingsDrawer from "../SettingsDrawer"; import { ThemeProvider } from "../theme-provider"; import { UpdaterProvider } from "../UpdaterProvider"; @@ -54,12 +54,13 @@ const WORKSPACE_FIXTURE: ConfigWorkspace = { bindings: {}, }; -function renderSettingsDrawer() { +// 权威偏好由 App 下发 prop,测试直接注入,不再依赖抽屉自行拉取工作区 +function renderSettingsDrawer(preferences: AppPreferences = WORKSPACE_FIXTURE.app) { render( - + @@ -559,16 +560,13 @@ describe("SettingsDrawer", () => { entries: [], }; } - return { - ...WORKSPACE_FIXTURE, - app: { - ...WORKSPACE_FIXTURE.app, - defaultTerminalApp: "ghostty", - defaultEditorApp: "vscode", - }, - }; + return WORKSPACE_FIXTURE; + }); + renderSettingsDrawer({ + ...WORKSPACE_FIXTURE.app, + defaultTerminalApp: "ghostty", + defaultEditorApp: "vscode", }); - renderSettingsDrawer(); const editorSelect = await screen.findByRole("combobox", { name: "默认编辑器" }); fireEvent.click(editorSelect); @@ -653,16 +651,13 @@ describe("SettingsDrawer", () => { entries: [], }; } - return { - ...WORKSPACE_FIXTURE, - app: { - ...WORKSPACE_FIXTURE.app, - defaultTerminalApp: "warp", - defaultEditorApp: "cursor", - }, - }; + return WORKSPACE_FIXTURE; + }); + renderSettingsDrawer({ + ...WORKSPACE_FIXTURE.app, + defaultTerminalApp: "warp", + defaultEditorApp: "cursor", }); - renderSettingsDrawer(); expect(await screen.findByText("未检测到可用终端。")).toBeInTheDocument(); expect(screen.getByText("未检测到可用编辑器。")).toBeInTheDocument(); diff --git a/src/i18n.ts b/src/i18n.ts index 7e635e0..f3f7571 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1468,7 +1468,6 @@ const translations = { "claudeOverview.operationError": "目录操作失败", // 操作通知(Toast) - "toast.configLoadError": "加载配置失败", "toast.configSaveError": "保存配置失败", "toast.sleepPreventionSwitched": "已切换防止休眠", "toast.keepDisplayAwakeOn": "已开启屏幕常亮", @@ -3216,7 +3215,6 @@ const translations = { "claudeOverview.operationError": "Directory operation failed", // 操作通知(Toast) - "toast.configLoadError": "Failed to load configs", "toast.configSaveError": "Failed to save config", "toast.sleepPreventionSwitched": "Prevent sleep switched", "toast.keepDisplayAwakeOn": "Display will stay awake", @@ -3517,6 +3515,10 @@ export function I18nProvider({ children }: { children: ReactNode }) { const setLanguage = useCallback((language: Language) => { setSettings((prev) => { + // 同值幂等:允许调用方无条件同步,不触发多余的持久化与重渲染 + if (prev.language === language) { + return prev; + } const next = { ...prev, language }; saveSettings(next); return next; From 35f915dbf3e0adfb7c01b1a3b6aacd751066e967 Mon Sep 17 00:00:00 2001 From: maguowei Date: Mon, 6 Jul 2026 22:43:26 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(settings):=20=E7=AA=81=E5=87=BA?= =?UTF-8?q?=E5=88=86=E6=AE=B5=E6=8E=A7=E4=BB=B6=E9=80=89=E4=B8=AD=E6=80=81?= =?UTF-8?q?=E5=B9=B6=E5=AE=88=E5=8D=AB=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=87=8D?= =?UTF-8?q?=E6=8B=89=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SegmentedControl 选中项改用主色高亮并覆盖 ghost hover,避免悬停时 被 accent 色盖住导致选中态不明显、快速切换看不到最新值。 loadWorkspace 增加请求序号守卫,并发/乱序重拉时只应用最新一次结果, 避免过期响应覆盖偏好开关的乐观更新。 Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 18 ++++++++++++++---- src/components/ui/segmented-control.tsx | 6 ++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 070ed7a..ec6b5cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,6 +95,7 @@ function App() { const editorExitGuardRef = useRef(null); const historyProjectRequestIdRef = useRef(0); const usageProjectRequestIdRef = useRef(0); + const workspaceRequestIdRef = useRef(0); const loadWorkspace = useCallback(async () => { if (!isTauri()) { @@ -103,14 +104,23 @@ function App() { return; } + // 请求序号守卫:并发/乱序重拉时只应用最新一次结果,避免过期响应覆盖乐观更新 + workspaceRequestIdRef.current += 1; + const requestId = workspaceRequestIdRef.current; try { const nextWorkspace = await ipc.getConfigWorkspace(); - setWorkspace(nextWorkspace); + if (requestId === workspaceRequestIdRef.current) { + setWorkspace(nextWorkspace); + } } catch (error) { - setWorkspace(EMPTY_WORKSPACE); - showOperationError(showToast, t("toast.configWorkspaceLoadError"), error); + if (requestId === workspaceRequestIdRef.current) { + setWorkspace(EMPTY_WORKSPACE); + showOperationError(showToast, t("toast.configWorkspaceLoadError"), error); + } } finally { - setLoading(false); + if (requestId === workspaceRequestIdRef.current) { + setLoading(false); + } } }, [showToast, t]); diff --git a/src/components/ui/segmented-control.tsx b/src/components/ui/segmented-control.tsx index 8902aa7..b541bc4 100644 --- a/src/components/ui/segmented-control.tsx +++ b/src/components/ui/segmented-control.tsx @@ -41,8 +41,10 @@ function SegmentedControl({ variant="ghost" size="xs" className={cn( - "h-auto rounded-sm px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-foreground", - selected && "bg-background text-foreground shadow-sm", + "h-auto rounded-sm px-2.5 py-1 text-xs font-medium", + selected + ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary hover:text-primary-foreground dark:hover:bg-primary" + : "text-muted-foreground hover:bg-transparent hover:text-foreground", itemClassName, )} aria-pressed={selected} From d35f9a8c33730f78e7a772cb62becb0a7ad082f0 Mon Sep 17 00:00:00 2001 From: maguowei Date: Mon, 6 Jul 2026 23:28:50 +0800 Subject: [PATCH 08/22] =?UTF-8?q?fix(projects):=20=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E4=BB=93=E5=BA=93=20URL=20=E6=94=B9=E5=8D=95=E8=A1=8C=E7=9C=81?= =?UTF-8?q?=E7=95=A5=E5=8F=B7=E9=81=BF=E5=85=8D=E7=AA=84=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E9=80=90=E5=AD=97=E7=AC=A6=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/components/ProjectDetailPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ProjectDetailPanel.tsx b/src/components/ProjectDetailPanel.tsx index 01d4957..e6596f9 100644 --- a/src/components/ProjectDetailPanel.tsx +++ b/src/components/ProjectDetailPanel.tsx @@ -969,12 +969,12 @@ function ProjectDetailPanel({ type="button" variant="ghost" size="sm" - className="projects-identity-value h-auto max-w-full min-w-0 shrink justify-start whitespace-normal rounded-md border border-transparent px-1 py-0 text-left text-sm leading-6 break-all text-foreground hover:border-border hover:bg-accent hover:text-accent-foreground focus-visible:border-primary/70 focus-visible:bg-accent focus-visible:ring-0" + className="projects-identity-value h-auto max-w-full min-w-0 shrink justify-start truncate rounded-md border border-transparent px-1 py-0 text-left text-sm leading-6 text-foreground hover:border-border hover:bg-accent hover:text-accent-foreground focus-visible:border-primary/70 focus-visible:bg-accent focus-visible:ring-0" title={repositoryUrl} aria-label={`${t("projects.copyRepositoryUrl")} ${repositoryUrl}`} onClick={() => void handleCopyRepositoryUrl(repositoryUrl)} > - {repositoryUrl} + {repositoryUrl} ) : ( From 1629c540e9f59a6140c17c7c647dcac40150f3d8 Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 8 Jul 2026 22:08:50 +0800 Subject: [PATCH 09/22] =?UTF-8?q?docs(claude):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=B8=8E=20rules=20=E9=87=8D=E5=A4=8D=E7=9A=84=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=96=B9=E5=BC=8F=E6=AE=B5=E5=B9=B6=E6=B6=88=E9=87=8D?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b760cfa..f53f16b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,14 +2,6 @@ 本文件面向在本仓库中工作的 AI Agent,例如 Claude Code、Codex 以及读取 `AGENTS.md` / `CLAUDE.md` 的同类代理。它是执行手册,不是产品介绍页;人类用户入口在 `README.md`,完整使用说明在 `docs/user-manual.md`。 -## 使用方式 - -- 每次会话先读本文件,再按“规则索引”读取命中路径的 `.claude/rules/*.md`。 -- `CLAUDE.md` 只保留会话级事实、硬约束、规则索引和验证入口,目标控制在 200 行以内。 -- 细粒度规则放在 `.claude/rules/*.md`,通过 `paths` frontmatter 触发;不要用 `@.claude/rules/...` 把大规则 import 回主文件。 -- `AGENTS.md` 是指向本文件的软链接,不单独维护。 -- 个人或机器特定指令放 `CLAUDE.local.md`,保持未提交;不要把本地偏好写入共享根文档。 - ## 项目速览 - 项目:Code Manager,基于 Tauri 2 的 Claude Code 本地配置管理桌面应用。 @@ -35,7 +27,7 @@ - 代码注释使用中文。 - 所有用户可见文本必须走 `useI18n()` 的 `t()` 函数,不要硬编码中英文字符串。 - 所有前端通知优先走 `useToast()`,不要把 `console.error` 当作用户反馈。 -- `pnpm check` 会执行 `biome check --write .` 并修改文件;只想做只读前端检查时用 `make lint-frontend`,只读格式检查用 `make fmt-check`。 +- `pnpm check` 会执行 `biome check --write .` 并修改文件,`make fmt` 同样改写,都别当只读验证;只读前端检查用 `make lint-frontend`,只读格式检查用 `make fmt-check`。 - 新增有层叠关系或浮层的样式时,使用 shadcn 语义变量和 shadcn 原子组件内置层级,不要硬编码十六进制色值或 z-index 数字。 - 前端视觉默认采用“均衡管理台”风格:克制、紧凑、可扫描,不做营销式 hero、大字号展示或装饰性卡片堆叠。 - Rust 新增文件读写、锁、时间、JSON 工具时,优先复用 `src-tauri/src/utils.rs`。 @@ -125,7 +117,7 @@ macOS 上应用数据刻意复用 `~/.config/code-manager/`,便于跨平台备 | UI 视觉 | 前端命令 + 本地应用或浏览器截图核验;无法截图时说明限制 | | 全量本地门禁 | `make verify` | -本地启动桌面应用优先用 `make dev`(底层是 `pnpm tauri dev`);`pnpm dev` 只启动 Vite。生产包优先用 `make build`。`make fmt` 与 `pnpm check` 会改写文件,避免把它们当只读验证;纯格式检查用 `make fmt-check`。 +本地启动桌面应用优先用 `make dev`(底层是 `pnpm tauri dev`);`pnpm dev` 只启动 Vite。生产包优先用 `make build`。 ## 已知陷阱 From f5d93cc137c57a74cf28933c81a467262a7300f5 Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 8 Jul 2026 22:30:36 +0800 Subject: [PATCH 10/22] =?UTF-8?q?docs(config):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=B7=B2=E5=88=A0=E9=99=A4=E7=9A=84=20official-plugin-catalog?= =?UTF-8?q?=20=E6=96=AD=E9=93=BE=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .claude/rules/config-system.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/rules/config-system.md b/.claude/rules/config-system.md index 7cb9229..c859b47 100644 --- a/.claude/rules/config-system.md +++ b/.claude/rules/config-system.md @@ -62,7 +62,7 @@ paths: ## 插件与 Marketplace -- 官方插件市场常量在 `marketplace-presets.ts`;通用 marketplace 拉取和 localStorage 缓存在 `marketplace-catalog.ts` / `useMarketplaceCatalog.ts`;官方插件兼容层在 `official-plugin-catalog.ts`。 +- 官方插件市场常量在 `marketplace-presets.ts`;通用 marketplace 拉取和 localStorage 缓存在 `marketplace-catalog.ts` / `useMarketplaceCatalog.ts`。 - `extraKnownMarketplaces` 存储层支持多种 `source` 形态;浏览市场当前只支持 `source: github`,其他来源显示 unsupported 状态,不伪造插件数据。 - `EnabledPluginsEditor.tsx` 是插件分区容器,表单模式分为“已配置”和“浏览市场”两个 Tab。 - 已配置列表只反映 `settings.enabledPlugins` 的真实条目,不要把浏览项混进配置列表。 From 008aee65991bc6720d4205573a3df1d4b4effe57 Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 8 Jul 2026 22:41:02 +0800 Subject: [PATCH 11/22] =?UTF-8?q?docs(readme):=20=E6=8B=86=E5=88=86=20Prof?= =?UTF-8?q?iles=20=E8=83=BD=E5=8A=9B=E6=A0=BC=E9=95=BF=E5=8F=A5=E4=B8=BA?= =?UTF-8?q?=E4=B8=89=E7=B0=87=E6=8F=90=E5=8D=87=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 153eb9a..820dbbc 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Code Manager does not replace Claude Code; it provides a management layer for lo | Capability | Description | | --- | --- | | `~/.claude` Overview | Browse, preview, edit, and locate the Claude Code user directory. | -| Profiles / Built-in Providers | Manage the profile layer that is ultimately written to `~/.claude/settings.json`, pick connection endpoints and model mappings from built-in (read-only) providers, with support for models, environment variables, permissions, Sandbox, hooks, plugins, status line, preview, copy, model testing, one-click apply, import an existing settings file, export (optionally with secrets, with a pre-save preview), diff comparison, and one-click sync of common options / marketplaces / plugins to other profiles. | +| Profiles / Built-in Providers | Manage the profile layer ultimately written to `~/.claude/settings.json`, picking connection endpoints and model mappings from built-in (read-only) providers. Edit models, environment variables, permissions, Sandbox, hooks, plugins, and the status line. Preview, copy, test models, apply in one click, import an existing settings file, export (optionally with secrets, with a pre-save preview), compare diffs, and sync common options / marketplaces / plugins to other profiles. | | Memory Management | Manage user-level `CLAUDE.md` and `rules/*.md`, with support for the Karpathy behavior guide preset, import, enable, disable, copy, preview, and path validation. | | Skills Management | Create, edit, delete, enable, and disable Claude Code Skills, and sync them as `~/.codex/skills/` symlinks. | | History & Sessions | Read `~/.claude/history.jsonl` and view history details by project and session. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 69cc08d..a39976d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,7 +30,7 @@ Code Manager 不替代 Claude Code,而是提供一个本机配置、会话数 | 能力 | 说明 | | --- | --- | | `~/.claude` 总览 | 浏览、预览、编辑和定位 Claude Code 用户目录。 | -| 配置 / 内置供应商 | 管理最终写入 `~/.claude/settings.json` 的配置层,从内置供应商(只读)选择连接地址与模型映射,支持模型、环境变量、权限、Sandbox、Hooks、插件、状态行、预览、复制、模型测试、一键应用、导入现有 settings、导出(可选含密钥、落盘前预览)、差异对比,以及一键把常用选项 / 市场 / 插件同步到其他配置。 | +| 配置 / 内置供应商 | 管理最终写入 `~/.claude/settings.json` 的配置层,从内置供应商(只读)选择连接地址与模型映射。可编辑模型、环境变量、权限、Sandbox、Hooks、插件与状态行。支持预览、复制、模型测试、一键应用、导入现有 settings、导出(可选含密钥、落盘前预览)、差异对比,以及一键把常用选项 / 市场 / 插件同步到其他配置。 | | 记忆管理 | 管理用户级 `CLAUDE.md` 与 `rules/*.md`,支持 Karpathy 行为指南预设、导入、启用、禁用、复制、预览和路径校验。 | | Skills 管理 | 新建、编辑、删除、启用、禁用 Claude Code Skills,并可同步为 `~/.codex/skills/` 软链接。 | | 历史与会话 | 读取 `~/.claude/history.jsonl`,按项目和会话查看历史详情。 | From d9c4b3264531058fa25dd74880ba9d78fd85cc5d Mon Sep 17 00:00:00 2001 From: maguowei Date: Wed, 8 Jul 2026 22:51:44 +0800 Subject: [PATCH 12/22] =?UTF-8?q?docs(site):=20Hero=20=E9=A6=96=E6=AE=B5?= =?UTF-8?q?=E6=8C=89=E4=B8=89=E5=A4=A7=E5=8A=9F=E8=83=BD=E5=9F=9F=E5=88=86?= =?UTF-8?q?=E7=B0=87=E5=B9=B6=E5=8E=98=E6=B8=85=E7=97=9B=E7=82=B9=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E6=8E=AA=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- site/index.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/index.html b/site/index.html index ffff72c..ee6208b 100644 --- a/site/index.html +++ b/site/index.html @@ -89,8 +89,8 @@

Take control of your Claude Code setup

- Code Manager 把配置、供应商、~/.claude 目录、记忆、Skills、历史、统计、Token 用量与费用、项目状态、系统托盘和诊断日志集中到一个 Tauri 2 桌面应用——让本地配置可见、可预览、可验证 - Code Manager brings profiles, providers, your ~/.claude directory, memory, skills, history, stats, token usage & cost, project status, the system tray and diagnostic logs into one Tauri 2 desktop app — so your local setup is visible, previewable and verifiable. + Code Manager 把配置、记忆与 Skills,历史、统计与 Token 费用,项目、~/.claude 总览与系统集成,集中到一个 Tauri 2 桌面应用——让本地配置可见、可预览、可验证 + Code Manager brings your profiles, memory and skills; history, stats and token cost; projects, the ~/.claude overview and system integration into one Tauri 2 desktop app — so your local setup is visible, previewable and verifiable.

From 643e3a55beb8bc76d811660140285ccdfc3a3819 Mon Sep 17 00:00:00 2001 From: maguowei Date: Thu, 9 Jul 2026 23:24:40 +0800 Subject: [PATCH 13/22] =?UTF-8?q?build(deps):=20=E5=8D=87=E7=BA=A7=20types?= =?UTF-8?q?cript=20=E5=88=B0=207.0.2=20=E5=8E=9F=E7=94=9F=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 6.0.3 升级到原生 Go 实现的 tsc 7.0.2;随之 pnpm 拉入各平台 @typescript/typescript-* 二进制,并将其追加进 minimumReleaseAgeExclude (同 vite@8.1.0 先例,放行 1 天龄的新发布)。make verify 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- pnpm-lock.yaml | 235 ++++++++++++++++++++++++++++++++++++++++---- pnpm-workspace.yaml | 21 ++++ 3 files changed, 240 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 17b1aeb..88eb342 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", "lefthook": "^2.1.9", - "typescript": "~6.0.3", + "typescript": "~7.0.2", "vite": "^8.1.0", "vitest": "^4.1.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92d2886..4cd34b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,7 +150,7 @@ importers: version: 2.5.1 '@commitlint/cli': specifier: ^21.1.0 - version: 21.1.0(@types/node@26.0.1)(conventional-commits-parser@6.4.0)(typescript@6.0.3) + version: 21.1.0(@types/node@26.0.1)(conventional-commits-parser@6.4.0)(typescript@7.0.2) '@commitlint/config-conventional': specifier: ^21.1.0 version: 21.1.0 @@ -188,8 +188,8 @@ importers: specifier: ^2.1.9 version: 2.1.9 typescript: - specifier: ~6.0.3 - version: 6.0.3 + specifier: ~7.0.2 + version: 7.0.2 vite: specifier: ^8.1.0 version: 8.1.0(@types/node@26.0.1)(jiti@2.7.0) @@ -1946,6 +1946,126 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@uiw/codemirror-extensions-basic-setup@4.25.10': resolution: {integrity: sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==} peerDependencies: @@ -3182,9 +3302,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true undici-types@8.3.0: @@ -3567,12 +3687,12 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@commitlint/cli@21.1.0(@types/node@26.0.1)(conventional-commits-parser@6.4.0)(typescript@6.0.3)': + '@commitlint/cli@21.1.0(@types/node@26.0.1)(conventional-commits-parser@6.4.0)(typescript@7.0.2)': dependencies: '@commitlint/config-conventional': 21.1.0 '@commitlint/format': 21.1.0 '@commitlint/lint': 21.1.0 - '@commitlint/load': 21.1.0(@types/node@26.0.1)(typescript@6.0.3) + '@commitlint/load': 21.1.0(@types/node@26.0.1)(typescript@7.0.2) '@commitlint/read': 21.1.0(conventional-commits-parser@6.4.0) '@commitlint/types': 21.1.0 tinyexec: 1.1.2 @@ -3617,14 +3737,14 @@ snapshots: '@commitlint/rules': 21.1.0 '@commitlint/types': 21.1.0 - '@commitlint/load@21.1.0(@types/node@26.0.1)(typescript@6.0.3)': + '@commitlint/load@21.1.0(@types/node@26.0.1)(typescript@7.0.2)': dependencies: '@commitlint/config-validator': 21.1.0 '@commitlint/execute-rule': 21.0.1 '@commitlint/resolve-extends': 21.1.0 '@commitlint/types': 21.1.0 - cosmiconfig: 9.0.1(typescript@6.0.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@26.0.1)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3) + cosmiconfig: 9.0.1(typescript@7.0.2) + cosmiconfig-typescript-loader: 6.3.0(@types/node@26.0.1)(cosmiconfig@9.0.1(typescript@7.0.2))(typescript@7.0.2) es-toolkit: 1.46.1 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -5143,6 +5263,66 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@uiw/codemirror-extensions-basic-setup@4.25.10(@codemirror/autocomplete@6.20.2)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.7.0)(@codemirror/view@6.43.3)': dependencies: '@codemirror/autocomplete': 6.20.2 @@ -5359,21 +5539,21 @@ snapshots: convert-source-map@2.0.0: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@26.0.1)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@26.0.1)(cosmiconfig@9.0.1(typescript@7.0.2))(typescript@7.0.2): dependencies: '@types/node': 26.0.1 - cosmiconfig: 9.0.1(typescript@6.0.3) + cosmiconfig: 9.0.1(typescript@7.0.2) jiti: 2.6.1 - typescript: 6.0.3 + typescript: 7.0.2 - cosmiconfig@9.0.1(typescript@6.0.3): + cosmiconfig@9.0.1(typescript@7.0.2): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 crelt@1.0.6: {} @@ -6624,7 +6804,28 @@ snapshots: tw-animate-css@1.4.0: {} - typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 undici-types@8.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b7cccc..999301f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,27 @@ allowBuilds: lefthook: true minimumReleaseAgeExclude: - vite@8.1.0 + - '@typescript/typescript-aix-ppc64@7.0.2' + - '@typescript/typescript-darwin-arm64@7.0.2' + - '@typescript/typescript-darwin-x64@7.0.2' + - '@typescript/typescript-freebsd-arm64@7.0.2' + - '@typescript/typescript-freebsd-x64@7.0.2' + - '@typescript/typescript-linux-arm64@7.0.2' + - '@typescript/typescript-linux-arm@7.0.2' + - '@typescript/typescript-linux-loong64@7.0.2' + - '@typescript/typescript-linux-mips64el@7.0.2' + - '@typescript/typescript-linux-ppc64@7.0.2' + - '@typescript/typescript-linux-riscv64@7.0.2' + - '@typescript/typescript-linux-s390x@7.0.2' + - '@typescript/typescript-linux-x64@7.0.2' + - '@typescript/typescript-netbsd-arm64@7.0.2' + - '@typescript/typescript-netbsd-x64@7.0.2' + - '@typescript/typescript-openbsd-arm64@7.0.2' + - '@typescript/typescript-openbsd-x64@7.0.2' + - '@typescript/typescript-sunos-x64@7.0.2' + - '@typescript/typescript-win32-arm64@7.0.2' + - '@typescript/typescript-win32-x64@7.0.2' + - typescript@7.0.2 overrides: js-yaml: 4.2.0 undici: 7.28.0 From 523b1985a2455388a17c8afbf6a41949dc4e4366 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sat, 11 Jul 2026 18:57:04 +0800 Subject: [PATCH 14/22] =?UTF-8?q?chore(docs):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E7=9A=84=20superpowers=20=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E4=B8=8E=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...6-22-claude-multi-config-launch-command.md | 757 ---------- .../2026-06-27-deep-session-inspector.md | 1225 ----------------- .../plans/2026-06-27-waiting-session-sound.md | 657 --------- ...aude-multi-config-launch-command-design.md | 125 -- ...06-27-harness-management-roadmap-design.md | 103 -- ...2026-06-27-waiting-session-sound-design.md | 143 -- 6 files changed, 3010 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-22-claude-multi-config-launch-command.md delete mode 100644 docs/superpowers/plans/2026-06-27-deep-session-inspector.md delete mode 100644 docs/superpowers/plans/2026-06-27-waiting-session-sound.md delete mode 100644 docs/superpowers/specs/2026-06-22-claude-multi-config-launch-command-design.md delete mode 100644 docs/superpowers/specs/2026-06-27-harness-management-roadmap-design.md delete mode 100644 docs/superpowers/specs/2026-06-27-waiting-session-sound-design.md diff --git a/docs/superpowers/plans/2026-06-22-claude-multi-config-launch-command.md b/docs/superpowers/plans/2026-06-22-claude-multi-config-launch-command.md deleted file mode 100644 index 546f4d3..0000000 --- a/docs/superpowers/plans/2026-06-22-claude-multi-config-launch-command.md +++ /dev/null @@ -1,757 +0,0 @@ -# Claude 多配置启动命令 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在配置卡片新增"复制启动命令"按钮,点击弹出 Dialog,提供 `claude --settings "<文件路径>"`(完整配置)与 `claude --settings '{"env":{...}}'`(仅 env)两种命令,实现 Claude 多配置并行运行。 - -**Architecture:** 后端新增 `prepare_profile_launch` command:复用 `resolve_profile_settings` 把完整配置原子写入 `~/.config/code-manager/launch/.settings.json`,并返回该路径 + 仅含 env 的紧凑 JSON;`delete_profile` 同步清理该文件。前端用纯函数 helper 把后端返回值拼成两条 POSIX shell 命令,在 shadcn Dialog 中展示、复制并附使用说明。 - -**Tech Stack:** Rust + Tauri command + tauri-specta;React 19 + TypeScript + shadcn Dialog + Tailwind v4;Vitest + Rust `#[cfg(test)]`。 - -参考设计文档:`docs/superpowers/specs/2026-06-22-claude-multi-config-launch-command-design.md` - ---- - -## 文件结构 - -- 修改 `src-tauri/src/config.rs`:新增 `ProfileLaunchPayload` 结构体、`launch_settings_dir()` / `launch_settings_path()` / `build_env_only_json()` 辅助函数、`prepare_profile_launch` command;在 `delete_profile` 中追加 launch 文件清理;新增对应单元测试。 -- 修改 `src-tauri/src/lib.rs`:在 `collect_commands![]` 注册 `prepare_profile_launch`。 -- 重新生成 `src/bindings.ts`(`make bindings`,自动生成文件,不手改)。 -- 创建 `src/components/profile-launch-utils.ts`:纯函数 `buildLaunchCommands` + 类型。 -- 创建 `src/components/__tests__/profile-launch-utils.test.ts`:helper 单元测试。 -- 修改 `src/i18n.ts`:新增中英文案 key。 -- 修改 `src/components/ProfilesPage.tsx`:新增按钮、Dialog、状态与处理函数。 - ---- - -## Task 1: 后端 `prepare_profile_launch` command 与辅助函数 - -**Files:** -- Modify: `src-tauri/src/config.rs`(结构体/辅助函数加在 `preview_profile` 附近约 2548 行之前;command 加在 `preview_profile` 之后) -- Test: `src-tauri/src/config.rs`(`#[cfg(test)] mod tests` 内,约 3100 行附近) - -- [ ] **Step 1: 写失败的单元测试** - -加到 `src-tauri/src/config.rs` 测试模块内(紧跟 `profile_settings_path_is_always_user_level` 测试之后): - -```rust - #[test] - fn prepare_profile_launch_writes_file_and_env_only_json() { - let _guard = crate::utils::lock_config().unwrap(); - let root = temp_root("prepare-launch"); - set_test_env(&root); - - // 写入一个带 env 的配置到 registry - let mut registry = ConfigRegistry::default(); - registry.profiles.push(sample_profile( - "p1", - None, - serde_json::json!({ - "model": "claude-opus-4-1", - "env": { - "ANTHROPIC_AUTH_TOKEN": "tok-123", - "ANTHROPIC_BASE_URL": "https://api.example.com", - "EMPTY_VALUE": "" - } - }), - )); - save_registry(®istry).unwrap(); - - let payload = prepare_profile_launch("p1".to_string()).unwrap(); - - // 1) 文件已落盘,内容是完整 resolve 后 settings(含 model 与 env) - let written = std::fs::read_to_string(&payload.settings_path).unwrap(); - let parsed: Value = serde_json::from_str(&written).unwrap(); - assert_eq!(parsed["model"], Value::String("claude-opus-4-1".to_string())); - assert_eq!( - parsed["env"]["ANTHROPIC_AUTH_TOKEN"], - Value::String("tok-123".to_string()) - ); - - // 2) 路径在应用数据目录的 launch 子目录下 - assert!(payload - .settings_path - .replace('\\', "/") - .contains("/code-manager/launch/p1.settings.json")); - - // 3) env_only_json 只含 env 块,丢弃空字符串值,不含 model - let inline: Value = serde_json::from_str(&payload.env_only_json).unwrap(); - assert_eq!( - inline["env"]["ANTHROPIC_AUTH_TOKEN"], - Value::String("tok-123".to_string()) - ); - assert_eq!( - inline["env"]["ANTHROPIC_BASE_URL"], - Value::String("https://api.example.com".to_string()) - ); - assert!(inline["env"].get("EMPTY_VALUE").is_none()); - assert!(inline.get("model").is_none()); - - clear_test_env(); - } - - #[test] - fn prepare_profile_launch_errors_for_missing_profile() { - let _guard = crate::utils::lock_config().unwrap(); - let root = temp_root("prepare-launch-missing"); - set_test_env(&root); - - let result = prepare_profile_launch("does-not-exist".to_string()); - assert!(result.is_err()); - - clear_test_env(); - } -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd src-tauri && cargo test prepare_profile_launch 2>&1 | tail -20` -Expected: 编译失败,提示 `cannot find function prepare_profile_launch` / `ProfileLaunchPayload`。 - -- [ ] **Step 3: 实现结构体与辅助函数** - -在 `src-tauri/src/config.rs` 中 `preview_profile` command(约 2546 行 `#[tauri::command]` 之前)插入: - -```rust -/// 多配置启动命令的返回载荷:配置文件绝对路径 + 仅含 env 的紧凑 JSON。 -#[derive(Debug, Clone, Serialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub struct ProfileLaunchPayload { - /// 写入磁盘的完整 settings 文件绝对路径,供 `claude --settings ""` 使用。 - pub settings_path: String, - /// 仅含 env 块的紧凑单行 JSON,供 `claude --settings ''` 内联使用。 - pub env_only_json: String, -} - -/// 多配置启动用的 settings 文件目录:应用数据目录下的 launch 子目录。 -fn launch_settings_dir() -> Result { - Ok(crate::utils::get_app_data_dir_strict()?.join("launch")) -} - -/// 单个配置的 launch settings 文件路径。id 来自 registry 中已存在的配置(后端生成的 UUID),无路径逃逸风险。 -fn launch_settings_path(id: &str) -> Result { - Ok(launch_settings_dir()?.join(format!("{id}.settings.json"))) -} - -/// 从完整 resolve 后 settings 中抽取仅含 env 块的紧凑 JSON, -/// 只保留非空字符串值,丢弃 $schema/model/permissions 等其它字段。 -fn build_env_only_json(resolved: &Value) -> Result { - let mut env_map = Map::new(); - if let Some(env) = resolved.get("env").and_then(Value::as_object) { - for (key, value) in env { - if let Some(text) = value.as_str() { - if !text.trim().is_empty() { - env_map.insert(key.clone(), Value::String(text.to_string())); - } - } - } - } - let mut root = Map::new(); - root.insert("env".to_string(), Value::Object(env_map)); - serde_json::to_string(&Value::Object(root)).map_err(|e| e.to_string()) -} -``` - -- [ ] **Step 4: 实现 `prepare_profile_launch` command** - -紧接 Step 3 的代码之后、`preview_profile` command 之前插入: - -```rust -#[tauri::command] -#[specta::specta] -pub fn prepare_profile_launch(id: String) -> Result { - let result = (|| { - let _lock = crate::utils::lock_config()?; - let registry = load_registry()?; - let profile = registry - .profiles - .iter() - .find(|profile| profile.id == id) - .ok_or_else(|| format!("未找到 profile '{}'", id))?; - - let resolved = resolve_profile_settings(profile)?; - let env_only_json = build_env_only_json(&resolved)?; - let target_path = launch_settings_path(&profile.id)?; - let content = serde_json::to_string_pretty(&resolved).map_err(|e| e.to_string())?; - crate::utils::ensure_dir_and_write_atomic(&target_path, &content)?; - - Ok(ProfileLaunchPayload { - settings_path: target_path.to_string_lossy().to_string(), - env_only_json, - }) - })(); - // 注意:payload 含密钥,只记录 profile_id,不记录内容 - crate::logging::log_command_result("profile.prepare_launch", &result, |_| { - format!("profile_id={id}") - }); - result -} -``` - -- [ ] **Step 5: 运行测试确认通过** - -Run: `cd src-tauri && cargo test prepare_profile_launch 2>&1 | tail -20` -Expected: `prepare_profile_launch_writes_file_and_env_only_json` 与 `prepare_profile_launch_errors_for_missing_profile` 均 PASS。 - -- [ ] **Step 6: 提交** - -```bash -git add src-tauri/src/config.rs -git commit -m "feat(config): 新增 prepare_profile_launch 生成多配置启动载荷" -``` - ---- - -## Task 2: `delete_profile` 清理 launch 文件 - -**Files:** -- Modify: `src-tauri/src/config.rs:2473`(`delete_profile` command) -- Test: `src-tauri/src/config.rs`(测试模块内) - -- [ ] **Step 1: 写失败的单元测试** - -加到测试模块内: - -```rust - #[test] - fn delete_profile_removes_launch_settings_file() { - let _guard = crate::utils::lock_config().unwrap(); - let root = temp_root("delete-launch"); - set_test_env(&root); - - let mut registry = ConfigRegistry::default(); - registry - .profiles - .push(sample_profile("p1", None, serde_json::json!({ "env": { "K": "v" } }))); - save_registry(®istry).unwrap(); - - // 先生成 launch 文件 - let payload = prepare_profile_launch("p1".to_string()).unwrap(); - assert!(std::path::Path::new(&payload.settings_path).exists()); - - // 直接调用清理辅助函数(command 需要 AppHandle,单测里只验证文件清理逻辑) - remove_launch_settings_file("p1"); - assert!(!std::path::Path::new(&payload.settings_path).exists()); - - clear_test_env(); - } -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cd src-tauri && cargo test delete_profile_removes_launch_settings_file 2>&1 | tail -20` -Expected: 编译失败,提示 `cannot find function remove_launch_settings_file`。 - -- [ ] **Step 3: 实现清理辅助函数** - -在 Task 1 的 `launch_settings_path` 函数之后插入: - -```rust -/// 删除单个配置的 launch settings 文件,best-effort(文件不存在不报错)。 -fn remove_launch_settings_file(id: &str) { - if let Ok(path) = launch_settings_path(id) { - let _ = std::fs::remove_file(path); - } -} -``` - -- [ ] **Step 4: 在 `delete_profile` 中调用清理** - -修改 `src-tauri/src/config.rs` 的 `delete_profile`(约 2473 行),在 `remove_profile_bindings(...)` 之后、`save_registry(®istry)?;` 之前插入一行: - -```rust - remove_profile_bindings(&mut registry.bindings, &id); - remove_launch_settings_file(&id); - save_registry(®istry)?; -``` - -- [ ] **Step 5: 运行测试确认通过** - -Run: `cd src-tauri && cargo test launch 2>&1 | tail -20` -Expected: 本任务测试与 Task 1 测试均 PASS。 - -- [ ] **Step 6: 提交** - -```bash -git add src-tauri/src/config.rs -git commit -m "feat(config): 删除配置时清理多配置启动文件" -``` - ---- - -## Task 3: 注册 command 并生成 bindings - -**Files:** -- Modify: `src-tauri/src/lib.rs:34`(import 列表)与 `collect_commands![]`(约 93 行 `preview_profile` 附近) -- Generated: `src/bindings.ts`(由 `make bindings` 生成,不手改) - -- [ ] **Step 1: 在 import 列表加入函数名** - -修改 `src-tauri/src/lib.rs` 顶部 config 模块 `use` 列表(约 32-35 行),把 `prepare_profile_launch` 加入(按字母序紧邻 `preview_profile` 之前): - -```rust - preview_profile, preview_profile_export, preview_profile_import, prepare_profile_launch, - reorder_profiles, -``` - -(仅需把 `prepare_profile_launch` 名字补进现有 `use crate::config::{...}` 块,保持其它项不变。) - -- [ ] **Step 2: 在 `collect_commands![]` 注册** - -在 `src-tauri/src/lib.rs` 的 `tauri_specta::collect_commands![]` 中,`preview_profile,`(约 93 行)之后插入: - -```rust - preview_profile, - prepare_profile_launch, -``` - -- [ ] **Step 3: 生成 bindings 并校验** - -Run: `make bindings && make bindings-check` -Expected: `src/bindings.ts` 更新,包含 `prepareProfileLaunch` 与 `ProfileLaunchPayload` 类型;`bindings-check` 通过(无漂移)。 - -- [ ] **Step 4: 确认 Rust 编译通过** - -Run: `make check 2>&1 | tail -15` -Expected: 编译通过,无错误。 - -- [ ] **Step 5: 提交** - -```bash -git add src-tauri/src/lib.rs src/bindings.ts -git commit -m "feat(ipc): 注册 prepare_profile_launch 并生成 bindings" -``` - ---- - -## Task 4: 前端命令拼接纯函数 helper - -**Files:** -- Create: `src/components/profile-launch-utils.ts` -- Test: `src/components/__tests__/profile-launch-utils.test.ts` - -- [ ] **Step 1: 写失败的测试** - -创建 `src/components/__tests__/profile-launch-utils.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { buildLaunchCommands } from "../profile-launch-utils"; - -describe("buildLaunchCommands", () => { - it("文件路径式:用双引号包裹路径", () => { - const { filePathCommand } = buildLaunchCommands({ - settingsPath: "/Users/dev/.config/code-manager/launch/p1.settings.json", - envOnlyJson: '{"env":{}}', - }); - expect(filePathCommand).toBe( - 'claude --settings "/Users/dev/.config/code-manager/launch/p1.settings.json"', - ); - }); - - it("文件路径含空格与特殊字符时转义", () => { - const { filePathCommand } = buildLaunchCommands({ - settingsPath: "/Users/My Name/launch/$p1.json", - envOnlyJson: "{}", - }); - expect(filePathCommand).toBe('claude --settings "/Users/My Name/launch/\\$p1.json"'); - }); - - it("内联式:用单引号包裹紧凑 JSON", () => { - const { inlineJsonCommand } = buildLaunchCommands({ - settingsPath: "/tmp/p1.json", - envOnlyJson: '{"env":{"ANTHROPIC_AUTH_TOKEN":"tok"}}', - }); - expect(inlineJsonCommand).toBe( - `claude --settings '{"env":{"ANTHROPIC_AUTH_TOKEN":"tok"}}'`, - ); - }); - - it("内联 JSON 含单引号时按 POSIX 规则转义", () => { - const { inlineJsonCommand } = buildLaunchCommands({ - settingsPath: "/tmp/p1.json", - envOnlyJson: `{"env":{"X":"a'b"}}`, - }); - expect(inlineJsonCommand).toBe(`claude --settings '{"env":{"X":"a'\\''b"}}'`); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `pnpm exec vitest run src/components/__tests__/profile-launch-utils.test.ts` -Expected: FAIL —— 找不到模块 `../profile-launch-utils`。 - -- [ ] **Step 3: 实现 helper** - -创建 `src/components/profile-launch-utils.ts`: - -```ts -/// 多配置启动命令拼接工具。仅面向 POSIX shell(bash/zsh),与现有 copy-env 的 export 约定一致。 - -/// 后端 prepare_profile_launch 返回的载荷(settingsPath + envOnlyJson)。 -export interface LaunchCommandInput { - settingsPath: string; - envOnlyJson: string; -} - -/// 拼接好的两条启动命令。 -export interface LaunchCommands { - filePathCommand: string; - inlineJsonCommand: string; -} - -// 双引号字符串内的 POSIX 转义:反斜杠、双引号、美元符、反引号。 -function quoteDouble(value: string): string { - return `"${value.replace(/(["\\$`])/g, "\\$1")}"`; -} - -// 单引号字符串内嵌单引号的 POSIX 写法:' -> '\''。 -function quoteSingle(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - -/// 由后端载荷拼出文件路径式与内联 JSON 式两条 claude 启动命令。 -export function buildLaunchCommands(input: LaunchCommandInput): LaunchCommands { - return { - filePathCommand: `claude --settings ${quoteDouble(input.settingsPath)}`, - inlineJsonCommand: `claude --settings ${quoteSingle(input.envOnlyJson)}`, - }; -} -``` - -- [ ] **Step 4: 运行测试确认通过** - -Run: `pnpm exec vitest run src/components/__tests__/profile-launch-utils.test.ts` -Expected: 4 个用例全部 PASS。 - -- [ ] **Step 5: 提交** - -```bash -git add src/components/profile-launch-utils.ts src/components/__tests__/profile-launch-utils.test.ts -git commit -m "feat(profiles): 新增多配置启动命令拼接 helper" -``` - ---- - -## Task 5: i18n 文案 - -**Files:** -- Modify: `src/i18n.ts`(中文块约 265 行 / 327 行附近,英文块约 1830 行 / 1895 行附近) - -- [ ] **Step 1: 新增中文 key** - -在 `src/i18n.ts` 中文块 `"profiles.actions.copyEnv": "复制环境变量",`(约 265 行)之后插入: - -```ts - "profiles.actions.copyLaunchCommand": "复制启动命令", - "profiles.launchDialog.title": "复制 Claude 启动命令", - "profiles.launchDialog.description": - "用 claude --settings 启动一个独立配置,不改动全局 ~/.claude/settings.json,可在不同终端并行运行多套配置。", - "profiles.launchDialog.filePathLabel": "配置文件路径式(推荐)", - "profiles.launchDialog.filePathHint": "命令简洁、不会把密钥写入 shell 历史,且保留完整配置(model、权限、插件等)。", - "profiles.launchDialog.inlineJsonLabel": "内联 JSON 式", - "profiles.launchDialog.inlineJsonHint": "自包含、不落额外文件,仅含 env;注意密钥会进入 shell 历史。", - "profiles.launchDialog.copy": "复制", - "profiles.launchDialog.usageTitle": "如何使用", - "profiles.launchDialog.usageStep1": "打开一个新终端窗口或标签页。", - "profiles.launchDialog.usageStep2": "粘贴上面任意一条命令并回车。", - "profiles.launchDialog.usageStep3": "该终端即以此配置运行 Claude,与其它终端互不干扰。", - "profiles.launchDialog.loadError": "生成启动命令失败,请重试。", -``` - -并在中文块 `"profiles.toast.envCopyError": "复制环境变量失败",`(约 328 行)之后插入: - -```ts - "profiles.toast.launchCommandCopied": "启动命令已复制", - "profiles.toast.launchCommandError": "复制启动命令失败", -``` - -- [ ] **Step 2: 新增英文 key** - -在 `src/i18n.ts` 英文块 `"profiles.actions.copyEnv": "Copy env vars",`(约 1830 行)之后插入: - -```ts - "profiles.actions.copyLaunchCommand": "Copy launch command", - "profiles.launchDialog.title": "Copy Claude launch command", - "profiles.launchDialog.description": - "Launch a standalone config with claude --settings without touching the global ~/.claude/settings.json, so you can run multiple configs in parallel across terminals.", - "profiles.launchDialog.filePathLabel": "Settings file path (recommended)", - "profiles.launchDialog.filePathHint": "Clean command, keeps secrets out of shell history, and preserves the full config (model, permissions, plugins).", - "profiles.launchDialog.inlineJsonLabel": "Inline JSON", - "profiles.launchDialog.inlineJsonHint": "Self-contained with no extra file, env only; note the secrets land in shell history.", - "profiles.launchDialog.copy": "Copy", - "profiles.launchDialog.usageTitle": "How to use", - "profiles.launchDialog.usageStep1": "Open a new terminal window or tab.", - "profiles.launchDialog.usageStep2": "Paste either command above and press Enter.", - "profiles.launchDialog.usageStep3": "That terminal runs Claude with this config, isolated from others.", - "profiles.launchDialog.loadError": "Failed to generate the launch command. Please retry.", -``` - -并在英文块 `"profiles.toast.envCopyError": "Failed to copy env vars",`(约 1896 行)之后插入: - -```ts - "profiles.toast.launchCommandCopied": "Launch command copied", - "profiles.toast.launchCommandError": "Failed to copy launch command", -``` - -- [ ] **Step 3: 运行 i18n 一致性测试** - -Run: `pnpm exec vitest run src/i18n.test.tsx` -Expected: PASS(中英 key 集合一致)。 - -- [ ] **Step 4: 提交** - -```bash -git add src/i18n.ts -git commit -m "feat(i18n): 新增多配置启动命令文案" -``` - ---- - -## Task 6: 配置卡片按钮与 Dialog - -**Files:** -- Modify: `src/components/ProfilesPage.tsx`(import、state、effect、处理函数、按钮、Dialog) - -- [ ] **Step 1: 补充 import** - -在 `src/components/ProfilesPage.tsx` 的 lucide 图标 import 块(约 8-13 行,`Variable` 附近)加入 `SquareTerminal`,保持字母序: - -```ts - SquareTerminal, - Variable, -``` - -在文件顶部的本地模块 import 区(`import { TYPOGRAPHY } from "./typography-classes";` 之前或之后均可)新增: - -```ts -import { buildLaunchCommands, type LaunchCommands } from "./profile-launch-utils"; -``` - -- [ ] **Step 2: 新增 state** - -在组件内其它 `useState` 附近(约 270 行 export 相关 state 之后)插入: - -```ts - const [launchProfile, setLaunchProfile] = useState(null); - const [launchCommands, setLaunchCommands] = useState(null); - const [isLaunchLoading, setIsLaunchLoading] = useState(false); - const [launchLoadError, setLaunchLoadError] = useState(false); -``` - -- [ ] **Step 3: 新增准备 effect 与处理函数** - -在 `handleCopyEnv`(约 887 行)之后插入: - -```ts - async function handleCopyLaunchCommand(command: string) { - try { - await navigator.clipboard.writeText(command); - showToast(t("profiles.toast.launchCommandCopied")); - } catch (err) { - showOperationError(showToast, t("profiles.toast.launchCommandError"), err); - } - } -``` - -在已有的 import preview effect(约 870-885 行)之后插入: - -```ts - useEffect(() => { - if (!launchProfile) return; - let cancelled = false; - setIsLaunchLoading(true); - setLaunchLoadError(false); - setLaunchCommands(null); - ipc - .prepareProfileLaunch(launchProfile.id) - .then((payload) => { - if (cancelled) return; - setLaunchCommands( - buildLaunchCommands({ - settingsPath: payload.settingsPath, - envOnlyJson: payload.envOnlyJson, - }), - ); - }) - .catch((err) => { - if (cancelled) return; - setLaunchLoadError(true); - showOperationError(showToast, t("profiles.toast.launchCommandError"), err); - }) - .finally(() => { - if (!cancelled) setIsLaunchLoading(false); - }); - return () => { - cancelled = true; - }; - }, [launchProfile]); -``` - -- [ ] **Step 4: 新增卡片按钮** - -在卡片操作组中"复制环境变量"按钮(约 1707-1720 行,图标 `` 的 ``)之后插入新按钮: - -```tsx - -``` - -- [ ] **Step 5: 新增 Dialog** - -在文件中现有 Dialog 渲染区附近(例如导出 Dialog 之后)插入。Dialog 内两条命令用数组渲染,避免重复: - -```tsx - { - if (!open) { - setLaunchProfile(null); - setLaunchCommands(null); - setLaunchLoadError(false); - } - }} - > - - - {t("profiles.launchDialog.title")} - {t("profiles.launchDialog.description")} - - {isLaunchLoading ? ( -
-
- ) : launchLoadError ? ( -

- {t("profiles.launchDialog.loadError")} -

- ) : launchCommands ? ( -
- {[ - { - key: "filePath", - label: t("profiles.launchDialog.filePathLabel"), - hint: t("profiles.launchDialog.filePathHint"), - command: launchCommands.filePathCommand, - }, - { - key: "inlineJson", - label: t("profiles.launchDialog.inlineJsonLabel"), - hint: t("profiles.launchDialog.inlineJsonHint"), - command: launchCommands.inlineJsonCommand, - }, - ].map((item) => ( -
-
- {item.label} - -
-
-                    {item.command}
-                  
- - {item.hint} - -
- ))} -
- - {t("profiles.launchDialog.usageTitle")} - -
    -
  1. {t("profiles.launchDialog.usageStep1")}
  2. -
  3. {t("profiles.launchDialog.usageStep2")}
  4. -
  5. {t("profiles.launchDialog.usageStep3")}
  6. -
-
-
- ) : null} -
-
-``` - -注意:`TYPOGRAPHY.auxiliary` / `TYPOGRAPHY.badge` 若不存在,改用文件中已使用的同级常量(运行 Step 6 lint 时若报错按报错项替换为已存在的 `TYPOGRAPHY` 字段)。 - -- [ ] **Step 6: 运行前端只读检查与构建** - -Run: `make lint-frontend && make build-frontend` -Expected: lint 与类型检查通过,构建成功。若 `TYPOGRAPHY.*` 字段名报错,替换为 `typography-classes.ts` 中实际存在的字段后重跑。 - -- [ ] **Step 7: 提交** - -```bash -git add src/components/ProfilesPage.tsx -git commit -m "feat(profiles): 配置卡片新增复制 Claude 启动命令 Dialog" -``` - ---- - -## Task 7: 覆盖语义验证与全量门禁 - -**Files:** 无(验证任务) - -- [ ] **Step 1: 范围内自动化验证** - -Run: `make bindings-check && make test-rust && pnpm exec vitest run src/components/__tests__/profile-launch-utils.test.ts src/i18n.test.tsx` -Expected: 全部 PASS。 - -- [ ] **Step 2: 手动验证多配置并行(覆盖语义,对应设计第 6 节)** - -前置:本机已安装 `claude` CLI,且存在两个指向不同 provider 的配置(如 Anthropic 与 DeepSeek)。 - -操作: -1. `make dev` 启动应用,在配置卡片点击新按钮,分别复制两个配置的"文件路径式"命令。 -2. 开两个终端,各粘贴一条并运行。 -3. 在各自会话内确认使用的是对应 provider/model(可在 Claude 内查看当前模型,或在命令前临时加 `claude --settings "" --print "report your model id"` 比对)。 -4. 确认全局 `~/.claude/settings.json` 未被改动(运行前后 `git`/时间戳/内容不变)。 - -Expected:两个终端各自使用对应配置,互不干扰,全局 settings 不变。 - -若发现 `--settings` 的 env 无法覆盖全局 settings(即两个终端表现一致 / 走了全局 provider):在 `profiles.launchDialog.description` 文案中补充该限制说明(中英各一句,提示"该命令在全局已绑定配置之上叠加,冲突字段以全局为准"),重跑 `pnpm exec vitest run src/i18n.test.tsx` 后提交。 - -- [ ] **Step 3: 全量本地门禁** - -Run: `make verify` -Expected: 通过。 - -- [ ] **Step 4: 最终提交(如 Step 2 触发文案修订)** - -```bash -git add -A -git commit -m "docs(i18n): 补充多配置启动命令覆盖语义说明" -``` - ---- - -## Self-Review 记录 - -- **Spec 覆盖**:设计第 3 节(按钮+Dialog+两形式+使用说明)→ Task 5/6;第 4 节(POSIX 命令拼接)→ Task 4;第 5 节(后端 command + 文件生命周期 + 删除清理)→ Task 1/2/3;第 6 节(覆盖语义实测)→ Task 7 Step 2;第 7 节 i18n → Task 5;第 8 节测试 → Task 1/2/4 + Task 7。验收标准 1-6 均有对应任务。 -- **占位符**:无 TBD / TODO;每个代码步骤含完整代码。 -- **类型一致**:Rust `ProfileLaunchPayload { settings_path, env_only_json }` ↔ 生成 bindings camelCase `{ settingsPath, envOnlyJson }` ↔ 前端 `buildLaunchCommands` 入参 `LaunchCommandInput`;命令字段 `filePathCommand` / `inlineJsonCommand` 在 helper、测试、Dialog 三处一致。`prepare_profile_launch` 函数名在 Rust 定义、lib.rs 注册、前端 `ipc.prepareProfileLaunch` 三处一致。 -- **ipc.ts/types.ts**:`prepare_profile_launch(id: String) -> ProfileLaunchPayload` 的生成签名与业务调用直接兼容,无需在 `src/ipc.ts` 的 `CompatibleIpcOverrides` 增加窄包装,也无需改 `src/types.ts`(类型随 bindings 生成)。 diff --git a/docs/superpowers/plans/2026-06-27-deep-session-inspector.md b/docs/superpowers/plans/2026-06-27-deep-session-inspector.md deleted file mode 100644 index 843af67..0000000 --- a/docs/superpowers/plans/2026-06-27-deep-session-inspector.md +++ /dev/null @@ -1,1225 +0,0 @@ -# 深度会话检查(合入历史)Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 把现有「历史」会话详情升级为深度会话检查器,surface transcript 里被丢弃的 harness 信号(hook 事件、subagent 侧链、mode 切换),并复用现有 usage 命令补上会话级成本/Token KPI。 - -**Architecture:** 后端 `history.rs::get_session_detail` 解析器扩展,新增三类 transcript-only 信号到 `MessageBlock` 枚举与 `SessionDetail`;成本/KPI 不在 history.rs 重复抽取,前端复用已有 `get_session_usage_detail` 命令。前端新增事件渲染器文件,给 `SessionDetailDrawer` 加 KPI 头部与新块渲染,并打通 Usage/Projects → 历史会话 的跳转。 - -**Tech Stack:** Rust + Tauri command + specta(IPC)、serde_json(transcript 解析)、React 19 + TypeScript、@tanstack/react-virtual、react-markdown、shadcn/ui、Vitest、cargo test。 - -## Global Constraints - -- 代码注释使用中文,技术术语保留英文。 -- 所有用户可见文本必须走 `useI18n()` 的 `t()`,不硬编码中英文。 -- 前端通知走 `useToast()` / `showOperationError`,不把 `console.error` 当用户反馈。 -- 业务前端只经 `src/ipc.ts` 的 `ipc` 调用,不直接 `invoke`;`src/bindings.ts` 是 `make bindings` 生成物,不手改。 -- Rust 公共边界返回 `Result`,可恢复错误不用 `unwrap()/expect()`;文件/JSON/路径优先复用 `src-tauri/src/utils.rs`。 -- 会话详情解析逻辑留在后端,不复制到前端(history-stats-usage rule)。 -- 浮层/折叠用 shadcn 语义变量与原子组件,不硬编码 z-index / 十六进制色值。 -- transcript 数据源是 `~/.claude/projects//.jsonl`;成本数据源是 SQLite usage DB(经 `get_session_usage_detail`)。两者不混用、不重复实现成本逻辑。 -- 解析必须沿用现有 `session_file_path` 路径校验,防 `../` 穿出 `projects`。 -- 新增 `SessionDetail` / `MessageBlock` 字段后,必须 `make bindings` 重新生成并同步 `src/types.ts`。 - -## 真实 transcript 字段(已抽样核实,解析代码依据) - -- **hook 记录**:`{ "type": <字符串>, "hookCount": int, "hookInfos": [{"command": str, "durationMs": int}], "hookErrors": [..], "preventedContinuation": bool, "stopReason": str, "toolUseID": str }`。 -- **mode 记录**:`{ "type": "mode", "mode": <如 "plan"/"default">, "sessionId": str }`。 -- **sidechain 消息**:`{ "isSidechain": true, "agentId": str, "slug": str, "message": {...}, "timestamp": str, "type": "user"|"assistant" }`,按 `agentId` 分组,`slug` 为 subagent 标识;与主线 tool_use 无可靠 inline 链接,故按会话级分组呈现。 -- **assistant usage**:`message.usage.{input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens,cache_creation.ephemeral_5m/1h_input_tokens}` —— 已由 `usage.rs` 落入 SQLite,本计划不重复抽取。 - ---- - -### Task 1: 后端解析 hook 记录为 `MessageBlock::Hook` - -**Files:** -- Modify: `src-tauri/src/history.rs:106-137`(`MessageBlock` 枚举) -- Modify: `src-tauri/src/history.rs:399-411`(`get_session_detail` 主循环类型过滤处) -- Test: `src-tauri/src/history.rs`(`#[cfg(test)] mod tests`,文件末尾) - -**Interfaces:** -- Produces: `MessageBlock::Hook { hooks: Vec, errors: Vec, prevented_continuation: bool, stop_reason: Option }`,其中 `pub struct HookCall { pub command: String, pub duration_ms: Option }`。serde 序列化为 `{ type: "hook", hooks: [{command, duration_ms}], errors: string[], prevented_continuation: bool, stop_reason: string | null }`。 -- Produces: `fn parse_hook_record(record: &serde_json::Value) -> Option`(模块私有)。 - -- [ ] **Step 1: 写失败测试** - -在 `mod tests` 末尾(`parse_text_with_tags_drops_pure_noise_tags` 之后)追加: - -```rust - // ─── hook 记录解析 ─── - - #[test] - fn parse_hook_record_extracts_hooks_errors_and_flags() { - let value: serde_json::Value = serde_json::from_str( - r#"{ - "type":"system", - "hookCount":2, - "hookInfos":[ - {"command":"lefthook pre-commit","durationMs":120}, - {"command":"format","durationMs":null} - ], - "hookErrors":["gitleaks failed"], - "preventedContinuation":true, - "stopReason":"hook blocked" - }"#, - ) - .unwrap(); - - let block = parse_hook_record(&value).expect("含 hookInfos 应解析为 Hook block"); - match block { - MessageBlock::Hook { - hooks, - errors, - prevented_continuation, - stop_reason, - } => { - assert_eq!(hooks.len(), 2); - assert_eq!(hooks[0].command, "lefthook pre-commit"); - assert_eq!(hooks[0].duration_ms, Some(120)); - assert_eq!(hooks[1].duration_ms, None); - assert_eq!(errors, vec!["gitleaks failed".to_string()]); - assert!(prevented_continuation); - assert_eq!(stop_reason.as_deref(), Some("hook blocked")); - } - other => panic!("应为 Hook: {:?}", serde_json::to_string(&other).ok()), - } - } - - #[test] - fn parse_hook_record_returns_none_without_hook_infos() { - let value: serde_json::Value = - serde_json::from_str(r#"{"type":"user","message":{"role":"user"}}"#).unwrap(); - assert!(parse_hook_record(&value).is_none(), "无 hookInfos 不应产出 Hook"); - } -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cargo test -p code-manager --lib history::tests::parse_hook_record_extracts_hooks_errors_and_flags` -Expected: 编译失败 `cannot find ... MessageBlock::Hook` / `parse_hook_record not found`。 - -- [ ] **Step 3: 写最小实现** - -在 `MessageBlock` 枚举(`src-tauri/src/history.rs:136` `Plan` 变体之后、`}` 之前)追加变体,并在枚举上方新增 `HookCall` 结构: - -```rust - /// hook 触发记录(hookInfos / hookErrors / preventedContinuation / stopReason) - #[serde(rename = "hook")] - Hook { - hooks: Vec, - errors: Vec, - prevented_continuation: bool, - stop_reason: Option, - }, -``` - -在 `MessageBlock` 枚举定义之前(`/// 对话消息内容块` 注释上方)新增: - -```rust -/// 单个 hook 调用:命令与耗时(耗时可能缺省) -#[derive(Debug, Serialize, specta::Type)] -pub struct HookCall { - pub command: String, - pub duration_ms: Option, -} -``` - -在 `extract_plan_path_from_record`(`src-tauri/src/history.rs:558` 上方)附近新增解析函数: - -```rust -/// 把含 hookInfos 的记录解析为 Hook block;无 hookInfos 返回 None -fn parse_hook_record(record: &serde_json::Value) -> Option { - let infos = record.get("hookInfos")?.as_array()?; - let hooks = infos - .iter() - .filter_map(|i| { - let command = i.get("command").and_then(|c| c.as_str())?.to_string(); - let duration_ms = i.get("durationMs").and_then(|d| d.as_u64()); - Some(HookCall { - command, - duration_ms, - }) - }) - .collect(); - let errors = record - .get("hookErrors") - .and_then(|e| e.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - let prevented_continuation = record - .get("preventedContinuation") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let stop_reason = record - .get("stopReason") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - Some(MessageBlock::Hook { - hooks, - errors, - prevented_continuation, - stop_reason, - }) -} -``` - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cargo test -p code-manager --lib history::tests::parse_hook_record` -Expected: 2 个测试 PASS。 - -- [ ] **Step 5: 把 hook 记录接入主循环** - -在 `get_session_detail` 主循环里,`只处理 user 和 assistant 类型` 过滤之前(`src-tauri/src/history.rs:398` `let msg_type = ...` 之后、`if msg_type != "user" ...` 之前)插入: - -```rust - // hook 记录独立成 message,按时间线插入(不属于 user/assistant 对话体) - if let Some(hook_block) = parse_hook_record(&record) { - let timestamp = record - .get("timestamp") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()); - messages.push(SessionMessage { - role: "system".to_string(), - blocks: vec![hook_block], - timestamp, - }); - continue; - } -``` - -- [ ] **Step 6: 写主循环集成测试** - -在 `mod tests` 追加: - -```rust - #[test] - fn get_session_detail_surfaces_hook_records() { - let env = TestEnv::new("session-hook"); - let content = "{\"type\":\"system\",\"hookInfos\":[{\"command\":\"fmt\",\"durationMs\":5}],\ - \"hookErrors\":[],\"preventedContinuation\":false}\n\ - {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"hi\"}}\n"; - env.write_session("/p", "s1", content); - - let detail = get_session_detail("/p", "s1").expect("解析应成功"); - - assert_eq!(detail.messages.len(), 2, "hook + user 共两条"); - assert!(matches!( - detail.messages[0].blocks[0], - MessageBlock::Hook { .. } - )); - } -``` - -- [ ] **Step 7: 运行测试确认通过** - -Run: `cargo test -p code-manager --lib history::` -Expected: 全部 PASS(含原有测试不回归)。 - -- [ ] **Step 8: 提交** - -```bash -git add src-tauri/src/history.rs -git commit -m "feat(history): 解析 hook 记录为 Hook 消息块" -``` - ---- - -### Task 2: 后端解析 `type:mode` 为 `MessageBlock::ModeChange` - -**Files:** -- Modify: `src-tauri/src/history.rs`(`MessageBlock` 枚举 + 主循环) -- Test: `src-tauri/src/history.rs`(`mod tests`) - -**Interfaces:** -- Produces: `MessageBlock::ModeChange { mode: String }` → serde `{ type: "mode_change", mode: string }`。 - -- [ ] **Step 1: 写失败测试** - -```rust - #[test] - fn get_session_detail_surfaces_mode_changes() { - let env = TestEnv::new("session-mode"); - let content = "{\"type\":\"mode\",\"mode\":\"plan\",\"sessionId\":\"s1\"}\n\ - {\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"go\"}}\n"; - env.write_session("/p", "s1", content); - - let detail = get_session_detail("/p", "s1").expect("解析应成功"); - - assert_eq!(detail.messages.len(), 2); - match &detail.messages[0].blocks[0] { - MessageBlock::ModeChange { mode } => assert_eq!(mode, "plan"), - other => panic!("应为 ModeChange: {:?}", serde_json::to_string(other).ok()), - } - } -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cargo test -p code-manager --lib history::tests::get_session_detail_surfaces_mode_changes` -Expected: 编译失败 `MessageBlock::ModeChange not found`。 - -- [ ] **Step 3: 写实现** - -在 `MessageBlock` 枚举追加变体(紧跟 Task 1 的 `Hook` 之后): - -```rust - /// 模式切换(plan / default 等) - #[serde(rename = "mode_change")] - ModeChange { mode: String }, -``` - -在 `get_session_detail` 主循环、Task 1 插入的 hook 块之后,插入 mode 处理: - -```rust - // mode 切换记录独立成 message - if msg_type == "mode" { - if let Some(mode) = record.get("mode").and_then(|m| m.as_str()) { - let timestamp = record - .get("timestamp") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()); - messages.push(SessionMessage { - role: "system".to_string(), - blocks: vec![MessageBlock::ModeChange { - mode: mode.to_string(), - }], - timestamp, - }); - } - continue; - } -``` - -> 注:`msg_type` 已在该处之前定义。确保 mode 处理放在 `if msg_type != "user" && msg_type != "assistant" { continue; }` 之前。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cargo test -p code-manager --lib history::` -Expected: 全部 PASS。 - -- [ ] **Step 5: 提交** - -```bash -git add src-tauri/src/history.rs -git commit -m "feat(history): 解析 mode 切换为 ModeChange 消息块" -``` - ---- - -### Task 3: 后端按 agentId 收集 subagent 侧链 - -**Files:** -- Modify: `src-tauri/src/history.rs`(`SessionDetail` 结构 + 主循环 sidechain 分支 + 返回) -- Test: `src-tauri/src/history.rs`(`mod tests`) - -**Interfaces:** -- Produces: `SessionDetail.subagents: Vec`,其中 - `pub struct SubagentChain { pub agent_id: String, pub slug: Option, pub messages: Vec }`。 -- 侧链消息仍**不进入主 `messages`**,改为按 `agentId` 聚合进 `subagents`。 - -- [ ] **Step 1: 写失败测试** - -```rust - #[test] - fn get_session_detail_groups_sidechains_by_agent_id() { - let env = TestEnv::new("session-subagent"); - let content = "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"main\"}}\n\ - {\"type\":\"user\",\"isSidechain\":true,\"agentId\":\"a1\",\"slug\":\"explore\",\ - \"message\":{\"role\":\"user\",\"content\":\"sub task\"}}\n\ - {\"type\":\"assistant\",\"isSidechain\":true,\"agentId\":\"a1\",\"slug\":\"explore\",\ - \"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"sub answer\"}]}}\n"; - env.write_session("/p", "s1", content); - - let detail = get_session_detail("/p", "s1").expect("解析应成功"); - - assert_eq!(detail.messages.len(), 1, "主线只剩 1 条 user"); - assert_eq!(detail.subagents.len(), 1, "侧链按 agentId 聚合为 1 个 chain"); - assert_eq!(detail.subagents[0].agent_id, "a1"); - assert_eq!(detail.subagents[0].slug.as_deref(), Some("explore")); - assert_eq!(detail.subagents[0].messages.len(), 2, "子时间线 2 条消息"); - } -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cargo test -p code-manager --lib history::tests::get_session_detail_groups_sidechains_by_agent_id` -Expected: 编译失败 `no field subagents` / `SubagentChain not found`。 - -- [ ] **Step 3: 写实现** - -在 `SessionMessage` 结构之后新增结构: - -```rust -/// 一个 subagent 侧链(按 agentId 聚合的子时间线) -#[derive(Debug, Serialize, specta::Type)] -pub struct SubagentChain { - pub agent_id: String, - pub slug: Option, - pub messages: Vec, -} -``` - -在 `SessionDetail` 结构追加字段(`plan_file_path` 之后): - -```rust - /// 按 agentId 聚合的 subagent 侧链子时间线 - pub subagents: Vec, -``` - -在 `get_session_detail` 函数体顶部、`messages` 声明附近,新增有序聚合容器: - -```rust - // 侧链按 agentId 聚合(保持首次出现顺序) - let mut subagent_order: Vec = Vec::new(); - let mut subagent_map: std::collections::HashMap = - std::collections::HashMap::new(); -``` - -把原有「跳过 sidechain 消息」分支(`src-tauri/src/history.rs:404-411`)整段替换为: - -```rust - // sidechain 消息按 agentId 聚合进 subagents,不进入主线 - if record - .get("isSidechain") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - let agent_id = record - .get("agentId") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let slug = record - .get("slug") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let message = match record.get("message") { - Some(m) => m, - None => continue, - }; - let role = message - .get("role") - .and_then(|r| r.as_str()) - .unwrap_or("user") - .to_string(); - let content_val = message - .get("content") - .cloned() - .unwrap_or(serde_json::Value::Null); - let blocks = parse_content_blocks(&content_val); - if blocks.is_empty() { - continue; - } - let timestamp = record - .get("timestamp") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()); - let chain = subagent_map.entry(agent_id.clone()).or_insert_with(|| { - subagent_order.push(agent_id.clone()); - SubagentChain { - agent_id: agent_id.clone(), - slug: slug.clone(), - messages: Vec::new(), - } - }); - if chain.slug.is_none() { - chain.slug = slug; - } - chain.messages.push(SessionMessage { - role, - blocks, - timestamp, - }); - continue; - } -``` - -在函数返回处(`src-tauri/src/history.rs:524` `Ok(SessionDetail {`)按 `subagent_order` 组装并加入返回值: - -```rust - let subagents: Vec = subagent_order - .into_iter() - .filter_map(|id| subagent_map.remove(&id)) - .collect(); - - Ok(SessionDetail { - session_id: session_id.to_string(), - project: project.to_string(), - messages, - plan_file_path, - subagents, - }) -``` - -同步更新「文件不存在」早返回分支(`src-tauri/src/history.rs:362-367`)补 `subagents: Vec::new(),`。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `cargo test -p code-manager --lib history::` -Expected: 全部 PASS(原 `get_session_detail_skips_sidechain_messages` 仍通过——该测试只断言主线为空,侧链进 subagents 不破坏它)。 - -- [ ] **Step 5: 提交** - -```bash -git add src-tauri/src/history.rs -git commit -m "feat(history): 按 agentId 聚合 subagent 侧链子时间线" -``` - ---- - -### Task 4: 重新生成 bindings 并同步前端类型 - -**Files:** -- Generate: `src/bindings.ts`(`make bindings` 产物,不手改) -- Modify: `src/types.ts:558-582`(`MessageBlock` / `SessionDetail` 手维护类型) -- Verify: `make bindings-check` - -**Interfaces:** -- Consumes: Task 1-3 的 Rust 结构。 -- Produces: 前端可用的 `MessageBlock`(含 hook / mode_change 变体)、`SessionDetail.subagents`、`SubagentChain`、`HookCall` 类型。 - -- [ ] **Step 1: 生成 bindings** - -Run: `make bindings` -Expected: `src/bindings.ts` 更新,新增 `Hook` / `ModeChange` 变体、`SubagentChain`、`HookCall` 类型,`SessionDetail` 含 `subagents`。 - -- [ ] **Step 2: 同步手维护的 `src/types.ts`** - -把 `src/types.ts:558` 的 `MessageBlock` 联合追加两个变体(与 Rust serde 对齐): - -```typescript - | { type: "plan"; summary: string; content: string } - | { - type: "hook"; - hooks: { command: string; duration_ms: number | null }[]; - errors: string[]; - prevented_continuation: boolean; - stop_reason: string | null; - } - | { type: "mode_change"; mode: string }; -``` - -在 `SessionMessage` 接口之后新增: - -```typescript -// 一个 subagent 侧链子时间线 -export interface SubagentChain { - agent_id: string; - slug: string | null; - messages: SessionMessage[]; -} -``` - -`SessionDetail` 接口追加字段: - -```typescript - plan_file_path: string | null; - // 按 agentId 聚合的 subagent 侧链 - subagents: SubagentChain[]; -} -``` - -- [ ] **Step 3: 校验契约与编译** - -Run: `make bindings-check && make build-frontend` -Expected: bindings-check 无 diff(生成物已提交),前端 TS 编译通过。 - -- [ ] **Step 4: 提交** - -```bash -git add src/bindings.ts src/types.ts -git commit -m "chore(bindings): 同步 hook/mode_change/subagent 会话详情类型" -``` - ---- - -### Task 5: 前端渲染 Hook 与 ModeChange 事件块 - -**Files:** -- Create: `src/components/SessionEventBlocks.tsx` -- Modify: `src/components/SessionDetailDrawer.tsx:609-660`(`MessageBlocks` 的 block switch 接入新渲染器) -- Modify: `src/i18n.ts`(新增文案 key) -- Test: `src/components/__tests__/SessionEventBlocks.test.tsx` - -**Interfaces:** -- Consumes: `MessageBlock`(`type: "hook" | "mode_change"`)。 -- Produces: `export function HookBlock(props: { block: Extract; t: (k: TranslationKey) => string })`、`export function ModeChangeBlock(props: { block: Extract; t: (k: TranslationKey) => string })`。 - -- [ ] **Step 1: 写失败测试** - -```tsx -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import type { TranslationKey } from "../../i18n"; -import { HookBlock, ModeChangeBlock } from "../SessionEventBlocks"; - -const t = (k: TranslationKey) => k as string; - -describe("SessionEventBlocks", () => { - it("HookBlock 展示命令并高亮错误", () => { - render( - , - ); - expect(screen.getByText(/lefthook pre-commit/)).toBeInTheDocument(); - expect(screen.getByText(/gitleaks failed/)).toBeInTheDocument(); - }); - - it("ModeChangeBlock 展示模式名", () => { - render(); - expect(screen.getByText(/plan/)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `pnpm exec vitest run src/components/__tests__/SessionEventBlocks.test.tsx` -Expected: FAIL(`Cannot find module '../SessionEventBlocks'`)。 - -- [ ] **Step 3: 写实现** - -新增 `src/components/SessionEventBlocks.tsx`: - -```tsx -import { AlertTriangle, GitBranch, Webhook } from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { TranslationKey } from "../i18n"; -import type { MessageBlock } from "../types"; -import { Badge } from "./ui/badge"; - -/** 渲染 hook 触发块:命令列表 + 错误高亮 + 拦截标记 */ -export function HookBlock({ - block, - t, -}: { - block: Extract; - t: (k: TranslationKey) => string; -}) { - const hasError = block.errors.length > 0 || block.prevented_continuation; - return ( -
-
- - {t("history.hookEvent")} - {block.prevented_continuation && ( - - {t("history.hookPrevented")} - - )} -
-
    - {block.hooks.map((h, i) => ( -
  • - {h.command} - {h.duration_ms != null && ( - {h.duration_ms} ms - )} -
  • - ))} -
- {block.errors.map((e, i) => ( -
- - {e} -
- ))} -
- ); -} - -/** 渲染模式切换块 */ -export function ModeChangeBlock({ - block, - t, -}: { - block: Extract; - t: (k: TranslationKey) => string; -}) { - return ( -
- - - {t("history.modeChange")}: {block.mode} - -
- ); -} -``` - -- [ ] **Step 4: 加 i18n key** - -在 `src/i18n.ts` 的 `zh` 与 `en` 文案表中各加(沿用现有 `history.*` 命名): - -```ts -// zh -"history.hookEvent": "Hook 触发", -"history.hookPrevented": "已拦截继续", -"history.modeChange": "模式切换", -// en -"history.hookEvent": "Hook fired", -"history.hookPrevented": "Continuation blocked", -"history.modeChange": "Mode change", -``` - -- [ ] **Step 5: 接入 `MessageBlocks` switch** - -在 `src/components/SessionDetailDrawer.tsx` 顶部 import: - -```tsx -import { HookBlock, ModeChangeBlock } from "./SessionEventBlocks"; -``` - -在 `MessageBlocks`(`src-tauri` 无关,`SessionDetailDrawer.tsx:609` 起)的 block 渲染 switch 中,为新 `type` 增加分支(与现有 `case "plan"` 等并列): - -```tsx - if (block.type === "hook") { - return ; - } - if (block.type === "mode_change") { - return ; - } -``` - -> 实现时按该文件现有 block 分发写法(`block.type === ...` 或 `switch`)对齐;`blockKey` 沿用现有循环里的 key 变量名。 - -- [ ] **Step 6: 运行测试确认通过** - -Run: `pnpm exec vitest run src/components/__tests__/SessionEventBlocks.test.tsx && make build-frontend` -Expected: 测试 PASS,前端编译通过。 - -- [ ] **Step 7: 提交** - -```bash -git add src/components/SessionEventBlocks.tsx src/components/__tests__/SessionEventBlocks.test.tsx src/components/SessionDetailDrawer.tsx src/i18n.ts -git commit -m "feat(history): 渲染 hook 与 mode 切换事件块" -``` - ---- - -### Task 6: 前端会话 KPI 头部(复用 get_session_usage_detail) - -**Files:** -- Create: `src/components/SessionKpiBar.tsx` -- Modify: `src/components/SessionDetailDrawer.tsx:843-852`(详情加载处并发拉取 usage detail)、`:910-1026`(SheetHeader 区渲染 KPI) -- Test: `src/components/__tests__/SessionKpiBar.test.tsx` - -**Interfaces:** -- Consumes: `AppTypes.SessionUsageDetail`(经 `ipc.getSessionUsageDetail(sessionId)`)、`SessionDetail`(用于统计 hook 错误数)。 -- Produces: `export function SessionKpiBar(props: { usage: SessionUsageDetail | null; hookErrorCount: number; t: (k: TranslationKey) => string })`。 - -- [ ] **Step 1: 写失败测试** - -```tsx -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import type { TranslationKey } from "../../i18n"; -import type { SessionUsageDetail } from "../../types"; -import { SessionKpiBar } from "../SessionKpiBar"; - -const t = (k: TranslationKey) => k as string; - -const usage = { - session: { - session_id: "s1", - project_path: "/p", - project_dir: "p", - started_at_ms: 1_000, - last_active_ms: 61_000, - messages: 4, - input_tokens: 100, - output_tokens: 50, - cache_creation_tokens: 0, - cache_read_tokens: 0, - cost: 0.1234, - models: ["claude-opus-4-8"], - }, - messages: [], -} as unknown as SessionUsageDetail; - -describe("SessionKpiBar", () => { - it("展示成本、token、hook 错误数", () => { - render(); - expect(screen.getByText(/\$0\.12/)).toBeInTheDocument(); - expect(screen.getByText(/2/)).toBeInTheDocument(); - }); - - it("usage 为 null 时不崩溃", () => { - render(); - expect(screen.getByText(/history\.kpiCost/)).toBeInTheDocument(); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `pnpm exec vitest run src/components/__tests__/SessionKpiBar.test.tsx` -Expected: FAIL(模块不存在)。 - -- [ ] **Step 3: 写实现** - -新增 `src/components/SessionKpiBar.tsx`(复用 `usage/format` 的 `formatCost` / `formatTokens`): - -```tsx -import { Clock3, Coins, Hash, ShieldAlert } from "lucide-react"; -import type { TranslationKey } from "../i18n"; -import type { SessionUsageDetail } from "../types"; -import { formatCost, formatTokens } from "./usage/format"; - -/** 把毫秒时长格式化为 mm:ss / h m */ -function formatElapsed(ms: number): string { - if (ms <= 0) return "—"; - const totalSec = Math.round(ms / 1000); - const h = Math.floor(totalSec / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - if (h > 0) return `${h}h ${m}m`; - return `${m}m ${s}s`; -} - -function Kpi({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { - return ( -
- {icon} - {label} - {value} -
- ); -} - -/** 会话级 KPI 条:成本 / Token / 时长 / hook 错误数。usage 来自 get_session_usage_detail */ -export function SessionKpiBar({ - usage, - hookErrorCount, - t, -}: { - usage: SessionUsageDetail | null; - hookErrorCount: number; - t: (k: TranslationKey) => string; -}) { - const s = usage?.session; - const tokens = s ? s.input_tokens + s.output_tokens : 0; - const elapsed = s ? formatElapsed(s.last_active_ms - s.started_at_ms) : "—"; - return ( -
- } label={t("history.kpiCost")} value={s ? formatCost(s.cost) : "—"} /> - } label={t("history.kpiTokens")} value={s ? formatTokens(tokens) : "—"} /> - } label={t("history.kpiDuration")} value={elapsed} /> - } - label={t("history.kpiHookErrors")} - value={String(hookErrorCount)} - /> -
- ); -} -``` - -- [ ] **Step 4: 加 i18n key** - -`src/i18n.ts` 加: - -```ts -// zh -"history.kpiCost": "成本", -"history.kpiTokens": "Token", -"history.kpiDuration": "时长", -"history.kpiHookErrors": "Hook 错误", -// en -"history.kpiCost": "Cost", -"history.kpiTokens": "Tokens", -"history.kpiDuration": "Duration", -"history.kpiHookErrors": "Hook errors", -``` - -- [ ] **Step 5: 在抽屉里并发拉取 usage 并渲染** - -在 `SessionDetailDrawer.tsx` 加载详情处(`src/components/SessionDetailDrawer.tsx:847` `.getSessionDetail(...)` 附近)增加并发拉取(usage 失败不阻断详情,置 null): - -```tsx - const [usageDetail, setUsageDetail] = useState(null); - // ... 在拉取 getSessionDetail 的同一 effect 内追加: - ipc - .getSessionUsageDetail(sessionId) - .then(setUsageDetail) - .catch(() => setUsageDetail(null)); -``` - -在 `SheetHeader`(`:910`)内、标题下方插入 KPI 条;`hookErrorCount` 由 `messages` 统计: - -```tsx -const hookErrorCount = useMemo( - () => - messages.reduce( - (acc, m) => - acc + - m.blocks.filter( - (b) => b.type === "hook" && (b.errors.length > 0 || b.prevented_continuation), - ).length, - 0, - ), - [messages], -); -// SheetHeader 内: - -``` - -import 顶部补:`import { SessionKpiBar } from "./SessionKpiBar";` 与 `type SessionUsageDetail`(并入现有 `from "../types"` 的 import)。 - -- [ ] **Step 6: 运行测试确认通过** - -Run: `pnpm exec vitest run src/components/__tests__/SessionKpiBar.test.tsx && make build-frontend` -Expected: 测试 PASS,编译通过。 - -- [ ] **Step 7: 提交** - -```bash -git add src/components/SessionKpiBar.tsx src/components/__tests__/SessionKpiBar.test.tsx src/components/SessionDetailDrawer.tsx src/i18n.ts -git commit -m "feat(history): 会话详情头部展示成本/Token/时长/hook 错误 KPI" -``` - ---- - -### Task 7: 前端 subagent 侧链可折叠子时间线 - -**Files:** -- Create: `src/components/SessionSubagents.tsx` -- Modify: `src/components/SessionDetailDrawer.tsx`(详情区底部渲染 subagents) -- Modify: `src/i18n.ts` -- Test: `src/components/__tests__/SessionSubagents.test.tsx` - -**Interfaces:** -- Consumes: `SubagentChain[]`、`MessageBlocks` 渲染(复用抽屉内的块渲染——通过 props 传入一个 `renderBlocks` 回调,避免循环依赖)。 -- Produces: `export function SessionSubagents(props: { subagents: SubagentChain[]; renderBlocks: (blocks: MessageBlock[]) => ReactNode; t: (k: TranslationKey) => string })`。 - -- [ ] **Step 1: 写失败测试** - -```tsx -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import type { TranslationKey } from "../../i18n"; -import type { SubagentChain } from "../../types"; -import { SessionSubagents } from "../SessionSubagents"; - -const t = (k: TranslationKey) => k as string; - -const chains: SubagentChain[] = [ - { - agent_id: "a1", - slug: "explore", - messages: [{ role: "assistant", blocks: [{ type: "text", text: "sub answer" }] }], - }, -]; - -describe("SessionSubagents", () => { - it("展示侧链 slug 与子消息", () => { - render( - <>{blocks.map((b, i) => (b.type === "text" ? {b.text} : null))}} - t={t} - />, - ); - expect(screen.getByText(/explore/)).toBeInTheDocument(); - expect(screen.getByText(/sub answer/)).toBeInTheDocument(); - }); - - it("空数组渲染为空", () => { - const { container } = render( null} t={t} />); - expect(container).toBeEmptyDOMElement(); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `pnpm exec vitest run src/components/__tests__/SessionSubagents.test.tsx` -Expected: FAIL(模块不存在)。 - -- [ ] **Step 3: 写实现** - -新增 `src/components/SessionSubagents.tsx`: - -```tsx -import { Bot } from "lucide-react"; -import type { ReactNode } from "react"; -import type { TranslationKey } from "../i18n"; -import type { MessageBlock, SubagentChain } from "../types"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; - -/** 渲染所有 subagent 侧链为可折叠子时间线 */ -export function SessionSubagents({ - subagents, - renderBlocks, - t, -}: { - subagents: SubagentChain[]; - renderBlocks: (blocks: MessageBlock[]) => ReactNode; - t: (k: TranslationKey) => string; -}) { - if (subagents.length === 0) return null; - return ( -
-
- {t("history.subagents")} ({subagents.length}) -
- {subagents.map((chain) => ( - - - - {chain.slug ?? t("history.subagentUnnamed")} - - · {chain.messages.length} {t("history.subagentMessages")} - - - - {chain.messages.map((m, i) => ( -
- {renderBlocks(m.blocks)} -
- ))} -
-
- ))} -
- ); -} -``` - -- [ ] **Step 4: 加 i18n key** - -```ts -// zh -"history.subagents": "Subagent 侧链", -"history.subagentUnnamed": "未命名 subagent", -"history.subagentMessages": "条消息", -// en -"history.subagents": "Subagent chains", -"history.subagentUnnamed": "Unnamed subagent", -"history.subagentMessages": "messages", -``` - -- [ ] **Step 5: 在抽屉详情区底部渲染** - -在 `SessionDetailDrawer.tsx` 的消息列表渲染之后(`messages.map(...)` 闭合后),插入: - -```tsx - } - t={t} -/> -``` - -import 顶部补:`import { SessionSubagents } from "./SessionSubagents";`。`detail` 沿用抽屉里持有完整 `SessionDetail` 的 state 变量名(实现时对齐现有变量;若当前只存 `messages`,则同时保留 `subagents` state)。 - -- [ ] **Step 6: 运行测试确认通过** - -Run: `pnpm exec vitest run src/components/__tests__/SessionSubagents.test.tsx && make build-frontend` -Expected: 测试 PASS,编译通过。 - -- [ ] **Step 7: 提交** - -```bash -git add src/components/SessionSubagents.tsx src/components/__tests__/SessionSubagents.test.tsx src/components/SessionDetailDrawer.tsx src/i18n.ts -git commit -m "feat(history): subagent 侧链可折叠子时间线" -``` - ---- - -### Task 8: Usage / Projects 跳转到历史会话 - -**Files:** -- Modify: `src/App.tsx:84-93`(扩展 `historyProjectRequest` 载荷加可选 `sessionId`)、`:217-234`(设置 request 处) -- Modify: `src/components/HistoryPage.tsx`(消费 request 的 `sessionId`,打开对应会话详情) -- Modify: `src/components/usage/`(Usage 会话行加「在历史中打开」动作)、`src/components/ProjectDetailPanel.tsx`(同) -- Modify: `src/i18n.ts` -- Test: `src/components/__tests__/HistoryPage.openSession.test.ts`(纯逻辑:request → 选中 session 的映射 helper) - -**Interfaces:** -- Consumes: 现有 `historyProjectRequest: { project: string; requestId: number }`。 -- Produces: 扩展为 `{ project: string; sessionId?: string; requestId: number }`;`HistoryPage` 收到带 `sessionId` 的 request 时自动选中并打开该会话。 - -- [ ] **Step 1: 写失败测试** - -把 request→选中 session 的纯逻辑抽成 helper 并测试。新建 `src/components/__tests__/HistoryPage.openSession.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { resolveRequestedSession } from "../history-utils"; - -describe("resolveRequestedSession", () => { - it("带 sessionId 时返回该 session", () => { - expect( - resolveRequestedSession({ project: "/p", sessionId: "s9", requestId: 1 }), - ).toEqual({ project: "/p", sessionId: "s9" }); - }); - - it("无 sessionId 时只返回 project", () => { - expect(resolveRequestedSession({ project: "/p", requestId: 1 })).toEqual({ - project: "/p", - sessionId: null, - }); - }); -}); -``` - -- [ ] **Step 2: 运行测试确认失败** - -Run: `pnpm exec vitest run src/components/__tests__/HistoryPage.openSession.test.ts` -Expected: FAIL(`resolveRequestedSession` 不存在)。 - -- [ ] **Step 3: 写 helper** - -在 `src/components/history-utils.ts`(历史页工具,history-stats-usage rule 已列其为先读文件)追加: - -```ts -/** 历史页跨页请求载荷 */ -export interface HistoryProjectRequest { - project: string; - sessionId?: string; - requestId: number; -} - -/** 从跨页请求解析要打开的项目与会话(无 sessionId 时 sessionId 为 null) */ -export function resolveRequestedSession( - req: HistoryProjectRequest, -): { project: string; sessionId: string | null } { - return { project: req.project, sessionId: req.sessionId ?? null }; -} -``` - -- [ ] **Step 4: 运行测试确认通过** - -Run: `pnpm exec vitest run src/components/__tests__/HistoryPage.openSession.test.ts` -Expected: PASS。 - -- [ ] **Step 5: 扩展 App.tsx 的 request 类型并接线** - -`src/App.tsx:84` 把 `historyProjectRequest` 的 state 类型改为带可选 `sessionId`: - -```tsx - const [historyProjectRequest, setHistoryProjectRequest] = useState<{ - project: string; - sessionId?: string; - requestId: number; - } | null>(null); -``` - -新增一个供子页调用的导航回调(与现有 `onOpenProjectHistory` 并列),携带 `sessionId`: - -```tsx - const openSessionInHistory = useCallback((project: string, sessionId: string) => { - historyProjectRequestIdRef.current += 1; - setHistoryProjectRequest({ - project, - sessionId, - requestId: historyProjectRequestIdRef.current, - }); - setActiveTab("history"); - }, []); -``` - -把 `openSessionInHistory` 作为 prop 传给 `UsagePage` 与 `ProjectsPage`(沿用它们现有接收 `onOpenProjectHistory` 的方式)。 - -- [ ] **Step 6: HistoryPage 消费 sessionId** - -`src/components/HistoryPage.tsx` 在处理 `projectRequest` 的 effect 里,用 `resolveRequestedSession` 取出 `sessionId`,非空时把它写入现有 `session` URL 查询参数(history-stats-usage rule:历史页用 `project/q/session` 同步状态),触发会话详情打开: - -```tsx -import { resolveRequestedSession } from "./history-utils"; -// effect 内: -const { project, sessionId } = resolveRequestedSession(projectRequest); -setProjectParam(project); -if (sessionId) setSessionParam(sessionId); -``` - -> `setProjectParam` / `setSessionParam` 对齐 `HistoryPage` 现有 URL 状态写入函数名(来自 `useUrlState`)。 - -- [ ] **Step 7: 源页加动作** - -在 Usage 会话行(`src/components/usage/` 下展示 session 的组件)与 `ProjectDetailPanel.tsx` 最近会话处,增加按钮调用 `props.openSessionInHistory(projectPath, sessionId)`,文案 `t("history.openSession")`。`src/i18n.ts` 加: - -```ts -// zh -"history.openSession": "在历史中打开", -// en -"history.openSession": "Open in history", -``` - -- [ ] **Step 8: 验证** - -Run: `pnpm exec vitest run src/components/__tests__/HistoryPage.openSession.test.ts && make build-frontend && make lint-frontend` -Expected: 测试 PASS,编译与 lint 通过。 - -- [ ] **Step 9: 提交** - -```bash -git add src/App.tsx src/components/HistoryPage.tsx src/components/history-utils.ts src/components/__tests__/HistoryPage.openSession.test.ts src/components/usage src/components/ProjectDetailPanel.tsx src/i18n.ts -git commit -m "feat(history): 支持从 Usage/Projects 跳转打开指定会话" -``` - ---- - -### Task 9: 全量验证与规则同步 - -**Files:** -- Modify: `.claude/rules/history-stats-usage.md`(补充会话详情新增 hook/mode/subagent 块与 KPI 的说明) - -- [ ] **Step 1: 跑后端契约与测试** - -Run: `make fmt-rust-check && make check && make lint-rust && make test-rust && make bindings-check` -Expected: 全部通过。 - -- [ ] **Step 2: 跑前端** - -Run: `make lint-frontend && make build-frontend && make test-frontend` -Expected: 全部通过。 - -- [ ] **Step 3: 更新规则** - -在 `.claude/rules/history-stats-usage.md` 的「历史页」小节,把「会话详情解析在后端,保留对 command、system、thinking、tool_use、tool_result、image、plan 等块类型的兼容」一行扩充 `hook`、`mode_change` 块类型,并新增一行说明:会话详情头部 KPI 复用 `get_session_usage_detail`(不在 `history.rs` 重复成本逻辑),subagent 侧链按 `agentId` 聚合进 `SessionDetail.subagents`。 - -- [ ] **Step 4: whitespace 检查并提交** - -Run: `git diff --check` - -```bash -git add .claude/rules/history-stats-usage.md -git commit -m "docs(rules): 补充会话详情 hook/mode/subagent 与 KPI 说明" -``` - ---- - -## Self-Review - -**Spec coverage:** -- 深度回放(hook/mode/subagent/逐步成本)→ Task 1/2/3/6/7 ✅ -- KPI 头部(成本/token/时长/hook 错误数)→ Task 6 ✅ -- 跳转(Usage/Projects → 历史会话)→ Task 8 ✅ -- 合入现有历史、不新增顶级页 → 全程在 `HistoryPage`/`SessionDetailDrawer` ✅ -- 测试(Rust 解析单测 + 前端 vitest)→ 每个 Task 均含 ✅ -- **对 spec 的两处实现级修正(已在计划开头声明)**:①成本/KPI 复用 `get_session_usage_detail` 而非在 history.rs 重抽(DRY);②tool durationMs / 归因不可靠 → v1 聚焦 hook/mode/subagent + 复用成本,归因暂不做(YAGNI)。这两处缩小了后端面,不减损用户可见价值。 - -**Placeholder 扫描:** 无 TBD/TODO;每个改码步骤均含具体代码。少数前端集成步骤标注「对齐现有变量/函数名」是因为 `SessionDetailDrawer.tsx`(1075 行)内部循环变量名需以实际代码为准,已给出精确行锚点与插入位置,非占位。 - -**类型一致性:** `MessageBlock::Hook { hooks: Vec, errors, prevented_continuation, stop_reason }` 在 Task 1 定义、Task 4 同步 TS、Task 5/6 消费,字段名一致;`SubagentChain { agent_id, slug, messages }` 在 Task 3 定义、Task 4 同步、Task 7 消费一致;`HistoryProjectRequest { project, sessionId?, requestId }` 在 Task 8 内部自洽。 diff --git a/docs/superpowers/plans/2026-06-27-waiting-session-sound.md b/docs/superpowers/plans/2026-06-27-waiting-session-sound.md deleted file mode 100644 index f8c90e3..0000000 --- a/docs/superpowers/plans/2026-06-27-waiting-session-sound.md +++ /dev/null @@ -1,657 +0,0 @@ -# 会话等待输入提示音效 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 当检测到新的"等待输入"会话时,按用户偏好播放一次 macOS 系统提示音效。 - -**Architecture:** 复用 `tray.rs` 已有的"新等待会话"检测点;新增独立、默认关闭的偏好开关 `waiting_sound_enabled` 与音效枚举 `waiting_sound`;新建 `sound.rs` 模块用 `afplay` 播放系统 `.aiff`(非 macOS no-op);前端在设置抽屉加开关 + 下拉 + 试听。 - -**Tech Stack:** Rust + Tauri 2 + tauri-specta;React 19 + TypeScript + Tailwind v4 + shadcn/ui。 - -## Global Constraints - -- 代码注释使用中文,技术术语保留英文。 -- 所有用户可见文本走 `useI18n()` 的 `t()`,中英文 key 同步加在 `src/i18n.ts`。 -- 用户反馈走 `useToast()`,不要用 `console.error` 当用户反馈。 -- 偏好开关**默认关闭**:`waiting_sound_enabled` 默认 `false`,音效默认 `Glass`。 -- 声音门控与 `system_notifications_enabled` **完全独立**:仅开音效也要发声。 -- macOS-only:非 macOS 后端 no-op,不报错;前端控件照常显示。 -- 音效白名单唯一来源是 `WaitingSound` 枚举 → 文件名映射,禁止任意路径传给 `afplay`。 -- 新增 / 修改 Tauri command 必须走:Rust command → `lib.rs` 注册 → `make bindings` → `make bindings-check`,业务前端经 `src/ipc.ts` 调用,不直接 `invoke`。 -- 播放不阻塞托盘事件循环:`Command::spawn()` 后不 `wait()`。 - ---- - -## File Structure - -| 文件 | 责任 | 动作 | -| --- | --- | --- | -| `src-tauri/src/config.rs` | `WaitingSound` 枚举、`AppPreferences` / `AppPreferencesInput` 新字段、`normalize_app_preferences`、Default | Modify | -| `src-tauri/src/sound.rs` | 枚举→文件名白名单映射、`play_waiting_sound`、`preview_waiting_sound` 命令 | Create | -| `src-tauri/src/lib.rs` | 声明 `mod sound`、注册 `preview_waiting_sound` | Modify | -| `src-tauri/src/tray.rs` | notifier 增 `last_had_new_waiting`、触发处播放、`test_preferences` 补字段、新单测 | Modify | -| `src/bindings.ts` | `make bindings` 重新生成(含枚举、字段、命令) | Generated | -| `src/types.ts` | `AppPreferences` 手工类型补 `waitingSoundEnabled` / `waitingSound` | Modify | -| `src/ipc.ts` | `CompatibleIpc` 接口补 `previewWaitingSound` | Modify | -| `src/components/SettingsDrawer.tsx` | 默认 state 补字段、音效区 UI(开关 + 下拉 + 试听) | Modify | -| `src/i18n.ts` | 新增中英文文案 | Modify | - -执行顺序:Task 1(后端配置) → Task 2(sound 模块 + bindings) → Task 3(tray 触发) → Task 4(前端 UI)。 - ---- - -## Task 1: 后端配置字段与 WaitingSound 枚举 - -**Files:** -- Modify: `src-tauri/src/config.rs`(枚举、`AppPreferences:71`、`Default:111`、`AppPreferencesInput:335`、`normalize_app_preferences:980`、测试构造 `:3885`、config 测试 `:2883` 区) -- Modify: `src-tauri/src/tray.rs:1311`(`test_preferences` 补字段,保持 crate 编译) - -**Interfaces:** -- Produces: - - `pub enum WaitingSound { Glass(default), Submarine, Hero, Ping, Sosumi, Tink }`(`#[serde(rename_all="camelCase")]`,序列化为 `"glass"|"submarine"|"hero"|"ping"|"sosumi"|"tink"`) - - `AppPreferences.waiting_sound_enabled: bool`、`AppPreferences.waiting_sound: WaitingSound` - - `AppPreferencesInput` 同名字段 - -- [ ] **Step 1: 写失败测试(config.rs 测试模块,紧邻 `app_preferences_default_to_expanded_sidebar` 之后)** - -```rust -#[test] -fn app_preferences_default_waiting_sound_disabled_glass() { - let prefs = AppPreferences::default(); - assert!(!prefs.waiting_sound_enabled, "等待音效默认必须关闭"); - assert_eq!(prefs.waiting_sound, WaitingSound::Glass); -} - -#[test] -fn normalize_app_preferences_passes_waiting_sound_through() { - let mut input = sample_app_preferences_input(); - input.waiting_sound_enabled = true; - input.waiting_sound = WaitingSound::Submarine; - - let normalized = normalize_app_preferences(input).expect("normalize 应成功"); - assert!(normalized.waiting_sound_enabled); - assert_eq!(normalized.waiting_sound, WaitingSound::Submarine); -} -``` - -> 若测试模块没有 `sample_app_preferences_input()` 辅助函数,改为在测试内联构造一个 `AppPreferencesInput`(参照 `normalize_app_preferences` 的入参字段逐个填默认值),并在末尾加 `waiting_sound_enabled: true, waiting_sound: WaitingSound::Submarine,`。先确认现有测试里已有的 input 构造方式并复用。 - -- [ ] **Step 2: 运行测试确认失败** - -Run: `cargo test -p code-manager --lib config:: 2>&1 | tail -20`(在 `src-tauri/` 下) -Expected: 编译失败,`no field waiting_sound_enabled` / `cannot find type WaitingSound`。 - -- [ ] **Step 3: 加 `WaitingSound` 枚举(config.rs,放在 `SessionTrayCountStyle` 枚举之后,约 `:69` 下方)** - -```rust -/// 会话等待输入时播放的提示音效。 -/// 变体映射到 macOS `/System/Library/Sounds/` 下的系统音效(映射见 `sound.rs`)。 -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, specta::Type)] -#[serde(rename_all = "camelCase")] -pub enum WaitingSound { - #[default] - Glass, - Submarine, - Hero, - Ping, - Sosumi, - Tink, -} -``` - -- [ ] **Step 4: 给 `AppPreferences` 加字段(`:108` `floating_widget_opacity` 之后,结构体闭合大括号前)** - -```rust - /// 会话等待输入时是否播放提示音效(独立于 system_notifications_enabled)。 - #[serde(default)] - pub waiting_sound_enabled: bool, - /// 等待提示音效,默认 Glass。 - #[serde(default)] - pub waiting_sound: WaitingSound, -``` - -- [ ] **Step 5: 给 `Default for AppPreferences` 补字段(`:129` `floating_widget_opacity: ...` 之后)** - -```rust - waiting_sound_enabled: false, - waiting_sound: WaitingSound::default(), -``` - -- [ ] **Step 6: 给 `AppPreferencesInput` 加字段(`:362` `floating_widget_opacity` 之后)** - -```rust - #[serde(default)] - pub waiting_sound_enabled: bool, - #[serde(default)] - pub waiting_sound: WaitingSound, -``` - -- [ ] **Step 7: `normalize_app_preferences` 输出补字段(`:1008` `floating_widget_opacity: ...` 之后)** - -```rust - waiting_sound_enabled: input.waiting_sound_enabled, - waiting_sound: input.waiting_sound, -``` - -- [ ] **Step 8: 修补其余全量构造点** - -- `config.rs:3885` 附近测试里的 `app: AppPreferences { ... }`:若是完整字段字面量,补 `waiting_sound_enabled: false, waiting_sound: WaitingSound::default(),`;若已是 `..AppPreferences::default()` 则跳过。 -- `src-tauri/src/tray.rs:1311` 的 `test_preferences` 闭合前(`floating_widget_opacity: 92,` 之后)补: - -```rust - waiting_sound_enabled: false, - waiting_sound: crate::config::WaitingSound::default(), -``` - -- [ ] **Step 9: 运行测试确认通过** - -Run: `cargo test -p code-manager --lib 2>&1 | tail -20` -Expected: 全绿,新两条测试 PASS,无其它构造点编译报错。 - -- [ ] **Step 10: 提交** - -```bash -git add src-tauri/src/config.rs src-tauri/src/tray.rs -git commit -m "feat(config): 新增等待提示音效偏好字段与 WaitingSound 枚举" -``` - ---- - -## Task 2: sound 模块与试听命令 - -**Files:** -- Create: `src-tauri/src/sound.rs` -- Modify: `src-tauri/src/lib.rs`(`mod sound;`、`use sound::preview_waiting_sound;`、`collect_commands!` 注册) -- Generated: `src/bindings.ts`(`make bindings`) - -**Interfaces:** -- Consumes: `crate::config::WaitingSound`(Task 1) -- Produces: - - `pub(crate) fn waiting_sound_file_name(sound: WaitingSound) -> &'static str` - - `pub(crate) fn play_waiting_sound(sound: WaitingSound)`(Task 3 调用) - - `#[tauri::command] pub fn preview_waiting_sound(sound: WaitingSound) -> Result<(), String>`(前端 `ipc.previewWaitingSound`) - -- [ ] **Step 1: 写失败测试(新建 `src-tauri/src/sound.rs`,先只放映射 + 测试)** - -```rust -//! 会话等待输入提示音效:macOS 通过 afplay 播放系统 .aiff;非 macOS no-op。 - -use crate::config::WaitingSound; - -/// 音效枚举 → `/System/Library/Sounds/` 下的文件名(不含目录)。 -/// 这是白名单的唯一来源:只允许内置系统音效,杜绝任意路径注入。 -pub(crate) fn waiting_sound_file_name(sound: WaitingSound) -> &'static str { - match sound { - WaitingSound::Glass => "Glass.aiff", - WaitingSound::Submarine => "Submarine.aiff", - WaitingSound::Hero => "Hero.aiff", - WaitingSound::Ping => "Ping.aiff", - WaitingSound::Sosumi => "Sosumi.aiff", - WaitingSound::Tink => "Tink.aiff", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn file_name_maps_every_variant_to_aiff() { - for sound in [ - WaitingSound::Glass, - WaitingSound::Submarine, - WaitingSound::Hero, - WaitingSound::Ping, - WaitingSound::Sosumi, - WaitingSound::Tink, - ] { - assert!( - waiting_sound_file_name(sound).ends_with(".aiff"), - "{sound:?} 必须映射到 .aiff 文件名" - ); - } - assert_eq!(waiting_sound_file_name(WaitingSound::Glass), "Glass.aiff"); - } -} -``` - -- [ ] **Step 2: 在 `lib.rs` 声明模块,运行测试确认失败** - -在 `src-tauri/src/lib.rs` 模块声明区(`mod led;` 附近,`:6`)加: - -```rust -mod sound; -``` - -Run: `cargo test -p code-manager --lib sound:: 2>&1 | tail -20` -Expected: PASS(映射纯函数已可测)——确认模块挂上、测试可跑。若此处直接 PASS,跳过"失败"语义,进入下一步补播放与命令。 - -- [ ] **Step 3: 补播放函数与试听命令(`sound.rs`,`tests` 模块之前)** - -```rust -/// 播放等待提示音效。macOS 用 afplay 异步播放系统音效;非 macOS no-op。 -/// fire-and-forget:spawn 后不 wait,避免阻塞调用方(托盘事件循环)。 -#[cfg(target_os = "macos")] -pub(crate) fn play_waiting_sound(sound: WaitingSound) { - use std::process::Command; - - let file = waiting_sound_file_name(sound); - let path = format!("/System/Library/Sounds/{file}"); - match Command::new("afplay").arg(&path).spawn() { - Ok(_) => log::info!("event=sound.waiting status=ok file={file}"), - Err(e) => log::warn!("event=sound.waiting status=err file={file} error={e}"), - } -} - -#[cfg(not(target_os = "macos"))] -pub(crate) fn play_waiting_sound(_sound: WaitingSound) { - // 非 macOS 暂不支持系统音效播放,静默 no-op。 -} - -/// 设置页"试听"入口:立即播放一次选中音效。 -#[tauri::command] -#[specta::specta] -pub fn preview_waiting_sound(sound: WaitingSound) -> Result<(), String> { - play_waiting_sound(sound); - Ok(()) -} -``` - -- [ ] **Step 4: 在 `lib.rs` 注册命令** - -`lib.rs` 顶部 use 区(`use led::{...}` 附近,`:48`)加: - -```rust -use sound::preview_waiting_sound; -``` - -`collect_commands![]` 列表内(`led_test_mode,` 附近,`:169`)加一行: - -```rust - preview_waiting_sound, -``` - -- [ ] **Step 5: 重新生成并校验 bindings** - -Run: -```bash -make bindings -make bindings-check -``` -Expected: `bindings-check` 无 diff 漂移;`src/bindings.ts` 出现 `previewWaitingSound`、`WaitingSound` 类型、`AppPreferences.waitingSound` / `waitingSoundEnabled`。 - -- [ ] **Step 6: 跑 Rust 校验** - -Run(在 `src-tauri/`): -```bash -cargo test -p code-manager --lib sound:: 2>&1 | tail -20 -``` -Expected: PASS。再跑 `cargo clippy -p code-manager 2>&1 | tail -15`,无新告警。 - -- [ ] **Step 7: 提交** - -```bash -git add src-tauri/src/sound.rs src-tauri/src/lib.rs src/bindings.ts -git commit -m "feat(sound): 新增等待提示音效播放模块与试听命令" -``` - ---- - -## Task 3: tray 触发播放 - -**Files:** -- Modify: `src-tauri/src/tray.rs`(`PendingSessionNotifier:146`、`observe:152`、`handle_pending_session_notifications:1039`、新单测) - -**Interfaces:** -- Consumes: `crate::sound::play_waiting_sound`(Task 2)、`AppPreferences.waiting_sound_enabled` / `waiting_sound`(Task 1) -- Produces: `PendingSessionNotifier.last_had_new_waiting: bool`(caller 读取) - -> 设计说明:spec 原写"改 `observe` 返回结构体",这里改为在 notifier 上加 `last_had_new_waiting` 字段,`observe` 签名与返回保持不变 —— 同样实现"音效门控独立于 system_notifications",但零改动现有 12 处 `observe` 测试调用,更外科手术。 - -- [ ] **Step 1: 写失败测试(tray.rs 测试模块,紧邻 `pending_session_notifier_reports_new_waiting_session_once` 之后)** - -```rust -#[test] -fn pending_session_notifier_flags_new_waiting_independent_of_system_notifications() { - let mut notifier = PendingSessionNotifier::default(); - let idle = test_session("/Users/demo/work/code-manager", "idle", 1000); - let waiting = test_session("/Users/demo/work/code-manager", "waiting", 2000); - - // 首帧基线:即便有 waiting 也不算"新出现" - notifier.observe( - &test_preferences(false, "terminal"), - std::slice::from_ref(&idle), - "zh", - PendingSessionNotificationInteraction::Plain, - ); - assert!(!notifier.last_had_new_waiting, "首帧不应触发音效信号"); - - // 出现新 waiting:系统通知关闭(false)时仍应置位音效信号,且不产生通知 - let notifications = notifier.observe( - &test_preferences(false, "terminal"), - std::slice::from_ref(&waiting), - "zh", - PendingSessionNotificationInteraction::Plain, - ); - assert!(notifier.last_had_new_waiting, "新等待会话应置位音效信号"); - assert!(notifications.is_empty(), "系统通知关闭时不产生通知"); - - // 同一 waiting 重复:不再是"新" - notifier.observe( - &test_preferences(false, "terminal"), - std::slice::from_ref(&waiting), - "zh", - PendingSessionNotificationInteraction::Plain, - ); - assert!(!notifier.last_had_new_waiting, "重复 waiting 不应再置位"); -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run(`src-tauri/`): `cargo test -p code-manager --lib tray:: 2>&1 | tail -20` -Expected: 编译失败,`no field last_had_new_waiting`。 - -- [ ] **Step 3: 给 `PendingSessionNotifier` 加字段(`:146` 结构体)** - -```rust -#[derive(Debug, Default)] -struct PendingSessionNotifier { - seen_snapshot: bool, - waiting_session_ids: BTreeSet, - /// 最近一次 observe 是否出现真正的新等待会话(已排除启动首帧)。 - /// 供音效门控读取,独立于 system_notifications_enabled。 - last_had_new_waiting: bool, -} -``` - -- [ ] **Step 4: 在 `observe` 内计算并写入该字段(`:172` `can_notify` 计算附近)** - -把: - -```rust - let can_notify = self.seen_snapshot && preferences.system_notifications_enabled; - self.seen_snapshot = true; - self.waiting_session_ids = waiting_session_ids; -``` - -改为: - -```rust - let has_new_waiting = self.seen_snapshot && !new_waiting_sessions.is_empty(); - let can_notify = self.seen_snapshot && preferences.system_notifications_enabled; - self.last_had_new_waiting = has_new_waiting; - self.seen_snapshot = true; - self.waiting_session_ids = waiting_session_ids; -``` - -- [ ] **Step 5: 运行确认新测试通过** - -Run: `cargo test -p code-manager --lib tray:: 2>&1 | tail -20` -Expected: 全绿,含新测试。 - -- [ ] **Step 6: 在 caller 触发播放(`handle_pending_session_notifications:1046`)** - -把: - -```rust - let notifications = match pending_session_notifier().lock() { - Ok(mut notifier) => notifier.observe(&state.app, sessions, labels.language, interaction), - Err(e) => { - log::warn!("event=tray.pending_session_notify status=err reason=lock error={e}"); - return; - } - }; - - for notification in notifications { - show_pending_session_notification(app, notification); - } -``` - -改为: - -```rust - let (notifications, has_new_waiting) = match pending_session_notifier().lock() { - Ok(mut notifier) => { - let notifications = - notifier.observe(&state.app, sessions, labels.language, interaction); - (notifications, notifier.last_had_new_waiting) - } - Err(e) => { - log::warn!("event=tray.pending_session_notify status=err reason=lock error={e}"); - return; - } - }; - - // 音效独立门控:仅看自身开关 + 是否出现新等待会话,每轮最多播一次 - if state.app.waiting_sound_enabled && has_new_waiting { - crate::sound::play_waiting_sound(state.app.waiting_sound); - } - - for notification in notifications { - show_pending_session_notification(app, notification); - } -``` - -- [ ] **Step 7: 跑 Rust 校验** - -Run(`src-tauri/`): -```bash -cargo test -p code-manager --lib 2>&1 | tail -20 -cargo clippy -p code-manager 2>&1 | tail -15 -``` -Expected: 测试全绿;clippy 无新告警。 - -- [ ] **Step 8: 提交** - -```bash -git add src-tauri/src/tray.rs -git commit -m "feat(tray): 检测到新等待会话时按偏好播放提示音效" -``` - ---- - -## Task 4: 前端设置 UI - -**Files:** -- Modify: `src/types.ts:60`(`AppPreferences` 接口) -- Modify: `src/ipc.ts`(`CompatibleIpc` 接口) -- Modify: `src/components/SettingsDrawer.tsx`(默认 state、音效区 UI) -- Modify: `src/i18n.ts`(中英文文案) - -**Interfaces:** -- Consumes: `ipc.previewWaitingSound(sound)`、`AppPreferences.waitingSoundEnabled` / `waitingSound` - -- [ ] **Step 1: `src/types.ts` 的 `AppPreferences` 接口补字段(与现有 camelCase 字段并列)** - -```ts - waitingSoundEnabled: boolean; - waitingSound: "glass" | "submarine" | "hero" | "ping" | "sosumi" | "tink"; -``` - -> 若 `src/types.ts` 直接 re-export 生成类型而非手写接口,则跳过本步(`make bindings` 已覆盖);先确认 `:60` 是手写 interface 还是 re-export。 - -- [ ] **Step 2: `src/ipc.ts` 的 `CompatibleIpc` 接口补一行(`setAppPreferences` 附近,`:168`)** - -```ts - previewWaitingSound(sound: AppTypes.AppPreferences["waitingSound"]): Promise; -``` - -- [ ] **Step 3: `SettingsDrawer.tsx` 默认 state 补字段(`useState` 字面量内,`floatingWidgetOpacity: 92,` 之后,`:644`)** - -```ts - waitingSoundEnabled: false, - waitingSound: "glass", -``` - -- [ ] **Step 4: `SettingsDrawer.tsx` 顶部加音效选项常量(`sessionTrayCountStyleOptions` 常量附近,`:121`)** - -```ts -const waitingSoundOptions: { - value: AppPreferences["waitingSound"]; - labelKey: TranslationKey; -}[] = [ - { value: "glass", labelKey: "settings.waitingSoundGlass" }, - { value: "submarine", labelKey: "settings.waitingSoundSubmarine" }, - { value: "hero", labelKey: "settings.waitingSoundHero" }, - { value: "ping", labelKey: "settings.waitingSoundPing" }, - { value: "sosumi", labelKey: "settings.waitingSoundSosumi" }, - { value: "tink", labelKey: "settings.waitingSoundTink" }, -]; -``` - -> `TranslationKey` 是 `i18n.ts` 导出的 key 联合类型;确认 SettingsDrawer 已 import(`sessionTrayCountStyleOptions` 同款)。若该常量用的是别的类型名,照抄它。 - -- [ ] **Step 5: 在系统通知设置区附近插入音效卡片 JSX(系统通知 `SettingsSectionCard` 之后)** - -```tsx - - - - - - - - void persistPreferences( - { ...preferences, waitingSoundEnabled: checked }, - preferences, - ) - } - aria-label={t("settings.waitingSound")} - /> - - - {preferences.waitingSoundEnabled && ( - - - {t("settings.waitingSoundChoice")} - -
- - -
-
- )} -
-
-``` - -> 复用文件内已有 import:`SettingsSectionCard`、`FieldGroup`、`Field`、`FieldContent`、`FieldTitle`、`SettingsStateLabel`、`Switch`、`Select*`、`Button`、`showOperationError`、`showToast`、`ipc`。逐一确认存在;缺哪个补 import(参照 LED 卡片 `:421` 的用法,它们都已在用)。 - -- [ ] **Step 6: `src/i18n.ts` 加中文文案(`settings.sessionTrayCountStyle*` 中文块附近,`:1251`)** - -```ts - "settings.waitingSound": "等待提示音效", - "settings.waitingSoundDesc": "会话等待输入时播放系统提示音(仅 macOS)", - "settings.waitingSoundChoice": "音效", - "settings.waitingSoundPreview": "试听", - "settings.waitingSoundGlass": "清脆 Glass", - "settings.waitingSoundSubmarine": "低沉 Submarine", - "settings.waitingSoundHero": "上扬 Hero", - "settings.waitingSoundPing": "短促 Ping", - "settings.waitingSoundSosumi": "经典 Sosumi", - "settings.waitingSoundTink": "轻响 Tink", - "toast.waitingSoundPreviewError": "试听音效失败", -``` - -- [ ] **Step 7: `src/i18n.ts` 加英文文案(英文块对应位置,`:2914` 附近)** - -```ts - "settings.waitingSound": "Waiting sound", - "settings.waitingSoundDesc": "Play a system sound when a session is waiting for input (macOS only)", - "settings.waitingSoundChoice": "Sound", - "settings.waitingSoundPreview": "Preview", - "settings.waitingSoundGlass": "Crisp Glass", - "settings.waitingSoundSubmarine": "Deep Submarine", - "settings.waitingSoundHero": "Rising Hero", - "settings.waitingSoundPing": "Short Ping", - "settings.waitingSoundSosumi": "Classic Sosumi", - "settings.waitingSoundTink": "Light Tink", - "toast.waitingSoundPreviewError": "Failed to preview sound", -``` - -> `toast.waitingSoundPreviewError` 若英文 toast 块在别处,放到对应 `toast.*` 区;保持中英 key 一一对应。 - -- [ ] **Step 8: 前端校验** - -Run: -```bash -make lint-frontend -make build-frontend -``` -Expected: 类型检查与构建通过,无 `t()` 未知 key 报错、无未用 import。 - -- [ ] **Step 9: 手动核验(macOS)** - -`make dev` 启动应用 → 打开设置抽屉 → 找到"等待提示音效" → 打开开关 → 下拉切到 Submarine → 点"试听"应听到 Submarine。关闭开关后下拉/试听隐藏。重启应用确认开关默认关闭仍持久化为上次选择。 - -> 无法截图 / 无 macOS 环境时,说明限制并以 `make build-frontend` + 单测为准。 - -- [ ] **Step 10: 提交** - -```bash -git add src/types.ts src/ipc.ts src/components/SettingsDrawer.tsx src/i18n.ts -git commit -m "feat(settings): 等待提示音效开关、音效选择与试听" -``` - ---- - -## Self-Review - -**Spec 覆盖:** -- 固定音效、非 TTS → Task 2 `afplay` 系统 `.aiff`。✓ -- 独立默认关闭开关 → Task 1 `waiting_sound_enabled` 默认 false + Task 3 独立门控。✓ -- curated 列表可选 + 默认 Glass → Task 1 枚举默认 Glass + Task 4 下拉。✓ -- 可试听 → Task 2 `preview_waiting_sound` + Task 4 试听按钮。✓ -- 每轮只播一次 → Task 3 `has_new_waiting` 单次门控。✓ -- macOS-only / 非 macOS no-op → Task 2 `#[cfg]`。✓ -- 白名单防注入 → Task 2 枚举→文件名映射。✓ -- 不阻塞托盘 → Task 2 `spawn()` 不 `wait()`。✓ -- 测试:observe 信号、映射白名单、默认值、normalize 透传 → Task 1/2/3。✓ -- 契约:`make bindings-check` / `build-frontend` / `test-rust` → Task 2/4 + 各 Rust 步。✓ - -**占位符扫描:** 无 TBD / "类似 Task N" / 空泛"加错误处理";每个代码步均含完整代码。少数"先确认现有写法"提示是为适配未知的既有辅助函数,非占位。 - -**类型一致性:** `WaitingSound`(Rust)↔ `"glass"|...`(serde camelCase ↔ TS 联合)一致;`waiting_sound_enabled`/`waiting_sound`(Rust snake)↔ `waitingSoundEnabled`/`waitingSound`(TS camel,经 `rename_all` + bindings)一致;`waiting_sound_file_name`、`play_waiting_sound`、`preview_waiting_sound`、`last_had_new_waiting` 全程同名。✓ diff --git a/docs/superpowers/specs/2026-06-22-claude-multi-config-launch-command-design.md b/docs/superpowers/specs/2026-06-22-claude-multi-config-launch-command-design.md deleted file mode 100644 index 80a1d56..0000000 --- a/docs/superpowers/specs/2026-06-22-claude-multi-config-launch-command-design.md +++ /dev/null @@ -1,125 +0,0 @@ -# 一键复制 Claude 多配置启动命令 —— 设计文档 - -- 日期:2026-06-22 -- 范围:配置管理(ProfilesPage 配置卡片 + 配置系统后端) -- 状态:已确认,待进入实现计划 - -## 1. 背景与目标 - -当前 Code Manager 是"单配置"模式:`apply_profile` 把某个配置 resolve 后写入全局 -`~/.claude/settings.json`,Claude Code 直接读取它。一次只能激活一个配置,无法在不同 -终端并行运行不同 provider/model。 - -目标:在**不改动全局 `~/.claude/settings.json`** 的前提下,为某个配置一键生成 -`claude --settings ...` 启动命令。用户在不同终端粘贴不同配置的命令,即可并行运行多套 -provider/model,互不干扰。 - -`claude --settings ` 已支持文件路径与内联 JSON 两种入参,语义是"在现有 -settings 之上叠加加载"。 - -## 2. 现状关键事实 - -- 配置卡片:`src/components/ProfilesPage.tsx`,操作按钮组在卡片 hover 时展开(同步常用选项 / - 复制环境变量 / 导出 / 复制 / 删除)。 -- 复制环境变量:`handleCopyEnv`(约 887-906 行)调用 `ipc.previewProfile`(后端 - `resolve_profile_settings`)拿到合并后完整 settings,再用 `buildEnvExportText`(约 - 633-663 行)只抽出 `env` 字段拼成 `export KEY="value"`,POSIX 约定。 -- `resolve_profile_settings`(`src-tauri/src/config.rs` 约 1341-1375 行)产出完整 settings: - `$schema` + `env` + `model` + `permissions` + `enabledPlugins` + `hooks` 等。 -- 应用配置链路:`apply_profile` → `apply_profile_inner` → `apply_profile_to_registry`, - 写入 `~/.claude/settings.json` 并更新 `registry.bindings`。 -- 删除配置:`delete_profile`(后端),需在此处追加 launch 文件清理。 -- 配置类型:`src/types.ts` `ConfigProfile`;后端 `src-tauri/src/config.rs` `ConfigProfile`。 - -## 3. 交互设计 - -在配置卡片操作按钮组中**新增一个按钮**: - -- 图标:`SquareTerminal`(lucide)。 -- 位置:紧挨现有"复制环境变量"按钮。 -- `aria-label`:`profiles.actions.copyLaunchCommand`。 - -点击弹出 shadcn **Dialog**,结构: - -1. 顶部一句话说明多配置并行场景。 -2. 两个命令区块,各带独立"复制"按钮和一句适用提示: - - **文件路径式**(推荐,第一项):`claude --settings "<绝对路径>"`,承载完整 resolve 后 - settings。提示:命令干净、不暴露密钥到 shell history、保留完整保真度。 - - **内联 JSON 式**:`claude --settings '{"env":{...}}'`,**仅 env 块**。提示:自包含、不落 - 额外文件;密钥会进入 shell history。 -3. 底部"如何使用"步骤:开新终端 → 粘贴 → 回车;每个终端独立、互不干扰。 - -复制走现有 `navigator.clipboard.writeText` + `useToast()`。Dialog / 浮层一律用 shadcn 组件, -不自实现。 - -## 4. 命令字符串生成(前端) - -- 文件路径式:`claude --settings ""`(双引号包裹路径)。 -- 内联式:`claude --settings ''`(POSIX 单引号包裹)。 -- 平台假设:仅面向 POSIX shell(bash/zsh),与现有 `buildEnvExportText` 的 `export` 约定一致; - Windows 用户走 WSL / git-bash。不扩大到 PowerShell/cmd 变体。 -- 拼接逻辑抽成可单测的纯函数 helper。 - -## 5. 数据与后端 - -新增 Rust command: - -``` -prepare_profile_launch(profile) -> ProfileLaunchPayload { settings_path, env_only_json } -``` - -- 复用 `resolve_profile_settings` 得到完整 settings(与 `previewProfile` 同源)。 -- 把完整 settings **原子写入** `~/.config/code-manager/launch/.settings.json` - (复用 `utils.rs` 的目录创建 + 原子写、`get_app_data_dir`),返回绝对路径 - `settings_path`。 -- 从完整 settings 抽出 `env` 块,生成紧凑单行 JSON 字符串 `env_only_json`(形如 - `{"env":{...}}`,不含 `$schema` / `model` / `permissions` / `hooks` / `enabledPlugins` / - `sandbox`),供内联式使用。 -- 走 Specta 注册(`lib.rs::build_specta_builder()` 的 `collect_commands![]`)+ - `make bindings` + `src/ipc.ts` 兼容包装。 - -文件生命周期: - -- 惰性写入:Dialog 打开时调用一次,总是覆盖为最新 resolve 结果 → 对配置编辑健壮。 -- 清理:在现有 `delete_profile` 删除配置时,一并删除对应 launch 文件,避免孤儿。 - -## 6. 需实测验证的关键点 - -`claude --settings ` 是"在全局 settings 之上叠加加载"。实现时需确认: - -> 当 launch 文件 / 内联 JSON 里的 `env.ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` 与全局 -> `~/.claude/settings.json` 冲突时,`--settings` 的值能覆盖全局。 - -这是多配置并行成立的前提。若实测覆盖语义不成立,则在 Dialog 提示里说明该限制(**不**自动改 -动全局绑定)。 - -## 7. i18n - -新增 key(中英双份,走 `useI18n()` 的 `t()`,不硬编码): - -- `profiles.actions.copyLaunchCommand` -- `profiles.launchDialog.*`:标题、说明、两种形式标签与适用提示、"如何使用"步骤、复制成功 - / 失败 toast。 - -## 8. 测试 - -- 前端 vitest:覆盖命令字符串拼接 helper(文件路径式 + 内联式)与 env-only JSON 提取逻辑。 -- Rust 单测:覆盖 `prepare_profile_launch` 写文件(路径与内容正确、env_only_json 正确),以及 - `delete_profile` 删除时清理 launch 文件。 - -## 9. 验收标准 - -1. 配置卡片出现"复制启动命令"按钮,点击弹出 Dialog,展示两种命令形式 + 区别说明 + 使用步骤。 -2. 文件路径式命令复制后,对应 launch 文件已写入且内容为该配置完整 resolve settings。 -3. 内联式命令复制后,JSON 仅含 env 块。 -4. 在两个终端分别粘贴两个不同配置的命令运行,互不干扰,全局 `~/.claude/settings.json` 不被 - 改动(实测覆盖语义见第 6 节)。 -5. 删除配置后,对应 launch 文件被清理。 -6. 范围内验证命令通过:`make bindings-check`、前端 vitest、`make test-rust`、`make lint-frontend`。 - -## 10. 非目标(YAGNI) - -- 不做 Windows PowerShell/cmd 命令变体。 -- 不做内置终端 / 直接拉起进程,只做"复制命令"。 -- 不改动现有"复制环境变量"按钮与全局 apply 流程。 -- 不为内联式提供字段可选项 UI,固定为 env-only。 diff --git a/docs/superpowers/specs/2026-06-27-harness-management-roadmap-design.md b/docs/superpowers/specs/2026-06-27-harness-management-roadmap-design.md deleted file mode 100644 index 74fa286..0000000 --- a/docs/superpowers/specs/2026-06-27-harness-management-roadmap-design.md +++ /dev/null @@ -1,103 +0,0 @@ -# Harness 管理工具路线图与深度会话检查(合入历史)设计 - -- 日期:2026-06-27 -- 状态:已确认,待生成实现计划 -- 范围:①把 Code Manager 从「Claude Code 配置管理工具」演进为「专业 harness 管理工具」的路线图骨架;②第一个落地子项目「深度会话检查」的详细设计——以**升级现有「历史」会话详情**的方式实现,不新增顶级页或后端模块。 - -## 背景与现状 - -Code Manager 已是一个成熟的 Claude Code 本地管理应用,覆盖:`~/.claude` 总览、配置/内置供应商、记忆(CLAUDE.md/rules)、Skills、历史、统计、Token 用量与费用、项目管理、系统托盘+会话聚焦、桌面浮窗、设置诊断、Cheat Sheet。对 Codex 已有轻度触达(`~/.codex/skills` 软链、`AGENTS.md` 配对)。 - -目标是把它做成「专业 harness 管理工具」,分阶段推进:先把 Claude Code 这一个 harness 的工程能力做深,再以此为模板横向扩展到其它 harness。 - -### 已识别的关键事实 - -- **一等公民不均衡**:Memory 与 Skills 拿到了结构化管理;MCP servers、subagents、slash commands、output styles 目前只能在 `~/.claude` 文件树里当原始文件浏览/编辑。settings schema 已覆盖 hooks/permissions/statusline/sandbox,但 `mcpServers` 等仍是二等公民。 -- **transcript 信号被严重低估**:`~/.claude/projects/**/*.jsonl` 每条记录包含大量未被利用的 harness 工程信号(见下表)。 -- **「历史」页已经在读 transcript**:`history.jsonl`(每条带 `sessionId`+`project`)做会话列表;打开会话时 `history.rs::get_session_detail(project, session_id)` 已解析 `~/.claude/projects//.jsonl`,`SessionDetailDrawer.tsx` 已渲染 text/thinking/tool_use/tool_result/command/system/image/plan。当前解析器**完全没抽取** `message.usage`、`durationMs`、hook 事件、`attributionSkill/Plugin`、`parentUuid`(侧链)、`type:mode`、`file-history-snapshot`。 - -| jsonl 字段 | 可还原的 harness 信号 | 现状 | -| --- | --- | --- | -| `parentUuid` / `uuid` / `isSidechain` | 消息 DAG 树 + subagent 侧链展开 | 未抽取(`isSidechain` 仅少量触及) | -| `attributionSkill` / `attributionPlugin` | 每个 assistant turn 由哪个 skill/plugin 触发 | 未抽取 | -| `toolUseResult` / `toolUseID` / `durationMs` | 每次 tool 调用的耗时与结果 | 未抽取 | -| `hookCount` / `hookInfos` / `hookErrors` / `preventedContinuation` / `stopReason` | hook 触发、失败、是否拦截 | 未抽取 | -| `message.usage` | 逐步 token / 成本 | 未抽取(仅 `usage.rs` 用于聚合) | -| `type: mode` | plan/normal 模式切换轨迹 | 未抽取 | -| `file-history-snapshot` / `snapshot` | 会话内文件编辑快照 | 未抽取 | -| `gitBranch` / `cwd` / `version` / `entrypoint` | 每条消息的仓库/版本上下文 | 部分可用 | - -## 路线图骨架 - -| 阶段 | 内容 | 状态 | -| --- | --- | --- | -| **Phase 0**(本次重点) | 深度会话检查:升级现有「历史」会话详情,把上表信号变成可下钻的回放/检查 | 本文详设,下一步出实现计划 | -| **Phase 1** | 聚合洞察/健康度:hook 可靠性、skill/tool 使用率、成本趋势、「某条 rule 从未命中」类洞察 | 复用 Phase 0 增强后的解析层,后续单独 spec | -| **Phase 2** | 完整原语覆盖:MCP servers / subagents / slash commands / output styles 升级为一等公民结构化管理 | 后续单独 spec | -| **Phase 3**(长期) | 多 harness 抽象:把配置/记忆/Skills/用量映射到 Codex CLI、Gemini CLI 等其它 harness,形成统一控制平面 | 长期愿景 | -| **暂缓** | 复用与分发(bundle/git/registry) | 明确 park,本轮不做 | - -> 决策依据:用户优先级落在「可观测与评估」(重心=深度回放与检查,被动读已有数据,不主动跑 eval);「复用与分发」暂缓;「完整原语覆盖」「多 harness」为后续阶段。集成方式定为合入现有「历史」,复用会话列表与详情解析,不另起入口。 - -## 子项目详设:深度会话检查(合入历史) - -### 目标与成功标准 - -- 用户从现有「历史」打开任意会话,看到完整的、逐步可下钻的回放:每条消息、每次 tool 调用及结果与耗时、每次 hook 触发与失败、subagent 侧链展开、模式切换、文件编辑快照、逐步 token/成本。 -- 成功标准: - - 给定真实 `.jsonl`,升级后的 `get_session_detail` 能正确抽取上述信号,并按 `parentUuid` 把 subagent 侧链归到触发它的 Task tool 下(Rust 单测对 fixture 验证通过)。 - - 会话详情能渲染增强后的时间线:hook 错误与 tool 错误醒目标记,会话头部 KPI(成本/token/时长/tool 次数/hook 错误数)准确。 - - 从 Usage/Projects 可「在历史中打开此会话」并定位到对应会话详情。 - -### 实现方式:升级现有两处,不新增模块/页面 - -**1. 后端解析器 `src-tauri/src/history.rs`** - -- 扩展 `SessionDetail` / `SessionMessage` / `MessageBlock`,补抽: - - 每个 assistant turn 的 `message.usage` → token 与成本(成本计算复用现有价目表逻辑,不复制 `usage.rs`)。 - - `toolUseResult.durationMs` 与错误标记,挂到对应 `tool_use`/`tool_result`。 - - hook 事件:`hookInfos` / `hookErrors` / `preventedContinuation` / `stopReason`。 - - `attributionSkill` / `attributionPlugin`,挂到对应 assistant turn。 - - 用 `parentUuid`/`uuid` 还原 DAG,把 `isSidechain` 分支归到触发它的 Task tool 节点下,形成可折叠嵌套子时间线。 - - `type: mode` 模式切换、`file-history-snapshot` 文件编辑快照,作为时间线事件。 -- 沿用现有 `session_file_path` 的路径校验(防 `../` 穿出 `projects`);文件读写复用 `utils.rs`。 -- 不改变现有 `get_session_detail` 命令签名/注册即可扩展返回结构;若结构体字段变更,同步 `make bindings` 与 `src/types.ts`。 - -**2. 前端会话详情 `src/components/SessionDetailDrawer.tsx`** - -- 新增: - - 会话头部 KPI 条:总成本/token、时长、消息数、用到的模型、tool 调用次数、**hook 错误数**(专业信号前置)。 - - tool 行展示 `durationMs`,错误高亮。 - - hook 事件行:hook 名 + 状态(ok / error / prevented),`hookErrors` 醒目。 - - assistant turn 的 skill/plugin 归因徽标。 - - subagent 侧链可折叠嵌套子时间线。 - - 逐步 token/成本 chip。 -- **重构约束**:该文件已 1075 行,本就过大。作为本次工作的一部分,把各 block 渲染器拆成聚焦子组件(外科手术式,仅服务本需求,不顺手改无关代码)。 -- 样式遵循「均衡管理台」风格;折叠/浮层用 shadcn 语义变量与原子组件,不硬编码 z-index/十六进制色值;文件快照 diff 可用 `@pierre/diffs`(已是依赖)。 - -**3. 跨页跳转** - -- Usage/Projects 增加「在历史中打开此会话」动作,携带 `{ project, sessionId }`,复用 `App.tsx` 现有 `historyProjectRequest` + `requestId` ref 自增模式。History 已是宿主,无需新增 request 类型;如现有 request 仅定位到项目维度,则扩展其载荷到会话维度(沿用同一 ref 自增机制)。 - -### 范围边界(v1 / YAGNI) - -- v1 = 只读、单会话深度回放与检查,长在现有「历史」里。 -- 明确不做(归后续阶段):跨会话对比、全局搜索、主动 eval/回归、导出/分享、聚合健康度看板、新顶级页。 -- 复用现有依赖(react-virtual、react-markdown、@pierre/diffs、CodeMirror),不引入新库。 - -### 测试策略 - -- **Rust 单测**:解析器是纯逻辑,TDD 友好。用 fixture jsonl 覆盖:`message.usage`→成本、`durationMs`/tool 错误抽取、hook 抽取(含 `hookErrors`/`preventedContinuation`/`stopReason`)、`attributionSkill/Plugin`、`parentUuid` 侧链嵌套、`type:mode`、`file-history-snapshot`。 -- **前端 vitest**:KPI 计算、各新事件类型渲染、hook/tool 错误高亮、侧链折叠、跳转 request 联动。 -- **契约/验证**:结构体变更后跑 `make bindings-check`、`make build-frontend`、`make test-rust`;前端补范围内 vitest;Rust 行为再跑 `make check`、`make lint-rust`。 - -### 同步点清单(实现时不可遗漏) - -- `SessionDetail` 结构体变更 → `make bindings`/`make bindings-check` → `src/types.ts`。 -- 所有用户可见文本走 `useI18n()` 的 `t()`;通知走 `useToast()`。 -- 跳转动作若涉及 Tauri 事件则用 `useTauriEvent`(本子项目主要为跨页 state request,通常不涉及)。 -- capability:只读本地文件,沿用现有读取权限,无需新增插件 API(实现时复核 `capabilities/default.json`)。 - -## 后续 - -本文 Phase 0 子项目进入 `writing-plans` 生成实现计划;Phase 1+ 各自单独 spec。 diff --git a/docs/superpowers/specs/2026-06-27-waiting-session-sound-design.md b/docs/superpowers/specs/2026-06-27-waiting-session-sound-design.md deleted file mode 100644 index ca7b616..0000000 --- a/docs/superpowers/specs/2026-06-27-waiting-session-sound-design.md +++ /dev/null @@ -1,143 +0,0 @@ -# 会话等待输入时播放固定提示音效 — 设计文档 - -- 日期:2026-06-27 -- 状态:设计已确认,待写实现计划 -- 范围:Code Manager(Tauri 2 桌面应用)后端 + 前端设置 - -## 背景与目标 - -应用已通过 `tray.rs` 轮询 `~/.claude/sessions/*.json`,识别"新出现的等待输入会话"(`new_waiting_sessions`),并据此发系统通知。现需要在**同一触发点**上增加一路**固定提示音效**:当检测到新的等待会话时播放一段提示音,提醒用户回到终端处理。 - -明确选择"固定音效"而非 TTS 朗读:实现简单、不依赖窗口、无需处理动态文本与多会话播报歧义。 - -### 成功标准 - -- 开启开关后,会话进入等待输入状态时听到一次提示音;关闭时无声。 -- 开关**默认关闭**,与现有 `system_notifications_enabled` 等"默认关闭"偏好一致。 -- 音效可在设置中从 curated 列表选择,默认 `Glass`,并可试听。 -- 声音门控与系统通知开关**完全独立**:仅开音效、不开系统通知也能工作。 -- macOS 正常发声;非 macOS 后端 no-op,不报错、不影响通知与托盘。 - -## 关键决策 - -| 决策点 | 结论 | -| --- | --- | -| 形式 | 固定音效,不做 TTS | -| 发声机制 | macOS `afplay /System/Library/Sounds/.aiff`,零依赖 | -| 开关关系 | 独立开关 `waiting_sound_enabled`,与 `system_notifications_enabled` 解耦(仿 LED / 浮窗) | -| 音效选择 | curated 枚举列表,设置内可选 + 可试听,默认 `Glass` | -| 多会话 | 每检测轮**只播一次**,与新等待会话数无关 | -| 平台 | macOS-only;非 macOS no-op,与 LED / 原生通知策略一致 | -| 试听 | 首版纳入 `preview_waiting_sound` 命令 | -| helper 落点 | 新建独立模块 `sound.rs`(一模块一职责,仿 `led.rs` / `widget.rs`) | - -## 架构与组件 - -### 后端 - -**1. 配置(`src-tauri/src/config.rs::AppPreferences`)** - -新增两个字段,沿用现有"默认关闭开关"模式: - -- `waiting_sound_enabled: bool`,`#[serde(default)]` → 默认 `false` -- `waiting_sound: WaitingSound`,新增 enum(仿 `SessionTrayCountStyle`),`#[serde(rename_all = "camelCase")]` + `specta::Type` - -`WaitingSound` 变体与默认: - -- `Glass`(`#[default]`) -- `Submarine` -- `Hero` -- `Ping` -- `Sosumi` -- `Tink` - -用 enum 而非 `String`:类型安全 + 天然白名单,enum → 文件名映射写死,杜绝从偏好注入任意路径给 `afplay`。同步更新 `AppPreferences::default()`。 - -**2. 播放 helper(新增 `src-tauri/src/sound.rs`)** - -- `WaitingSound → /System/Library/Sounds/.aiff` 的映射函数(白名单唯一来源)。 -- 播放函数:`Command::new("afplay").arg(path).spawn()` 后**立即 detach,不 `wait()`**,遵守"托盘 handler 不阻塞事件循环"约束。 -- 非 macOS 用 `#[cfg]` 编译为 no-op。 -- afplay 失败(spawn 错误 / 文件缺失)只记 `warn` 日志,静默降级。 -- 日志遵守脱敏规范,形如 `event=sound.waiting status=ok|err sound=glass`,不记录无关数据。 - -**3. 触发点(`src-tauri/src/tray.rs`)** - -- 将 `PendingSessionNotifier::observe()` 的返回从 `Vec` 改为携带额外信号的小结构,例如: - - ```rust - struct ObserveOutcome { - notifications: Vec, - has_new_waiting: bool, // seen_snapshot && !new_waiting_sessions.is_empty() - } - ``` - - `has_new_waiting` 与 `system_notifications_enabled` 无关,仅表达"本轮出现了真正的新等待会话(已排除启动首帧)"。 -- `handle_pending_session_notifications()`(`tray.rs:1039` 附近):在展示通知之外,若 `preferences.waiting_sound_enabled && outcome.has_new_waiting` → 调 `sound::play_waiting_sound(preferences.waiting_sound)`,**每轮只播一次**。 -- 现有 `system_notifications_enabled` 的通知逻辑保持不变。 - -**4. 试听命令(`src-tauri/src/sound.rs` + `lib.rs` 注册)** - -- `#[tauri::command] #[specta::specta] fn preview_waiting_sound(sound: WaitingSound) -> Result<(), String>`,内部复用播放 helper。 -- 在 `lib.rs::build_specta_builder()` 的 `collect_commands![]` 注册,运行 `make bindings` 重新生成 `src/bindings.ts`,再 `make bindings-check`。 -- 该命令仅调用本地 `afplay`,不触碰文件系统受控目录,无需新增 capability。 - -### 前端 - -**`src/components/SettingsDrawer.tsx`**(通知相关区): - -- 新增 **开关**(绑定 `waiting_sound_enabled`)。 -- 新增 **音效下拉选择**(绑定 `waiting_sound`,选项来自枚举)。 -- 新增 **试听按钮**,调用 `ipc` 包装的 `previewWaitingSound(sound)`。 -- 开关关闭时,下拉与试听按钮禁用。 -- 所有用户可见文案走 `useI18n()` 的 `t()`,新增 i18n key(`src/i18n.ts`)。 -- `AppPreferences` 新增字段经现有 get / update 偏好命令 + `make bindings` 自动贯通;试听命令在 `src/ipc.ts` 增窄包装(如生成类型不直接兼容)。 - -## 数据流 - -``` -tray 轮询 sessions/*.json - → PendingSessionNotifier.observe() - → 计算 new_waiting_sessions、has_new_waiting - → handle_pending_session_notifications() - ├─ system_notifications_enabled? → 现有系统通知(不变) - └─ waiting_sound_enabled && has_new_waiting? → sound::play_waiting_sound() - → afplay (spawn detach) -``` - -## 错误处理与边界 - -- 非 macOS:`sound.rs` no-op;前端控件照常显示(与现有"系统通知"开关一致),仅不发声。 -- afplay 不存在 / 系统音效文件缺失:`warn` 日志,静默降级,不影响通知与托盘。 -- 偏好出现未知音效值:`serde` 反序列化回退默认 `Glass`。 -- 播放不阻塞托盘事件循环(spawn 不 wait)。 - -## 测试 - -- Rust 单测: - - `observe()` 的 `has_new_waiting` 仅在 `seen_snapshot` 之后、有新等待会话时为 `true`;启动首帧为 `false`;无新会话为 `false`。 - - `WaitingSound → 文件名` 映射白名单覆盖全部变体。 - - 更新现有 `observe` 相关单测以适配新返回类型。 -- 契约:`make bindings-check`、`make build-frontend`、`make test-rust`。 -- Rust 行为变更补 `make check`、`make lint-rust`。 - -## 明确不做(YAGNI) - -- 不做自定义音频文件上传 / 打包自定义 chime。 -- 不做音量、循环、多音效编排。 -- 不引入跨平台音频库(非 macOS 直接 no-op)。 -- 不复用 / 改动系统通知自带声音。 -- 不做 TTS 朗读、不携带项目名 / 会话数等动态内容。 - -## 同步点清单 - -- `src-tauri/src/config.rs`(`AppPreferences` + `WaitingSound` 枚举 + `default()`) -- `src-tauri/src/sound.rs`(新模块:映射 + 播放 + 试听命令) -- `src-tauri/src/tray.rs`(`observe` 返回结构 + 触发处播放) -- `src-tauri/src/lib.rs`(注册 `preview_waiting_sound`) -- `src/bindings.ts`(`make bindings` 生成) -- `src/ipc.ts`(试听命令窄包装,按需) -- `src/components/SettingsDrawer.tsx`(开关 + 下拉 + 试听) -- `src/i18n.ts`(新增文案) -- `src/types.ts`(如手工类型需同步) -- 相关 Rust 单测 From 6dcfb1e825f0454e0ce3adf4be27c44a66392743 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sat, 11 Jul 2026 19:06:08 +0800 Subject: [PATCH 15/22] =?UTF-8?q?docs(agents):=20=E6=96=B0=E5=A2=9E=20Agen?= =?UTF-8?q?t=20skills=20=E9=85=8D=E7=BD=AE=E4=B8=8E=20issue=20tracker/tria?= =?UTF-8?q?ge/domain=20=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 14 ++++++++++ docs/agents/domain.md | 51 ++++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 45 +++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 15 +++++++++++ 4 files changed, 125 insertions(+) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/CLAUDE.md b/CLAUDE.md index f53f16b..64929f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,3 +137,17 @@ macOS 上应用数据刻意复用 `~/.config/code-manager/`,便于跨平台备 4. `src/App.tsx`、`src/main.tsx`、`src-tauri/src/lib.rs`、`src-tauri/src/utils.rs` 5. 需要人类产品背景时再读 `README.md` 或 `docs/user-manual.md` 6. 涉及发版流程时阅读 `.claude/skills/release-new-version/SKILL.md`(手动触发,模型不得自动调用) + +## Agent skills + +### Issue tracker + +Issues 追踪在仓库的 GitHub Issues,通过 `gh` CLI 操作;外部 PR 不作为 triage 队列来源。详见 `docs/agents/issue-tracker.md`。 + +### Triage labels + +采用默认标签词汇:`needs-triage` / `needs-info` / `ready-for-agent` / `ready-for-human` / `wontfix`。详见 `docs/agents/triage-labels.md`。 + +### Domain docs + +单上下文布局:根目录 `CONTEXT.md` + `docs/adr/`。详见 `docs/agents/domain.md`。 diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..b548c53 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..82cfbf5 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me` — the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..b716855 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. From 17a15463176c079416451870f34e7092307dcc74 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 12 Jul 2026 20:35:15 +0800 Subject: [PATCH 16/22] =?UTF-8?q?fix(profiles):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=B5=8B=E8=AF=95=E7=BB=93=E6=9E=9C=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=B1=95=E7=A4=BA=E4=B8=8E=E5=A4=B1=E8=B4=A5=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将测试状态从模型行拆为独立「测试:」行,展示 Provider 合并后的有效 模型/effort,缩短耗时文案并补充批量 Toast、保存清状态与 Dialog 失败 原因卡,修复无模型覆盖时结果不可见与布局挤压。 --- src/components/ProfilesPage.tsx | 97 +++++++++++++-- .../__tests__/ModelTestResultDialog.test.tsx | 28 +++++ .../__tests__/ProfilesPage.test.tsx | 114 +++++++++++++++-- .../__tests__/config-workspace-utils.test.ts | 62 ++++++++++ src/components/config-workspace-utils.ts | 74 ++++++++++- .../profile-editor/ModelTestResultDialog.tsx | 115 +++++++++++++----- src/i18n.ts | 12 +- 7 files changed, 446 insertions(+), 56 deletions(-) diff --git a/src/components/ProfilesPage.tsx b/src/components/ProfilesPage.tsx index 8f35712..2dd7f52 100644 --- a/src/components/ProfilesPage.tsx +++ b/src/components/ProfilesPage.tsx @@ -40,10 +40,14 @@ import type { import ConfigPreview from "./ConfigPreview"; import ConfirmAlertDialog from "./ConfirmAlertDialog"; import { + formatModelTestDurationMs, getEnabledPluginsSummary, isPlainObject, providerNameById, providerSlugFromId, + resolveProfileEffectiveEffort, + resolveProfileEffectiveModel, + truncateModelTestErrorMessage, } from "./config-workspace-utils"; import EmptyState from "./EmptyState"; import type { EditorExitGuard } from "./editor-exit-guard"; @@ -556,7 +560,8 @@ function ProfilesPage({ } function profilePrimaryModel(profile: ConfigProfile) { - return settingsPrimaryModel(profile.settings); + // 列表展示有效模型:配置覆盖 ⊕ 供应商默认,与一键测试 resolve 语义一致 + return resolveProfileEffectiveModel(profile, allProviders); } function settingsEffortLevel(settings: Record) { @@ -571,7 +576,7 @@ function ProfilesPage({ } function profileEffortLevel(profile: ConfigProfile) { - return settingsEffortLevel(profile.settings); + return resolveProfileEffectiveEffort(profile, allProviders); } function profileEffortLevelClass(effort: string) { @@ -671,6 +676,18 @@ function ProfilesPage({ .join("\n"); } + function clearProfileModelTestState(profileId: string) { + setProfileModelTestStates((current) => { + if (!(profileId in current)) { + return current; + } + const next = { ...current }; + delete next[profileId]; + return next; + }); + setActiveModelTestDialog((current) => (current?.profileId === profileId ? null : current)); + } + async function handleSave(data: { id?: string; name: string; @@ -681,6 +698,10 @@ function ProfilesPage({ try { await ipc.upsertProfile(data); await onWorkspaceChange(); + // 配置已变,旧连通性快照作废 + if (data.id) { + clearProfileModelTestState(data.id); + } closeDrawer(); showToast(t("profiles.toast.saved")); return true; @@ -713,6 +734,7 @@ function ProfilesPage({ settings: activeSettingsMismatch.actualSettings, }); await onWorkspaceChange(); + clearProfileModelTestState(profile.id); setIsSettingsMismatchDialogOpen(false); showToast(t("profiles.mismatch.toast.accepted")); } catch (err) { @@ -752,6 +774,7 @@ function ProfilesPage({ try { await ipc.deleteProfile(id); await onWorkspaceChange(); + clearProfileModelTestState(id); showToast(t("profiles.toast.deleted")); } catch (err) { showOperationError(showToast, t("profiles.toast.deleteError"), err); @@ -1014,18 +1037,28 @@ function ProfilesPage({ setActiveModelTestDialog(null); setIsRawResponseExpanded(false); + let successCount = 0; + let failedCount = 0; + async function testProfile(profile: ConfigProfile) { try { const result = await invokeProfileModelTest(profile); if (modelTestRunIdRef.current === runId) { + const nextState = modelTestStateFromResult(result); + if (nextState.status === "success") { + successCount += 1; + } else { + failedCount += 1; + } setProfileModelTestStates((current) => ({ ...current, - [profile.id]: modelTestStateFromResult(result), + [profile.id]: nextState, })); } return result; } catch (error) { if (modelTestRunIdRef.current === runId) { + failedCount += 1; setProfileModelTestStates((current) => ({ ...current, [profile.id]: { @@ -1062,6 +1095,11 @@ function ProfilesPage({ if (modelTestRunIdRef.current === runId) { setIsTestingAllProfiles(false); + showToast( + t("profiles.testAll.summary") + .replace("{successCount}", String(successCount)) + .replace("{failedCount}", String(failedCount)), + ); } } @@ -1164,7 +1202,10 @@ function ProfilesPage({ function modelTestResultLabel(state: Exclude) { return state.status === "success" - ? t("profiles.testAll.successResult").replace("{durationMs}", String(state.result.durationMs)) + ? t("profiles.testAll.successResult").replace( + "{duration}", + formatModelTestDurationMs(state.result.durationMs), + ) : t("profiles.testAll.failed"); } @@ -1211,6 +1252,11 @@ function ProfilesPage({ const ariaLabel = modelTestResultAriaLabel(profile, state); const isSuccess = state.status === "success"; const ResultIcon = isSuccess ? CircleCheck : CircleAlert; + const failureTip = + state.status === "failed" && state.errorMessage + ? truncateModelTestErrorMessage(state.errorMessage) + : ""; + const titleText = failureTip || ariaLabel; return ( ); } + function renderProfileTestSummaryRow(profile: ConfigProfile) { + const state = profileModelTestStates[profile.id]; + if (!state) { + return null; + } + + return ( +
+ + {t("profiles.summary.testTitle")} + +
+ {renderProfileModelTestState(profile)} +
+
+ ); + } + function renderUnmanagedUserSettingsCard(userSettings: UnmanagedUserSettings) { const statusLabel = t( unmanagedUserSettingsStatusLabels[userSettings.importStatus] ?? @@ -1530,7 +1597,13 @@ function ProfilesPage({ const permissionMode = profilePermissionMode(profile); const sandboxEnabled = profileSandboxEnabled(profile); const plugins = profilePluginsSummary(profile); - const hasSummary = model || permissionMode || sandboxEnabled || plugins.totalCount > 0; + const hasTestState = Boolean(profileModelTestStates[profile.id]); + const hasSummary = + Boolean(model) || + Boolean(permissionMode) || + sandboxEnabled || + plugins.totalCount > 0 || + hasTestState; const isEditingProfile = isDrawerOpen && editingProfile?.id === profile.id; const isAppliedProfile = isAppliedToUserSettings(profile); const settingsMismatch = profileSettingsMismatch(profile); @@ -1645,7 +1718,7 @@ function ProfilesPage({ {hasSummary && (
- {model && ( + {model ? (
{t("profiles.summary.modelTitle")} @@ -1655,8 +1728,7 @@ function ProfilesPage({ className="flex min-w-0 flex-wrap items-center gap-1.5" > {model} - {renderProfileModelTestState(profile)} - {effort && ( + {effort ? ( {effort} - )} + ) : null}
- )} + ) : null} + {renderProfileTestSummaryRow(profile)} {(permissionMode || sandboxEnabled) && (
diff --git a/src/components/__tests__/ModelTestResultDialog.test.tsx b/src/components/__tests__/ModelTestResultDialog.test.tsx index 740f596..b2ce838 100644 --- a/src/components/__tests__/ModelTestResultDialog.test.tsx +++ b/src/components/__tests__/ModelTestResultDialog.test.tsx @@ -354,4 +354,32 @@ describe("ModelTestResultDialog", () => { expect(clipboardWriteMock).toHaveBeenCalledWith(expect.stringContaining("请只回复 OK")); expect(showToastMock).toHaveBeenCalledWith("已复制请求 cURL"); }); + + it("shows a failure reason card with model unset when request never resolved a model", () => { + render( + + + {}} + onToggleRawResponse={() => {}} + /> + + , + ); + + const dialog = screen.getByRole("dialog", { name: "模型测试结果" }); + const reasonCard = within(dialog).getByTestId("model-test-error-reason-card"); + expect(within(reasonCard).getByText("失败原因")).toBeInTheDocument(); + expect( + within(reasonCard).getByText("缺少默认模型,请先在模型与行为中填写默认模型"), + ).toBeInTheDocument(); + expect(within(reasonCard).getByTestId("model-test-error-reason-model")).toHaveTextContent( + "未设置", + ); + }); }); diff --git a/src/components/__tests__/ProfilesPage.test.tsx b/src/components/__tests__/ProfilesPage.test.tsx index a528929..6dada98 100644 --- a/src/components/__tests__/ProfilesPage.test.tsx +++ b/src/components/__tests__/ProfilesPage.test.tsx @@ -779,20 +779,27 @@ describe("ProfilesPage", () => { const alphaCard = getProfileCard("Alpha"); const betaCard = getProfileCard("Beta"); - expect(within(alphaCard).getByText("成功 · 52 ms")).toBeInTheDocument(); + expect(within(alphaCard).getByText("成功 52ms")).toBeInTheDocument(); expect(within(betaCard).getByText("失败")).toBeInTheDocument(); expect(within(alphaCard).getByText("model-profile-a")).toBeInTheDocument(); expect(within(betaCard).getByText("model-profile-b")).toBeInTheDocument(); + // 测试结果在独立「测试:」行,不再挂在模型行 + expect(within(alphaCard).getByText("测试")).toBeInTheDocument(); + expect( + within(alphaCard).getByText("成功 52ms").closest('[data-slot="profile-test-summary-value"]'), + ).not.toBeNull(); + expect(showToastMock).toHaveBeenCalledWith("测试完成:1 成功,1 失败"); - fireEvent.click( - within(alphaCard).getByRole("button", { name: "Alpha 测试结果:成功 · 52 ms" }), - ); + fireEvent.click(within(alphaCard).getByRole("button", { name: "Alpha 测试结果:成功 52ms" })); expect(await screen.findByRole("dialog", { name: "模型测试结果" })).toBeInTheDocument(); expect(screen.getByText("Alpha 测试成功")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "关闭" })); - fireEvent.click(within(betaCard).getByRole("button", { name: "Beta 测试结果:失败" })); + const failedButton = within(betaCard).getByRole("button", { name: "Beta 测试结果:失败" }); + expect(failedButton).toHaveAttribute("title", "Beta 认证失败"); + fireEvent.click(failedButton); expect(await screen.findByRole("dialog", { name: "模型测试结果" })).toBeInTheDocument(); + expect(screen.getByTestId("model-test-error-reason-card")).toBeInTheDocument(); expect(screen.getByText("Beta 认证失败")).toBeInTheDocument(); }); @@ -1066,9 +1073,12 @@ describe("ProfilesPage", () => { expect(within(dialog).getByTestId("model-test-request-url-row")).toBeInTheDocument(); expect(within(dialog).getByTestId("model-test-content-grid")).toBeInTheDocument(); expect(within(dialog).getByTestId("model-test-prompt-panel")).toBeInTheDocument(); - expect(within(dialog).getByTestId("model-test-response-panel")).toBeInTheDocument(); + expect(within(dialog).getByTestId("model-test-error-reason-card")).toBeInTheDocument(); + expect(within(dialog).queryByTestId("model-test-response-panel")).not.toBeInTheDocument(); expect(within(dialog).getByTestId("model-test-exchange-details")).toBeInTheDocument(); - expect(within(dialog).getByText(/No choices in OpenAI response/)).toBeInTheDocument(); + expect(within(dialog).getByTestId("model-test-error-reason-message")).toHaveTextContent( + /No choices in OpenAI response/, + ); selectTab(dialog, "请求"); await waitFor(() => @@ -1204,7 +1214,7 @@ describe("ProfilesPage", () => { expect(within(dialog).getByText("重新测试成功")).toBeInTheDocument(); expect(within(dialog).getByText("88 ms")).toBeInTheDocument(); expect( - within(card).getByRole("button", { name: "OpenRouter User 测试结果:成功 · 88 ms" }), + within(card).getByRole("button", { name: "OpenRouter User 测试结果:成功 88ms" }), ).toBeInTheDocument(); }); @@ -1469,7 +1479,7 @@ describe("ProfilesPage", () => { expect(screen.getByText("Model")).toBeInTheDocument(); }); - it("keeps long english model test result pills inside the model summary", async () => { + it("renders batch test results on an independent test summary row", async () => { localStorage.setItem( SETTINGS_STORAGE_KEY, JSON.stringify({ @@ -1517,16 +1527,94 @@ describe("ProfilesPage", () => { const card = getProfileCard("OpenRouter User"); const resultButton = within(card).getByRole("button", { - name: "OpenRouter User test result: Success · 3652 ms", + name: "OpenRouter User test result: Success 3.7s", }); - const modelSummaryValue = resultButton.closest('[data-slot="profile-model-summary-value"]'); + const testSummaryValue = resultButton.closest('[data-slot="profile-test-summary-value"]'); + const modelSummaryValue = within(card) + .getByText("claude-opus-4-7") + .closest('[data-slot="profile-model-summary-value"]'); expect(resultButton).toHaveClass("max-w-full"); expect(resultButton).toHaveClass("overflow-hidden"); - expect(within(resultButton).getByText("Success · 3652 ms")).toHaveClass("truncate"); + expect(within(resultButton).getByText("Success 3.7s")).toHaveClass("truncate"); + expect(testSummaryValue).not.toBeNull(); expect(modelSummaryValue).not.toBeNull(); - expect(modelSummaryValue).toHaveClass("flex-wrap"); expect(within(modelSummaryValue as HTMLElement).getByText("xhigh")).toBeInTheDocument(); + expect(within(modelSummaryValue as HTMLElement).queryByText("Success 3.7s")).toBeNull(); + expect(showToastMock).toHaveBeenCalledWith("Tests finished: 1 succeeded, 0 failed"); + }); + + it("shows provider-default model and test results when profile does not override model", async () => { + localStorage.setItem( + SETTINGS_STORAGE_KEY, + JSON.stringify({ + language: "zh", + theme: "dark", + }), + ); + invokeMock.mockImplementation((command: string) => { + if (command === "test_profile_model") { + return Promise.resolve({ + ok: true, + responseText: "ok", + promptText: "请确认测试成功。", + resolvedModel: "deepseek-v4-pro[1m]", + durationMs: 119, + rawResponse: JSON.stringify({ content: [{ type: "text", text: "ok" }] }), + }); + } + return Promise.resolve(null); + }); + + const workspace: ConfigWorkspace = { + ...WORKSPACE_FIXTURE, + builtinProviders: [ + { + id: "builtin:deepseek", + name: "DeepSeek", + localizedName: { zh: "DeepSeek", en: "DeepSeek" }, + description: "DeepSeek", + modelSuggestions: ["deepseek-v4-pro[1m]"], + env: { + ANTHROPIC_MODEL: "deepseek-v4-pro[1m]", + CLAUDE_CODE_EFFORT_LEVEL: "max", + }, + }, + ], + profiles: [ + { + id: "user-deepseek", + name: "DeepSeek User", + description: "无模型覆盖", + providerId: "builtin:deepseek", + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: "token", + }, + }, + createdAt: "2026-04-18T12:00:00Z", + updatedAt: "2026-04-18T12:00:00Z", + }, + ], + bindings: { userProfileId: undefined }, + } as ConfigWorkspace; + + renderPage(workspace); + + const card = getProfileCard("DeepSeek User"); + expect(within(card).getByText("deepseek-v4-pro[1m]")).toBeInTheDocument(); + expect(within(card).getByText("max")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "一键测试" })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(within(card).getByText("成功 119ms")).toBeInTheDocument(); + expect( + within(card).getByText("成功 119ms").closest('[data-slot="profile-test-summary-value"]'), + ).not.toBeNull(); }); it("colors profile permission and effort summary values by risk and intensity", () => { diff --git a/src/components/__tests__/config-workspace-utils.test.ts b/src/components/__tests__/config-workspace-utils.test.ts index 5916961..1cee440 100644 --- a/src/components/__tests__/config-workspace-utils.test.ts +++ b/src/components/__tests__/config-workspace-utils.test.ts @@ -3,6 +3,7 @@ import type { Provider } from "../../types"; import { applyEnvDefaults, applyProviderAutofill, + formatModelTestDurationMs, getEnabledPluginsSummary, prettyJson, providerDisplayName, @@ -12,12 +13,15 @@ import { readMappedString, readScopedSettingsWithEnv, replaceScopedSettingsWithEnv, + resolveProfileEffectiveEffort, + resolveProfileEffectiveModel, resolveProviderAutofillValues, setEnvString, setMappedString, setTopLevelBoolean, setTopLevelObject, setTopLevelString, + truncateModelTestErrorMessage, } from "../config-workspace-utils"; /** 段 B:Provider 默认模型完全来自 env 显式声明,不再按模型 category 隐式推断 */ @@ -235,6 +239,64 @@ describe("config-workspace-utils preset autofill", () => { }); }); +describe("config-workspace-utils profile effective summary", () => { + it("uses provider env model/effort when profile does not override", () => { + const profile = { + providerId: "builtin:deepseek", + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: "token", + }, + }, + }; + + expect(resolveProfileEffectiveModel(profile, PRESETS)).toBe("deepseek-v4-pro[1m]"); + expect(resolveProfileEffectiveEffort(profile, PRESETS)).toBe("max"); + }); + + it("prefers profile overrides over provider defaults", () => { + const profile = { + providerId: "builtin:deepseek", + settings: { + env: { + ANTHROPIC_MODEL: "profile-model", + CLAUDE_CODE_EFFORT_LEVEL: "high", + }, + }, + }; + + expect(resolveProfileEffectiveModel(profile, PRESETS)).toBe("profile-model"); + expect(resolveProfileEffectiveEffort(profile, PRESETS)).toBe("high"); + }); + + it("falls back to top-level model/effortLevel when env is empty", () => { + const profile = { + providerId: "builtin:openrouter", + settings: { + model: "top-level-model", + effortLevel: "medium", + }, + }; + + expect(resolveProfileEffectiveModel(profile, PRESETS)).toBe("top-level-model"); + expect(resolveProfileEffectiveEffort(profile, PRESETS)).toBe("medium"); + }); + + it("formats model test durations with ms under 1s and short seconds otherwise", () => { + expect(formatModelTestDurationMs(119)).toBe("119ms"); + expect(formatModelTestDurationMs(999)).toBe("999ms"); + expect(formatModelTestDurationMs(1000)).toBe("1s"); + expect(formatModelTestDurationMs(3000)).toBe("3s"); + expect(formatModelTestDurationMs(3512)).toBe("3.5s"); + expect(formatModelTestDurationMs(-1)).toBe("0ms"); + }); + + it("truncates long model test error messages for tooltips", () => { + expect(truncateModelTestErrorMessage("short")).toBe("short"); + expect(truncateModelTestErrorMessage("a".repeat(130))).toBe(`${"a".repeat(119)}…`); + }); +}); + describe("config-workspace-utils settings mutators", () => { it("serializes nullish values to an empty object literal", () => { expect(prettyJson(undefined)).toBe("{}"); diff --git a/src/components/config-workspace-utils.ts b/src/components/config-workspace-utils.ts index ef92496..e8bb435 100644 --- a/src/components/config-workspace-utils.ts +++ b/src/components/config-workspace-utils.ts @@ -1,4 +1,4 @@ -import type { LocalizedText, Provider } from "../types"; +import type { ConfigProfile, LocalizedText, Provider } from "../types"; export function prettyJson(value: unknown): string { return JSON.stringify(value ?? {}, null, 2); @@ -353,3 +353,75 @@ export function applyProviderAutofill( // 仅在切到可解析供应商时清掉残留的地址覆盖——地址由 Provider 合并层提供(单一事实源),与后端一致。 return providerResolved ? setEnvString(settings, "ANTHROPIC_BASE_URL", "") : settings; } + +/** + * 读取配置 env 覆盖;没有时回退到 Provider.env。 + * 与后端 resolve_profile_settings 对 env 键的叠法对齐(配置覆盖供应商)。 + */ +export function resolveProfileEffectiveEnvString( + profile: Pick, + providers: Provider[], + envKey: string, +): string { + const settingsEnv = isPlainObject(profile.settings.env) ? profile.settings.env : {}; + const fromProfile = normalizeProviderEnvValue(settingsEnv[envKey]); + if (fromProfile) { + return fromProfile; + } + + if (!profile.providerId) { + return ""; + } + const provider = providers.find((item) => item.id === profile.providerId); + return normalizeProviderEnvValue(provider?.env?.[envKey]) ?? ""; +} + +/** 列表展示用:Provider ⊕ 配置合并后的默认模型(与测试 resolvedModel 语义对齐) */ +export function resolveProfileEffectiveModel( + profile: Pick, + providers: Provider[], +): string { + const fromEnv = resolveProfileEffectiveEnvString(profile, providers, "ANTHROPIC_MODEL"); + if (fromEnv) { + return fromEnv; + } + return normalizeProviderEnvValue(profile.settings.model) ?? ""; +} + +/** 列表展示用:合并后的 effort 等级 */ +export function resolveProfileEffectiveEffort( + profile: Pick, + providers: Provider[], +): string { + const fromEnv = resolveProfileEffectiveEnvString(profile, providers, "CLAUDE_CODE_EFFORT_LEVEL"); + if (fromEnv) { + return fromEnv; + } + return normalizeProviderEnvValue(profile.settings.effortLevel) ?? ""; +} + +/** + * 模型测试耗时短文案:<1s 用 ms,≥1s 用短秒(一位小数,整数秒去尾 .0)。 + */ +export function formatModelTestDurationMs(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return "0ms"; + } + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + const seconds = Math.round((durationMs / 1000) * 10) / 10; + if (Number.isInteger(seconds)) { + return `${seconds}s`; + } + return `${seconds.toFixed(1)}s`; +} + +/** 失败 tip/title 截断,避免超长错误撑破布局 */ +export function truncateModelTestErrorMessage(message: string, maxLength = 120): string { + const trimmed = message.trim().replace(/\s+/g, " "); + if (trimmed.length <= maxLength) { + return trimmed; + } + return `${trimmed.slice(0, Math.max(0, maxLength - 1))}…`; +} diff --git a/src/components/profile-editor/ModelTestResultDialog.tsx b/src/components/profile-editor/ModelTestResultDialog.tsx index 11d190b..beb266a 100644 --- a/src/components/profile-editor/ModelTestResultDialog.tsx +++ b/src/components/profile-editor/ModelTestResultDialog.tsx @@ -502,6 +502,67 @@ function ModelTestResultDialog({
+ {!isSuccess && summaryText ? ( +
+
+ + {t("profiles.editor.modelTest.errorReasonTitle")} + + {rawResponse ? ( + + ) : null} +
+

+ {summaryText} +

+
+
+
+ {t("profiles.editor.modelTest.resolvedModel")} +
+
+ {result?.resolvedModel?.trim() + ? result.resolvedModel.trim() + : t("profiles.editor.modelTest.modelUnset")} +
+
+ {requestUrl ? ( +
+
+ {t("profiles.editor.modelTest.requestUrl")} +
+
+ {requestUrl} +
+
+ ) : null} +
+
+ ) : null} + {trimmedProfileName || requestUrl ? (
) : null} -
-
- - {isSuccess - ? t("profiles.editor.modelTest.response") - : t("profiles.editor.modelTest.errorMessage")} - - {rawResponse ? ( - - ) : null} + {/* 失败文案已在顶部原因卡展示,避免概览区重复 */} + {isSuccess ? ( +
+
+ + {t("profiles.editor.modelTest.response")} + + {rawResponse ? ( + + ) : null} +
+

+ {summaryText} +

-

- {summaryText} -

-
+ ) : null}
diff --git a/src/i18n.ts b/src/i18n.ts index f3f7571..e9bf2c5 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -293,9 +293,11 @@ const translations = { "profiles.actions.testingAll": "测试中...", "profiles.testAll.running": "测试中...", "profiles.testAll.runningBadge": "测试中", - "profiles.testAll.successResult": "成功 · {durationMs} ms", + "profiles.testAll.successResult": "成功 {duration}", "profiles.testAll.failed": "失败", "profiles.testAll.resultAriaLabel": "{name} 测试结果:{result}", + "profiles.testAll.summary": "测试完成:{successCount} 成功,{failedCount} 失败", + "profiles.summary.testTitle": "测试", "profiles.badges.inUse": "使用中", "profiles.badges.editing": "编辑中", "profiles.mismatch.button": "配置被手动修改", @@ -450,6 +452,8 @@ const translations = { "profiles.editor.modelTest.editPrompt": "编辑提示词", "profiles.editor.modelTest.sendPromptRequest": "发起请求", "profiles.editor.modelTest.errorMessage": "错误信息", + "profiles.editor.modelTest.errorReasonTitle": "失败原因", + "profiles.editor.modelTest.modelUnset": "未设置", "profiles.editor.modelTest.resolvedModel": "使用模型", "profiles.editor.modelTest.providerModel": "返回模型", "profiles.editor.modelTest.statusCode": "状态码", @@ -1980,9 +1984,11 @@ const translations = { "profiles.actions.testingAll": "Testing...", "profiles.testAll.running": "Testing...", "profiles.testAll.runningBadge": "Testing", - "profiles.testAll.successResult": "Success · {durationMs} ms", + "profiles.testAll.successResult": "Success {duration}", "profiles.testAll.failed": "Failed", "profiles.testAll.resultAriaLabel": "{name} test result: {result}", + "profiles.testAll.summary": "Tests finished: {successCount} succeeded, {failedCount} failed", + "profiles.summary.testTitle": "Test", "profiles.badges.inUse": "In Use", "profiles.badges.editing": "Editing", "profiles.mismatch.button": "Settings changed manually", @@ -2144,6 +2150,8 @@ const translations = { "profiles.editor.modelTest.editPrompt": "Edit Prompt", "profiles.editor.modelTest.sendPromptRequest": "Send Request", "profiles.editor.modelTest.errorMessage": "Error Message", + "profiles.editor.modelTest.errorReasonTitle": "Failure Reason", + "profiles.editor.modelTest.modelUnset": "Not set", "profiles.editor.modelTest.resolvedModel": "Resolved Model", "profiles.editor.modelTest.providerModel": "Provider Model", "profiles.editor.modelTest.statusCode": "Status Code", From c9cf4ce3569c97f8cad1ebe4fe03d5801cdda542 Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 12 Jul 2026 21:41:11 +0800 Subject: [PATCH 17/22] =?UTF-8?q?feat(claude-overview):=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=80=BB=E8=A7=88=E5=B1=95=E7=A4=BA=E8=BD=AF=E9=93=BE?= =?UTF-8?q?=E5=B9=B6=E5=8F=AA=E8=AF=BB=E8=B7=9F=E9=9A=8F=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扫描收录软链(目录可展开)、预览可跟随出界目标;写操作经软链路径仍拒绝。 二进制改扩展名黑名单,文本一律可预览;修复上下文菜单 portal 与软链提示 UI。 --- CONTEXT.md | 18 + ...ctory-overview-readonly-follow-symlinks.md | 19 + docs/user-manual.md | 2 +- docs/user-manual.zh-CN.md | 2 +- src-tauri/src/claude_directory.rs | 699 ++++++++++++++---- src-tauri/src/project.rs | 6 +- src/App.test.tsx | 45 +- src/bindings.ts | 22 +- src/components/ClaudeOverviewPage.tsx | 121 ++- src/components/ProjectClaudeExplorer.tsx | 31 +- .../__tests__/ClaudeFilePreviewPane.test.tsx | 5 + .../ProjectAutoMemoryExplorer.test.tsx | 18 +- .../__tests__/ProjectsPage.test.tsx | 23 +- .../claude-overview/ClaudeDirectoryTree.tsx | 39 +- .../claude-overview/ClaudeFilePreviewPane.tsx | 37 +- .../claude-overview/file-viewer-utils.ts | 73 ++ src/i18n.ts | 18 +- src/types.ts | 20 +- 18 files changed, 1023 insertions(+), 175 deletions(-) create mode 100644 docs/adr/0002-directory-overview-readonly-follow-symlinks.md diff --git a/CONTEXT.md b/CONTEXT.md index 389cfe3..67eef4e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,3 +31,21 @@ _Avoid_: 自动模式 **活动会话(Active Session)**: 一个处于 running 类状态(`running / busy / active / starting`)的 Claude Code 会话。**waiting(等待用户操作)不计入**——那时 Claude 卡在等人,机器休眠也不会杀死会话。"仅活动时"模式据此判断是否保持唤醒。 _Avoid_: 运行中会话(running session,只是其中一个具体状态) + +### 目录总览(Directory Overview) + +**目录总览(Directory Overview)**: +对 Claude Code 本地目录树(`~/.claude` 或项目级 `.claude/`)的只读浏览视图,用于查看布局、预览文件内容与诊断异常条目。 +_Avoid_: 文件管理器、资源管理器、通用编辑器 + +**软链条目(Symlink Entry)**: +目录总览中**自身**为符号链接的树节点。可只读查看(目录可展开、文件可预览),不可经总览写入。 +_Avoid_: 快捷方式、别名、映射(单独指代软链时) + +**经软链路径(Path Via Symlink)**: +解析时经过至少一个[软链条目](#软链条目symlink-entry)的逻辑路径。只读可预览;新建、重命名、删除一律拒绝。 +_Avoid_: 外部路径、越界路径(经软链可读但写仍拒绝,与恶意路径逃逸不同) + +**可预览文本(Previewable Text)**: +目录总览中非已知二进制类型的文件内容,以 UTF-8 或有损 UTF-8 形式展示供阅读。 +_Avoid_: 源码、纯文本白名单(判定是「非已知二进制」,不是穷举文本扩展名) diff --git a/docs/adr/0002-directory-overview-readonly-follow-symlinks.md b/docs/adr/0002-directory-overview-readonly-follow-symlinks.md new file mode 100644 index 0000000..6d844fd --- /dev/null +++ b/docs/adr/0002-directory-overview-readonly-follow-symlinks.md @@ -0,0 +1,19 @@ +# 目录总览只读跟随软链(含出界目标) + +## Context + +`~/.claude` 中大量合法布局依赖软链(例如 `skills/ -> ~/.agents/skills/`)。旧实现扫描时跳过全部软链、预览路径遇软链即拒绝,导致总览里 Skills 等目录「几乎为空」,用户无法诊断或阅读真实内容。安全上又不希望总览变成可改写 `~/.claude` 外源文件的通用文件管理器。 + +## Decision + +1. **扫描收录软链**:目录软链可展开并递归列子项;文件软链可点开预览。顶栏统计为「含 N 个软链接」,不再「跳过」。 +2. **预览只读跟随,允许出界**:相对路径仍禁止 `..` / 绝对段;解析时允许跟随符号链接,目标可在 root 外。写操作(新建 / 重命名 / 删除)在路径上一旦经过软链则全部拒绝。 +3. **损坏与环**:悬空软链仍显示并标损坏;以 canonicalize 真实路径去重,再次遇到标循环且不递归。 +4. **二进制预览**:按已知二进制扩展名(及 `.DS_Store` 等特殊名)黑名单判定;其余一律按 UTF-8 / lossy 文本预览,不再因内容含 `NUL` 清空。 +5. **范围**:用户级 `~/.claude` 总览与项目级 `.claude` 浏览共用同一契约。 + +## Consequences + +- 读预览的可达面扩大到软链指向的任意本地目标;风险靠「只读 + 大小截断 + 写拒绝」收敛。 +- UI 必须标明软链与目标路径,避免用户把出界内容误认为 root 内真文件。 +- 删除 / 修复悬空软链需在终端或源目录完成,总览不提供写入口。 diff --git a/docs/user-manual.md b/docs/user-manual.md index 828862d..179b51a 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -96,7 +96,7 @@ Click the `AI` entry in the upper-left corner to open it. After you select a fil The file preview toolbar lets you copy the absolute path, reveal it in the file browser, and open it with the default editor; right-clicking in the directory tree lets you create a new file, create a new folder, rename, or delete. Deletion cannot be undone, so make sure you have a copy before handling `settings.json`, `CLAUDE.md`, `rules/`, or `skills/`. -The Directory Overview only allows operations on paths inside `~/.claude`; the scan skips symlinks and `node_modules`, and shows a notice when the entry count or depth limit is reached. +The Directory Overview lists paths under `~/.claude` (including symlinks). Symlink entries are labeled and can be opened read-only—directory symlinks expand, and file previews may follow targets outside `~/.claude`. Create, rename, and delete stay disabled on any path that crosses a symlink. The scan still skips `node_modules`, and shows a notice when the entry count or depth limit is reached. Binary preview uses a known-extension denylist; other files open as UTF-8 / lossy text. ## Configurations diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index 00d1c02..4ea3b90 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -96,7 +96,7 @@ Skills 对应 `~/.claude/skills//SKILL.md`。启用的 Skill 保存在 `~/.c 文件预览工具栏可复制绝对路径、在文件浏览器中定位、用默认编辑器打开;目录树右键可新建文件、新建文件夹、重命名、删除。删除不可撤销,处理 `settings.json`、`CLAUDE.md`、`rules/` 或 `skills/` 前请确认副本。 -目录总览只允许操作 `~/.claude` 内部路径;扫描会跳过软链接和 `node_modules`,达到条目数或深度上限时显示提示。 +目录总览列出 `~/.claude` 下的路径(含软链接)。软链条目有明确标记且可只读打开:目录软链可展开,文件预览可跟随指向 `~/.claude` 外的目标。新建、重命名、删除在任何经过软链的路径上均不可用。扫描仍会跳过 `node_modules`;达到条目数或深度上限时显示提示。二进制预览按已知扩展名黑名单判定,其余文件以 UTF-8 / 有损 UTF-8 文本打开。 ## 配置 diff --git a/src-tauri/src/claude_directory.rs b/src-tauri/src/claude_directory.rs index 3f57348..9f3fbd9 100644 --- a/src-tauri/src/claude_directory.rs +++ b/src-tauri/src/claude_directory.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::fs; use std::io::Read; use std::path::{Component, Path, PathBuf}; @@ -10,6 +11,8 @@ const NODE_MODULES_DIR_NAME: &str = "node_modules"; const PREVIEW_ENCODING_UTF8: &str = "utf-8"; const PREVIEW_ENCODING_UTF8_LOSSY: &str = "utf-8-lossy"; const PREVIEW_ENCODING_BINARY: &str = "binary"; +const SYMLINK_READ_ONLY_ERROR: &str = "软链接路径只读,无法修改"; +const SYMLINK_TARGET_UNAVAILABLE_ERROR: &str = "软链接目标不可用"; #[derive(Debug, Clone, Copy)] pub(crate) struct ScanOptions { @@ -32,6 +35,16 @@ pub struct ClaudeDirectoryEntry { pub kind: ClaudeDirectoryEntryKind, pub size: u64, pub modified_at: u64, + /// 该项自身是否为软链(后代经软链可达时仍为 false) + pub is_symlink: bool, + /// `read_link` 原始目标(相对或绝对,按磁盘存储) + pub link_target: Option, + /// 解析后的绝对目标路径;损坏时为 None + pub link_target_absolute: Option, + /// 目标不存在或不可解析 + pub is_broken: bool, + /// 目标真实路径已在本次扫描中访问过(环或菱形汇合),不再递归 + pub is_cycle: bool, } #[derive(Debug, Clone, Serialize, specta::Type)] @@ -44,7 +57,8 @@ pub struct ClaudeDirectoryOverview { pub truncated: bool, pub reached_entry_limit: bool, pub reached_depth_limit: bool, - pub skipped_symlink_count: usize, + /// 扫描到的软链条目数(已收录,非跳过) + pub symlink_count: usize, pub skipped_node_modules_count: usize, } @@ -57,7 +71,7 @@ pub struct ClaudeDirectoryListing { pub entries: Vec, pub truncated: bool, pub reached_entry_limit: bool, - pub skipped_symlink_count: usize, + pub symlink_count: usize, } #[derive(Debug, Clone, Serialize, specta::Type)] @@ -71,6 +85,24 @@ pub struct ClaudeFilePreview { pub size: u64, pub modified_at: u64, pub encoding: &'static str, + /// 叶子节点自身是否为软链 + pub is_symlink: bool, + /// 路径上第一个软链的逻辑相对路径 + pub via_symlink_path: Option, + pub link_target: Option, + pub link_target_absolute: Option, + pub is_broken: bool, +} + +/// 只读解析结果:允许跟随软链(可出界) +struct ResolvedReadPath { + /// root 下的逻辑路径(仍可经软链由 OS 跟随打开) + logical_path: PathBuf, + is_symlink: bool, + via_symlink_path: Option, + link_target: Option, + link_target_absolute: Option, + is_broken: bool, } #[tauri::command] @@ -86,12 +118,12 @@ pub fn get_claude_directory_overview() -> Result Result Result<(), String> { let result = (|| { let root = claude_dir()?; let rel_path = validate_relative_claude_path(&path)?; - let target_path = resolve_existing_path_inside_root(&root, &rel_path)?; - let metadata = - fs::metadata(&target_path).map_err(|e| mask_io_error("读取文件元数据", &e))?; + let resolved = resolve_path_for_read(&root, &rel_path)?; + if resolved.is_broken { + return Err(SYMLINK_TARGET_UNAVAILABLE_ERROR.to_string()); + } + let metadata = fs::metadata(&resolved.logical_path) + .map_err(|e| mask_io_error("读取文件元数据", &e))?; if !metadata.is_file() { return Err("只能用默认编辑器打开 ~/.claude 内的文件".to_string()); } @@ -154,7 +191,7 @@ pub fn open_claude_file_in_editor(path: String) -> Result<(), String> { .default_editor_app .as_deref() .ok_or_else(|| "请先在设置中选择默认编辑器".to_string())?; - crate::native_open::open_path_in_editor(&target_path, editor) + crate::native_open::open_path_in_editor(&resolved.logical_path, editor) })(); crate::logging::log_command_result("claude_directory.open_editor", &result, |_| { format!("path={}", crate::utils::truncate(&path, 160)) @@ -245,7 +282,7 @@ pub(crate) fn scan_claude_directory_with_options( truncated: false, reached_entry_limit: false, reached_depth_limit: false, - skipped_symlink_count: 0, + symlink_count: 0, skipped_node_modules_count: 0, }; @@ -258,7 +295,11 @@ pub(crate) fn scan_claude_directory_with_options( Err(_) => return Ok(overview), } - collect_entries(root, root, 0, options, &mut overview)?; + let mut visited = HashSet::new(); + if let Ok(root_canonical) = fs::canonicalize(root) { + visited.insert(root_canonical); + } + collect_entries(root, root, 0, options, &mut overview, &mut visited)?; Ok(overview) } @@ -272,7 +313,13 @@ pub(crate) fn list_claude_directory_children_from_root( None => None, }; let parent_path = match &parent_rel_path { - Some(rel_path) => resolve_existing_path_inside_root(root, rel_path)?, + Some(rel_path) => { + let resolved = resolve_path_for_read(root, rel_path)?; + if resolved.is_broken { + return Err(SYMLINK_TARGET_UNAVAILABLE_ERROR.to_string()); + } + resolved.logical_path + } None => root.to_path_buf(), }; let parent_path_label = parent_rel_path.as_deref().map(normalize_relative_path); @@ -283,7 +330,7 @@ pub(crate) fn list_claude_directory_children_from_root( entries: Vec::new(), truncated: false, reached_entry_limit: false, - skipped_symlink_count: 0, + symlink_count: 0, }; match fs::metadata(root) { @@ -294,11 +341,14 @@ pub(crate) fn list_claude_directory_children_from_root( } Err(_) => return Ok(listing), } - if !parent_path.is_dir() { + let parent_meta = + fs::metadata(&parent_path).map_err(|e| mask_io_error("读取目录元数据", &e))?; + if !parent_meta.is_dir() { return Err("只能读取 ~/.claude 内的目录".to_string()); } - collect_direct_entries(root, &parent_path, max_entries, &mut listing)?; + // 子列表不做全局环去重展开(无递归);仅标注本层软链是否损坏 + collect_direct_entries(root, &parent_path, max_entries, &mut listing, None)?; Ok(listing) } @@ -307,6 +357,7 @@ fn collect_direct_entries( current: &Path, max_entries: usize, listing: &mut ClaudeDirectoryListing, + visited: Option<&HashSet>, ) -> Result<(), String> { let mut entries = fs::read_dir(current) .map_err(|e| mask_io_error("读取目录", &e))? @@ -321,22 +372,20 @@ fn collect_direct_entries( return Ok(()); } + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); let file_type = entry .file_type() .map_err(|e| mask_io_error("获取文件类型", &e))?; - if file_type.is_symlink() { - listing.skipped_symlink_count += 1; + let Some(directory_entry) = + build_directory_entry(root, &path, name, file_type.is_symlink(), visited)? + else { continue; + }; + if directory_entry.is_symlink { + listing.symlink_count += 1; } - - let path = entry.path(); - let metadata = entry - .metadata() - .map_err(|e| mask_io_error("读取文件元数据", &e))?; - let name = entry.file_name().to_string_lossy().to_string(); - if let Some(directory_entry) = claude_directory_entry(root, &path, name, &metadata)? { - listing.entries.push(directory_entry); - } + listing.entries.push(directory_entry); } Ok(()) @@ -348,6 +397,7 @@ fn collect_entries( depth: usize, options: ScanOptions, overview: &mut ClaudeDirectoryOverview, + visited: &mut HashSet, ) -> Result<(), String> { if depth >= options.max_depth { overview.truncated = true; @@ -368,65 +418,139 @@ fn collect_entries( return Ok(()); } + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); let file_type = entry .file_type() .map_err(|e| mask_io_error("获取文件类型", &e))?; - if file_type.is_symlink() { - overview.skipped_symlink_count += 1; + let is_symlink = file_type.is_symlink(); + + let Some(directory_entry) = + build_directory_entry(root, &path, name.clone(), is_symlink, Some(visited))? + else { continue; + }; + + if directory_entry.is_symlink { + overview.symlink_count += 1; } - let path = entry.path(); - let metadata = entry - .metadata() - .map_err(|e| mask_io_error("读取文件元数据", &e))?; - let rel_path = relative_path(root, &path)?; - let name = entry.file_name().to_string_lossy().to_string(); + // node_modules:真目录与指向目录的软链都跳过展开(仍不收录其内部) + if directory_entry.kind == ClaudeDirectoryEntryKind::Directory + && name == NODE_MODULES_DIR_NAME + && !directory_entry.is_broken + { + overview.skipped_node_modules_count += 1; + continue; + } + + let should_recurse = directory_entry.kind == ClaudeDirectoryEntryKind::Directory + && !directory_entry.is_broken + && !directory_entry.is_cycle; - if metadata.is_dir() { - if name == NODE_MODULES_DIR_NAME { - overview.skipped_node_modules_count += 1; - continue; + overview.entries.push(directory_entry); + + if should_recurse { + // 递归前登记真实路径,防止环与菱形重复展开 + if let Ok(canonical) = fs::canonicalize(&path) { + visited.insert(canonical); } - overview.entries.push(ClaudeDirectoryEntry { - path: rel_path, - name, - kind: ClaudeDirectoryEntryKind::Directory, - size: 0, - modified_at: crate::utils::metadata_modified_secs(&metadata), - }); - collect_entries(root, &path, depth + 1, options, overview)?; + collect_entries(root, &path, depth + 1, options, overview, visited)?; if overview.reached_entry_limit { return Ok(()); } - } else if metadata.is_file() { - overview.entries.push(ClaudeDirectoryEntry { - path: rel_path, - name, - kind: ClaudeDirectoryEntryKind::File, - size: metadata.len(), - modified_at: crate::utils::metadata_modified_secs(&metadata), - }); } } Ok(()) } -fn claude_directory_entry( +fn build_directory_entry( root: &Path, path: &Path, name: String, - metadata: &fs::Metadata, + is_symlink: bool, + visited: Option<&HashSet>, ) -> Result, String> { let rel_path = relative_path(root, path)?; + + if is_symlink { + let link_target = fs::read_link(path) + .ok() + .map(|target| target.to_string_lossy().into_owned()); + let link_target_absolute = fs::canonicalize(path) + .ok() + .map(|target| target.to_string_lossy().into_owned()); + let symlink_meta = fs::symlink_metadata(path).ok(); + let modified_at = symlink_meta + .as_ref() + .map(crate::utils::metadata_modified_secs) + .unwrap_or(0); + + match fs::metadata(path) { + Ok(target_meta) if target_meta.is_dir() => { + let is_cycle = link_target_absolute + .as_ref() + .and_then(|absolute| visited.map(|set| set.contains(&PathBuf::from(absolute)))) + .unwrap_or(false); + return Ok(Some(ClaudeDirectoryEntry { + path: rel_path, + name, + kind: ClaudeDirectoryEntryKind::Directory, + size: 0, + modified_at, + is_symlink: true, + link_target, + link_target_absolute, + is_broken: false, + is_cycle, + })); + } + Ok(target_meta) if target_meta.is_file() => { + return Ok(Some(ClaudeDirectoryEntry { + path: rel_path, + name, + kind: ClaudeDirectoryEntryKind::File, + size: target_meta.len(), + modified_at: crate::utils::metadata_modified_secs(&target_meta), + is_symlink: true, + link_target, + link_target_absolute, + is_broken: false, + is_cycle: false, + })); + } + _ => { + // 损坏或非常规目标:以文件叶子展示,避免空展开 + return Ok(Some(ClaudeDirectoryEntry { + path: rel_path, + name, + kind: ClaudeDirectoryEntryKind::File, + size: 0, + modified_at, + is_symlink: true, + link_target, + link_target_absolute: None, + is_broken: true, + is_cycle: false, + })); + } + } + } + + let metadata = fs::metadata(path).map_err(|e| mask_io_error("读取文件元数据", &e))?; if metadata.is_dir() { return Ok(Some(ClaudeDirectoryEntry { path: rel_path, name, kind: ClaudeDirectoryEntryKind::Directory, size: 0, - modified_at: crate::utils::metadata_modified_secs(metadata), + modified_at: crate::utils::metadata_modified_secs(&metadata), + is_symlink: false, + link_target: None, + link_target_absolute: None, + is_broken: false, + is_cycle: false, })); } if metadata.is_file() { @@ -435,7 +559,12 @@ fn claude_directory_entry( name, kind: ClaudeDirectoryEntryKind::File, size: metadata.len(), - modified_at: crate::utils::metadata_modified_secs(metadata), + modified_at: crate::utils::metadata_modified_secs(&metadata), + is_symlink: false, + link_target: None, + link_target_absolute: None, + is_broken: false, + is_cycle: false, })); } Ok(None) @@ -447,13 +576,65 @@ pub(crate) fn read_claude_file_preview_from_root( max_bytes: usize, ) -> Result { let rel_path = validate_relative_claude_path(path)?; - let file_path = resolve_existing_path_inside_root(root, &rel_path)?; - let metadata = fs::metadata(&file_path).map_err(|e| mask_io_error("读取文件元数据", &e))?; + let resolved = resolve_path_for_read(root, &rel_path)?; + let normalized_path = normalize_relative_path(&rel_path); + let name = rel_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| normalized_path.clone()); + + if resolved.is_broken { + let modified_at = fs::symlink_metadata(&resolved.logical_path) + .ok() + .map(|meta| crate::utils::metadata_modified_secs(&meta)) + .unwrap_or(0); + return Ok(ClaudeFilePreview { + path: normalized_path, + name, + content: String::new(), + is_binary: false, + truncated: false, + size: 0, + modified_at, + encoding: PREVIEW_ENCODING_UTF8, + is_symlink: resolved.is_symlink, + via_symlink_path: resolved.via_symlink_path, + link_target: resolved.link_target, + link_target_absolute: resolved.link_target_absolute, + is_broken: true, + }); + } + + let metadata = + fs::metadata(&resolved.logical_path).map_err(|e| mask_io_error("读取文件元数据", &e))?; if !metadata.is_file() { return Err("只能读取 ~/.claude 内的文件".to_string()); } - let mut file = fs::File::open(&file_path).map_err(|e| mask_io_error("打开文件", &e))?; + let size = metadata.len(); + let modified_at = crate::utils::metadata_modified_secs(&metadata); + + // 已知二进制:只返回元信息,不把内容灌进预览器 + if is_known_binary_path(&resolved.logical_path) || is_known_binary_path(Path::new(&name)) { + return Ok(ClaudeFilePreview { + path: normalized_path, + name, + content: String::new(), + is_binary: true, + truncated: false, + size, + modified_at, + encoding: PREVIEW_ENCODING_BINARY, + is_symlink: resolved.is_symlink, + via_symlink_path: resolved.via_symlink_path, + link_target: resolved.link_target, + link_target_absolute: resolved.link_target_absolute, + is_broken: false, + }); + } + + let mut file = + fs::File::open(&resolved.logical_path).map_err(|e| mask_io_error("打开文件", &e))?; let read_limit = max_bytes.saturating_add(1) as u64; let mut bytes = Vec::new(); file.by_ref() @@ -466,36 +647,29 @@ pub(crate) fn read_claude_file_preview_from_root( bytes.truncate(max_bytes); } - let (content, is_binary, encoding) = match String::from_utf8(bytes) { - Ok(content) => (content, false, PREVIEW_ENCODING_UTF8), - Err(error) => { - let bytes = error.into_bytes(); - if bytes.contains(&0) { - (String::new(), true, PREVIEW_ENCODING_BINARY) - } else { - ( - String::from_utf8_lossy(&bytes).into_owned(), - false, - PREVIEW_ENCODING_UTF8_LOSSY, - ) - } - } + // 非黑名单一律可预览:合法 UTF-8 或 lossy,不再因 NUL 清空 + let (content, encoding) = match String::from_utf8(bytes) { + Ok(content) => (content, PREVIEW_ENCODING_UTF8), + Err(error) => ( + String::from_utf8_lossy(&error.into_bytes()).into_owned(), + PREVIEW_ENCODING_UTF8_LOSSY, + ), }; - let normalized_path = normalize_relative_path(&rel_path); - let name = rel_path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| normalized_path.clone()); Ok(ClaudeFilePreview { path: normalized_path, name, content, - is_binary, + is_binary: false, truncated, - size: metadata.len(), - modified_at: crate::utils::metadata_modified_secs(&metadata), + size, + modified_at, encoding, + is_symlink: resolved.is_symlink, + via_symlink_path: resolved.via_symlink_path, + link_target: resolved.link_target, + link_target_absolute: resolved.link_target_absolute, + is_broken: false, }) } @@ -607,9 +781,27 @@ fn validate_relative_claude_operation_path(path: &str) -> Result Result { - resolve_existing_path_inside_root(root, rel_path) - .map_err(|_| "只能操作 ~/.claude 内的文件".to_string()) + let root_canonical = + fs::canonicalize(root).map_err(|_| "只能操作 ~/.claude 内的文件".to_string())?; + let mut current = root.to_path_buf(); + for component in rel_path.components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t) + .map_err(|_| "只能操作 ~/.claude 内的文件".to_string())?; + if metadata.file_type().is_symlink() { + return Err(SYMLINK_READ_ONLY_ERROR.to_string()); + } + } + + let current_canonical = + fs::canonicalize(¤t).map_err(|_| "只能操作 ~/.claude 内的文件".to_string())?; + if !current_canonical.starts_with(&root_canonical) { + return Err("只能操作 ~/.claude 内的文件".to_string()); + } + + Ok(current) } pub(crate) fn validate_relative_claude_path(path: &str) -> Result { @@ -647,30 +839,157 @@ pub(crate) fn validate_relative_claude_path(path: &str) -> Result Result { + let mut logical = root.to_path_buf(); + let mut via_symlink_path: Option = None; + let mut link_target: Option = None; + let mut link_target_absolute: Option = None; + let mut crossed_symlink = false; + let mut leaf_is_symlink = false; + let mut is_broken = false; + + let components: Vec<_> = rel_path.components().collect(); + for (index, component) in components.iter().enumerate() { + logical.push(component.as_os_str()); + let metadata = fs::symlink_metadata(&logical) + .map_err(|_| "只能读取 ~/.claude 内的文件".to_string())?; + if !metadata.file_type().is_symlink() { + continue; + } + + crossed_symlink = true; + let raw = fs::read_link(&logical) + .ok() + .map(|target| target.to_string_lossy().into_owned()); + let absolute = fs::canonicalize(&logical) + .ok() + .map(|target| target.to_string_lossy().into_owned()); + let is_leaf = index + 1 == components.len(); + + if via_symlink_path.is_none() { + let mut via_rel = PathBuf::new(); + for via_component in components.iter().take(index + 1) { + via_rel.push(via_component.as_os_str()); + } + via_symlink_path = Some(normalize_relative_path(&via_rel)); + link_target = raw.clone(); + link_target_absolute = absolute.clone(); + } + + if is_leaf { + leaf_is_symlink = true; + link_target = raw; + link_target_absolute = absolute.clone(); + if absolute.is_none() { + is_broken = true; + } + } else if absolute.is_none() { + return Err(SYMLINK_TARGET_UNAVAILABLE_ERROR.to_string()); + } + } + + // 未经过软链时,最终路径必须仍在 root 内(防意外挂载/越界) + if !crossed_symlink { + let root_canonical = + fs::canonicalize(root).map_err(|_| "只能读取 ~/.claude 内的文件".to_string())?; + let current_canonical = + fs::canonicalize(&logical).map_err(|_| "只能读取 ~/.claude 内的文件".to_string())?; + if !current_canonical.starts_with(&root_canonical) { + return Err("只能读取 ~/.claude 内的文件".to_string()); + } + } + + Ok(ResolvedReadPath { + logical_path: logical, + is_symlink: leaf_is_symlink, + via_symlink_path, + link_target, + link_target_absolute, + is_broken, + }) +} + +/// 旧名保留给调用方:仅用于写路径语义的「禁止软链」解析 pub(crate) fn resolve_existing_path_inside_root( root: &Path, rel_path: &Path, ) -> Result { - // 所有 IO 错误统一为越界文案,防止攻击者通过错误差异判断"路径是否存在但不在白名单"。 - let root_canonical = - fs::canonicalize(root).map_err(|_| "只能读取 ~/.claude 内的文件".to_string())?; - let mut current = root.to_path_buf(); - for component in rel_path.components() { - current.push(component.as_os_str()); - let metadata = fs::symlink_metadata(¤t) - .map_err(|_| "只能读取 ~/.claude 内的文件".to_string())?; - if metadata.file_type().is_symlink() { - return Err("只能读取 ~/.claude 内的文件".to_string()); + resolve_operation_path_inside_root(root, rel_path).map_err(|err| { + if err == SYMLINK_READ_ONLY_ERROR { + // 历史调用方(删除等)期望统一操作文案;读路径已改走 resolve_path_for_read + "只能操作 ~/.claude 内的文件".to_string() + } else { + err } - } + }) +} - let current_canonical = - fs::canonicalize(¤t).map_err(|_| "只能读取 ~/.claude 内的文件".to_string())?; - if !current_canonical.starts_with(root_canonical) { - return Err("只能读取 ~/.claude 内的文件".to_string()); +/// 与 Skills 支持文件一致的扩展名黑名单,并加入常见 macOS 垃圾文件名 +fn is_known_binary_path(path: &Path) -> bool { + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + if name.eq_ignore_ascii_case(".DS_Store") + || name.eq_ignore_ascii_case("Thumbs.db") + || name.eq_ignore_ascii_case("Desktop.ini") + { + return true; + } } - Ok(current) + let Some(extension) = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + else { + return false; + }; + + matches!( + extension.as_str(), + "7z" | "a" + | "ai" + | "apk" + | "app" + | "avi" + | "bin" + | "bmp" + | "class" + | "dmg" + | "doc" + | "docx" + | "dll" + | "dylib" + | "eot" + | "exe" + | "gif" + | "gz" + | "ico" + | "jar" + | "jpeg" + | "jpg" + | "mov" + | "mp3" + | "mp4" + | "o" + | "otf" + | "pdf" + | "png" + | "ppt" + | "pptx" + | "psd" + | "rar" + | "so" + | "sqlite" + | "tar" + | "ttf" + | "wasm" + | "webp" + | "woff" + | "woff2" + | "xls" + | "xlsx" + | "zip" + ) } fn relative_path(root: &Path, path: &Path) -> Result { @@ -756,7 +1075,7 @@ mod tests { } #[test] - fn overview_returns_sorted_entries_and_skips_symlinks() { + fn overview_returns_sorted_entries_and_includes_symlinks() { let env = TestEnv::new("overview"); fs::create_dir_all(env.claude_dir().join("plugins/demo/node_modules/lodash")) .expect("应可创建 node_modules 嵌套目录"); @@ -771,7 +1090,11 @@ mod tests { .expect("应可写入依赖文件"); fs::write(env.claude_dir().join("settings.json"), "{}").expect("应可写入配置文件"); fs::write(env.claude_dir().join("skills/demo/SKILL.md"), "hello").expect("应可写入文件"); - create_test_symlink(&env.root, &env.claude_dir().join("escape")); + // 出界目录软链:应展开并收录子文件 + let outside_skill = env.root.join("outside-skill"); + fs::create_dir_all(&outside_skill).expect("应可创建外部 skill 目录"); + fs::write(outside_skill.join("SKILL.md"), "# outside").expect("应可写入外部 SKILL.md"); + create_test_symlink(&outside_skill, &env.claude_dir().join("skills/linked")); let overview = scan_claude_directory_with_options( &env.claude_dir(), @@ -787,28 +1110,35 @@ mod tests { .iter() .map(|entry| entry.path.as_str()) .collect(); - assert_eq!( - paths, - vec![ - "plugins", - "plugins/demo", - "plugins/demo/index.js", - "settings.json", - "skills", - "skills/demo", - "skills/demo/SKILL.md" - ] - ); - assert_eq!(overview.skipped_symlink_count, 1); + assert!(paths.contains(&"plugins")); + assert!(paths.contains(&"plugins/demo")); + assert!(paths.contains(&"plugins/demo/index.js")); + assert!(paths.contains(&"settings.json")); + assert!(paths.contains(&"skills")); + assert!(paths.contains(&"skills/demo")); + assert!(paths.contains(&"skills/demo/SKILL.md")); + assert!(paths.contains(&"skills/linked")); + assert!(paths.contains(&"skills/linked/SKILL.md")); + assert!(!paths.iter().any(|path| path.contains("node_modules"))); + assert!(overview.symlink_count >= 1); assert_eq!(overview.skipped_node_modules_count, 1); assert!(!overview.truncated); - assert!(!overview.reached_entry_limit); - assert!(!overview.reached_depth_limit); - assert_eq!( - overview.entries[0].kind, - ClaudeDirectoryEntryKind::Directory - ); - assert_eq!(overview.entries[3].kind, ClaudeDirectoryEntryKind::File); + + let linked = overview + .entries + .iter() + .find(|entry| entry.path == "skills/linked") + .expect("应包含目录软链"); + assert!(linked.is_symlink); + assert!(!linked.is_broken); + assert_eq!(linked.kind, ClaudeDirectoryEntryKind::Directory); + + let nested = overview + .entries + .iter() + .find(|entry| entry.path == "skills/linked/SKILL.md") + .expect("应展开软链内文件"); + assert!(!nested.is_symlink); } #[test] @@ -919,6 +1249,35 @@ mod tests { assert!(err.contains("只能读取 ~/.claude 内的文件")); } + #[test] + fn preview_follows_out_of_root_symlink_and_rejects_write() { + let env = TestEnv::new("symlink-follow"); + let outside = env.root.join("outside-skill"); + fs::create_dir_all(&outside).expect("应可创建外部目录"); + fs::write(outside.join("SKILL.md"), "hello-from-outside").expect("应可写入"); + fs::create_dir_all(env.claude_dir().join("skills")).expect("应可创建 skills"); + create_test_symlink(&outside, &env.claude_dir().join("skills/linked")); + + let preview = + read_claude_file_preview_from_root(&env.claude_dir(), "skills/linked/SKILL.md", 512) + .expect("应可读出界软链内文件"); + assert_eq!(preview.content, "hello-from-outside"); + assert!(!preview.is_binary); + assert!(!preview.is_symlink); + assert_eq!(preview.via_symlink_path.as_deref(), Some("skills/linked")); + assert!(preview.link_target.is_some()); + assert!(preview.link_target_absolute.is_some()); + + let write_err = create_claude_directory_entry_in_root( + &env.claude_dir(), + Some("skills/linked"), + "new.md", + ClaudeDirectoryEntryKind::File, + ) + .expect_err("经软链路径禁止写入"); + assert!(write_err.contains("软链接路径只读") || write_err.contains("只能操作")); + } + #[test] fn preview_reads_text_binary_and_truncated_files() { let env = TestEnv::new("preview"); @@ -932,6 +1291,10 @@ mod tests { fs::write(env.claude_dir().join("long.txt"), "abcdef").expect("应可写入长文本"); fs::write(env.claude_dir().join("lossy.txt"), [b'a', 0x80, b'b']) .expect("应可写入非 UTF-8 文本"); + // 无扩展名但含 NUL:非黑名单,应 lossy 可预览而非清空 + fs::write(env.claude_dir().join("with-null"), [b'a', 0, b'b']) + .expect("应可写入含 NUL 文件"); + fs::write(env.claude_dir().join(".gitignore"), "*.log\n").expect("应可写入 gitignore"); let text = read_claude_file_preview_from_root(&env.claude_dir(), "settings.json", 512) .expect("文本预览应成功"); @@ -955,6 +1318,16 @@ mod tests { let lossy_json = serde_json::to_value(&lossy).expect("预览结果应可序列化"); assert_eq!(lossy_json["encoding"], "utf-8-lossy"); + let with_null = read_claude_file_preview_from_root(&env.claude_dir(), "with-null", 512) + .expect("含 NUL 非黑名单文件应可预览"); + assert!(!with_null.is_binary); + assert!(!with_null.content.is_empty()); + + let gitignore = read_claude_file_preview_from_root(&env.claude_dir(), ".gitignore", 512) + .expect(".gitignore 应可预览"); + assert_eq!(gitignore.content, "*.log\n"); + assert!(!gitignore.is_binary); + let truncated = read_claude_file_preview_from_root(&env.claude_dir(), "long.txt", 3) .expect("截断预览应成功"); assert_eq!(truncated.content, "abc"); @@ -963,6 +1336,67 @@ mod tests { assert_eq!(truncated_json["encoding"], "utf-8"); } + #[test] + fn overview_marks_broken_and_cycle_symlinks() { + let env = TestEnv::new("symlink-edge"); + // 损坏软链 + create_test_symlink( + Path::new("missing-target-xyz"), + &env.claude_dir().join("broken-link"), + ); + + // 环:a -> b, b -> a + let a = env.claude_dir().join("cycle-a"); + let b = env.claude_dir().join("cycle-b"); + create_test_symlink(Path::new("cycle-b"), &a); + create_test_symlink(Path::new("cycle-a"), &b); + + // 两个软链指向同一真实目录:第二次应标 cycle 且不重复展开 + let shared = env.root.join("shared-dir"); + fs::create_dir_all(&shared).expect("应可创建共享目录"); + fs::write(shared.join("note.txt"), "shared").expect("应可写入"); + create_test_symlink(&shared, &env.claude_dir().join("link-one")); + create_test_symlink(&shared, &env.claude_dir().join("link-two")); + + let overview = scan_claude_directory_with_options( + &env.claude_dir(), + ScanOptions { + max_entries: 100, + max_depth: 8, + }, + ) + .expect("扫描应成功"); + + let broken = overview + .entries + .iter() + .find(|entry| entry.path == "broken-link") + .expect("应显示损坏软链"); + assert!(broken.is_symlink); + assert!(broken.is_broken); + + let link_one = overview + .entries + .iter() + .find(|entry| entry.path == "link-one") + .expect("应有 link-one"); + let link_two = overview + .entries + .iter() + .find(|entry| entry.path == "link-two") + .expect("应有 link-two"); + // 其中一个展开,另一个因真实路径去重标 cycle + assert!( + (link_one.is_cycle && !link_two.is_cycle) || (!link_one.is_cycle && link_two.is_cycle) + ); + let nested_count = overview + .entries + .iter() + .filter(|entry| entry.path.ends_with("note.txt")) + .count(); + assert_eq!(nested_count, 1); + } + #[test] fn mask_io_error_omits_path_and_system_message() { // 模拟系统会带的具体路径和 OS 原始描述,验证最终文案完全脱敏。 @@ -1152,7 +1586,9 @@ mod tests { assert!(colon_name.contains("名称不能包含路径分隔符")); assert!(overwrite.contains("目标已存在")); assert!(escape.contains("只能操作 ~/.claude 内的文件")); - assert!(symlink.contains("只能操作 ~/.claude 内的文件")); + assert!( + symlink.contains("只能操作 ~/.claude 内的文件") || symlink.contains("软链接路径只读") + ); } #[cfg(unix)] @@ -1162,6 +1598,13 @@ mod tests { #[cfg(windows)] fn create_test_symlink(src: &std::path::Path, dest: &std::path::Path) { - std::os::windows::fs::symlink_dir(src, dest).expect("应可创建软链接"); + if src.is_dir() { + std::os::windows::fs::symlink_dir(src, dest).expect("应可创建目录软链接"); + } else { + // 目标可能不存在(损坏软链测试);按目录尝试,失败再按文件 + std::os::windows::fs::symlink_dir(src, dest) + .or_else(|_| std::os::windows::fs::symlink_file(src, dest)) + .expect("应可创建软链接"); + } } } diff --git a/src-tauri/src/project.rs b/src-tauri/src/project.rs index 7951f4e..7ba170d 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -2918,12 +2918,12 @@ mod tests { let project = TestDir::new(); let claude = project.path().join(".claude"); std::fs::create_dir_all(&claude).unwrap(); - // 包含 NUL 字节的内容会被识别为二进制 - std::fs::write(claude.join("bin.dat"), [0u8, 1, 2, 3, 0xff]).unwrap(); + // 已知二进制扩展名走黑名单,不依赖内容 NUL 嗅探 + std::fs::write(claude.join("asset.bin"), [0u8, 1, 2, 3, 0xff]).unwrap(); let preview = get_project_claude_file_preview( project.path().to_str().unwrap(), - "bin.dat".to_string(), + "asset.bin".to_string(), ) .expect("应可读取"); assert!(preview.is_binary); diff --git a/src/App.test.tsx b/src/App.test.tsx index 1aa1020..e06cedb 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -496,6 +496,14 @@ class ResizeObserverMock { disconnect() {} } +const DIRECTORY_ENTRY_DEFAULTS = { + isSymlink: false, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, + isCycle: false, +} as const; + const CLAUDE_OVERVIEW_FIXTURE = { rootPath: "/Users/test/.claude", maxEntries: 100000, @@ -507,6 +515,7 @@ const CLAUDE_OVERVIEW_FIXTURE = { kind: "directory", size: 0, modifiedAt: 1, + ...DIRECTORY_ENTRY_DEFAULTS, }, { path: "scripts/check-license-rule.js", @@ -514,6 +523,7 @@ const CLAUDE_OVERVIEW_FIXTURE = { kind: "file", size: 48, modifiedAt: 1, + ...DIRECTORY_ENTRY_DEFAULTS, }, { path: "settings.json", @@ -521,12 +531,13 @@ const CLAUDE_OVERVIEW_FIXTURE = { kind: "file", size: 19, modifiedAt: 2, + ...DIRECTORY_ENTRY_DEFAULTS, }, ], truncated: false, reachedEntryLimit: false, reachedDepthLimit: false, - skippedSymlinkCount: 0, + symlinkCount: 0, skippedNodeModulesCount: 0, }; @@ -616,6 +627,11 @@ describe("App", () => { size: 7, modifiedAt: 2, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return null; @@ -941,7 +957,7 @@ describe("App", () => { truncated: false, reachedEntryLimit: false, reachedDepthLimit: false, - skippedSymlinkCount: 0, + symlinkCount: 0, skippedNodeModulesCount: 2, }; invokeMock.mockImplementation(async (command, args) => { @@ -963,6 +979,11 @@ describe("App", () => { size: 19, modifiedAt: 2, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return { @@ -974,6 +995,11 @@ describe("App", () => { size: 48, modifiedAt: 1, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return null; @@ -1189,6 +1215,11 @@ describe("App", () => { size: 18, modifiedAt: 2, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return null; @@ -1528,6 +1559,11 @@ describe("App", () => { size: previewCallCount === 1 ? 18 : 16, modifiedAt: previewCallCount === 1 ? 2 : 8, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return null; @@ -1594,6 +1630,11 @@ describe("App", () => { size: 18, modifiedAt: 2, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return null; diff --git a/src/bindings.ts b/src/bindings.ts index 14a8032..f29790c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -206,6 +206,16 @@ export type ClaudeDirectoryEntry = { kind: ClaudeDirectoryEntryKind, size: number, modifiedAt: number, + /** 该项自身是否为软链(后代经软链可达时仍为 false) */ + isSymlink: boolean, + /** `read_link` 原始目标(相对或绝对,按磁盘存储) */ + linkTarget: string | null, + /** 解析后的绝对目标路径;损坏时为 None */ + linkTargetAbsolute: string | null, + /** 目标不存在或不可解析 */ + isBroken: boolean, + /** 目标真实路径已在本次扫描中访问过(环或菱形汇合),不再递归 */ + isCycle: boolean, }; export type ClaudeDirectoryEntryKind = "file" | "directory"; @@ -217,7 +227,7 @@ export type ClaudeDirectoryListing = { entries: ClaudeDirectoryEntry[], truncated: boolean, reachedEntryLimit: boolean, - skippedSymlinkCount: number, + symlinkCount: number, }; export type ClaudeDirectoryOverview = { @@ -228,7 +238,8 @@ export type ClaudeDirectoryOverview = { truncated: boolean, reachedEntryLimit: boolean, reachedDepthLimit: boolean, - skippedSymlinkCount: number, + /** 扫描到的软链条目数(已收录,非跳过) */ + symlinkCount: number, skippedNodeModulesCount: number, }; @@ -241,6 +252,13 @@ export type ClaudeFilePreview = { size: number, modifiedAt: number, encoding: string, + /** 叶子节点自身是否为软链 */ + isSymlink: boolean, + /** 路径上第一个软链的逻辑相对路径 */ + viaSymlinkPath: string | null, + linkTarget: string | null, + linkTargetAbsolute: string | null, + isBroken: boolean, }; /** 从 ~/.claude.json 解析的完整统计数据 */ diff --git a/src/components/ClaudeOverviewPage.tsx b/src/components/ClaudeOverviewPage.tsx index 66eb650..d627763 100644 --- a/src/components/ClaudeOverviewPage.tsx +++ b/src/components/ClaudeOverviewPage.tsx @@ -13,6 +13,7 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { showOperationError } from "@/lib/user-facing-error"; import { useIsNarrowViewport } from "../hooks/useIsNarrowViewport"; import useTauriEvent from "../hooks/useTauriEvent"; @@ -37,8 +38,10 @@ import { ClaudeFilePreviewPane } from "./claude-overview/ClaudeFilePreviewPane"; import { absolutePreviewPath, defaultViewModeForPath, + formatSymlinkTargetLabel, normalizeTreePath, type PreviewViewMode, + pathCrossesSymlink, treePathForEntry, } from "./claude-overview/file-viewer-utils"; import { PANEL_SURFACE_CLASS } from "./surface-classes"; @@ -67,7 +70,7 @@ const EMPTY_OVERVIEW_STATE: ClaudeDirectoryOverview = { truncated: false, reachedEntryLimit: false, reachedDepthLimit: false, - skippedSymlinkCount: 0, + symlinkCount: 0, skippedNodeModulesCount: 0, }; @@ -156,10 +159,24 @@ function contextMenuItemKind(item: ContextMenuItem): ClaudeDirectoryEntryOperati } function contextMenuParentPath(item: ContextMenuItem) { - const path = normalizeTreePath(item.path); + const path = normalizeTreePath(item.path ?? ""); + if (!path) { + return null; + } return item.kind === "directory" ? path : getParentPath(path); } +// pierre 三点触发器的 aria-label 固定为 "Options",不能当文件名用;右键菜单才带真实 basename。 +function contextMenuEntryName(item: ContextMenuItem) { + const rawName = item.name?.trim() ?? ""; + if (rawName && rawName !== "Options" && !rawName.startsWith("Options ")) { + return rawName; + } + const path = normalizeTreePath(item.path); + const base = path.split("/").pop(); + return base && base.length > 0 ? base : path; +} + function isSameOrDescendantPath(path: string, targetPath: string) { return path === targetPath || path.startsWith(`${targetPath}/`); } @@ -198,6 +215,7 @@ function getClaudeDirectoryDocsUrl(language: Language) { interface ClaudeOverviewContextMenuProps { context: ContextMenuOpenContext; item: ContextMenuItem; + readOnly: boolean; onCreate: (item: ContextMenuItem, kind: ClaudeDirectoryEntryOperationKind) => void; onDelete: (item: ContextMenuItem) => void; onRename: (item: ContextMenuItem) => void; @@ -207,6 +225,7 @@ interface ClaudeOverviewContextMenuProps { function ClaudeOverviewContextMenu({ context, item, + readOnly, onCreate, onDelete, onRename, @@ -218,11 +237,26 @@ function ClaudeOverviewContextMenu({ action(); }; - return ( + // 必须 portal 到 body:树面板有 overflow-hidden + contain:content, + // 菜单若留在 slot 内会被裁切,表现为三点/右键「完全没反应」。 + // data-file-tree-context-menu-root 让 pierre 把 portal 内点击识别为菜单内部点击。 + const menu = readOnly ? ( +
+
+ {t("claudeOverview.symlinkReadOnly")} +
+
+ ) : (
); + + if (typeof document === "undefined") { + return menu; + } + return createPortal(menu, document.body); } interface ClaudeOverviewNameDialogProps { @@ -657,7 +696,7 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { kind: contextMenuItemKind(item), path: normalizeTreePath(item.path), parentPath: getParentPath(item.path), - initialName: item.name, + initialName: contextMenuEntryName(item), titleKey: "claudeOverview.renameTitle", confirmKey: "claudeOverview.contextMenu.rename", }); @@ -666,7 +705,7 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { const handleDeleteFromContextMenu = useCallback((item: ContextMenuItem) => { setPendingDeleteEntry({ kind: contextMenuItemKind(item), - name: item.name, + name: contextMenuEntryName(item), path: normalizeTreePath(item.path), }); }, []); @@ -757,20 +796,64 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { } }, [loadOverview, pendingDeleteEntry, showToast, t]); + // 用 ref 读 entryByPath,避免 Map 换引用导致 renderContextMenu 身份抖动 + const entryByPathRef = useRef(entryByPath); + entryByPathRef.current = entryByPath; + const renderTreeContextMenu = useCallback( - (item: ContextMenuItem, context: ContextMenuOpenContext) => ( - - ), + (item: ContextMenuItem, context: ContextMenuOpenContext) => { + const itemPath = normalizeTreePath(item.path ?? ""); + const parentPath = contextMenuParentPath(item); + // 软链节点自身、经软链路径、以及在软链目录内新建,一律只读 + const entries = entryByPathRef.current; + const readOnly = + pathCrossesSymlink(itemPath, entries) || + (parentPath != null && pathCrossesSymlink(parentPath, entries)); + return ( + + ); + }, [handleCreateFromContextMenu, handleDeleteFromContextMenu, handleRenameFromContextMenu, t], ); + const symlinkMetaByPath = useMemo(() => { + const map = new Map< + string, + { + isSymlink: boolean; + isBroken: boolean; + isCycle: boolean; + tooltip: string; + } + >(); + for (const entry of deferredOverviewEntries) { + if (!entry.isSymlink) { + continue; + } + map.set(entry.path, { + isSymlink: true, + isBroken: entry.isBroken, + isCycle: entry.isCycle, + tooltip: formatSymlinkTargetLabel({ + linkTarget: entry.linkTarget, + linkTargetAbsolute: entry.linkTargetAbsolute, + isBroken: entry.isBroken, + isCycle: entry.isCycle, + t, + }), + }); + } + return map; + }, [deferredOverviewEntries, t]); + const handleSelectPreviewTab = useCallback((path: string) => { latestPreviewRequestPathRef.current = path; setSelectedPath(path); @@ -918,12 +1001,9 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { {t("claudeOverview.truncatedEntries").replace("{count}", String(overview.maxEntries))} ) : null} - {overview.skippedSymlinkCount > 0 ? ( + {overview.symlinkCount > 0 ? ( - {t("claudeOverview.skippedSymlinks").replace( - "{count}", - String(overview.skippedSymlinkCount), - )} + {t("claudeOverview.symlinkCount").replace("{count}", String(overview.symlinkCount))} ) : null} {overview.skippedNodeModulesCount > 0 ? ( @@ -1059,6 +1139,7 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { > diff --git a/src/components/ProjectClaudeExplorer.tsx b/src/components/ProjectClaudeExplorer.tsx index e26584f..1fa5da3 100644 --- a/src/components/ProjectClaudeExplorer.tsx +++ b/src/components/ProjectClaudeExplorer.tsx @@ -21,6 +21,7 @@ import { ClaudeFilePreviewPane } from "./claude-overview/ClaudeFilePreviewPane"; import { absolutePreviewPath, defaultViewModeForPath, + formatSymlinkTargetLabel, normalizeTreePath, type PreviewViewMode, treePathForEntry, @@ -160,6 +161,30 @@ function ProjectClaudeExplorerBody({ () => (overview?.entries ?? []).map(treePathForEntry), [overview?.entries], ); + const symlinkMetaByPath = useMemo(() => { + const map = new Map< + string, + { isSymlink: boolean; isBroken: boolean; isCycle: boolean; tooltip: string } + >(); + for (const entry of overview?.entries ?? []) { + if (!entry.isSymlink) { + continue; + } + map.set(entry.path, { + isSymlink: true, + isBroken: entry.isBroken, + isCycle: entry.isCycle, + tooltip: formatSymlinkTargetLabel({ + linkTarget: entry.linkTarget, + linkTargetAbsolute: entry.linkTargetAbsolute, + isBroken: entry.isBroken, + isCycle: entry.isCycle, + t, + }), + }); + } + return map; + }, [overview?.entries, t]); const activePreview = useMemo( () => openPreviews.find((preview) => preview.path === activePreviewPath) ?? null, [activePreviewPath, openPreviews], @@ -389,7 +414,11 @@ function ProjectClaudeExplorerBody({ PANEL_SURFACE_CLASS, )} > - +
) : (
> = {}) { diff --git a/src/components/__tests__/ProjectAutoMemoryExplorer.test.tsx b/src/components/__tests__/ProjectAutoMemoryExplorer.test.tsx index ec86ff1..5d1abdc 100644 --- a/src/components/__tests__/ProjectAutoMemoryExplorer.test.tsx +++ b/src/components/__tests__/ProjectAutoMemoryExplorer.test.tsx @@ -67,7 +67,16 @@ vi.mock("../claude-overview/ClaudeFilePreviewPane", () => ({ const PROJECT = "/Users/test/Work/demo"; function makeEntry(path: string): ClaudeDirectoryEntry { - return { path, name: path, kind: "file", size: 12, modifiedAt: 0 }; + return { + path, + name: path, + kind: "file", + size: 12, + modifiedAt: 0, + isSymlink: false, + isBroken: false, + isCycle: false, + }; } function makeOverview(paths: string[]): ClaudeDirectoryOverview { @@ -79,7 +88,7 @@ function makeOverview(paths: string[]): ClaudeDirectoryOverview { truncated: false, reachedEntryLimit: false, reachedDepthLimit: false, - skippedSymlinkCount: 0, + symlinkCount: 0, skippedNodeModulesCount: 0, }; } @@ -94,6 +103,11 @@ function makePreview(path: string): ClaudeFilePreview { size: 12, modifiedAt: 0, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } diff --git a/src/components/__tests__/ProjectsPage.test.tsx b/src/components/__tests__/ProjectsPage.test.tsx index 5b88259..4737faa 100644 --- a/src/components/__tests__/ProjectsPage.test.tsx +++ b/src/components/__tests__/ProjectsPage.test.tsx @@ -370,6 +370,14 @@ async function findProjectButton(project: string) { return button as HTMLButtonElement; } +const PROJECT_CLAUDE_ENTRY_DEFAULTS = { + isSymlink: false, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, + isCycle: false, +} as const; + const PROJECT_CLAUDE_OVERVIEW_FIXTURE = { rootPath: `${PROJECT_ALPHA}/.claude`, maxEntries: 100000, @@ -381,6 +389,7 @@ const PROJECT_CLAUDE_OVERVIEW_FIXTURE = { kind: "directory" as const, size: 0, modifiedAt: 1, + ...PROJECT_CLAUDE_ENTRY_DEFAULTS, }, { path: "rules/frontend-ui.md", @@ -388,6 +397,7 @@ const PROJECT_CLAUDE_OVERVIEW_FIXTURE = { kind: "file" as const, size: 64, modifiedAt: 2, + ...PROJECT_CLAUDE_ENTRY_DEFAULTS, }, { path: "settings.json", @@ -395,12 +405,13 @@ const PROJECT_CLAUDE_OVERVIEW_FIXTURE = { kind: "file" as const, size: 18, modifiedAt: 3, + ...PROJECT_CLAUDE_ENTRY_DEFAULTS, }, ], truncated: false, reachedEntryLimit: false, reachedDepthLimit: false, - skippedSymlinkCount: 0, + symlinkCount: 0, skippedNodeModulesCount: 0, }; @@ -421,6 +432,11 @@ function mockProjectClaudeExplorerInvokes() { size: 64, modifiedAt: 2, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } return { @@ -432,6 +448,11 @@ function mockProjectClaudeExplorerInvokes() { size: 18, modifiedAt: 3, encoding: "utf-8", + isSymlink: false, + viaSymlinkPath: null, + linkTarget: null, + linkTargetAbsolute: null, + isBroken: false, }; } if (command === "open_project_claude_file_in_editor") { diff --git a/src/components/claude-overview/ClaudeDirectoryTree.tsx b/src/components/claude-overview/ClaudeDirectoryTree.tsx index 22a647b..4187284 100644 --- a/src/components/claude-overview/ClaudeDirectoryTree.tsx +++ b/src/components/claude-overview/ClaudeDirectoryTree.tsx @@ -25,9 +25,19 @@ export type ClaudeOverviewTreeContextMenuRenderer = ( context: ContextMenuOpenContext, ) => ReactNode; +export interface ClaudeDirectoryTreeSymlinkMeta { + isSymlink: boolean; + isBroken: boolean; + isCycle: boolean; + /** 用于 title tooltip 的完整说明 */ + tooltip: string; +} + interface ClaudeDirectoryTreeProps { paths: string[]; onSelectPath: (path: string) => void; + /** 逻辑路径 → 软链元信息;仅软链节点自身需要 */ + symlinkMetaByPath?: Map; renderContextMenu?: ClaudeOverviewTreeContextMenuRenderer; } @@ -116,11 +126,14 @@ function isFileTreeDirectoryHandle( export function ClaudeDirectoryTree({ paths, onSelectPath, + symlinkMetaByPath, renderContextMenu, }: ClaudeDirectoryTreeProps) { const onSelectPathRef = useRef(onSelectPath); + const symlinkMetaByPathRef = useRef(symlinkMetaByPath); const lastHandledPathRef = useRef<{ path: string; timestamp: number } | null>(null); const previousPathsRef = useRef(paths); + symlinkMetaByPathRef.current = symlinkMetaByPath; // 搜索激活期间暂存最新目录数据,延迟到搜索清空后再 resetPaths。 const pendingResetRef = useRef<{ paths: string[]; @@ -189,10 +202,28 @@ export function ClaudeDirectoryTree({ // 借「行装饰」渲染主文件名 + 下方 unsafeCSS 隐藏库默认 content 段,是为了实现单行省略号: // @pierre/trees(beta) 默认主标签不支持 truncate/ellipsis。库支持主标签 truncate 后, // 可回归默认渲染并删除该段 unsafeCSS。 - renderRowDecoration: ({ item }) => ({ - text: item.name, - title: item.name, - }), + // 软链节点在名称后追加标记,完整目标放 title tooltip。 + renderRowDecoration: ({ item }) => { + const itemPath = + typeof item.path === "string" && item.path.length > 0 ? normalizeTreePath(item.path) : ""; + const meta = itemPath ? symlinkMetaByPathRef.current?.get(itemPath) : undefined; + if (!meta?.isSymlink) { + return { + text: item.name, + title: item.name, + }; + } + let suffix = " ↗"; + if (meta.isBroken) { + suffix = " ⚠"; + } else if (meta.isCycle) { + suffix = " ⟳"; + } + return { + text: `${item.name}${suffix}`, + title: meta.tooltip || item.name, + }; + }, unsafeCSS: ` button[data-type='item'] { border-radius: 6px; diff --git a/src/components/claude-overview/ClaudeFilePreviewPane.tsx b/src/components/claude-overview/ClaudeFilePreviewPane.tsx index 80e28da..5ce9147 100644 --- a/src/components/claude-overview/ClaudeFilePreviewPane.tsx +++ b/src/components/claude-overview/ClaudeFilePreviewPane.tsx @@ -26,6 +26,7 @@ import { formatBytes, formatModifiedAt, formatPreviewEncoding, + formatPreviewSymlinkFooter, isMarkdownPath, type PreviewViewMode, } from "./file-viewer-utils"; @@ -193,7 +194,7 @@ export function ClaudeFilePreviewPane({ onOpenEditor, }: ClaudeFilePreviewPaneProps) { const previewFile = useMemo(() => { - if (!activePreview || activePreview.isBinary) { + if (!activePreview || activePreview.isBinary || activePreview.isBroken) { return null; } return fileContentsForPreview(activePreview); @@ -203,8 +204,13 @@ export function ClaudeFilePreviewPane({ activePreview?.modifiedAt, activePreview?.content, activePreview?.isBinary, + activePreview?.isBroken, activePreview, ]); + const symlinkFooter = useMemo( + () => (activePreview ? formatPreviewSymlinkFooter(activePreview, t) : null), + [activePreview, t], + ); const previewFileOptions = useMemo( () => ({ ...PIERRE_FILE_OPTIONS, @@ -375,7 +381,11 @@ export function ClaudeFilePreviewPane({ ref={contentFreezeRef} className="claude-overview-preview-body flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" > - {activePreview.isBinary ? ( + {activePreview.isBroken ? ( +
+ {t("claudeOverview.brokenSymlinkPreview")} +
+ ) : activePreview.isBinary ? (
{t("claudeOverview.binaryFile")}
@@ -403,11 +413,24 @@ export function ClaudeFilePreviewPane({ className="claude-overview-preview-footer flex min-h-[34px] shrink-0 items-center border-t bg-card/95 px-4 py-1 max-[700px]:flex-col max-[700px]:items-start" data-testid="claude-overview-preview-footer" > -
- {formatBytes(activePreview.size)} - {formatModifiedAt(activeEntry?.modifiedAt ?? activePreview.modifiedAt)} - {formatPreviewEncoding(activePreview.encoding, t)} - {activePreview.truncated ? {t("claudeOverview.fileTruncated")} : null} +
+ {formatBytes(activePreview.size)} + + {formatModifiedAt(activeEntry?.modifiedAt ?? activePreview.modifiedAt)} + + {formatPreviewEncoding(activePreview.encoding, t)} + {activePreview.truncated ? ( + {t("claudeOverview.fileTruncated")} + ) : null} + {symlinkFooter ? ( + + {symlinkFooter} + + ) : null}
diff --git a/src/components/claude-overview/file-viewer-utils.ts b/src/components/claude-overview/file-viewer-utils.ts index 77b1f1c..86a6300 100644 --- a/src/components/claude-overview/file-viewer-utils.ts +++ b/src/components/claude-overview/file-viewer-utils.ts @@ -22,9 +22,82 @@ export function treePathForEntry(entry: ClaudeDirectoryEntry) { } export function normalizeTreePath(path: string) { + if (!path) { + return ""; + } return path.endsWith("/") ? path.slice(0, -1) : path; } +/** 路径自身或其任一祖先是软链时,总览写操作只读 */ +export function pathCrossesSymlink( + path: string, + entryByPath: Map, +): boolean { + const parts = path.split("/").filter(Boolean); + let current = ""; + for (const part of parts) { + current = current ? `${current}/${part}` : part; + if (entryByPath.get(current)?.isSymlink) { + return true; + } + } + return false; +} + +export function formatSymlinkTargetLabel(options: { + linkTarget?: string | null; + linkTargetAbsolute?: string | null; + isBroken?: boolean; + isCycle?: boolean; + t: (key: TranslationKey) => string; +}): string { + const { linkTarget, linkTargetAbsolute, isBroken, isCycle, t } = options; + if (isBroken) { + const raw = linkTarget?.trim(); + return raw ? `${t("claudeOverview.symlinkBroken")}: ${raw}` : t("claudeOverview.symlinkBroken"); + } + if (isCycle) { + const absolute = linkTargetAbsolute?.trim() || linkTarget?.trim() || ""; + return absolute + ? `${t("claudeOverview.symlinkCycle")}: ${absolute}` + : t("claudeOverview.symlinkCycle"); + } + const absolute = linkTargetAbsolute?.trim(); + const raw = linkTarget?.trim(); + if (absolute && raw && absolute !== raw) { + return `${absolute} ← ${raw}`; + } + return absolute || raw || t("claudeOverview.symlinkBadge"); +} + +export function formatPreviewSymlinkFooter( + preview: ClaudeFilePreview, + t: (key: TranslationKey) => string, +): string | null { + if (preview.isBroken) { + return formatSymlinkTargetLabel({ + linkTarget: preview.linkTarget, + linkTargetAbsolute: preview.linkTargetAbsolute, + isBroken: true, + t, + }); + } + if (!preview.viaSymlinkPath && !preview.isSymlink) { + return null; + } + const target = formatSymlinkTargetLabel({ + linkTarget: preview.linkTarget, + linkTargetAbsolute: preview.linkTargetAbsolute, + t, + }); + if (preview.isSymlink) { + return `${t("claudeOverview.symlinkBadge")} → ${target}`; + } + return t("claudeOverview.viaSymlink") + .replace("{path}", preview.viaSymlinkPath ?? "") + .replace("{target}", target); +} + export function formatBytes(bytes: number) { if (bytes < 1024) { return `${bytes} B`; diff --git a/src/i18n.ts b/src/i18n.ts index e9bf2c5..d396222 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1424,7 +1424,13 @@ const translations = { "claudeOverview.loadedEntryCount": "已加载 {count} 个条目", "claudeOverview.truncatedEntries": "已达到 {count} 个条目上限", "claudeOverview.truncatedDepth": "已达到 {count} 层目录深度上限", - "claudeOverview.skippedSymlinks": "已跳过 {count} 个软链接", + "claudeOverview.symlinkCount": "含 {count} 个软链接", + "claudeOverview.symlinkBadge": "软链接", + "claudeOverview.symlinkBroken": "损坏的软链接", + "claudeOverview.symlinkCycle": "循环软链接", + "claudeOverview.viaSymlink": "经软链 {path} → {target}", + "claudeOverview.symlinkReadOnly": "软链接路径只读,无法新建、重命名或删除", + "claudeOverview.brokenSymlinkPreview": "软链接目标不可用,无法预览内容", "claudeOverview.skippedNodeModules": "已跳过 {count} 个 node_modules 目录", "claudeOverview.empty": "~/.claude 目录为空或不存在", "claudeOverview.scanning": "正在扫描 ~/.claude...", @@ -3175,7 +3181,15 @@ const translations = { "claudeOverview.loadedEntryCount": "{count} entries loaded", "claudeOverview.truncatedEntries": "Reached the {count} entry limit", "claudeOverview.truncatedDepth": "Reached the {count}-level directory depth limit", - "claudeOverview.skippedSymlinks": "Skipped {count} symlinks", + "claudeOverview.symlinkCount": "{count} symlinks", + "claudeOverview.symlinkBadge": "Symlink", + "claudeOverview.symlinkBroken": "Broken symlink", + "claudeOverview.symlinkCycle": "Cyclic symlink", + "claudeOverview.viaSymlink": "Via symlink {path} → {target}", + "claudeOverview.symlinkReadOnly": + "Symlink paths are read-only; create, rename, and delete are disabled", + "claudeOverview.brokenSymlinkPreview": + "Symlink target is unavailable; content cannot be previewed", "claudeOverview.skippedNodeModules": "Skipped {count} node_modules directories", "claudeOverview.empty": "~/.claude is empty or missing", "claudeOverview.scanning": "Scanning ~/.claude...", diff --git a/src/types.ts b/src/types.ts index 3a3a41d..e20ba01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -361,6 +361,16 @@ export interface ClaudeDirectoryEntry { kind: ClaudeDirectoryEntryKind; size: number; modifiedAt: number; + /** 该项自身是否为软链(后代经软链可达时仍为 false) */ + isSymlink: boolean; + /** read_link 原始目标 */ + linkTarget?: string | null; + /** 解析后的绝对目标;损坏时为空 */ + linkTargetAbsolute?: string | null; + /** 目标不存在或不可解析 */ + isBroken: boolean; + /** 真实路径已扫描过(环/菱形),不再递归 */ + isCycle: boolean; } export interface ClaudeDirectoryOverview { @@ -371,7 +381,8 @@ export interface ClaudeDirectoryOverview { truncated: boolean; reachedEntryLimit: boolean; reachedDepthLimit: boolean; - skippedSymlinkCount: number; + /** 扫描到的软链条目数(已收录) */ + symlinkCount: number; skippedNodeModulesCount: number; } @@ -384,6 +395,13 @@ export interface ClaudeFilePreview { size: number; modifiedAt: number; encoding: string; + /** 叶子节点自身是否为软链 */ + isSymlink: boolean; + /** 路径上第一个软链的逻辑相对路径 */ + viaSymlinkPath?: string | null; + linkTarget?: string | null; + linkTargetAbsolute?: string | null; + isBroken: boolean; } /** 项目级 settings 文件的归属(共享 vs 本地覆盖) */ From 0fa24e39c9f0b971a8a42aac2c1ee8c66953335a Mon Sep 17 00:00:00 2001 From: maguowei Date: Sun, 12 Jul 2026 22:17:44 +0800 Subject: [PATCH 18/22] =?UTF-8?q?feat(deep-link):=20=E6=94=AF=E6=8C=81=20c?= =?UTF-8?q?ode-manager://=20=E9=85=8D=E7=BD=AE=E5=AF=BC=E5=85=A5=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注册自定义 scheme 与单实例协作,支持内嵌 payload 与远端 HTTPS 导入; 导出可复制 Deep Link,预览确认后新建配置且不自动应用。 --- .claude/rules/tauri-backend.md | 1 + CONTEXT.md | 18 + docs/adr/0003-deeplink-profile-import.md | 24 + src-tauri/Cargo.lock | 122 ++++ src-tauri/Cargo.toml | 6 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/config.rs | 103 +++- src-tauri/src/deep_link.rs | 551 ++++++++++++++++++ src-tauri/src/lib.rs | 32 +- src-tauri/tauri.conf.json | 5 + src/App.test.tsx | 11 +- src/App.tsx | 34 ++ src/bindings.ts | 21 + src/components/ProfilesPage.tsx | 190 +++++- .../__tests__/ProfilesPage.test.tsx | 107 ++++ src/i18n.ts | 23 + src/types.ts | 9 + 17 files changed, 1213 insertions(+), 47 deletions(-) create mode 100644 docs/adr/0003-deeplink-profile-import.md create mode 100644 src-tauri/src/deep_link.rs diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md index a01a30e..9725664 100644 --- a/.claude/rules/tauri-backend.md +++ b/.claude/rules/tauri-backend.md @@ -16,6 +16,7 @@ paths: | `main.rs` | 二进制入口,仅调用 `code_manager_lib::run()` | | `utils.rs` | 公共锁、JSON 读写、原子文件写入、应用数据目录解析 | | `config.rs` | 配置 / Provider 合并落盘、`resolve_profile_settings()`、模型测试、`config-registry.json` | +| `deep_link.rs` | 深度链接:`code-manager://profiles/import` 解析、内嵌 payload / 远端 HTTPS 拉取(SSRF 防护)、pending 队列与导入链接生成 | | `memory.rs` | 用户级 `CLAUDE.md` 与 `rules/*.md` 的托管、导入、启停 | | `skills.rs` | Skills 启停、`~/.codex/skills/` 软链同步、`SKILL.md` 读写、文件树扫描 | | `history.rs` | `~/.claude/history.jsonl` 读取、会话详情解析、轮询变更 | diff --git a/CONTEXT.md b/CONTEXT.md index 67eef4e..0c36381 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -49,3 +49,21 @@ _Avoid_: 外部路径、越界路径(经软链可读但写仍拒绝,与恶意路 **可预览文本(Previewable Text)**: 目录总览中非已知二进制类型的文件内容,以 UTF-8 或有损 UTF-8 形式展示供阅读。 _Avoid_: 源码、纯文本白名单(判定是「非已知二进制」,不是穷举文本扩展名) + +### 深度链接(Deep Link) + +**深度链接(Deep Link)**: +由操作系统协议处理器打开 Code Manager 并触发既定业务动作的自定义 URL。当前注册 scheme 为 `code-manager://`。 +_Avoid_: 万能链接(Universal Link / App Link,指 https 关联应用)、自定义协议(泛称实现手段时) + +**配置导入链接(Profile Import Link)**: +一种[深度链接](#深度链接deep-link),动作为将外部 Claude settings 导入为**新配置(Profile)**。打开后须经预览确认才入库,且**不自动应用/绑定**到 `~/.claude/settings.json`。 +_Avoid_: 一键应用链接、配置同步链接、远程配置下发 + +**内嵌载荷导入(Embedded Payload Import)**: +[配置导入链接](#配置导入链接profile-import-link)的一种来源:settings JSON 经 base64url 编码后放在 query `payload` 中随链接携带。 +_Avoid_: 附件导入、剪贴板导入(那是别的入口) + +**远端 URL 导入(Remote URL Import)**: +[配置导入链接](#配置导入链接profile-import-link)的一种来源:query `url` 指向一份 HTTPS 上的 settings JSON,由应用拉取后再走同一套预览导入。 +_Avoid_: 在线配置中心、配置订阅、远程同步 diff --git a/docs/adr/0003-deeplink-profile-import.md b/docs/adr/0003-deeplink-profile-import.md new file mode 100644 index 0000000..2e7ec10 --- /dev/null +++ b/docs/adr/0003-deeplink-profile-import.md @@ -0,0 +1,24 @@ +# 深度链接首期只做配置导入(双来源、预览入库、不自动应用) + +## Context + +需要用系统自定义 URL 把「分享来的 Claude settings」快速送进 Code Manager。可选来源包括把 JSON 塞进 URL、让应用去拉远端文件、或只打开本地路径。导入后是否自动写入 `~/.claude/settings.json`、密钥是否允许出现在链接里、远端是否限制主机白名单,会直接决定安全面、分享体验和以后能否扩展其它 deep link 动作。 + +## Decision + +1. **Scheme 与动作命名空间**:注册 `code-manager://`;首期只实现 `profiles/import`。后续动作挂在同 scheme 下不同 path,不另起 scheme。 +2. **双来源、互斥 query**: + - **内嵌载荷**:`payload` = base64url(UTF-8 裸 settings JSON),解码后硬上限 **16KB**。 + - **远端 URL**:`url` = HTTPS 地址;应用拉取后再导入。仅 HTTPS;**不设主机白名单**;必须做 SSRF 防护(禁 loopback/私网/链路本地/云元数据等);响应体 **≤256KB**;最多 **3** 次 HTTPS 跳转且每跳再检 SSRF;超时约 **10s**。 + - `payload` 与 `url` 都缺或都有 → 拒绝。可选 query `name` / `description` 仅预填导入对话框,不参与签名或信任。 +3. **载荷契约对齐文件导入**:内容是**裸 Claude settings JSON**(不是带 `providerId` 的私有 envelope)。校验与入库语义对齐现有 `preview_profile_import` / `import_profile_from_file`:预览确认后**新建配置**,`providerId` 为空,**不自动应用/绑定**。 +4. **密钥策略**:不强制剥离密钥。预览若检测到认证密钥,必须**风险横幅 + 勾选确认**后才能导入。导出侧新增「复制 Deep Link」,默认**不含密钥**;用户显式包含密钥时,复制前使用**同一档确认**。 +5. **生命周期与 UI**:单实例;冷启动与热启动都处理链接;多条链接**排队**(取消或失败后自动处理下一条,取消不清空整队)。成功解析后聚焦 **main** 窗口、切到配置页、打开与文件导入同一套预览 UI。三端(macOS / Windows / Linux)均注册 scheme。 +6. **首期明确不做**:自动 apply、应用代托管 JSON、主机白名单、payload 压缩、分享 envelope、多实例、仅热启动处理。 + +## Consequences + +- 分享链路由应用生成时优先走内嵌 `payload`;超 16KB 必须改由用户自行托管后用 `url=`,应用不提供托管。 +- 任意公网 HTTPS 可成为导入源,攻击面靠 SSRF 限制、体大小/跳转/超时、以及「必须预览确认才入库」收敛;不做白名单是为了 gist/raw/对象存储等常见分享不失效。 +- 密钥可以出现在 URL 与聊天记录中——这是显式产品选择;靠默认导出脱敏 + 含密钥时强制勾选降低误操作,**不能**当作密钥保险库。 +- URL 与导入语义一旦对外分享即难改;扩展新动作应新增 path,避免重载 `profiles/import` 的 query 含义。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 07e954f..3363abf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -620,6 +620,7 @@ dependencies = [ name = "code-manager" version = "1.3.1" dependencies = [ + "base64 0.22.1", "block2", "dirs 6.0.0", "fancy-regex", @@ -643,6 +644,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-log", @@ -650,11 +652,13 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-specta", "tempfile", "time", "tokio", + "url", "uuid", ] @@ -677,6 +681,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.1" @@ -784,6 +808,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1016,6 +1046,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1786,6 +1825,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -2976,6 +3021,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3637,6 +3692,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.42.1" @@ -4718,6 +4783,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -4866,6 +4952,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -5137,6 +5239,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5997,6 +6108,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 09867d6..a6c33aa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -54,6 +54,9 @@ tokio = { version = "1", features = ["sync"] } specta = { version = "=2.0.0-rc.25", features = ["serde_json"] } specta-typescript = "0.0.12" tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } +tauri-plugin-deep-link = "2" +base64 = "0.22" +url = "2" [target.'cfg(target_os = "macos")'.dependencies] # USB HID 控制 ANTICATER LED;仅 macOS 接入真机,避免 Linux 引入 libudev 系统依赖 @@ -63,6 +66,9 @@ objc2 = { version = "0.6.4", features = ["exception"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSBundle", "NSDictionary", "NSError", "NSObject", "NSString"] } objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationTrigger", "UNUserNotificationCenter"] } +[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } + # 集成测试依赖(仅 tests/ 下使用) [dev-dependencies] tempfile = "3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 61bf84c..90ce88d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -26,6 +26,7 @@ "global-shortcut:allow-unregister-all", "global-shortcut:allow-is-registered", "updater:default", - "process:default" + "process:default", + "deep-link:default" ] } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 9778ad5..a37c33e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1504,6 +1504,11 @@ fn redact_model_test_json_value(value: &mut Value) { } fn is_sensitive_model_test_json_key(key: &str) -> bool { + is_sensitive_settings_key(key) +} + +/// 判定 settings JSON 键是否为认证/密钥类敏感字段(导出脱敏、deep link 风险检测共用)。 +pub(crate) fn is_sensitive_settings_key(key: &str) -> bool { let normalized = key.to_ascii_lowercase(); normalized == "authorization" || normalized == "token" @@ -2719,6 +2724,24 @@ fn build_profile_export( serde_json::to_string_pretty(&resolved).map_err(|e| e.to_string()) } +/// deep link 生成等跨模块复用入口。 +pub(crate) fn build_profile_export_public( + registry: &ConfigRegistry, + id: &str, + include_secrets: bool, +) -> Result { + build_profile_export(registry, id, include_secrets) +} + +/// 解析并校验导入用 JSON 文本,返回去掉 `$schema` 的 settings。 +pub(crate) fn parse_and_validate_import_content(content: &str) -> Result { + let parsed: Value = + serde_json::from_str(content).map_err(|error| format!("解析 JSON 失败: {}", error))?; + let settings = normalize_settings_document(parsed)?; + validate_settings_document(&settings)?; + Ok(settings_without_schema(&settings)) +} + /// 读取并校验待导入的配置文件,返回去掉 `$schema` 的 settings。预览与导入共用。 fn read_and_validate_import(source_path: &str) -> Result { let path = PathBuf::from(source_path); @@ -2729,11 +2752,38 @@ fn read_and_validate_import(source_path: &str) -> Result { } let content = fs::read_to_string(&path).map_err(|error| format!("读取文件失败 {:?}: {}", path, error))?; - let parsed: Value = - serde_json::from_str(&content).map_err(|error| format!("解析 JSON 失败: {}", error))?; - let settings = normalize_settings_document(parsed)?; - validate_settings_document(&settings)?; - Ok(settings_without_schema(&settings)) + parse_and_validate_import_content(&content) +} + +/// 将已校验的 settings 写入 registry 为新配置(不自动绑定 / 激活)。 +fn insert_imported_profile( + app_handle: &AppHandle, + settings: Value, + name: String, + description: String, +) -> Result { + let mut registry = load_registry()?; + let now = crate::utils::current_rfc3339_timestamp(); + let trimmed_name = name.trim(); + let profile = ConfigProfile { + id: Uuid::new_v4().to_string(), + name: if trimmed_name.is_empty() { + "Imported Profile".to_string() + } else { + trimmed_name.to_string() + }, + description: description.trim().to_string(), + // 导入的是裸 settings,不关联 Provider;也不自动绑定 / 激活外来配置 + provider_id: None, + settings, + created_at: now.clone(), + updated_at: now, + }; + registry.profiles.insert(0, profile.clone()); + save_registry(®istry)?; + rebuild_tray_menu(app_handle, Some(®istry)); + let _ = app_handle.emit("config-workspace-changed", ()); + Ok(profile) } #[tauri::command] @@ -2780,28 +2830,7 @@ pub fn import_profile_from_file( let result = (|| { let _lock = crate::utils::lock_config()?; let settings = read_and_validate_import(&source_path)?; - let mut registry = load_registry()?; - let now = crate::utils::current_rfc3339_timestamp(); - let trimmed_name = name.trim(); - let profile = ConfigProfile { - id: Uuid::new_v4().to_string(), - name: if trimmed_name.is_empty() { - "Imported Profile".to_string() - } else { - trimmed_name.to_string() - }, - description: description.trim().to_string(), - // 导入的是裸 settings,不关联 Provider;也不自动绑定 / 激活外来配置 - provider_id: None, - settings, - created_at: now.clone(), - updated_at: now, - }; - registry.profiles.insert(0, profile.clone()); - save_registry(®istry)?; - rebuild_tray_menu(&app_handle, Some(®istry)); - let _ = app_handle.emit("config-workspace-changed", ()); - Ok(profile) + insert_imported_profile(&app_handle, settings, name, description) })(); crate::logging::log_command_result("profile.import_from_file", &result, |profile| { format!("profile_id={}", profile.id) @@ -2809,6 +2838,26 @@ pub fn import_profile_from_file( result } +/// 从已解析的 settings JSON 文本导入为新配置(deep link 预览确认后入库)。 +#[tauri::command] +#[specta::specta] +pub fn import_profile_from_settings_json( + app_handle: AppHandle, + settings_json: String, + name: String, + description: String, +) -> Result { + let result = (|| { + let _lock = crate::utils::lock_config()?; + let settings = parse_and_validate_import_content(&settings_json)?; + insert_imported_profile(&app_handle, settings, name, description) + })(); + crate::logging::log_command_result("profile.import_from_settings_json", &result, |profile| { + format!("profile_id={}", profile.id) + }); + result +} + #[tauri::command] #[specta::specta] pub async fn test_profile_model(data: ModelTestInput) -> Result { diff --git a/src-tauri/src/deep_link.rs b/src-tauri/src/deep_link.rs new file mode 100644 index 0000000..ac78172 --- /dev/null +++ b/src-tauri/src/deep_link.rs @@ -0,0 +1,551 @@ +//! 深度链接:配置导入(`code-manager://profiles/import`)。 +//! +//! 契约见 `docs/adr/0003-deeplink-profile-import.md` 与 `CONTEXT.md`。 + +use std::collections::VecDeque; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::sync::Mutex; +use std::time::Duration; + +use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Manager, State}; +use url::Url; + +/// 与前端 / 系统协议处理器约定的 scheme。 +pub const DEEP_LINK_SCHEME: &str = "code-manager"; +/// 配置导入动作 path(不含前导 `/` 的规范化形式为 `profiles/import`)。 +pub const PROFILE_IMPORT_PATH: &str = "profiles/import"; +/// 内嵌 payload 解码后硬上限(字节)。 +pub const MAX_PAYLOAD_DECODED_BYTES: usize = 16 * 1024; +/// 远端响应体上限。 +pub const MAX_REMOTE_BODY_BYTES: usize = 256 * 1024; +/// 远端 HTTPS 最大跳转次数。 +pub const MAX_REMOTE_REDIRECTS: usize = 3; +/// 远端拉取超时。 +pub const REMOTE_FETCH_TIMEOUT: Duration = Duration::from_secs(10); + +const EVENT_PROFILE_IMPORT_DEEP_LINK: &str = "profile-import-deep-link"; + +/// 冷启动时前端尚未订阅事件,先入队;前端 `drain` 与热事件合并处理。 +#[derive(Default)] +pub struct PendingProfileImportDeepLinks { + urls: Mutex>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedProfileImportDeepLink { + /// 预填名称(query 或默认值)。 + pub name: String, + /// 预填描述。 + pub description: String, + /// 校验后的 settings 美化 JSON,供预览与入库。 + pub settings_json: String, + /// 是否含非空认证类敏感字段。 + pub contains_secrets: bool, + /// `payload` 或 `url`。 + pub source: String, +} + +/// 解析配置导入 deep link(含远端拉取 / payload 解码与 schema 校验)。 +#[tauri::command] +#[specta::specta] +pub async fn resolve_profile_import_deep_link( + url: String, +) -> Result { + let parsed = parse_profile_import_deep_link(&url)?; + let source_label = match &parsed.source { + ProfileImportSource::Payload(_) => "payload", + ProfileImportSource::RemoteUrl(_) => "url", + } + .to_string(); + let settings = match parsed.source { + ProfileImportSource::Payload(payload) => { + let raw = decode_payload(&payload)?; + crate::config::parse_and_validate_import_content(&raw)? + } + ProfileImportSource::RemoteUrl(remote) => { + let raw = fetch_remote_settings_json(&remote).await?; + crate::config::parse_and_validate_import_content(&raw)? + } + }; + let settings_json = + serde_json::to_string_pretty(&settings).map_err(|error| error.to_string())?; + let contains_secrets = settings_contain_secrets(&settings); + Ok(ResolvedProfileImportDeepLink { + name: parsed.name, + description: parsed.description, + settings_json, + contains_secrets, + source: source_label, + }) +} + +/// 从配置导出内容生成内嵌载荷 deep link(默认路径 A)。 +#[tauri::command] +#[specta::specta] +pub fn build_profile_import_deep_link(id: String, include_secrets: bool) -> Result { + let registry = crate::config::load_registry()?; + let profile = registry + .profiles + .iter() + .find(|profile| profile.id == id) + .ok_or_else(|| format!("未找到 profile '{}'", id))?; + let export_json = crate::config::build_profile_export_public(®istry, &id, include_secrets)?; + if export_json.len() > MAX_PAYLOAD_DECODED_BYTES { + return Err(format!( + "导出内容超过内嵌载荷上限 {} 字节,请改用文件导出或托管后使用 url= 链接", + MAX_PAYLOAD_DECODED_BYTES + )); + } + // 校验导出 JSON 仍是合法 settings(含密钥时同样走 schema) + let _ = crate::config::parse_and_validate_import_content(&export_json)?; + let payload = URL_SAFE_NO_PAD.encode(export_json.as_bytes()); + // 使用 host+path 形态,与系统/浏览器打开自定义 scheme 时的常见解析一致 + let mut url = Url::parse(&format!("{DEEP_LINK_SCHEME}://profiles/import")) + .map_err(|error| format!("构造 deep link 失败: {error}"))?; + { + let mut pairs = url.query_pairs_mut(); + pairs.append_pair("payload", &payload); + if !profile.name.trim().is_empty() { + pairs.append_pair("name", profile.name.trim()); + } + if !profile.description.trim().is_empty() { + pairs.append_pair("description", profile.description.trim()); + } + } + Ok(url.into()) +} + +/// 取出冷启动积压的配置导入 deep link(取出后清空)。 +#[tauri::command] +#[specta::specta] +pub fn drain_pending_profile_import_deep_links( + pending: State<'_, PendingProfileImportDeepLinks>, +) -> Result, String> { + let mut guard = pending + .urls + .lock() + .map_err(|_| "深度链接队列锁异常".to_string())?; + Ok(guard.drain(..).collect()) +} + +/// 注册 deep-link 监听与 pending 队列;在 setup 中调用。 +pub fn setup_deep_link_handlers(app: &AppHandle) -> Result<(), String> { + app.manage(PendingProfileImportDeepLinks::default()); + + use tauri_plugin_deep_link::DeepLinkExt; + + // Linux/Windows 开发态与 AppImage 需运行时注册;macOS 仅打包安装后生效 + #[cfg(any(windows, target_os = "linux"))] + { + if let Err(error) = app.deep_link().register_all() { + log::warn!("event=deep_link.register_all status=err error={error}"); + } + } + + let handle = app.clone(); + app.deep_link().on_open_url(move |event| { + let urls: Vec = event.urls().iter().map(|url| url.to_string()).collect(); + for url in urls { + enqueue_profile_import_deep_link(&handle, url); + } + }); + + // 冷启动:get_current 可能已有 URL + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + enqueue_profile_import_deep_link(app, url.to_string()); + } + } + Ok(None) => {} + Err(error) => { + log::warn!("event=deep_link.get_current status=err error={error}"); + } + } + + Ok(()) +} + +fn enqueue_profile_import_deep_link(app: &AppHandle, url: String) { + if !url_looks_like_profile_import(&url) { + log::warn!("event=deep_link.enqueue status=skip reason=unsupported_url"); + return; + } + log::info!("event=deep_link.enqueue status=ok action=profiles.import"); + // 唯一事实源是 pending 队列;事件仅唤醒前端 drain,避免「事件 + 队列」双投递 + if let Some(pending) = app.try_state::() { + if let Ok(mut guard) = pending.urls.lock() { + if guard.back() != Some(&url) { + guard.push_back(url); + } + } + } + let _ = app.emit(EVENT_PROFILE_IMPORT_DEEP_LINK, ()); + crate::tray::show_main_window(app); +} + +fn url_looks_like_profile_import(raw: &str) -> bool { + Url::parse(raw) + .map(|url| { + url.scheme() == DEEP_LINK_SCHEME && deep_link_action_path(&url) == PROFILE_IMPORT_PATH + }) + .unwrap_or(false) +} + +#[derive(Debug)] +enum ProfileImportSource { + Payload(String), + RemoteUrl(String), +} + +#[derive(Debug)] +struct ParsedProfileImportDeepLink { + source: ProfileImportSource, + name: String, + description: String, +} + +fn parse_profile_import_deep_link(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|error| format!("无效的深度链接: {error}"))?; + if url.scheme() != DEEP_LINK_SCHEME { + return Err(format!( + "不支持的 scheme '{}', 期望 '{}'", + url.scheme(), + DEEP_LINK_SCHEME + )); + } + let action = deep_link_action_path(&url); + if action != PROFILE_IMPORT_PATH { + return Err(format!( + "不支持的 deep link 路径 '{}', 期望 '{PROFILE_IMPORT_PATH}'", + action + )); + } + + let mut payload: Option = None; + let mut remote_url: Option = None; + let mut name = String::new(); + let mut description = String::new(); + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "payload" => payload = Some(value.into_owned()), + "url" => remote_url = Some(value.into_owned()), + "name" => name = value.into_owned(), + "description" => description = value.into_owned(), + _ => {} + } + } + + let source = match (payload, remote_url) { + (Some(payload), None) => ProfileImportSource::Payload(payload), + (None, Some(remote)) => ProfileImportSource::RemoteUrl(remote), + (None, None) => { + return Err("配置导入链接缺少 payload 或 url 参数".to_string()); + } + (Some(_), Some(_)) => { + return Err("配置导入链接不能同时包含 payload 与 url".to_string()); + } + }; + + if name.trim().is_empty() { + name = "Imported Profile".to_string(); + } + + Ok(ParsedProfileImportDeepLink { + source, + name: name.trim().to_string(), + description: description.trim().to_string(), + }) +} + +/// 自定义 scheme 下 `code-manager://profiles/import` 的 host 是 `profiles`、path 是 `/import`, +/// 拼成动作路径 `profiles/import`;也兼容 `code-manager:///profiles/import`。 +fn deep_link_action_path(url: &Url) -> String { + let host = url.host_str().unwrap_or("").trim_matches('/'); + let path = url.path().trim_matches('/'); + match (host.is_empty(), path.is_empty()) { + (true, true) => String::new(), + (true, false) => path.to_string(), + (false, true) => host.to_string(), + (false, false) => format!("{host}/{path}"), + } +} + +fn decode_payload(payload: &str) -> Result { + let trimmed = payload.trim(); + if trimmed.is_empty() { + return Err("payload 为空".to_string()); + } + let bytes = URL_SAFE_NO_PAD + .decode(trimmed) + .or_else(|_| URL_SAFE.decode(trimmed)) + .map_err(|error| format!("payload base64url 解码失败: {error}"))?; + if bytes.len() > MAX_PAYLOAD_DECODED_BYTES { + return Err(format!( + "payload 解码后超过 {} 字节上限", + MAX_PAYLOAD_DECODED_BYTES + )); + } + String::from_utf8(bytes).map_err(|error| format!("payload 不是合法 UTF-8: {error}")) +} + +fn settings_contain_secrets(value: &Value) -> bool { + match value { + Value::Object(object) => { + for (key, child) in object { + if crate::config::is_sensitive_settings_key(key) { + if let Value::String(text) = child { + if !text.trim().is_empty() { + return true; + } + } else if !child.is_null() { + return true; + } + } else if settings_contain_secrets(child) { + return true; + } + } + false + } + Value::Array(items) => items.iter().any(settings_contain_secrets), + _ => false, + } +} + +async fn fetch_remote_settings_json(remote: &str) -> Result { + let mut current = validate_https_url_for_fetch(remote)?; + let client = reqwest::Client::builder() + .timeout(REMOTE_FETCH_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建 HTTP 客户端失败: {error}"))?; + + for redirect_count in 0..=MAX_REMOTE_REDIRECTS { + // 每次请求前解析并拦截私网/环回,降低 DNS rebinding 窗口 + ensure_host_not_blocked(¤t)?; + + let response = client + .get(current.clone()) + .send() + .await + .map_err(|error| format!("拉取远端配置失败: {error}"))?; + + let status = response.status(); + if status.is_redirection() { + if redirect_count == MAX_REMOTE_REDIRECTS { + return Err(format!("远端配置跳转超过 {MAX_REMOTE_REDIRECTS} 次上限")); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "远端配置跳转缺少 Location".to_string())?; + current = resolve_redirect_url(¤t, location)?; + continue; + } + + if !status.is_success() { + return Err(format!("拉取远端配置失败: HTTP {status}")); + } + + let bytes = response + .bytes() + .await + .map_err(|error| format!("读取远端配置失败: {error}"))?; + if bytes.len() > MAX_REMOTE_BODY_BYTES { + return Err(format!("远端配置超过 {} 字节上限", MAX_REMOTE_BODY_BYTES)); + } + return String::from_utf8(bytes.to_vec()) + .map_err(|error| format!("远端配置不是合法 UTF-8: {error}")); + } + + Err(format!("远端配置跳转超过 {MAX_REMOTE_REDIRECTS} 次上限")) +} + +fn validate_https_url_for_fetch(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|error| format!("无效的远端 URL: {error}"))?; + if url.scheme() != "https" { + return Err("远端导入仅支持 HTTPS".to_string()); + } + if url.username() != "" || url.password().is_some() { + return Err("远端导入不支持带用户名密码的 URL".to_string()); + } + if url.host_str().is_none() { + return Err("远端 URL 缺少主机名".to_string()); + } + Ok(url) +} + +fn resolve_redirect_url(base: &Url, location: &str) -> Result { + let next = if let Ok(absolute) = Url::parse(location) { + absolute + } else { + base.join(location) + .map_err(|error| format!("无效的跳转 Location: {error}"))? + }; + validate_https_url_for_fetch(next.as_str()) +} + +fn ensure_host_not_blocked(url: &Url) -> Result<(), String> { + let host = url + .host_str() + .ok_or_else(|| "远端 URL 缺少主机名".to_string())?; + // 字面量 IP + if let Ok(ip) = host.parse::() { + if is_blocked_ip(ip) { + return Err("远端导入禁止访问内网或本机地址".to_string()); + } + return Ok(()); + } + + let port = url.port_or_known_default().unwrap_or(443); + let addrs = (host, port) + .to_socket_addrs() + .map_err(|error| format!("解析远端主机失败: {error}"))?; + let mut saw_any = false; + for addr in addrs { + saw_any = true; + if is_blocked_ip(addr.ip()) { + return Err("远端导入禁止访问内网或本机地址".to_string()); + } + // 仅检查 IP,不实际连接 + let _: SocketAddr = addr; + } + if !saw_any { + return Err("远端主机未能解析到任何地址".to_string()); + } + Ok(()) +} + +fn is_blocked_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_broadcast() + || v4.is_unspecified() + || v4.is_multicast() + // CGNAT 100.64.0.0/10 + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64) + // 文档/基准 198.18.0.0/15、192.0.2.0/24 等一并拦截常见非公网 + || v4.is_documentation() + || (v4.octets()[0] == 0) + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unique_local() + || v6.is_unicast_link_local() + || v6.is_unspecified() + || v6.is_multicast() + || v6 + .to_ipv4_mapped() + .is_some_and(|v4| is_blocked_ip(IpAddr::V4(v4))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_payload_link_accepts_name_description() { + let settings = br#"{"env":{"ANTHROPIC_BASE_URL":"https://example.com"}}"#; + let payload = URL_SAFE_NO_PAD.encode(settings); + let raw = + format!("code-manager://profiles/import?payload={payload}&name=Demo&description=Note"); + let parsed = parse_profile_import_deep_link(&raw).unwrap(); + assert_eq!(parsed.name, "Demo"); + assert_eq!(parsed.description, "Note"); + assert!(matches!(parsed.source, ProfileImportSource::Payload(_))); + } + + #[test] + fn parse_rejects_both_payload_and_url() { + let err = parse_profile_import_deep_link( + "code-manager://profiles/import?payload=abc&url=https://example.com/a.json", + ) + .unwrap_err(); + assert!(err.contains("不能同时")); + } + + #[test] + fn parse_rejects_missing_source() { + let err = + parse_profile_import_deep_link("code-manager://profiles/import?name=x").unwrap_err(); + assert!(err.contains("缺少")); + } + + #[test] + fn decode_payload_enforces_size_limit() { + let big = "x".repeat(MAX_PAYLOAD_DECODED_BYTES + 1); + let payload = URL_SAFE_NO_PAD.encode(big.as_bytes()); + let err = decode_payload(&payload).unwrap_err(); + assert!(err.contains("上限")); + } + + #[test] + fn decode_payload_accepts_valid_json_bytes() { + let payload = URL_SAFE_NO_PAD.encode(br#"{"model":"claude"}"#); + let raw = decode_payload(&payload).unwrap(); + assert!(raw.contains("claude")); + } + + #[test] + fn settings_contain_secrets_detects_non_empty_token() { + let value = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "secret", + "ANTHROPIC_BASE_URL": "https://api.example.com" + } + }); + assert!(settings_contain_secrets(&value)); + } + + #[test] + fn settings_contain_secrets_ignores_blank_token() { + let value = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_BASE_URL": "https://api.example.com" + } + }); + assert!(!settings_contain_secrets(&value)); + } + + #[test] + fn validate_https_url_rejects_http_and_credentials() { + assert!(validate_https_url_for_fetch("http://example.com/a.json").is_err()); + assert!(validate_https_url_for_fetch("https://user:pass@example.com/a.json").is_err()); + assert!(validate_https_url_for_fetch("https://example.com/a.json").is_ok()); + } + + #[test] + fn is_blocked_ip_catches_private_and_metadata() { + assert!(is_blocked_ip("127.0.0.1".parse().unwrap())); + assert!(is_blocked_ip("10.0.0.1".parse().unwrap())); + assert!(is_blocked_ip("192.168.1.1".parse().unwrap())); + assert!(is_blocked_ip("169.254.169.254".parse().unwrap())); + assert!(is_blocked_ip("::1".parse().unwrap())); + assert!(!is_blocked_ip("1.1.1.1".parse().unwrap())); + } + + #[test] + fn url_looks_like_profile_import_filters_other_actions() { + assert!(url_looks_like_profile_import( + "code-manager://profiles/import?payload=x" + )); + assert!(!url_looks_like_profile_import( + "code-manager://memory/import?payload=x" + )); + assert!(!url_looks_like_profile_import("https://example.com")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60949ec..b97de35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod auto_memory; mod claude_directory; mod claude_directory_watcher; mod config; +mod deep_link; mod history; mod led; mod logging; @@ -38,10 +39,14 @@ use claude_directory::{ }; use config::{ apply_profile, delete_profile, duplicate_profile, export_profile, get_config_workspace, - import_profile_from_file, import_user_settings_profile, install_status_line_preset, - prepare_profile_launch, preview_profile, preview_profile_export, preview_profile_import, - reorder_profiles, set_app_preferences, sync_shared_profile_settings, test_profile_model, - upsert_profile, + import_profile_from_file, import_profile_from_settings_json, import_user_settings_profile, + install_status_line_preset, prepare_profile_launch, preview_profile, preview_profile_export, + preview_profile_import, reorder_profiles, set_app_preferences, sync_shared_profile_settings, + test_profile_model, upsert_profile, +}; +use deep_link::{ + build_profile_import_deep_link, drain_pending_profile_import_deep_links, + resolve_profile_import_deep_link, }; use history::{ get_history, get_history_if_changed, get_session_detail, open_session_file_in_editor, @@ -107,6 +112,10 @@ fn build_specta_builder() -> tauri_specta::Builder { export_profile, preview_profile_import, import_profile_from_file, + import_profile_from_settings_json, + resolve_profile_import_deep_link, + build_profile_import_deep_link, + drain_pending_profile_import_deep_links, test_profile_model, set_app_preferences, toggle_floating_widget, @@ -232,7 +241,18 @@ pub fn run() { export_typescript_bindings(default_typescript_bindings_path()) .expect("specta: 导出 TypeScript bindings 失败"); - tauri::Builder::default() + // single-instance 必须最先注册,才能与 deep-link 协作把二次启动的 URL 交给首实例 + let mut builder = tauri::Builder::default(); + #[cfg(any(target_os = "macos", windows, target_os = "linux"))] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + // 二次启动时聚焦主窗口;deep-link feature 会另行触发 on_open_url + tray::show_main_window(app); + })); + } + + builder + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_log::Builder::new() @@ -288,6 +308,8 @@ pub fn run() { tray::apply_focus_session_shortcut(app.handle()); // 启动菜单栏待处理会话呼吸灯脉动线程(仅 macOS 有视觉效果,其它平台空跑无害) tray::start_pulse_task(app.handle().clone()); + // 深度链接:注册监听、冷启动积压、Linux/Windows 运行时 register + deep_link::setup_deep_link_handlers(app.handle()).map_err(std::io::Error::other)?; log::info!("event=app.setup status=ok"); let claude_directory_watcher = claude_directory_watcher::start_claude_directory_watcher(app.handle().clone()); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2c739b0..5b6aa78 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,6 +37,11 @@ ] }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["code-manager"] + } + }, "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhGOEM0QTkwQTNEMjdCRTkKUldUcGU5S2prRXFNanhkK0RJUXJjSDNVUTJiTFpVZk9pOUdpMzRaUDNYd05nWjlPdlg3ampreU0K", "endpoints": [ diff --git a/src/App.test.tsx b/src/App.test.tsx index e06cedb..c3092d3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -582,7 +582,16 @@ describe("App", () => { openUrlMock.mockClear(); revealItemInDirMock.mockClear(); usagePageRenderMock.mockClear(); - invokeMock.mockResolvedValue(WORKSPACE_FIXTURE); + // 默认 workspace;deep link drain 必须返回数组,避免 null.length 干扰其它用例 + invokeMock.mockImplementation(async (command) => { + if (command === "drain_pending_profile_import_deep_links") { + return []; + } + if (command === "get_config_workspace") { + return WORKSPACE_FIXTURE; + } + return null; + }); Object.defineProperty(window, "__TAURI_INTERNALS__", { configurable: true, value: undefined, diff --git a/src/App.tsx b/src/App.tsx index ec6b5cc..b22c11a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,10 +91,15 @@ function App() { project: string; requestId: number; } | null>(null); + const [deepLinkImportRequest, setDeepLinkImportRequest] = useState<{ + urls: string[]; + requestId: number; + } | null>(null); const previousContentTabRef = useRef("configs"); const editorExitGuardRef = useRef(null); const historyProjectRequestIdRef = useRef(0); const usageProjectRequestIdRef = useRef(0); + const deepLinkImportRequestIdRef = useRef(0); const workspaceRequestIdRef = useRef(0); const loadWorkspace = useCallback(async () => { @@ -276,6 +281,34 @@ function App() { [activateTab, runWithEditorExitGuard], ); + // 配置导入 deep link:drain 后端 pending 队列,切到配置页交给 ProfilesPage 排队预览 + const drainProfileImportDeepLinks = useCallback(async () => { + if (!isTauri()) return; + try { + const urls = await ipc.drainPendingProfileImportDeepLinks(); + if (!urls?.length) return; + runWithEditorExitGuard(() => { + deepLinkImportRequestIdRef.current += 1; + setDeepLinkImportRequest({ + urls, + requestId: deepLinkImportRequestIdRef.current, + }); + activateTab("configs"); + }); + } catch (error) { + showOperationError(showToast, t("profiles.import.deepLink.toast.resolveError"), error); + } + }, [activateTab, runWithEditorExitGuard, showToast, t]); + + useEffect(() => { + if (loading) return; + void drainProfileImportDeepLinks(); + }, [loading, drainProfileImportDeepLinks]); + + useTauriEvent("profile-import-deep-link", () => { + void drainProfileImportDeepLinks(); + }); + if (loading) { return ( @@ -340,6 +373,7 @@ function App() { workspace={workspace} onWorkspaceChange={loadWorkspace} onEditorExitGuardChange={setEditorExitGuard} + deepLinkImportRequest={deepLinkImportRequest} /> ) : (
typedError(__TAURI_INVOKE("export_profile", { id, targetPath, includeSecrets })), previewProfileImport: (sourcePath: string) => typedError(__TAURI_INVOKE("preview_profile_import", { sourcePath })), importProfileFromFile: (sourcePath: string, name: string, description: string) => typedError(__TAURI_INVOKE("import_profile_from_file", { sourcePath, name, description })), + /** 从已解析的 settings JSON 文本导入为新配置(deep link 预览确认后入库)。 */ + importProfileFromSettingsJson: (settingsJson: string, name: string, description: string) => typedError(__TAURI_INVOKE("import_profile_from_settings_json", { settingsJson, name, description })), + /** 解析配置导入 deep link(含远端拉取 / payload 解码与 schema 校验)。 */ + resolveProfileImportDeepLink: (url: string) => typedError(__TAURI_INVOKE("resolve_profile_import_deep_link", { url })), + /** 从配置导出内容生成内嵌载荷 deep link(默认路径 A)。 */ + buildProfileImportDeepLink: (id: string, includeSecrets: boolean) => typedError(__TAURI_INVOKE("build_profile_import_deep_link", { id, includeSecrets })), + /** 取出冷启动积压的配置导入 deep link(取出后清空)。 */ + drainPendingProfileImportDeepLinks: () => typedError(__TAURI_INVOKE("drain_pending_profile_import_deep_links")), testProfileModel: (data: ModelTestInput) => typedError(__TAURI_INVOKE("test_profile_model", { data })), setAppPreferences: (data: AppPreferencesInput) => typedError(__TAURI_INVOKE("set_app_preferences", { data })), /** 切换浮窗显隐(幂等):显示时不存在则创建,隐藏时隐藏而非关闭以保留位置与状态。 */ @@ -946,6 +954,19 @@ export type Provider_Serialize = { env: { [key in string]: string }, }; +export type ResolvedProfileImportDeepLink = { + /** 预填名称(query 或默认值)。 */ + name: string, + /** 预填描述。 */ + description: string, + /** 校验后的 settings 美化 JSON,供预览与入库。 */ + settingsJson: string, + /** 是否含非空认证类敏感字段。 */ + containsSecrets: boolean, + /** `payload` 或 `url`。 */ + source: string, +}; + export type ScanResult = { filesScanned: number, newRecords: number, diff --git a/src/components/ProfilesPage.tsx b/src/components/ProfilesPage.tsx index 2dd7f52..aedb38f 100644 --- a/src/components/ProfilesPage.tsx +++ b/src/components/ProfilesPage.tsx @@ -78,6 +78,7 @@ import UnsavedChangesAlertDialog from "./UnsavedChangesAlertDialog"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Card } from "./ui/card"; +import { Checkbox } from "./ui/checkbox"; import { Dialog, DialogContent, @@ -102,8 +103,20 @@ interface ProfilesPageProps { workspace: ConfigWorkspace; onWorkspaceChange: () => Promise; onEditorExitGuardChange?: (guard: EditorExitGuard | null) => void; + /** App 从 deep link pending 队列 drain 后下发的导入请求 */ + deepLinkImportRequest?: { urls: string[]; requestId: number } | null; } +/** 导入对话框来源:本地文件或已解析的 deep link */ +type ImportDialogSource = + | { kind: "file"; sourcePath: string } + | { + kind: "deepLink"; + settingsJson: string; + containsSecrets: boolean; + source: string; + }; + type ProfileModelTestState = | { status: "running" } | { status: "success"; result: ModelTestResult } @@ -255,6 +268,7 @@ function ProfilesPage({ workspace, onWorkspaceChange, onEditorExitGuardChange, + deepLinkImportRequest = null, }: ProfilesPageProps) { const { language, t } = useI18n(); const { showToast } = useToast(); @@ -281,14 +295,20 @@ function ProfilesPage({ const [exportPreviewError, setExportPreviewError] = useState(null); const [isExportPreviewLoading, setIsExportPreviewLoading] = useState(false); const [isExporting, setIsExporting] = useState(false); - // 导入预览对话框:源文件路径、预览内容/错误、名称与描述、加载与导入中状态 - const [importSourcePath, setImportSourcePath] = useState(null); + const [exportSecretsAcknowledged, setExportSecretsAcknowledged] = useState(false); + const [isCopyingDeepLink, setIsCopyingDeepLink] = useState(false); + // 导入预览对话框:文件或 deep link、预览内容/错误、名称与描述、加载与导入中状态 + const [importDialogSource, setImportDialogSource] = useState(null); const [importPreview, setImportPreview] = useState(""); const [importPreviewError, setImportPreviewError] = useState(null); const [isImportPreviewLoading, setIsImportPreviewLoading] = useState(false); const [importName, setImportName] = useState(""); const [importDescription, setImportDescription] = useState(""); const [isImporting, setIsImporting] = useState(false); + const [importSecretsAcknowledged, setImportSecretsAcknowledged] = useState(false); + const deepLinkQueueRef = useRef([]); + const importDialogOpenRef = useRef(false); + const deepLinkResolveBusyRef = useRef(false); const [profileModelTestStates, setProfileModelTestStates] = useState< Record >({}); @@ -785,6 +805,7 @@ function ProfilesPage({ function openExportDialog(profile: ConfigProfile) { setExportTargetProfile(profile); setExportIncludeSecrets(false); + setExportSecretsAcknowledged(false); setExportPreview(""); setExportPreviewError(null); } @@ -794,6 +815,8 @@ function ProfilesPage({ setExportPreview(""); setExportPreviewError(null); setIsExporting(false); + setExportSecretsAcknowledged(false); + setIsCopyingDeepLink(false); } // 确认导出:弹保存对话框选路径,落盘含/不含密钥的完整配置 @@ -822,6 +845,25 @@ function ProfilesPage({ } } + // 复制配置导入 Deep Link(默认不含密钥;含密钥需勾选确认) + async function handleCopyDeepLink() { + if (!exportTargetProfile || exportPreviewError) return; + if (exportIncludeSecrets && !exportSecretsAcknowledged) return; + setIsCopyingDeepLink(true); + try { + const link = await ipc.buildProfileImportDeepLink( + exportTargetProfile.id, + exportIncludeSecrets, + ); + await navigator.clipboard.writeText(link); + showToast(t("profiles.export.toast.deepLinkCopied")); + } catch (err) { + showOperationError(showToast, t("profiles.export.toast.deepLinkCopyError"), err); + } finally { + setIsCopyingDeepLink(false); + } + } + // 顶部导入入口:选择 .json 文件后打开导入预览对话框 async function handleImportProfile() { let selected: string | string[] | null; @@ -837,28 +879,53 @@ function ProfilesPage({ } const sourcePath = Array.isArray(selected) ? selected[0] : selected; if (!sourcePath) return; - setImportSourcePath(sourcePath); + importDialogOpenRef.current = true; + setImportDialogSource({ kind: "file", sourcePath }); setImportName(fileStemFromPath(sourcePath)); setImportDescription(""); setImportPreview(""); setImportPreviewError(null); + setImportSecretsAcknowledged(false); } function closeImportDialog() { - setImportSourcePath(null); + importDialogOpenRef.current = false; + setImportDialogSource(null); setImportPreview(""); setImportPreviewError(null); setImportName(""); setImportDescription(""); setIsImporting(false); + setImportSecretsAcknowledged(false); + // 关闭当前预览后继续处理排队中的 deep link + void pumpDeepLinkQueue(); } // 确认导入:校验通过后创建新配置(不自动绑定/激活) async function handleConfirmImport() { - if (!importSourcePath || importPreviewError) return; + if (!importDialogSource || importPreviewError) return; + if ( + importDialogSource.kind === "deepLink" && + importDialogSource.containsSecrets && + !importSecretsAcknowledged + ) { + return; + } setIsImporting(true); try { - await ipc.importProfileFromFile(importSourcePath, importName, importDescription); + if (importDialogSource.kind === "file") { + await ipc.importProfileFromFile( + importDialogSource.sourcePath, + importName, + importDescription, + ); + } else { + await ipc.importProfileFromSettingsJson( + importDialogSource.settingsJson, + importName, + importDescription, + ); + } await onWorkspaceChange(); showToast(t("profiles.import.toast.imported")); closeImportDialog(); @@ -868,12 +935,56 @@ function ProfilesPage({ } } + // 解析并弹出下一条 deep link;失败则 Toast 后自动继续 + const pumpDeepLinkQueue = useCallback(async () => { + if (importDialogOpenRef.current || deepLinkResolveBusyRef.current) return; + const nextUrl = deepLinkQueueRef.current.shift(); + if (!nextUrl) return; + deepLinkResolveBusyRef.current = true; + setIsImportPreviewLoading(true); + setImportPreviewError(null); + try { + const resolved = await ipc.resolveProfileImportDeepLink(nextUrl); + importDialogOpenRef.current = true; + setImportDialogSource({ + kind: "deepLink", + settingsJson: resolved.settingsJson, + containsSecrets: resolved.containsSecrets, + source: resolved.source, + }); + setImportName(resolved.name); + setImportDescription(resolved.description); + setImportPreview(resolved.settingsJson); + setImportSecretsAcknowledged(false); + if (deepLinkQueueRef.current.length > 0) { + showToast(t("profiles.import.deepLink.toast.queueHint")); + } + } catch (err) { + showOperationError(showToast, t("profiles.import.deepLink.toast.resolveError"), err); + deepLinkResolveBusyRef.current = false; + setIsImportPreviewLoading(false); + void pumpDeepLinkQueue(); + return; + } + deepLinkResolveBusyRef.current = false; + setIsImportPreviewLoading(false); + }, [showToast, t]); + + // App drain 下发的 deep link 入队并泵出 + useEffect(() => { + if (!deepLinkImportRequest) return; + deepLinkQueueRef.current.push(...deepLinkImportRequest.urls); + void pumpDeepLinkQueue(); + }, [deepLinkImportRequest, pumpDeepLinkQueue]); + // 拉取导出预览:目标配置或「包含密钥」开关变化时重新加载,保证预览所见即所得 useEffect(() => { if (!exportTargetProfile) return; let cancelled = false; setIsExportPreviewLoading(true); setExportPreviewError(null); + // 切换含密钥时重置确认勾选 + setExportSecretsAcknowledged(false); ipc .previewProfileExport(exportTargetProfile.id, exportIncludeSecrets) .then((content) => { @@ -894,12 +1005,13 @@ function ProfilesPage({ // 拉取导入预览:选定文件后加载,提前暴露解析/校验错误 useEffect(() => { - if (!importSourcePath) return; + if (importDialogSource?.kind !== "file") return; + const sourcePath = importDialogSource.sourcePath; let cancelled = false; setIsImportPreviewLoading(true); setImportPreviewError(null); ipc - .previewProfileImport(importSourcePath) + .previewProfileImport(sourcePath) .then((content) => { if (!cancelled) setImportPreview(content); }) @@ -914,7 +1026,7 @@ function ProfilesPage({ return () => { cancelled = true; }; - }, [importSourcePath]); + }, [importDialogSource]); // 选定配置后准备启动命令:生成文件路径式与内联 JSON 式两条命令 useEffect(() => { @@ -2132,13 +2244,37 @@ function ProfilesPage({ onCheckedChange={setExportIncludeSecrets} />
+ {exportIncludeSecrets ? ( + + ) : null}
- + +