diff --git a/.claude/rules/projects-tray-diagnostics.md b/.claude/rules/projects-tray-diagnostics.md index 4cc25ad..f4668e4 100644 --- a/.claude/rules/projects-tray-diagnostics.md +++ b/.claude/rules/projects-tray-diagnostics.md @@ -13,6 +13,7 @@ paths: - "src-tauri/src/native_open.rs" - "src-tauri/src/tray.rs" - "src-tauri/src/terminal_focus.rs" + - "src-tauri/src/herdr.rs" - "src-tauri/src/logging.rs" - "src-tauri/src/config.rs" - "src-tauri/src/lib.rs" @@ -76,6 +77,13 @@ paths: - 会话菜单项 id 需要能安全携带 `pid` 和 `cwd`;`cwd` 可能包含中文、空格、引号和 `::`。 - Terminal.app 与 iTerm2 通过 `pid -> tty -> AppleScript` 精确聚焦已有 tab。 - Ghostty 的 AppleScript 目前按 `working directory` 近似匹配,命中后直接 `focus term`;不要使用 `select tab t of w` 这类循环变量 specifier,容易触发 `-1700` 类型错误。 +- herdr 会话(跑在 herdr pane 里的 Claude Code)走"两跳聚焦":先连 herdr unix socket API 按 pid 定位 pane 并 `pane.focus`,再找到附着的 herdr client 进程(其 tty 即宿主 tab 的 tty)复用宿主终端 AppleScript。逻辑集中在 `src-tauri/src/herdr.rs`,`terminal_focus.rs` 只做编排。 +- herdr 检测:先读会话进程 env(`HERDR_SESSION` / `HERDR_SOCKET_PATH` 标记,命名会话才有),无标记时沿 ppid 链找名为 `herdr` 的祖先进程(默认会话没有 env 标记,只能靠进程树)。 +- herdr socket 路径:`HERDR_SOCKET_PATH` > `/herdr/sessions//herdr.sock`(命名会话)> `/herdr/herdr.sock`;`HERDR_SESSION` 必须按 herdr 命名规则白名单校验,防路径穿越。 +- herdr pane 匹配:pid 精确匹配优先(`pane.list` + 逐 pane `pane.process_info`,命中 foreground pid / shell_pid / 进程组 id),失配或歧义时用 `agent.list` 按 cwd 兜底;兜底也只允许唯一匹配。 +- herdr 宿主跳:扫描 `herdr` 进程(comm 可能带完整路径,如 `/opt/homebrew/bin/herdr`,需同时匹配)+ 会话一致性(env 的 `HERDR_SESSION`/`HERDR_SOCKET_PATH`,或 argv 的 `--session ` / `session attach `——herdr 0.7.x 的 client env 不带标记,会话名只在 argv)+ 真实 tty 过滤(daemon 无 tty 天然排除);Ghostty 宿主按 client 进程 cwd(`lsof`)匹配,不要用 pane 的 cwd。 +- herdr 降级语义:socket 跳失败(`HerdrNotRunning` / `HerdrPaneNotFound`)才向用户报错;宿主跳失败或找不到 client(detach / ssh 远程附着)按"部分成功"只记 warn,不弹通知。 +- 聚焦可用性门禁按会话判定(`session_focus_available`):macOS 且(默认终端支持聚焦,或 pid 自身宿主终端支持,或会话在 herdr 里);全局快捷键只挑可聚焦会话。 - 未命中或聚焦失败只记录 warn 日志,不要自动新开窗口或 tab。 - `osascript` 可能较慢,托盘点击 handler 不应阻塞 UI 事件循环。 diff --git a/.claude/rules/tauri-backend.md b/.claude/rules/tauri-backend.md index a0758d0..afa40f3 100644 --- a/.claude/rules/tauri-backend.md +++ b/.claude/rules/tauri-backend.md @@ -26,7 +26,8 @@ paths: | `claude_directory.rs` | `~/.claude` 文件树、文件预览、创建、重命名、删除与外部打开 | | `claude_directory_watcher.rs` | `~/.claude` 变更监听并广播 `claude-directory-changed` | | `native_open.rs` | 默认终端 / 编辑器跨平台启动、本机检测受支持工具清单 | -| `terminal_focus.rs` | macOS 上 `pid -> tty -> AppleScript` 聚焦 Terminal.app / iTerm / Ghostty | +| `terminal_focus.rs` | macOS 上 `pid -> tty -> AppleScript` 聚焦 Terminal.app / iTerm / Ghostty;herdr 会话两跳聚焦编排 | +| `herdr.rs` | herdr 会话聚焦:socket API 客户端(NDJSON)、pane 定位(pid 精确 + cwd 兜底)、附着 client 进程发现 | | `tray.rs` | 系统托盘:配置切换、会话视图、页面导航 | | `logging.rs` | tauri-plugin-log 配置、日志脱敏 helper、panic hook | | `plugins.rs` | 插件市场后端操作:触发 `claude plugin list --available --json`,让 claude 按 24h TTL 默认策略刷新插件安装数缓存(不主动删缓存、不强制刷新) | diff --git a/CLAUDE.md b/CLAUDE.md index 64929f3..6731292 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,8 @@ - 记忆与 Skills:`src-tauri/src/memory.rs`、`src-tauri/src/skills.rs` - 历史、统计、用量:`src-tauri/src/history.rs`、`src-tauri/src/stats.rs`、`src-tauri/src/usage.rs` - 项目、打开本机应用、托盘:`src-tauri/src/project.rs`、`src-tauri/src/native_open.rs`、`src-tauri/src/tray.rs` +- 会话终端聚焦:`src-tauri/src/terminal_focus.rs`(AppleScript)、`src-tauri/src/herdr.rs`(herdr 两跳聚焦) +- 会话终端聚焦:`src-tauri/src/terminal_focus.rs`(AppleScript)、`src-tauri/src/herdr.rs`(herdr 两跳聚焦) - 日志与诊断:`src-tauri/src/logging.rs` - 内置 provider、模型价格和状态行脚本:`src-tauri/resources/` - Tauri capability:`src-tauri/capabilities/default.json` @@ -122,9 +124,7 @@ macOS 上应用数据刻意复用 `~/.config/code-manager/`,便于跨平台备 ## 已知陷阱 - CodeMirror 多版本冲突会导致空白页或配置预览渲染崩溃(`@codemirror/state` 必须全局单例)。排查:`grep "'@codemirror/state@" pnpm-lock.yaml`,预期只有一个版本;如出现多个版本,在 `pnpm-workspace.yaml` 的 `overrides` 段统一(pnpm 11 已不再读取 `package.json` 的 `pnpm.overrides`)。 -- 业务代码不要直接调用 `invoke`;新增 IPC 先更新 Specta command 集合并走 `src/ipc.ts`。`src/bindings.ts` 是生成文件,不手改。 - 不要自实现浮层:抽屉、设置面板、模态框、下拉菜单和 Toast 都用 shadcn `Sheet` / `Dialog` / `DropdownMenu` / `Popover` / sonner。 -- 不要混淆 Projects、Stats 与 Usage 的数据源。 - 不要把日志当成配置数据:日志目录由 Tauri 的 `app_log_dir()` 解析。 - 当前配置 schema 是 `src/schemas/claude-settings.schema.json`。 - Tauri 事件监听器必须在组件卸载时清理;使用 `useTauriEvent` hook 而非直接调用 `listen()`。 diff --git a/CONTEXT.md b/CONTEXT.md index 69d9ebc..a2ffea0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -58,6 +58,12 @@ _Avoid_: 自动模式 一个处于 running 类状态(`running / busy / active / starting`)的 Claude Code 会话。**waiting(等待用户操作)不计入**——那时 Claude 卡在等人,机器休眠也不会杀死会话。"仅活动时"模式据此判断是否保持唤醒。 _Avoid_: 运行中会话(running session,只是其中一个具体状态) +### 会话聚焦(Session Focus) + +**会话聚焦(Session Focus)**: +把承载[活动会话](#活动会话active-session)的终端视图带到前台的动作:宿主终端窗口/tab 激活,以及多路复用器(如 herdr)内部的 pane 选中。聚焦的载体随会话所在环境不同而不同(终端 tab、herdr pane、Ghostty term),但语义不变:用户眼睛看到承载该会话的视图并可直接交互。 +_Avoid_: 聚焦终端 tab(herdr 场景没有 tab 概念)、激活窗口(只覆盖一半语义) + ### 目录总览(Directory Overview) **目录总览(Directory Overview)**: diff --git a/README.md b/README.md index 820dbbc..3a11caa 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 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. | +| 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 / export (optionally with secrets, with a pre-save preview), compare diffs, and sync common options / marketplaces / plugins to other profiles in one click. | | 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. | @@ -88,7 +88,7 @@ Code Manager mainly reads and writes local files. Profile merging, directory sca | Usage SQLite | `~/Library/Application Support/com.gotobeta.app.code-manager/usage.db` | `$XDG_CONFIG_HOME/com.gotobeta.app.code-manager/usage.db` or `~/.config/com.gotobeta.app.code-manager/usage.db` | `%APPDATA%\com.gotobeta.app.code-manager\usage.db` | | Log directory | `~/Library/Logs/com.gotobeta.app.code-manager/` | `$XDG_DATA_HOME/com.gotobeta.app.code-manager/logs/` or `~/.local/share/com.gotobeta.app.code-manager/logs/` | `%LOCALAPPDATA%\com.gotobeta.app.code-manager\logs\` | -The application data directory contains `config-registry.json`, `memories.json`, `model-pricing.json`, and `skills-disabled/`. On macOS, application data deliberately uses `~/.config/code-manager/` for easier cross-platform backup and script access; SQLite uses Tauri's `app_config_dir()`, and logs use the Tauri plugin's default path. +The application data directory contains `config-registry.json`, `memories.json`, `model-pricing.json`, and `skills-disabled/`. On macOS, application data deliberately reuses `~/.config/code-manager/` for easier cross-platform backup and script access. ## Local Development @@ -129,15 +129,11 @@ Build artifacts are located by default in `src-tauri/target/release/bundle/`. ### Repository at a Glance -| Path | Purpose | -| --- | --- | -| `src/` | React frontend pages, components, hooks, schemas, and tests. | -| `src/components/` | Page-level components and reusable UI; finer component entry points are in `CLAUDE.md`. | -| `src-tauri/src/` | Rust backend, Tauri commands, and local file and data capabilities. | -| `src-tauri/resources/` | Built-in providers, model pricing, status line scripts, and other resources. | -| `src-tauri/capabilities/` | Tauri permission declarations. | -| `docs/` | User manual, platform differences, and extended documentation. | -| `.claude/rules/` | Path-scoped maintenance rules for AI agents. | +- `src/`: React frontend pages, components, hooks, schemas, and tests. +- `src-tauri/`: Rust backend, Tauri commands, built-in resources, and permission declarations. +- `docs/`: user manual, platform differences, and extended documentation. + +For fine-grained component entry points, module responsibilities, and path navigation for AI agents, see [CLAUDE.md](./CLAUDE.md). ## Contributing and Feedback @@ -152,7 +148,6 @@ When filing an issue, please include as much as possible: - [docs/user-manual.md](./docs/user-manual.md): the complete user manual - [docs/platform-support.md](./docs/platform-support.md): platform support differences -- [docs/claude-code-best-practices.md](./docs/claude-code-best-practices.md): extended best practices for Claude Code / Codex in this repo - [CLAUDE.md](./CLAUDE.md): the repository execution manual for AI agents - [LICENSE](./LICENSE): license diff --git a/README.zh-CN.md b/README.zh-CN.md index a39976d..8341b3e 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、插件与状态行;支持预览、复制、模型测试、一键应用、导入 / 导出(可选含密钥、落盘前预览)、差异对比,以及把常用选项 / 市场 / 插件一键同步到其他配置。 | | 记忆管理 | 管理用户级 `CLAUDE.md` 与 `rules/*.md`,支持 Karpathy 行为指南预设、导入、启用、禁用、复制、预览和路径校验。 | | Skills 管理 | 新建、编辑、删除、启用、禁用 Claude Code Skills,并可同步为 `~/.codex/skills/` 软链接。 | | 历史与会话 | 读取 `~/.claude/history.jsonl`,按项目和会话查看历史详情。 | @@ -88,7 +88,7 @@ Code Manager 主要读写本机文件。配置合并、目录扫描、用量聚 | 用量 SQLite | `~/Library/Application Support/com.gotobeta.app.code-manager/usage.db` | `$XDG_CONFIG_HOME/com.gotobeta.app.code-manager/usage.db` 或 `~/.config/com.gotobeta.app.code-manager/usage.db` | `%APPDATA%\com.gotobeta.app.code-manager\usage.db` | | 日志目录 | `~/Library/Logs/com.gotobeta.app.code-manager/` | `$XDG_DATA_HOME/com.gotobeta.app.code-manager/logs/` 或 `~/.local/share/com.gotobeta.app.code-manager/logs/` | `%LOCALAPPDATA%\com.gotobeta.app.code-manager\logs\` | -应用数据目录包含 `config-registry.json`、`memories.json`、`model-pricing.json` 和 `skills-disabled/`。macOS 上应用数据刻意使用 `~/.config/code-manager/`,便于跨平台备份和脚本访问;SQLite 使用 Tauri `app_config_dir()`,日志使用 Tauri 插件默认路径。 +应用数据目录包含 `config-registry.json`、`memories.json`、`model-pricing.json` 和 `skills-disabled/`。macOS 上应用数据刻意复用 `~/.config/code-manager/`,便于跨平台备份和脚本访问。 ## 本地开发 @@ -129,15 +129,11 @@ make test-frontend # 运行前端测试 ### 仓库速览 -| 路径 | 用途 | -| --- | --- | -| `src/` | React 前端页面、组件、hooks、schema 与测试。 | -| `src/components/` | 页面级组件和复用 UI;更细组件入口见 `CLAUDE.md`。 | -| `src-tauri/src/` | Rust 后端、Tauri command、本地文件与数据能力。 | -| `src-tauri/resources/` | 内置 provider、模型价格和状态行脚本等资源。 | -| `src-tauri/capabilities/` | Tauri 权限声明。 | -| `docs/` | 用户手册、平台差异和扩展文档。 | -| `.claude/rules/` | 面向 AI Agent 的 path-scoped 维护规则。 | +- `src/`:React 前端页面、组件、hooks、schema 与测试。 +- `src-tauri/`:Rust 后端、Tauri command、内置资源与权限声明。 +- `docs/`:用户手册、平台差异和扩展文档。 + +细粒度的组件入口、模块职责和面向 AI Agent 的路径导航见 [CLAUDE.md](./CLAUDE.md)。 ## 贡献与反馈 @@ -152,7 +148,6 @@ make test-frontend # 运行前端测试 - [docs/user-manual.zh-CN.md](./docs/user-manual.zh-CN.md):完整用户说明书 - [docs/platform-support.zh-CN.md](./docs/platform-support.zh-CN.md):平台支持差异 -- [docs/claude-code-best-practices.md](./docs/claude-code-best-practices.md):Claude Code / Codex 在本仓库的扩展最佳实践 - [docs/claude-code/plugin-update.md](./docs/claude-code/plugin-update.md):Claude Code 插件更新方式 - [CLAUDE.md](./CLAUDE.md):面向 AI Agent 的仓库执行手册 - [LICENSE](./LICENSE):许可证 diff --git a/docs/adr/0004-herdr-session-focus.md b/docs/adr/0004-herdr-session-focus.md new file mode 100644 index 0000000..d2e1bff --- /dev/null +++ b/docs/adr/0004-herdr-session-focus.md @@ -0,0 +1,22 @@ +# herdr 会话聚焦走"两跳":socket 定位 pane + 宿主终端激活 + +## Context + +会话托盘"聚焦终端"依赖 `pid -> tty -> AppleScript`:把会话进程的 tty 拿去宿主终端里按 tab 匹配。herdr(agent 多路复用器,跑在宿主终端内部,类 tmux)打破了这个前提——Claude Code 进程跑在 herdr 自己创建的 pty 上,宿主终端 tab 列表里根本没有这个 tty,聚焦必然失配(TabNotFound)。 + +调查 herdr 源码后确认三个事实:server 是 detached 的 headless daemon,pane 是它的子进程;socket API(NDJSON over unix socket)只改 herdr 内部焦点,没有任何接口能激活宿主窗口;TUI client 进程跑在宿主终端 tab 里,其 tty 就是宿主 tab 的 tty,但它不是 pane 进程的祖先,pid 反查不到。此外默认会话的 pane 进程没有任何环境标记(只有命名会话带 `HERDR_SESSION`)。 + +## Decision + +1. **两跳聚焦**:第一跳连 herdr socket API,`pane.list` + 逐 pane `pane.process_info` 按 pid 精确匹配(命中 foreground pid / shell_pid / 进程组 id),失配时 `agent.list` 按 cwd 兜底且只允许唯一匹配;命中后 `pane.focus`。第二跳扫描本机 `herdr` 进程,按会话一致性(env 标记或 argv 会话参数)+ 真实 tty 过滤找到 client(daemon 无 tty 天然排除),其 tty 即宿主 tab tty,复用现有 Terminal/iTerm AppleScript;Ghostty 宿主按 client 进程 cwd(`lsof`)匹配。socket 跳失败即报错并跳过宿主跳。 +2. **降级语义**:socket 跳成功但宿主跳失败或找不到 client(detach / ssh 远程附着)按"部分成功"处理,只记 warn 不弹通知——herdr 内部焦点已切换,没有窗口可激活不是错误。 +3. **检测**:先读 pane 进程 env(`HERDR_SESSION` / `HERDR_SOCKET_PATH` 标记),无标记再沿 ppid 链找名为 `herdr` 的祖先进程(覆盖默认会话);`HERDR_SESSION` 按 herdr 命名规则白名单校验防路径穿越。 +4. **门禁放宽**:聚焦可用性从"只看默认终端 slug"改为按会话判定(macOS 且默认终端支持,或 pid 自身宿主终端支持,或会话在 herdr 里);全局快捷键只挑可聚焦会话。不做"菜单扫描成本"的缓存——ps 调用在菜单重建频率下可忽略。 +5. **实现位置**:herdr 侧逻辑独立成 `herdr.rs`,`terminal_focus.rs` 只做编排,AppleScript 模板不重复。socket 用 std `UnixStream`,零新依赖,1s 读写超时防后台线程卡死。 + +## Consequences + +- 协议按 herdr 源码快照实现(无官方 schema 文件);herdr 协议若变动,socket 调用失败会走 `HerdrNotRunning` / 通用 `ScriptError` 兜底,不会崩溃,但需要跟进。 +- 宿主终端必须仍受支持(Terminal / iTerm / Ghostty)才有完整两跳;herdr 跑在 Warp 等无 AppleScript 宿主里只能得到内部聚焦 + warn。 +- 多 tab 附着同一 herdr 会话时取第一个 client(UI 内容相同,聚焦任一都正确);本地找不到 client 时不激活窗口。 +- 后续其它多路复用器(如 tmux)支持会以"检测 → 内部聚焦 API → 宿主激活"三段式结构为参照,但各自机制不同,不应直接套用 herdr 实现。 diff --git a/docs/claude-code-best-practices.md b/docs/claude-code-best-practices.md deleted file mode 100644 index c57a494..0000000 --- a/docs/claude-code-best-practices.md +++ /dev/null @@ -1,251 +0,0 @@ -# Claude Code 使用最佳实践 - -> 本文档是 `CLAUDE.md` 与 `.claude/rules/*.md` 的扩展手册。硬约束、快速入口、验证清单和已知陷阱以 `CLAUDE.md` 为权威来源;本文档只补充工作流、提示模板、上下文管理和失败模式,避免重复。 - -本文档面向在 Code Manager 仓库中使用 Claude Code、Codex 或兼容代理的开发者。目标不是复述 Claude Code 官方教程,而是把官方建议映射到本项目的真实协作规则上。 - -参考来源: - -- [Claude Code 概述](https://code.claude.com/docs/zh-CN/overview) -- [Claude Code 最佳实践](https://code.claude.com/docs/zh-CN/best-practices) -- [Claude 如何记住你的项目](https://code.claude.com/docs/zh-CN/memory) -- [选择权限模式](https://code.claude.com/docs/zh-CN/permission-modes) -- [常见工作流程](https://code.claude.com/docs/zh-CN/common-workflows) - -## 项目画像 - -Code Manager 是面向 Claude Code 用户的本地 Tauri 2 桌面管理台,让 Claude Code 的配置、记忆、Skills、历史、统计、用量、项目状态和诊断信息可见、可编辑、可验证。版本、技术栈、应用标识符等基础事实在 `CLAUDE.md` 的「项目速览」和 `README.md` 中维护;功能域到关键文件的映射在 `CLAUDE.md` 的「快速入口」和对应 `.claude/rules/*.md` 中维护,本文档不再重复一份。 - -## 总体原则 - -官方最佳实践里最重要的几个点,在本项目中应落成以下规则: - -1. **先探索,再计划,最后编码**:涉及 3 个以上步骤、跨前后端、改配置契约或改 UI 体系时,先只读分析并列计划。拼写、文档小修、单行配置这类明确小改可以直接做。 -2. **给代理可验证的成功标准**:每个任务都要说明或自行选择验证命令。没有新鲜验证证据,不要声称完成。 -3. **主动管理上下文**:长任务先定位入口文件和规则文件,不要一次性读取全仓。大型调查优先拆成独立问题,必要时使用 subagent 或新会话。 -4. **把持久规则写进合适位置**:长期项目规则放 `CLAUDE.md` 或 `.claude/rules/`;偶发、可复用流程放 `.claude/skills/`;必须每次执行的动作放 hooks。 -5. **最小影响面**:只改目标功能需要的文件,不做顺手重构,不回退用户已有变更。 - -## 推荐工作流 - -### 1. 进入任务 - -每次开始前先做两件事: - -```bash -git status --short -rg --files -``` - -然后按修改范围读取命中的 `.claude/rules/*.md`(完整索引见 `CLAUDE.md` 的「规则索引」表)。如果只改文档,仍要先确认现有 `docs/` 结构和相邻文档风格。 - -### 2. 计划 - -适合使用 Plan Mode 的任务: - -- 跨 `src/` 与 `src-tauri/` 的契约改动。 -- 新增或调整 Claude settings 字段。 -- 涉及数据落盘、路径安全、符号链接、日志脱敏或权限能力。 -- UI 改动会影响多个页面、共享组件或设计令牌。 -- 用户只说“全面分析”“深度 review”“重新设计”“结合最佳实践给方案”。 - -不需要 Plan Mode 的任务: - -- 单个文档 typo。 -- 单个测试断言修正。 -- 明确、低风险的样式微调。 -- 用户已经给出可执行计划并明确要求实现。 - -计划必须包含: - -- 目标和非目标。 -- 要读或要改的文件。 -- 关键风险。 -- 验证命令。 -- 如果有用户决策点,先停下确认。 - -### 3. 实施 - -实施时的硬约束(i18n、Toast、shadcn 语义变量、Tauri command 同步、`utils.rs` 复用、无外键等)在 `CLAUDE.md` 的「硬约束」与「架构同步点」中维护,本节只列具体执行习惯: - -- 搜索用 `rg` / `rg --files`。 -- 文件编辑保持小补丁,避免把无关格式化混入业务 diff。 -- 改动跨前后端时按"先 Rust command + Specta 注册 + bindings,再 `src/ipc.ts` 包装 + i18n + 测试"的顺序写,避免反复来回切换。 - -### 4. 验证 - -按改动范围选最小充分集(完整清单见 `CLAUDE.md` 的「测试与验证」),并注意: - -- 标准验证优先走 `Makefile` target,例如 `make lint-frontend`、`make bindings-check`、`make build-frontend`、`make test-rust`、`make verify`。 -- `pnpm check` 会写文件,只做只读前端检查时使用 `make lint-frontend`;只读格式检查用 `make fmt-check`。 -- `pnpm dev` 只启动 Vite;需要原生壳时使用 `pnpm tauri dev`。 -- Tauri capability 或插件改动除了契约命令,还要本地实际触发相关路径说明。 -- UI 视觉改动在前端命令之外补本地应用或浏览器截图核验。 -- 文档变更不触发当前 CI 的代码检查,但本地仍要跑 `git diff --check`,避免尾随空格和 Markdown 断裂。 - -### 5. 收尾 - -最终回复或 PR 描述应包含: - -- 改了什么。 -- 为什么这么改。 -- 运行了哪些验证命令。 -- 如果没跑某个合理验证,说明原因。 -- 是否存在剩余风险或需要用户确认的后续项。 - -提交信息使用 Conventional Commits,并由本地 `commit-msg` hook 与 GitHub Actions commitlint workflow 共同检查,例如: - -```text -docs: 添加 Claude Code 使用最佳实践 -fix(config): 修复 Profile 预览权限合并 -refactor(settings): 收敛设置抽屉表单结构 -``` - -## Claude Code 配置建议 - -> CLAUDE.md / `.claude/rules/` 的取舍规则、维护检查与 200 行硬约束已在 `.claude/rules/agent-memory-layout.md` 中维护,本文档不再重复。本节只补充 Skills、Hooks 与权限模式三类没有 path-scoped rule 的话题。 - -### Skills - -官方建议把可复用工作流做成 skills。本项目当前已落地 `.claude/skills/release-new-version/SKILL.md`(手动触发,`disable-model-invocation: true`)。 - -未来候选(尚未落地,不要假设已存在): - -- 批量同步 Claude Code 配置。 -- 复杂 review 模板。 -- 用量价格表对账。 -- 回归测试矩阵生成。 - -创建 skill 时要明确: - -- `name` 和 `description` 是否能让模型自动选择。 -- 是否有副作用;有副作用且需要人工触发的流程应考虑 `disable-model-invocation: true`。 -- 输入、输出、验证命令和失败处理。 - -### Hooks - -官方文档把 hooks 定位为"必须每次发生且没有例外"的确定性动作。本项目采用三层门禁: - -- `.claude/settings.json` 的 hooks 做会话级 guardrail:阻止绕过 lefthook 的 Bash 命令、Bash 侧敏感文件读取,并在 Stop 时按变更范围提示验证命令。 -- `lefthook.yml` 做本地 Git 门禁:pre-commit 负责 staged Biome 自动修复、Gitleaks 密钥扫描、Rust 格式检查和轻量配置检查,commit-msg 负责 commitlint,分支 pre-push 负责 `make verify`。 -- GitHub Actions 做远端权威门禁:commitlint workflow 检查提交信息,CI 和 release quality job 执行不可绕过的构建、lint、test。 - -不建议把耗时全量命令无条件放进每次编辑后执行,例如 `make build-frontend`、`make test-rust`。这些更适合任务收尾、pre-push 或 CI。 - -### 权限模式 - -权限模式要按任务风险选择: - -| 场景 | 建议模式 | -| --- | --- | -| 初次理解仓库、审查设计、排查风险 | `plan` 或默认只读 | -| 小范围实现且用户持续看 diff | `acceptEdits` | -| 长时间、低风险、可验证的批量任务 | `auto` | -| CI / 脚本中只允许预批准工具 | `dontAsk` | -| 本机真实工作区 | 不使用 `bypassPermissions` | - -本项目涉及本地 `~/.claude/`、Code Manager 应用数据目录、日志目录、SQLite 缓存和 Tauri capability。凡是会写用户目录、清理项目数据、删除 Skill/Memory、修改权限配置的任务,都应保留人工确认或先做 preview。 - -## 上下文管理 - -Claude Code 官方文档反复强调 context 是稀缺资源。这个仓库应采用以下策略: - -- 先问“我要改哪个功能域”,再读对应规则和入口文件。 -- 用 `rg` 定位调用链,不要一次性打开大量组件。 -- 大范围审查时按功能域分批输出,不把所有细节塞进同一个会话。 -- 跨任务时使用 `/clear`;长任务阶段性使用 `/compact`,并要求保留已改文件、测试命令和剩余风险。 -- 跨天继续任务时使用 `claude --continue` 或 `claude --resume`,并先让 Claude 复述当前 diff 和验证状态。 -- 大型调查可以委派 subagent,只把结论、证据路径和风险返回主会话。 - -## Worktree 与并行会话 - -官方推荐用 worktree 运行并行会话,避免多个代理编辑同一个检出目录。本项目建议: - -- 一个功能分支只承载一个明确目标。 -- 需要同时做 UI、Rust、文档三条线时,优先拆 worktree 或明确文件所有权。 -- 并行会话结束后由主会话统一检查 diff、运行验证和合并结论。 -- 不要在多个会话中同时改 `src/types.ts`、`src/i18n.ts`、`src-tauri/src/lib.rs` 这类高冲突文件,除非已明确分工。 - -## 项目专用提示模板 - -### 代码审查 - -```text -请按代码审查方式检查当前分支相对 main 的变更。 -先运行 git status 和 git diff 定位范围。 -重点看行为回归、缺失测试、路径安全、日志脱敏、i18n、Tauri command 同步。 -结论按严重程度排序,给出文件和行号;没有问题也说明剩余测试风险。 -``` - -### 前端功能 - -```text -实现 [功能]。 -先读 CLAUDE.md 和 .claude/rules/frontend-ui.md,再定位相关组件。 -所有用户可见文本走 useI18n(),通知走 useToast()。 -沿用 shadcn/ui、TYPOGRAPHY、surface-classes 和现有测试选择器模式。 -完成后运行 make lint-frontend、make build-frontend、make test-frontend。 -``` - -### Tauri command - -```text -新增/修改 [command]。 -先读 .claude/rules/tauri-backend.md。 -同步 Rust command 的 #[tauri::command] / #[specta::specta]、lib.rs collect_commands、make bindings、src/ipc.ts、src/types.ts、capability、i18n 和测试。 -路径输入必须防止绝对路径、..、符号链接逃逸;日志不得包含密钥或完整配置内容。 -完成后运行 make bindings-check、make build-frontend 和 make test-rust。 -``` - -### 配置字段 - -```text -调整 Claude settings 字段 [字段名]。 -先读 .claude/rules/config-system.md。 -不要在前端复制 Provider/Profile 合并逻辑;最终配置以 src-tauri/src/config.rs 为准。 -同步 JSON Schema、表单注册、类型、Rust 校验、预览/应用路径、i18n 和测试。 -``` - -### 文档任务 - -```text -更新 docs 下的 [主题] 文档。 -先检查现有 docs 结构和相邻文档风格。 -用真实仓库路径、命令和数据源写,不写泛泛建议。 -如涉及版本或功能清单,先对照 package.json、src-tauri/tauri.conf.json 和近期 git log。 -完成后运行 git diff --check,并检查是否有乱码、过期版本号、旧文件名或断链。 -``` - -## 常见失败模式 - -| 失败模式 | 本项目中的后果 | 预防方式 | -| --- | --- | --- | -| 未读规则直接改代码 | i18n、Toast、shadcn、capability 或数据源边界被破坏 | 每次按路径读取 `.claude/rules/`。 | -| 前端复制后端逻辑 | Profile 预览、应用和模型测试结果不一致 | 合并和落盘逻辑只在 Rust 维护。 | -| 混淆 Stats 与 Usage | 总费用、Token、最近会话口径错误 | 牢记 Stats 用 `~/.claude.json`,Usage 用 `~/.claude/projects/**/*.jsonl`。 | -| 把日志当配置 | 日志目录、轮转和脱敏策略失效 | 日志只走系统日志目录和 `logging.rs`。 | -| 用 `pnpm check` 做只读检查 | 工作区被自动格式化,混入无关 diff | 只读前端检查用 `make lint-frontend`。 | -| 路径校验只做前端 | 用户目录写入存在逃逸风险 | 路径安全必须在 Rust command 边界校验。 | -| 视觉改动不截图 | UI 重叠、密度失衡、抽屉断裂 | 前端验证后补本地视觉核验。 | -| 文档写成教程但不落项目 | 读者无法直接执行 | 每条建议都绑定本仓库路径或命令。 | - -## 推荐的日常使用节奏 - -1. 用 Code Manager 管理配置(Profile) / 供应商(Provider),把模型、API 地址、环境变量、权限、Hooks、插件和状态行显式化。 -2. 用 Memory 页面管理 `CLAUDE.md` 与 `rules/*.md`,保持规则短小,避免把临时知识塞进长期指令。 -3. 用 Skills 页面沉淀高频流程,并同步到 Codex 时确认目标不是普通目录。 -4. 开发前让 Claude Code 先读项目规则和目标模块;开发中让它跑局部验证;开发后让它检查 diff。 -5. 长任务用 Plan Mode、worktree、subagent 和 `/compact` 管理上下文。 -6. 对用户目录、权限、日志和数据清理类操作保持 preview 和人工确认。 -7. 提交前按变更范围运行验证命令,再写 Conventional Commit。 - -## 最小完成标准 - -一次 Claude Code 任务在本仓库中只有满足以下条件,才算完成: - -- 目标需求已经落到代码或文档。 -- 没有无关文件被修改。 -- 已检查 `git diff`。 -- 已运行匹配范围的验证命令。 -- 对未验证项、环境限制或残余风险有明确说明。 -- 用户可见文本、日志、路径安全、数据源口径和配置契约没有破坏既有规则。 diff --git a/docs/platform-support.md b/docs/platform-support.md index 93b5e51..7ff4ab3 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -2,8 +2,6 @@ [English](./platform-support.md) | [中文](./platform-support.zh-CN.md) -Applies to version: `1.0.0` - > This document is intended for both users and maintainers. The user perspective answers "which features work on my machine, and which are degraded or unavailable"; the maintainer perspective answers "which platform gap to close, and where the risks and code locations are." > > The authoritative sources for hard constraints, module topology, and verification checklists are the repository-root `CLAUDE.md` and `.claude/rules/*.md`; this document only records platform-related slices of fact. When code or configuration changes, sync this document according to the "Maintenance Guide" at the end. @@ -219,4 +217,4 @@ git diff --check Manually review two points: - Each row's support level in the matrix matches the current code state (spot-check `src-tauri/src/terminal_focus.rs`, `src-tauri/src/config.rs:1561-1571`, `src-tauri/Cargo.toml:46-47`, `src/components/SettingsDrawer.tsx:124-137`). -- The style is consistent with `docs/user-manual.md` and `docs/claude-code-best-practices.md` (English, table-driven, file paths in backticks). +- The style is consistent with `docs/user-manual.md` (English, table-driven, file paths in backticks). diff --git a/docs/platform-support.zh-CN.md b/docs/platform-support.zh-CN.md index fab1a26..80e04b2 100644 --- a/docs/platform-support.zh-CN.md +++ b/docs/platform-support.zh-CN.md @@ -2,8 +2,6 @@ [English](./platform-support.md) | [中文](./platform-support.zh-CN.md) -适用版本:`1.0.0` - > 本文档面向用户与维护者。用户视角用来确认"我这台机器上能用哪些功能、哪些是降级或不可用";维护者视角用来判断"补哪个平台缺口、风险与代码位置在哪"。 > > 硬约束、模块拓扑和验证清单的权威来源是仓库根目录的 `CLAUDE.md` 与 `.claude/rules/*.md`;本文档只记录与平台相关的事实切片。当代码或配置发生变更时,按本文末尾的「维护指引」同步本文档。 @@ -219,4 +217,4 @@ git diff --check 人工审阅两点: - 矩阵每一行支持度与代码现状一致(spot-check `src-tauri/src/terminal_focus.rs`、`src-tauri/src/config.rs:1561-1571`、`src-tauri/Cargo.toml:46-47`、`src/components/SettingsDrawer.tsx:124-137`)。 -- 风格与 `docs/user-manual.zh-CN.md`、`docs/claude-code-best-practices.md` 一致(中文、表格驱动、文件路径用反引号)。 +- 风格与 `docs/user-manual.zh-CN.md` 一致(中文、表格驱动、文件路径用反引号)。 diff --git a/docs/user-manual.md b/docs/user-manual.md index 52e5a14..f74a27c 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -2,8 +2,6 @@ [English](./user-manual.md) | [中文](./user-manual.zh-CN.md) -Applies to version: `1.0.0` - > This document is intended for end users. The execution handbook for coding agents such as Claude Code / Codex is in `CLAUDE.md` at the repository root. Code Manager is a local desktop management tool for Claude Code users. It brings the `~/.claude` directory, configurations, providers, memories, Skills, history, statistics, token usage, project status, the system tray, and diagnostic logs together into a single Tauri application, helping you maintain your local Claude Code configuration in a more visible, previewable, and verifiable way. @@ -39,7 +37,7 @@ If `~/.claude/settings.json` already exists on this machine, the Configurations ### Provider -A provider carries only objective provider information (connection endpoint, model mapping, and optional additional environment variables). It contains no authentication keys and is built-in and read-only, with no custom providers. Built-in providers cover Anthropic, DeepSeek, Zhipu GLM Coding Plan, Kimi Code Plan, MiniMax Token Plan, Xiaomi MiMo Token Plan, OpenRouter, Volcengine Ark Coding Plan, Alibaba Cloud Bailian Coding Plan, Wanjie Ark, and Ollama. After a configuration references a provider, fields with the same name in the configuration override the provider's `env` (except the endpoint: the endpoint uses the provider as the single source of truth). +A provider carries only objective provider information (connection endpoint, model mapping, and optional additional environment variables). It contains no authentication keys and is built-in and read-only, with no custom providers. After a configuration references a provider, fields with the same name in the configuration override the provider's `env` (except the endpoint: the endpoint uses the provider as the single source of truth). See the [Providers](#providers) section for the full built-in list. ### Memory diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index 526af0c..09d5376 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -2,8 +2,6 @@ [English](./user-manual.md) | [中文](./user-manual.zh-CN.md) -适用版本:`1.0.0` - > 本文档面向终端用户。面向 Claude Code / Codex 等编程代理的执行手册见仓库根目录的 `CLAUDE.md`。 Code Manager 是面向 Claude Code 用户的本地桌面管理工具。它把 `~/.claude` 目录、配置、供应商、记忆、Skills、历史、统计、Token 用量、项目状态、系统托盘和诊断日志集中到一个 Tauri 应用中,帮助你用更可见、可预览、可验证的方式维护 Claude Code 本地配置。 @@ -39,7 +37,7 @@ Code Manager 是面向 Claude Code 用户的本地桌面管理工具。它把 `~ ### Provider(供应商) -供应商只承载供应商客观信息(连接地址、模型映射与可选附加环境变量),不含认证密钥,且均为内置只读、不可自定义。内置供应商覆盖 Anthropic、DeepSeek、智谱 GLM Coding Plan、Kimi Code Plan、MiniMax Token Plan、小米 MiMo Token Plan、OpenRouter、火山方舟 Coding Plan、阿里云百炼 Coding Plan、万界方舟和 Ollama。配置引用供应商后,配置中的同名字段会覆盖供应商的 `env`(地址除外:地址以供应商为单一事实源)。 +供应商只承载供应商客观信息(连接地址、模型映射与可选附加环境变量),不含认证密钥,且均为内置只读、不可自定义。配置引用供应商后,配置中的同名字段会覆盖供应商的 `env`(地址除外:地址以供应商为单一事实源)。完整内置清单见 [供应商 Provider](#供应商-provider) 章节。 ### 记忆 diff --git a/src-tauri/src/herdr.rs b/src-tauri/src/herdr.rs new file mode 100644 index 0000000..6985de8 --- /dev/null +++ b/src-tauri/src/herdr.rs @@ -0,0 +1,918 @@ +//! herdr(https://github.com/herdrdev/herdr)会话聚焦支持。 +//! +//! herdr 是跑在宿主终端里的 agent 多路复用器(类 tmux):TUI client 跑在宿主终端 +//! 的 tab 里,并 spawn 一个 detached 的 headless server daemon;daemon 为每个 pane +//! 创建自己的 pty。因此 Claude Code 会话进程的 tty 属于 herdr 而非宿主终端, +//! 宿主终端的 AppleScript 按 tty 匹配必然失配,需要"两跳"聚焦: +//! +//! 1. socket 跳:连接 herdr 的 unix socket API,按 pid 精确匹配 pane 并 `pane.focus`; +//! 该调用只改 herdr 内部焦点,server 是 headless 的,没有任何 API 能激活宿主窗口。 +//! 2. 宿主跳:扫描本机 `herdr` 进程找到附着同一会话的 client(其 tty 即宿主 tab +//! 的 tty),复用 `terminal_focus` 的 AppleScript 链路激活宿主终端。 +//! 找不到 client(ssh 远程附着 / detach 状态)或宿主跳失败时按"部分成功"降级, +//! 只记 warn 日志,不打扰用户。 +//! +//! 协议事实来自 herdr 源码(Apache-2.0,socket API 为 NDJSON over unix socket): +//! - socket 路径:env `HERDR_SOCKET_PATH` 优先,否则 `/herdr/herdr.sock`; +//! 命名会话(`herdr --session `)为 `/herdr/sessions//herdr.sock`, +//! config = `XDG_CONFIG_HOME` 或 `~/.config`。 +//! - 请求:`{"id": "...", "method": "...", "params": {...}}`; +//! 响应:`{"id": "...", "result": {...}}` 或 `{"id": "...", "error": {...}}`, +//! result 内带 `type` 标签(`pane_list` / `pane_process_info` / `agent_list` / `pane_info`)。 +//! - 检测:命名会话的 pane 进程 env 继承 `HERDR_SESSION=`;默认会话没有任何 +//! 环境标记,只能沿 ppid 链向上找到名为 `herdr` 的 server 进程。 + +#[cfg(unix)] +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +#[cfg(unix)] +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::terminal_focus::FocusFailure; + +/// herdr 设置的会话名环境变量(命名会话才存在)。 +const HERDR_SESSION_ENV: &str = "HERDR_SESSION"; +/// herdr socket 路径覆盖环境变量。 +const HERDR_SOCKET_PATH_ENV: &str = "HERDR_SOCKET_PATH"; +/// herdr 会话名最大长度(与其源码 validate_name 对齐)。 +const HERDR_SESSION_NAME_MAX: usize = 64; +/// ppid 链向上查找 herdr server 的最大层数。 +const ANCESTOR_WALK_MAX: usize = 8; +/// socket 读写超时:herdr server 是本地进程,1 秒足够,超时避免后台线程卡死。 +#[cfg(unix)] +const SOCKET_TIMEOUT: Duration = Duration::from_secs(1); + +/// 一个 herdr 会话上下文:从 pane 进程环境解析出的定位信息。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HerdrSessionContext { + /// `HERDR_SESSION` 值(命名会话);默认会话为 None。 + pub session_name: Option, + /// `HERDR_SOCKET_PATH` 覆盖值。 + pub socket_override: Option, + /// pane 环境的 `TERM_PROGRAM` 白名单值,即宿主终端。 + pub host_terminal: Option<&'static str>, +} + +/// 检测 pid 是否运行在 herdr pane 里,命中返回会话上下文。 +/// +/// 先读一次 `ps eww`:命中 `HERDR_SESSION` / `HERDR_SOCKET_PATH` 即 herdr; +/// 都没有再沿 ppid 链找名为 `herdr` 的祖先(覆盖默认会话)。 +pub fn detect_herdr_session(pid: u32) -> Option { + let env_output = ps_env_output(pid)?; + let ctx = herdr_context_from_ps_output(&env_output); + if ctx.session_name.is_some() || ctx.socket_override.is_some() { + return Some(ctx); + } + has_herdr_ancestor(pid).then_some(ctx) +} + +/// 会话是否运行在 herdr pane 里(托盘门禁用,只做存在性判断,不解析上下文)。 +/// 与 `detect_herdr_session` 共用一条检测路径,但返回更便宜。 +pub fn session_runs_in_herdr(pid: u32) -> bool { + detect_herdr_session(pid).is_some() +} + +/// 执行 herdr 两跳聚焦:socket 跳(定位并聚焦 pane)+ 宿主跳(激活宿主终端 tab)。 +/// +/// - socket 跳失败:返回对应 `FocusFailure`,调用方负责用户提示;跳过宿主跳。 +/// - socket 跳成功但宿主跳失败/找不到 client:按部分成功处理,记 warn 日志并返回 +/// Ok,不打扰用户(herdr 内部焦点已切换,detach/远程附着时没有窗口可激活)。 +pub fn focus_herdr_session( + pid: u32, + cwd: &str, + ctx: &HerdrSessionContext, + fallback_slug: &str, +) -> Result<(), FocusFailure> { + let socket_path = resolve_socket_path(ctx); + + // ---- 第一跳:herdr socket 聚焦 ---- + // pid 精确匹配优先,失配再按 cwd 兜底;两条查找同签名,串联后错误转换只写一次。 + let pane_id = find_pane_by_pid(&socket_path, pid) + .and_then(|hit| match hit { + Some(pane_id) => Ok(Some(pane_id)), + None => find_pane_by_cwd(&socket_path, cwd), + }) + .map_err(SocketError::into_focus_failure)?; + let Some(pane_id) = pane_id else { + log::warn!( + "event=tray.session_focus status=miss reason=pane_not_found app=herdr pid={pid}" + ); + return Err(FocusFailure::HerdrPaneNotFound); + }; + if let Err(e) = focus_pane(&socket_path, &pane_id) { + log::warn!( + "event=tray.session_focus status=miss reason=focus_failed app=herdr pane_id={pane_id} error={e:?}" + ); + return Err(e.into_focus_failure()); + } + log::info!("event=tray.session_focus status=ok hop=socket app=herdr pane_id={pane_id}"); + + // ---- 第二跳:激活宿主终端 ---- + let Some(client) = find_attached_client(ctx) else { + log::warn!( + "event=tray.session_focus status=degraded hop=host reason=client_not_found app=herdr" + ); + return Ok(()); + }; + // client 自己 env 里的 TERM_PROGRAM 最直接;缺失时回退 pane 的,再回退默认终端设置。 + let host_slug = client + .host_terminal + .or(ctx.host_terminal) + .unwrap_or(fallback_slug); + let host_result = match host_slug { + // Ghostty 没有 tty API,按 working directory 匹配;匹配对象必须是 client + // 进程的 cwd(用户敲 `herdr` 的目录),而不是 pane 的 cwd。 + "ghostty" => match process_cwd(client.pid) { + Some(client_cwd) => crate::terminal_focus::focus_ghostty_via_cwd(&client_cwd), + None => Err(FocusFailure::EmptyCwd), + }, + // tty 类终端复用 terminal_focus 的单点映射,client.tty 直接聚焦,不重复 pid 反查。 + slug => match crate::terminal_focus::tty_terminal_script(slug) { + Some((label, build_script)) => { + crate::terminal_focus::focus_tty(label, &client.tty, build_script) + } + None => Err(FocusFailure::Unsupported(slug.to_string())), + }, + }; + match host_result { + Ok(()) => { + log::info!( + "event=tray.session_focus status=ok hop=host app=herdr host={host_slug} tty={}", + client.tty + ); + Ok(()) + } + Err(failure) => { + // 宿主跳失败不影响 herdr 内部聚焦,按部分成功处理。 + log::warn!( + "event=tray.session_focus status=degraded hop=host app=herdr failure={failure:?}" + ); + Ok(()) + } + } +} + +/// herdr socket 通信错误,仅在模块内部流转,出口统一转成 `FocusFailure`。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum SocketError { + /// 连接失败:server 未运行或 socket 已失效。 + #[cfg(unix)] + NotRunning, + /// 读写超时 / 协议解析失败 / 响应里带 error。 + Protocol, +} + +impl SocketError { + fn into_focus_failure(self) -> FocusFailure { + match self { + #[cfg(unix)] + SocketError::NotRunning => FocusFailure::HerdrNotRunning, + SocketError::Protocol => FocusFailure::ScriptError, + } + } +} + +/// 解析 `ps eww` 输出中的 herdr 定位信息(白名单提取,原始输出绝不写日志)。 +/// 抽成纯函数便于单测。 +fn herdr_context_from_ps_output(output: &str) -> HerdrSessionContext { + let mut session_name = None; + let mut socket_override = None; + for part in output.split_ascii_whitespace() { + if session_name.is_none() { + if let Some(raw) = part.strip_prefix(format!("{HERDR_SESSION_ENV}=").as_str()) { + session_name = sanitize_session_name(raw); + continue; + } + } + if socket_override.is_none() { + if let Some(raw) = part.strip_prefix(format!("{HERDR_SOCKET_PATH_ENV}=").as_str()) { + socket_override = (!raw.is_empty()).then(|| raw.to_string()); + continue; + } + } + if session_name.is_some() && socket_override.is_some() { + break; + } + } + HerdrSessionContext { + session_name, + socket_override, + host_terminal: crate::terminal_focus::terminal_app_from_ps_output(output), + } +} + +/// 校验/清洗 `HERDR_SESSION`:只放行 herdr 命名规则(ASCII 字母数字 `._-`,≤64), +/// `default` 与非法值视为默认会话(None),防路径穿越。 +fn sanitize_session_name(raw: &str) -> Option { + if raw.is_empty() || raw == "default" || raw.len() > HERDR_SESSION_NAME_MAX { + return None; + } + raw.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + .then(|| raw.to_string()) +} + +/// 解析 herdr socket 路径。优先级:`HERDR_SOCKET_PATH` > 命名会话路径 > 默认路径。 +fn resolve_socket_path(ctx: &HerdrSessionContext) -> PathBuf { + resolve_socket_path_with(std::env::var("XDG_CONFIG_HOME").ok().as_deref(), ctx) +} + +/// 纯函数版 socket 路径解析,便于单测(config_home 为 None 时用 `~/.config`)。 +fn resolve_socket_path_with(config_home: Option<&str>, ctx: &HerdrSessionContext) -> PathBuf { + if let Some(override_path) = &ctx.socket_override { + return PathBuf::from(override_path); + } + let config = match config_home { + Some(home) => PathBuf::from(home), + None => crate::utils::home_dir_or_fallback().join(".config"), + }; + match &ctx.session_name { + Some(name) => config + .join("herdr") + .join("sessions") + .join(name) + .join("herdr.sock"), + None => config.join("herdr").join("herdr.sock"), + } +} + +/// 读进程完整环境(`ps eww`);失败返回 None。原始输出绝不能写入日志。 +fn ps_env_output(pid: u32) -> Option { + let output = Command::new("ps") + .args(["eww", "-p", &pid.to_string(), "-o", "command="]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// 沿 ppid 链向上找名为 `herdr` 的祖先进程(默认会话没有 env 标记,只能靠进程树)。 +/// 注意 daemon 的 comm 可能是完整路径(如 `/opt/homebrew/bin/herdr`),需同时匹配。 +fn has_herdr_ancestor(pid: u32) -> bool { + let mut current = pid; + for _ in 0..ANCESTOR_WALK_MAX { + let Some((ppid, comm)) = read_ppid_comm(current) else { + return false; + }; + if ppid == 0 { + return false; + } + if is_herdr_comm(&comm) { + return true; + } + current = ppid; + } + false +} + +/// herdr 进程名匹配:直接叫 `herdr`,或以 `/herdr` 结尾的完整路径 +/// (launchd/daemon 方式启动的进程 comm 显示 argv[0] 全路径)。 +fn is_herdr_comm(comm: &str) -> bool { + comm == "herdr" || comm.ends_with("/herdr") +} + +/// 一次 `ps -p -o ppid=,comm=`,返回 (ppid, comm)。 +fn read_ppid_comm(pid: u32) -> Option<(u32, String)> { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "ppid=,comm="]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_ppid_comm_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// 解析 `ps -o ppid=,comm=` 输出(如 `"12345 herdr"`);抽出来便于单测。 +fn parse_ppid_comm_output(raw: &str) -> Option<(u32, String)> { + let mut parts = raw.split_ascii_whitespace(); + let ppid: u32 = parts.next()?.parse().ok()?; + let comm = parts.next()?.to_string(); + Some((ppid, comm)) +} + +/// 向 herdr socket 发一个 NDJSON 请求并返回 `result` 对象。 +#[cfg(unix)] +fn request_socket(socket_path: &Path, method: &str, params: Value) -> Result { + use std::os::unix::net::UnixStream; + + let mut stream = UnixStream::connect(socket_path).map_err(|_| SocketError::NotRunning)?; + // 超时兜底:server 异常时避免后台线程无限阻塞。 + let _ = stream.set_read_timeout(Some(SOCKET_TIMEOUT)); + let _ = stream.set_write_timeout(Some(SOCKET_TIMEOUT)); + + let request = json!({ "id": "code-manager:herdr", "method": method, "params": params }); + let mut line = serde_json::to_string(&request).map_err(|_| SocketError::Protocol)?; + line.push('\n'); + stream + .write_all(line.as_bytes()) + .map_err(|_| SocketError::Protocol)?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader + .read_line(&mut response) + .map_err(|_| SocketError::Protocol)?; + if response.trim().is_empty() { + return Err(SocketError::Protocol); + } + let value: Value = serde_json::from_str(&response).map_err(|_| SocketError::Protocol)?; + if value.get("error").is_some() { + return Err(SocketError::Protocol); + } + value.get("result").cloned().ok_or(SocketError::Protocol) +} + +/// 非 unix 平台没有 unix socket,直接按协议失败处理(功能整体由 tray 层 macOS 门禁把关)。 +#[cfg(not(unix))] +fn request_socket( + _socket_path: &Path, + _method: &str, + _params: Value, +) -> Result { + Err(SocketError::Protocol) +} + +/// 按 pid 精确匹配 pane:`pane.list` + 逐 pane `pane.process_info`。 +/// 命中 foreground_processes[].pid / shell_pid / foreground_process_group_id 即命中。 +fn find_pane_by_pid(socket_path: &Path, pid: u32) -> Result, SocketError> { + let result = request_socket(socket_path, "pane.list", json!({}))?; + let panes = result + .get("panes") + .and_then(Value::as_array) + .ok_or(SocketError::Protocol)?; + for pane in panes { + let Some(pane_id) = pane.get("pane_id").and_then(Value::as_str) else { + continue; + }; + // 单个 pane 在 list 与 process_info 之间被关闭(pane_not_found)时跳过 + // 继续查下一个;server 掉线(NotRunning)才整体失败。 + let Ok(info_result) = request_socket( + socket_path, + "pane.process_info", + json!({ "pane_id": pane_id }), + ) else { + continue; + }; + let process_info = info_result + .get("process_info") + .ok_or(SocketError::Protocol)?; + if pane_process_contains_pid(process_info, pid) { + return Ok(Some(pane_id.to_string())); + } + } + Ok(None) +} + +/// 判断 pane.process_info 响应是否包含目标 pid(三个字段任一命中即可)。 +fn pane_process_contains_pid(process_info: &Value, pid: u32) -> bool { + let target = pid as u64; + if process_info.get("shell_pid").and_then(Value::as_u64) == Some(target) { + return true; + } + if process_info + .get("foreground_process_group_id") + .and_then(Value::as_u64) + == Some(target) + { + return true; + } + process_info + .get("foreground_processes") + .and_then(Value::as_array) + .is_some_and(|processes| { + processes + .iter() + .any(|p| p.get("pid").and_then(Value::as_u64) == Some(target)) + }) +} + +/// cwd 兜底匹配:`agent.list` 一次往返,按 pane/foreground cwd 找 agent。 +/// 0 个或多个匹配都视为未命中(歧义时宁可失败,不聚焦错 pane)。 +fn find_pane_by_cwd(socket_path: &Path, cwd: &str) -> Result, SocketError> { + let result = request_socket(socket_path, "agent.list", json!({}))?; + let agents = result + .get("agents") + .and_then(Value::as_array) + .ok_or(SocketError::Protocol)?; + let matches: Vec<&str> = agents + .iter() + .filter_map(|agent| { + let cwd_matches = [agent.get("cwd"), agent.get("foreground_cwd")] + .into_iter() + .flatten() + .any(|v| v.as_str() == Some(cwd)); + cwd_matches + .then(|| agent.get("pane_id").and_then(Value::as_str)) + .flatten() + }) + .collect(); + Ok((matches.len() == 1).then(|| matches[0].to_string())) +} + +/// 聚焦指定 pane(`pane.focus` 接受 pane.list / agent.list 返回的 public pane_id)。 +fn focus_pane(socket_path: &Path, pane_id: &str) -> Result<(), SocketError> { + let result = request_socket(socket_path, "pane.focus", json!({ "pane_id": pane_id }))?; + result.get("pane").ok_or(SocketError::Protocol).map(|_| ()) +} + +/// 找到附着同一 herdr 会话的本地 client 进程;其 tty 就是宿主终端 tab 的 tty。 +/// +/// 策略:扫全部 `herdr` 进程 → 逐个按 env / argv 做会话一致性匹配 → 过滤出有真实 +/// tty 的(server daemon 是 detached 进程没有 tty,天然被排除)→ 取第一个。 +/// +/// 会话匹配双通道(herdr 0.7.x 实测:client 进程 env 不带 `HERDR_SESSION`,会话名 +/// 只体现在 argv 的 `--session `;而新版 herdr 会把标记注入 env): +/// - env 通道:`HERDR_SESSION` / `HERDR_SOCKET_PATH` 相等; +/// - argv 通道:`--session ` / `--session=` / `session attach `。 +fn find_attached_client(ctx: &HerdrSessionContext) -> Option { + let processes = list_herdr_processes()?; + processes.into_iter().find_map(|(pid, argv)| { + let env = ps_env_output(pid)?; + if !client_matches_session(&env, &argv, ctx) { + return None; + } + let tty = crate::terminal_focus::pid_to_tty(pid)?; + Some(HerdrClientInfo { + pid, + tty, + host_terminal: crate::terminal_focus::terminal_app_from_ps_output(&env), + }) + }) +} + +/// 已附着的 herdr client 进程信息。 +struct HerdrClientInfo { + pid: u32, + /// 宿主终端 tab 的 tty。 + tty: String, + /// client 环境里的宿主终端(TERM_PROGRAM 白名单值)。 + host_terminal: Option<&'static str>, +} + +/// 列出本机所有 `herdr` 进程的 (pid, argv)。 +/// `-ww` 防止长命令行被截断;daemon 的 argv[0] 可能是完整路径,用 `is_herdr_comm` 匹配。 +fn list_herdr_processes() -> Option> { + let output = Command::new("ps") + .args(["axww", "-o", "pid=,command="]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let raw = String::from_utf8_lossy(&output.stdout); + Some( + raw.lines() + .filter_map(|line| { + let mut parts = line.split_ascii_whitespace(); + let pid: u32 = parts.next()?.parse().ok()?; + let argv0 = parts.next()?; + is_herdr_comm(argv0).then(|| (pid, line.trim().to_string())) + }) + .collect(), + ) +} + +/// client 进程与 pane 会话上下文是否一致:env / argv 双通道(见 `find_attached_client` 文档)。 +fn client_matches_session(env: &str, argv: &str, ctx: &HerdrSessionContext) -> bool { + let env_ctx = herdr_context_from_ps_output(env); + let argv_session = herdr_session_from_argv(argv); + match (&ctx.session_name, &ctx.socket_override) { + (Some(name), _) => { + env_ctx.session_name.as_deref() == Some(name.as_str()) + // herdr 0.7.x 的 client env 不带标记,会话名只在 argv 里 + || (env_ctx.session_name.is_none() && argv_session.as_deref() == Some(name.as_str())) + } + (None, Some(socket_path)) => { + env_ctx.socket_override.as_deref() == Some(socket_path.as_str()) + && env_ctx.session_name.is_none() + && argv_session.is_none() + } + // 默认会话:client 必须既无 env 标记、argv 也无 `--session` / `session attach`, + // 避免误配到命名会话的 client(0.7.x 下命名会话 client env 无标记,只能靠 argv 区分)。 + (None, None) => { + env_ctx.session_name.is_none() + && env_ctx.socket_override.is_none() + && argv_session.is_none() + } + } +} + +/// 从 herdr client 的 argv 提取会话名:`--session ` / `--session=` / +/// `session attach `;无会话参数返回 None。会话名校验与 env 通道同规则。 +fn herdr_session_from_argv(argv: &str) -> Option { + let args: Vec<&str> = argv.split_ascii_whitespace().collect(); + for (i, arg) in args.iter().enumerate() { + if *arg == "--session" { + if let Some(name) = args.get(i + 1) { + return sanitize_session_name(name); + } + } + if let Some(name) = arg.strip_prefix("--session=") { + return sanitize_session_name(name); + } + } + if args.get(1) == Some(&"session") && args.get(2) == Some(&"attach") { + if let Some(name) = args.get(3) { + return sanitize_session_name(name); + } + } + None +} + +/// 取进程当前工作目录(macOS 的 ps 没有 cwd 列,走 `lsof -a -p -d cwd -Fn`)。 +fn process_cwd(pid: u32) -> Option { + let output = Command::new("lsof") + .args(["-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_lsof_cwd_output(&String::from_utf8_lossy(&output.stdout)) +} + +/// 解析 `lsof -Fn` 输出(`p` / `fcwd` / `n` 三行一组),取 `n` 行。 +/// 抽出来便于单测。 +fn parse_lsof_cwd_output(raw: &str) -> Option { + raw.lines() + .find_map(|line| line.strip_prefix('n').map(str::to_string)) + .filter(|cwd| !cwd.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn herdr_context_parses_named_session_env() { + let ctx = herdr_context_from_ps_output( + "zsh TERM_PROGRAM=Apple_Terminal HERDR_SESSION=work TERM=xterm-256color", + ); + assert_eq!(ctx.session_name.as_deref(), Some("work")); + assert_eq!(ctx.socket_override, None); + assert_eq!(ctx.host_terminal, Some("terminal")); + } + + #[test] + fn herdr_context_parses_socket_override_env() { + let ctx = herdr_context_from_ps_output( + "zsh TERM_PROGRAM=iTerm.app HERDR_SOCKET_PATH=/tmp/custom-herdr.sock", + ); + assert_eq!(ctx.session_name, None); + assert_eq!( + ctx.socket_override.as_deref(), + Some("/tmp/custom-herdr.sock") + ); + assert_eq!(ctx.host_terminal, Some("iterm")); + } + + #[test] + fn herdr_context_default_session_has_no_markers() { + let ctx = herdr_context_from_ps_output("zsh TERM_PROGRAM=ghostty TERM=xterm-ghostty"); + assert_eq!(ctx.session_name, None); + assert_eq!(ctx.socket_override, None); + assert_eq!(ctx.host_terminal, Some("ghostty")); + } + + #[test] + fn herdr_context_unknown_term_program_is_none() { + let ctx = herdr_context_from_ps_output("zsh TERM_PROGRAM=WezTerm"); + assert_eq!(ctx.host_terminal, None); + } + + #[test] + fn sanitize_session_name_rejects_traversal_and_default() { + assert_eq!(sanitize_session_name("work"), Some("work".to_string())); + assert_eq!( + sanitize_session_name("a.b-c_d"), + Some("a.b-c_d".to_string()) + ); + // herdr 保留名与非法字符一律视为默认会话,防路径穿越 + assert_eq!(sanitize_session_name("default"), None); + assert_eq!(sanitize_session_name("../evil"), None); + assert_eq!(sanitize_session_name("a/b"), None); + assert_eq!(sanitize_session_name(""), None); + assert_eq!(sanitize_session_name("工作"), None); + let long = "x".repeat(HERDR_SESSION_NAME_MAX + 1); + assert_eq!(sanitize_session_name(&long), None); + } + + #[test] + fn resolve_socket_path_prefers_override_then_named_then_default() { + let override_ctx = HerdrSessionContext { + session_name: None, + socket_override: Some("/tmp/custom.sock".to_string()), + host_terminal: None, + }; + assert_eq!( + resolve_socket_path_with(Some("/Users/demo/.config"), &override_ctx), + PathBuf::from("/tmp/custom.sock") + ); + + let named_ctx = HerdrSessionContext { + session_name: Some("work".to_string()), + socket_override: None, + host_terminal: None, + }; + assert_eq!( + resolve_socket_path_with(Some("/Users/demo/.config"), &named_ctx), + PathBuf::from("/Users/demo/.config/herdr/sessions/work/herdr.sock") + ); + + let default_ctx = HerdrSessionContext { + session_name: None, + socket_override: None, + host_terminal: None, + }; + assert_eq!( + resolve_socket_path_with(Some("/Users/demo/.config"), &default_ctx), + PathBuf::from("/Users/demo/.config/herdr/herdr.sock") + ); + // XDG_CONFIG_HOME 未设置时回退 ~/.config + assert!(resolve_socket_path_with(None, &default_ctx).ends_with(".config/herdr/herdr.sock")); + } + + #[test] + fn parse_ppid_comm_output_handles_normal_and_garbage() { + assert_eq!( + parse_ppid_comm_output("12345 herdr"), + Some((12345, "herdr".to_string())) + ); + assert_eq!( + parse_ppid_comm_output("0 launchd"), + Some((0, "launchd".to_string())) + ); + assert_eq!(parse_ppid_comm_output(""), None); + assert_eq!(parse_ppid_comm_output("abc herdr"), None); + } + + #[test] + fn pane_process_contains_pid_matches_any_foreground_process() { + let info = json!({ + "pane_id": "ws1:p1", + "shell_pid": 100, + "foreground_process_group_id": 200, + "tty": "/dev/ttys003", + "foreground_processes": [ + { "pid": 201, "name": "claude" }, + { "pid": 202, "name": "node" } + ] + }); + // 前台进程列表命中 + assert!(pane_process_contains_pid(&info, 201)); + // 进程组 id 命中 + assert!(pane_process_contains_pid(&info, 200)); + // shell pid 命中 + assert!(pane_process_contains_pid(&info, 100)); + // 未命中 + assert!(!pane_process_contains_pid(&info, 999)); + } + + #[test] + fn pane_process_contains_pid_handles_missing_fields() { + let info = json!({ "pane_id": "ws1:p1" }); + assert!(!pane_process_contains_pid(&info, 1)); + } + + #[test] + fn client_matches_session_by_env_then_socket_then_default() { + let named = HerdrSessionContext { + session_name: Some("work".to_string()), + socket_override: None, + host_terminal: None, + }; + // env 通道:新版 herdr 把标记注入 client env + assert!(client_matches_session( + "herdr HERDR_SESSION=work TERM_PROGRAM=Apple_Terminal", + "herdr --session work", + &named + )); + // argv 通道:herdr 0.7.x 的 client env 不带标记,会话名只在 argv + assert!(client_matches_session( + "herdr TERM_PROGRAM=Apple_Terminal", + "herdr --session work", + &named + )); + assert!(client_matches_session( + "herdr TERM_PROGRAM=Apple_Terminal", + "herdr --session=work", + &named + )); + assert!(client_matches_session( + "herdr TERM_PROGRAM=Apple_Terminal", + "herdr session attach work", + &named + )); + // env 与 argv 都不一致 / 都缺失 → 不匹配 + assert!(!client_matches_session( + "herdr HERDR_SESSION=other TERM_PROGRAM=Apple_Terminal", + "herdr --session work", + &named + )); + assert!(!client_matches_session( + "herdr HERDR_SESSION=other TERM_PROGRAM=Apple_Terminal", + "herdr", + &named + )); + assert!(!client_matches_session( + "herdr TERM_PROGRAM=Apple_Terminal", + "herdr --session other", + &named + )); + + let overridden = HerdrSessionContext { + session_name: None, + socket_override: Some("/tmp/custom.sock".to_string()), + host_terminal: None, + }; + assert!(client_matches_session( + "herdr HERDR_SOCKET_PATH=/tmp/custom.sock", + "herdr", + &overridden + )); + assert!(!client_matches_session( + "herdr HERDR_SOCKET_PATH=/tmp/other.sock", + "herdr", + &overridden + )); + + // 默认会话:client 必须同样没有任何 herdr 标记(env 与 argv 都要干净) + let default = HerdrSessionContext { + session_name: None, + socket_override: None, + host_terminal: None, + }; + assert!(client_matches_session( + "herdr TERM_PROGRAM=Apple_Terminal", + "herdr", + &default + )); + assert!(!client_matches_session( + "herdr HERDR_SESSION=work", + "herdr --session work", + &default + )); + assert!(!client_matches_session( + "herdr HERDR_SOCKET_PATH=/tmp/custom.sock", + "herdr", + &default + )); + // 0.7.x 下命名会话 client env 无标记,靠 argv 排除 + assert!(!client_matches_session( + "herdr TERM_PROGRAM=Apple_Terminal", + "herdr --session work", + &default + )); + } + + #[test] + fn herdr_session_from_argv_extracts_session_name() { + assert_eq!( + herdr_session_from_argv("herdr --session cloudhub"), + Some("cloudhub".to_string()) + ); + assert_eq!( + herdr_session_from_argv("herdr --session=cloudhub"), + Some("cloudhub".to_string()) + ); + assert_eq!( + herdr_session_from_argv("herdr session attach cloudhub"), + Some("cloudhub".to_string()) + ); + // 无会话参数 / daemon / 非法值 + assert_eq!(herdr_session_from_argv("herdr"), None); + assert_eq!( + herdr_session_from_argv("/opt/homebrew/bin/herdr server"), + None + ); + assert_eq!(herdr_session_from_argv("herdr --session"), None); + assert_eq!(herdr_session_from_argv("herdr --session default"), None); + assert_eq!(herdr_session_from_argv("herdr session attach"), None); + } + + #[test] + fn is_herdr_comm_matches_name_and_full_path() { + assert!(is_herdr_comm("herdr")); + assert!(is_herdr_comm("/opt/homebrew/bin/herdr")); + assert!(!is_herdr_comm("herdr-server")); + assert!(!is_herdr_comm("zsh")); + assert!(!is_herdr_comm("")); + } + + #[test] + fn parse_lsof_cwd_output_extracts_path_line() { + assert_eq!( + parse_lsof_cwd_output("p4242\nfcwd\nn/Users/demo/work/code-manager\n"), + Some("/Users/demo/work/code-manager".to_string()) + ); + // 无输出 / 空路径 + assert_eq!(parse_lsof_cwd_output(""), None); + assert_eq!(parse_lsof_cwd_output("p4242\nfcwd\nn\n"), None); + } + + #[test] + fn socket_error_maps_to_focus_failure() { + #[cfg(unix)] + assert_eq!( + SocketError::NotRunning.into_focus_failure(), + FocusFailure::HerdrNotRunning + ); + assert_eq!( + SocketError::Protocol.into_focus_failure(), + FocusFailure::ScriptError + ); + } + + /// 进程内 mock herdr server:temp 目录 unix socket + 线程按脚本应答 NDJSON, + /// 覆盖 request_socket 的往返、连接失败、畸形响应与超时四条路径。 + #[cfg(unix)] + #[test] + fn request_socket_roundtrip_and_error_paths() { + use std::os::unix::net::UnixListener; + use std::sync::mpsc; + use std::thread; + + let dir = std::env::temp_dir().join(format!("herdr-mock-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let socket_path = dir.join("herdr.sock"); + + struct MockServer { + listener: UnixListener, + responses: Vec, + } + let server = MockServer { + listener: UnixListener::bind(&socket_path).unwrap(), + responses: vec![ + // 1. 正常响应 + "{\"id\":\"code-manager:herdr\",\"result\":{\"type\":\"pane_list\",\"panes\":[]}}\n".to_string(), + // 2. 畸形 JSON + "not-json-at-all\n".to_string(), + // 3. 响应里带 error + "{\"id\":\"code-manager:herdr\",\"error\":{\"code\":\"pane_not_found\",\"message\":\"pane not found\"}}\n".to_string(), + ], + }; + + // server 线程:每次连接读一行请求、按序号回一段响应;4 号连接静默(测超时)。 + let (tx, rx) = mpsc::channel::<()>(); + thread::spawn(move || { + let MockServer { + listener, + responses, + } = server; + let mut request_count = 0usize; + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + request_count += 1; + if request_count == 4 { + // 模拟 server 卡死:不读不写,等 client 读超时自行断开 + tx.send(()).ok(); + continue; + } + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request = String::new(); + let _ = reader.read_line(&mut request); + if let Some(response) = responses.get(request_count - 1) { + use std::io::Write; + let mut writer = stream; + let _ = writer.write_all(response.as_bytes()); + } + tx.send(()).ok(); + } + }); + + // 1. 正常往返:pane.list 返回空列表 + let result = request_socket(&socket_path, "pane.list", json!({})); + assert!(result.is_ok(), "正常响应应成功: {result:?}"); + assert_eq!(result.unwrap()["type"], "pane_list"); + rx.recv_timeout(Duration::from_secs(3)).unwrap(); + + // 2. 畸形 JSON → Protocol + let result = request_socket(&socket_path, "pane.list", json!({})); + assert_eq!(result, Err(SocketError::Protocol)); + rx.recv_timeout(Duration::from_secs(3)).unwrap(); + + // 3. 响应带 error → Protocol + let result = request_socket(&socket_path, "pane.list", json!({})); + assert_eq!(result, Err(SocketError::Protocol)); + rx.recv_timeout(Duration::from_secs(3)).unwrap(); + + // 4. server 沉默 → 读超时 → Protocol + let result = request_socket(&socket_path, "pane.list", json!({})); + assert_eq!(result, Err(SocketError::Protocol)); + rx.recv_timeout(Duration::from_secs(3)).unwrap(); + + // 5. socket 不存在 → NotRunning + let missing = dir.join("missing.sock"); + let result = request_socket(&missing, "pane.list", json!({})); + assert_eq!(result, Err(SocketError::NotRunning)); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c88ffe..f6acffd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod claude_directory; mod claude_directory_watcher; mod config; mod deep_link; +mod herdr; mod history; mod led; mod logging; diff --git a/src-tauri/src/terminal_focus.rs b/src-tauri/src/terminal_focus.rs index e9be00f..53b598d 100644 --- a/src-tauri/src/terminal_focus.rs +++ b/src-tauri/src/terminal_focus.rs @@ -22,6 +22,13 @@ pub enum FocusFailure { Unsupported(String), /// osascript 调用本身失败,详情已写入日志。 ScriptError, + /// herdr socket 连不上(server 未运行或 socket 已失效)。 + /// 唯一构造点在 `herdr.rs` 的 `#[cfg(unix)]` socket 错误转换里;non-unix 平台 + /// 没有 unix socket,该变体永不构造,但消息表与测试仍需在所有平台引用它。 + #[cfg_attr(not(unix), allow(dead_code))] + HerdrNotRunning, + /// herdr 中按 pid 精确匹配与 cwd 兜底都未命中 pane。 + HerdrPaneNotFound, } impl FocusFailure { @@ -47,11 +54,19 @@ impl FocusFailure { (true, Self::ScriptError) => { "Failed to invoke the terminal. See logs for details.".to_string() } + (true, Self::HerdrNotRunning) => "No running herdr server was found.".to_string(), + (true, Self::HerdrPaneNotFound) => { + "No matching herdr pane was found. It may have been closed.".to_string() + } (false, Self::TtyNotFound) => "会话进程已退出,无法定位终端 tab。".to_string(), (false, Self::TabNotFound) => "未找到对应的终端 tab,可能已被关闭。".to_string(), (false, Self::EmptyCwd) => "会话缺少工作目录,无法聚焦。".to_string(), (false, Self::Unsupported(slug)) => format!("终端 {slug} 不支持外部聚焦。"), (false, Self::ScriptError) => "调用终端失败,详情可查看日志。".to_string(), + (false, Self::HerdrNotRunning) => "未检测到运行中的 herdr 服务。".to_string(), + (false, Self::HerdrPaneNotFound) => { + "herdr 中未找到对应 pane,可能已被关闭。".to_string() + } }; (title.to_string(), body) } @@ -66,7 +81,8 @@ pub fn terminal_supports_focus(app_slug: &str) -> bool { /// /// `ps eww` 会返回完整环境,所以原始输出绝不能写入日志;这里只提取白名单内的 /// `TERM_PROGRAM` 值,避免用户的默认终端设置与实际会话终端不一致时错误聚焦。 -fn terminal_app_from_pid(pid: u32) -> Option<&'static str> { +/// pub(crate):tray 门禁与 herdr 宿主跳都会复用同一检测。 +pub(crate) fn terminal_app_from_pid(pid: u32) -> Option<&'static str> { let pid = pid.to_string(); let output = Command::new("ps") .args(["eww", "-p", pid.as_str(), "-o", "command="]) @@ -79,7 +95,8 @@ fn terminal_app_from_pid(pid: u32) -> Option<&'static str> { } /// 解析 `ps eww` 输出中白名单化的 `TERM_PROGRAM` 值。 -fn terminal_app_from_ps_output(output: &str) -> Option<&'static str> { +/// pub(crate):herdr 模块解析 client 进程环境时复用。 +pub(crate) fn terminal_app_from_ps_output(output: &str) -> Option<&'static str> { let term_program = output .split_ascii_whitespace() .find_map(|part| part.strip_prefix("TERM_PROGRAM="))?; @@ -98,13 +115,35 @@ fn terminal_app_from_ps_output(output: &str) -> Option<&'static str> { /// - 未命中或调用失败:返回 Err(FocusFailure),同时在内部记 warn 日志。 /// 调用方仅负责把失败原因转成系统通知 / Toast,不会自动新开 tab。 pub fn focus_session_in_terminal(pid: u32, cwd: &str, app_slug: &str) -> Result<(), FocusFailure> { + // herdr 会话优先走两跳聚焦:socket 定位 pane + 宿主终端激活。 + // 检测本身也是从 pid 环境/进程树判断,失败即视为非 herdr 会话。 + if let Some(ctx) = crate::herdr::detect_herdr_session(pid) { + return crate::herdr::focus_herdr_session(pid, cwd, &ctx, app_slug); + } // 优先使用目标进程的终端,读取失败再回退设置中的默认终端。 let app_slug = terminal_app_from_pid(pid).unwrap_or(app_slug); match app_slug { - "terminal" => focus_via_tty("Terminal", pid, terminal_app_script), - "iterm" => focus_via_tty("iTerm", pid, iterm_script), + // Ghostty 没有 tty API,按 working directory 匹配。 "ghostty" => focus_ghostty_via_cwd(cwd), - _ => Err(FocusFailure::Unsupported(app_slug.to_string())), + // tty 类终端(Terminal/iTerm)共用 pid → tty → AppleScript 路径。 + slug => match tty_terminal_script(slug) { + Some((label, build_script)) => focus_via_tty(label, pid, build_script), + None => Err(FocusFailure::Unsupported(slug.to_string())), + }, + } +} + +/// tty 类终端的 AppleScript 生成器:入参是转义后的 tty,返回完整脚本。 +pub(crate) type TtyScriptBuilder = fn(&str) -> String; + +/// slug → (终端展示名, tty AppleScript 生成器)。tty 类终端(Terminal/iTerm)的唯一映射源, +/// terminal_focus 的 pid 分发与 herdr 宿主跳共用;Ghostty 走 cwd 不在此表内。 +/// 新增 tty 类终端只改这一处。 +pub(crate) fn tty_terminal_script(slug: &str) -> Option<(&'static str, TtyScriptBuilder)> { + match slug { + "terminal" => Some(("Terminal", terminal_app_script)), + "iterm" => Some(("iTerm", iterm_script)), + _ => None, } } @@ -120,23 +159,33 @@ fn focus_via_tty( ); return Err(FocusFailure::TtyNotFound); }; - let script = build_script(&escape_applescript_string(&tty)); + focus_tty(app_label, &tty, build_script) +} + +/// 对已知 tty 执行对应终端的 AppleScript 选中 tab。 +/// herdr 宿主跳拿到的 client tty 直接复用此函数,不重复 pid 反查。 +pub(crate) fn focus_tty( + app_label: &'static str, + tty: &str, + build_script: fn(&str) -> String, +) -> Result<(), FocusFailure> { + let script = build_script(&escape_applescript_string(tty)); match run_osascript_returning_bool(&script) { Ok(true) => Ok(()), Ok(false) => { log::warn!( - "event=tray.session_focus status=miss reason=tab_not_found app={app_label} pid={pid} tty={tty}" + "event=tray.session_focus status=miss reason=tab_not_found app={app_label} tty={tty}" ); Err(FocusFailure::TabNotFound) } Err(e) => { - log::warn!("event=tray.session_focus status=err app={app_label} pid={pid} error={e}"); + log::warn!("event=tray.session_focus status=err app={app_label} error={e}"); Err(FocusFailure::ScriptError) } } } -fn focus_ghostty_via_cwd(cwd: &str) -> Result<(), FocusFailure> { +pub(crate) fn focus_ghostty_via_cwd(cwd: &str) -> Result<(), FocusFailure> { if cwd.is_empty() { log::warn!("event=tray.session_focus status=miss reason=empty_cwd app=Ghostty"); return Err(FocusFailure::EmptyCwd); @@ -159,7 +208,8 @@ fn focus_ghostty_via_cwd(cwd: &str) -> Result<(), FocusFailure> { } /// 调 `ps -p -o tty=` 拿到 tty,trim 后非 `??` 即拼成 `/dev/tty`。 -fn pid_to_tty(pid: u32) -> Option { +/// pub(crate):herdr 宿主跳需要反查 client 进程的 tty。 +pub(crate) fn pid_to_tty(pid: u32) -> Option { let output = Command::new("ps") .args(["-p", &pid.to_string(), "-o", "tty="]) .output() @@ -282,6 +332,11 @@ fn ghostty_script(escaped_cwd: &str) -> String { mod tests { use super::*; + /// 必然不存在的 pid:真实 pid 不可能达到 u32::MAX。聚焦链路会做真实 ps 检测 + /// (TERM_PROGRAM / herdr 进程树),用小 pid(如 123)在装有 herdr 的机器上 + /// 可能命中真实进程,导致单测依赖宿主环境而偶发失败。 + const GHOST_PID: u32 = u32::MAX; + #[test] fn terminal_supports_focus_covers_known_slugs() { assert!(terminal_supports_focus("terminal")); @@ -363,16 +418,16 @@ mod tests { #[test] fn focus_session_in_terminal_rejects_unknown_slug() { - let err = focus_session_in_terminal(123, "/tmp", "warp").expect_err("warp 应被拒绝"); + let err = focus_session_in_terminal(GHOST_PID, "/tmp", "warp").expect_err("warp 应被拒绝"); assert_eq!(err, FocusFailure::Unsupported("warp".to_string())); - let err = focus_session_in_terminal(123, "/tmp", "").expect_err("空 slug 应被拒绝"); + let err = focus_session_in_terminal(GHOST_PID, "/tmp", "").expect_err("空 slug 应被拒绝"); assert_eq!(err, FocusFailure::Unsupported(String::new())); } #[test] fn ghostty_rejects_empty_cwd_with_focus_failure() { - let err = - focus_session_in_terminal(123, "", "ghostty").expect_err("空 cwd 应返回 EmptyCwd"); + let err = focus_session_in_terminal(GHOST_PID, "", "ghostty") + .expect_err("空 cwd 应返回 EmptyCwd"); assert_eq!(err, FocusFailure::EmptyCwd); } @@ -417,6 +472,14 @@ mod tests { let (_, body_zh_script) = FocusFailure::ScriptError.user_message("zh"); assert!(body_zh_script.contains("调用终端失败")); + let (_, body_zh_herdr_not_running) = FocusFailure::HerdrNotRunning.user_message("zh"); + assert!(body_zh_herdr_not_running.contains("herdr")); + assert!(body_zh_herdr_not_running.contains("未检测到")); + + let (_, body_zh_pane) = FocusFailure::HerdrPaneNotFound.user_message("zh"); + assert!(body_zh_pane.contains("pane")); + assert!(body_zh_pane.contains("可能已被关闭")); + // 英文 let (title_en, body_en) = FocusFailure::TabNotFound.user_message("en"); assert_eq!(title_en, "Session focus failed"); @@ -445,6 +508,13 @@ mod tests { assert!(body .to_lowercase() .contains("failed to invoke the terminal")); + + let (_, body) = FocusFailure::HerdrNotRunning.user_message("en"); + assert!(body.to_lowercase().contains("no running herdr")); + + let (_, body) = FocusFailure::HerdrPaneNotFound.user_message("en"); + assert!(body.to_lowercase().contains("herdr pane")); + assert!(body.to_lowercase().contains("may have been closed")); } /// 之前的 applescript_templates 只验证了 Terminal.app 与 Ghostty 的模板, diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 082878e..5036e12 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -158,7 +158,7 @@ impl PendingSessionNotifier { preferences: &AppPreferences, sessions: &[TraySession], language: &str, - interaction: PendingSessionNotificationInteraction, + default_terminal_app: &str, ) -> Vec { let waiting_sessions = sessions .iter() @@ -183,8 +183,12 @@ impl PendingSessionNotifier { } if new_waiting_sessions.len() == 1 { + let session = &new_waiting_sessions[0]; + // 交互类型按会话判定:herdr 会话即使默认终端不支持也可点击聚焦。 + let interaction = + pending_session_notification_interaction(session, default_terminal_app); return vec![build_pending_session_notification( - &new_waiting_sessions[0], + session, language, &preferences.default_terminal_app, interaction, @@ -503,31 +507,35 @@ fn session_focus_failure_notification_enabled(preferences: &AppPreferences) -> b } fn pending_session_notification_interaction( + session: &TraySession, default_terminal_app: &str, ) -> PendingSessionNotificationInteraction { - pending_session_notification_interaction_for_platform( - default_terminal_app, - cfg!(target_os = "macos"), - ) -} - -fn pending_session_notification_interaction_for_platform( - default_terminal_app: &str, - is_macos: bool, -) -> PendingSessionNotificationInteraction { - if is_macos && crate::terminal_focus::terminal_supports_focus(default_terminal_app) { + if session_focus_available(session.pid, default_terminal_app) { PendingSessionNotificationInteraction::FocusTerminal } else { PendingSessionNotificationInteraction::Plain } } -fn session_menu_focus_enabled(default_terminal_app: &str) -> bool { - session_menu_focus_enabled_for_platform(default_terminal_app, cfg!(target_os = "macos")) +/// 单个会话是否可聚焦:macOS 且(默认终端支持聚焦,或 pid 自身宿主终端支持, +/// 或会话跑在 herdr pane 里)。herdr 检测与 pid 宿主检测各会发起一次 ps, +/// 只在菜单构建 / 通知决策时调用;点击时 `focus_session_in_terminal` 会重新检测。 +fn session_focus_available(pid: u32, default_terminal_app: &str) -> bool { + session_focus_available_for_platform(pid, default_terminal_app, cfg!(target_os = "macos")) } -fn session_menu_focus_enabled_for_platform(default_terminal_app: &str, is_macos: bool) -> bool { - is_macos && crate::terminal_focus::terminal_supports_focus(default_terminal_app) +fn session_focus_available_for_platform( + pid: u32, + default_terminal_app: &str, + is_macos: bool, +) -> bool { + if !is_macos { + return false; + } + crate::terminal_focus::terminal_supports_focus(default_terminal_app) + || crate::terminal_focus::terminal_app_from_pid(pid) + .is_some_and(crate::terminal_focus::terminal_supports_focus) + || crate::herdr::session_runs_in_herdr(pid) } fn build_pending_session_notification( @@ -833,20 +841,27 @@ fn build_sessions_tray_menu( items.push(Box::new(header)); items.push(Box::new(PredefinedMenuItem::separator(app)?)); - let supports_focus = session_menu_focus_enabled(&state.app.default_terminal_app); - for session in sessions { + // 聚焦可用性按会话判定:herdr 会话即使默认终端不支持也能聚焦,因此逐会话计算。 + // session_focus_available 内部会 spawn ps,预先算一次复用,避免 enabled 与底部提示行 + // 各扫描一遍。 + let focusable: Vec = sessions + .iter() + .map(|session| session_focus_available(session.pid, &state.app.default_terminal_app)) + .collect(); + let any_focusable = focusable.iter().any(|&b| b); + for (session, &enabled) in sessions.iter().zip(&focusable) { let item = MenuItemBuilder::with_id( session_menu_item_id(session), session_menu_item_label(session, labels.language), ) - .enabled(supports_focus) + .enabled(enabled) .build(app)?; items.push(Box::new(item)); } // 底部提示行:当聚焦快捷键可用且有会话时,展示快捷键告知用户可一键聚焦。 // 禁用项不可点击;非 session_ 前缀 id 在 on_menu_event 中天然被忽略。 - if supports_focus && !sessions.is_empty() { + if any_focusable && !sessions.is_empty() { if let Some(accelerator) = &state.app.focus_session_shortcut { items.push(Box::new(PredefinedMenuItem::separator(app)?)); let hint = MenuItemBuilder::with_id( @@ -1056,15 +1071,22 @@ pub fn rebuild_sessions_tray_only(app_handle: &AppHandle) { pub fn focus_most_urgent_session(app: &AppHandle) { let prefs = load_registry_or_default().app; let slug = prefs.default_terminal_app.clone(); - // 非 macOS 或当前终端不支持聚焦时直接返回 - if !session_menu_focus_enabled(&slug) { - return; - } let sessions = load_tray_sessions(); - let Some(target) = pick_focus_target_session(&sessions) else { + if sessions.is_empty() { // 无活跃会话:不弹通知,仅记日志 log::info!("event=tray.focus_shortcut status=skip reason=no_sessions"); return; + } + // 全局快捷键没有具体会话上下文,只在可聚焦会话里挑最该处理的; + // 全部不可聚焦(非 macOS / 默认终端不支持且无 herdr)时静默跳过。 + let focusable: Vec = sessions + .iter() + .filter(|session| session_focus_available(session.pid, &slug)) + .cloned() + .collect(); + let Some(target) = pick_focus_target_session(&focusable) else { + log::info!("event=tray.focus_shortcut status=skip reason=no_focusable_sessions"); + return; }; let pid = target.pid; let cwd = target.cwd.clone(); @@ -1148,11 +1170,14 @@ fn handle_pending_session_notifications( sessions: &[TraySession], ) { let labels = tray_labels_for_language(&state.app.ui_language); - let interaction = pending_session_notification_interaction(&state.app.default_terminal_app); let (notifications, has_new_waiting) = match pending_session_notifier().lock() { Ok(mut notifier) => { - let notifications = - notifier.observe(&state.app, sessions, labels.language, interaction); + let notifications = notifier.observe( + &state.app, + sessions, + labels.language, + &state.app.default_terminal_app, + ); (notifications, notifier.last_had_new_waiting) } Err(e) => { @@ -1281,7 +1306,8 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> { let notifications_enabled = session_focus_failure_notification_enabled(&prefs); let slug = prefs.default_terminal_app; let language = prefs.ui_language; - if !session_menu_focus_enabled(&slug) { + // 菜单项 enabled 已按会话判定过,这里再按同一口径兜底一次。 + if !session_focus_available(pid, &slug) { return; } // osascript 可能耗数百毫秒,丢线程避免阻塞 UI 事件循环。 @@ -1417,9 +1443,9 @@ mod tests { apply_pulse_update, build_pending_session_notification, format_shortcut_for_display, get_tray_title, is_running_session_status, is_starting_session_status, is_waiting_session_status, load_tray_sessions_from_dir, main_tray_navigation_items, - parse_session_menu_item_id, pending_session_notification_interaction_for_platform, - pick_focus_target_session, session_focus_failure_notification_enabled, - session_menu_focus_enabled_for_platform, session_menu_item_id, session_menu_item_label, + parse_session_menu_item_id, pending_session_notification_interaction, + pick_focus_target_session, session_focus_available_for_platform, + session_focus_failure_notification_enabled, session_menu_item_id, session_menu_item_label, session_project_name, session_status_emoji, session_status_label, sessions_tray_title, should_pulse, tick_pulse, to_superscript, tray_labels_for_language, PendingSessionFocusTarget, PendingSessionNotificationInteraction, PendingSessionNotifier, @@ -1437,9 +1463,14 @@ mod tests { fs::write(path, content).expect("应可写入测试会话文件"); } + /// 必然不存在的 pid:真实 pid 不可能达到 u32::MAX。托盘通知决策会做真实 ps + /// 进程树检测(herdr / TERM_PROGRAM),固定小 pid(如 123)在装有 herdr 的 + /// 机器上可能命中真实进程,导致单测依赖宿主环境而偶发失败。 + const GHOST_PID: u32 = u32::MAX; + fn test_session(cwd: &str, status: &str, updated_at: u64) -> TraySession { TraySession { - pid: 123, + pid: GHOST_PID, session_id: "session-1".to_string(), cwd: cwd.to_string(), status: status.to_string(), @@ -1910,7 +1941,7 @@ mod tests { &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert!(notifications.is_empty()); @@ -1926,20 +1957,20 @@ mod tests { &test_preferences(true, "terminal"), std::slice::from_ref(&idle), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let first_notifications = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let repeated_notifications = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert_eq!(first_notifications.len(), 1); @@ -1960,7 +1991,7 @@ mod tests { &test_preferences(false, "terminal"), std::slice::from_ref(&idle), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert!(!notifier.last_had_new_waiting, "首帧不应触发音效信号"); @@ -1969,7 +2000,7 @@ mod tests { &test_preferences(false, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert!(notifier.last_had_new_waiting, "新等待会话应置位音效信号"); assert!(notifications.is_empty(), "系统通知关闭时不产生通知"); @@ -1979,7 +2010,7 @@ mod tests { &test_preferences(false, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert!(!notifier.last_had_new_waiting, "重复 waiting 不应再置位"); } @@ -1993,14 +2024,14 @@ mod tests { &test_preferences(true, "terminal"), std::slice::from_ref(&idle), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let first_notifications = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let repeated_count = (0..10) .map(|_| { @@ -2009,7 +2040,7 @@ mod tests { &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ) .len() }) @@ -2028,26 +2059,26 @@ mod tests { &test_preferences(true, "terminal"), std::slice::from_ref(&idle), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let _ = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let _ = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&idle), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let notifications = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert_eq!(notifications.len(), 1); @@ -2062,20 +2093,20 @@ mod tests { &test_preferences(false, "terminal"), std::slice::from_ref(&idle), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let disabled_notifications = notifier.observe( &test_preferences(false, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); let enabled_notifications = notifier.observe( &test_preferences(true, "terminal"), std::slice::from_ref(&waiting), "zh", - PendingSessionNotificationInteraction::Plain, + "warp", ); assert!(disabled_notifications.is_empty()); @@ -2095,12 +2126,7 @@ mod tests { #[test] fn pending_session_notifier_summarizes_multiple_new_waiting_sessions_without_focus_target() { let mut notifier = PendingSessionNotifier::default(); - notifier.observe( - &test_preferences(true, "terminal"), - &[], - "zh", - PendingSessionNotificationInteraction::FocusTerminal, - ); + notifier.observe(&test_preferences(true, "terminal"), &[], "zh", "terminal"); let first = test_session_with_id( "session-1", "/Users/demo/work/code-manager", @@ -2113,7 +2139,7 @@ mod tests { &test_preferences(true, "terminal"), &[first, second], "zh", - PendingSessionNotificationInteraction::FocusTerminal, + "terminal", ); assert_eq!(notifications.len(), 1); @@ -2181,28 +2207,48 @@ mod tests { } #[test] - fn pending_session_notification_interaction_requires_macos_and_focusable_terminal() { - assert_eq!( - pending_session_notification_interaction_for_platform("terminal", true), + fn pending_session_notification_interaction_follows_session_focus_availability() { + // 默认终端支持聚焦 → 可点击聚焦(pid 无关,短路判定) + let terminal_session = test_session("/Users/demo/work/code-manager", "waiting", 2000); + let expected_terminal_interaction = if cfg!(target_os = "macos") { PendingSessionNotificationInteraction::FocusTerminal - ); - assert_eq!( - pending_session_notification_interaction_for_platform("warp", true), + } else { PendingSessionNotificationInteraction::Plain + }; + assert_eq!( + pending_session_notification_interaction(&terminal_session, "terminal"), + expected_terminal_interaction ); + // 默认终端不支持(warp)→ 不可聚焦 assert_eq!( - pending_session_notification_interaction_for_platform("terminal", false), + pending_session_notification_interaction(&terminal_session, "warp"), PendingSessionNotificationInteraction::Plain ); } #[test] - fn session_menu_focus_enablement_requires_macos_and_focusable_terminal() { - assert!(session_menu_focus_enabled_for_platform("terminal", true)); - assert!(session_menu_focus_enabled_for_platform("ghostty", true)); - assert!(!session_menu_focus_enabled_for_platform("warp", true)); - assert!(!session_menu_focus_enabled_for_platform("terminal", false)); - assert!(!session_menu_focus_enabled_for_platform("ghostty", false)); + fn session_focus_availability_requires_macos_or_herdr_or_focusable_terminal() { + // 默认终端支持聚焦:与 pid 无关,短路径直接放行 + assert!(session_focus_available_for_platform( + GHOST_PID, "terminal", true + )); + assert!(session_focus_available_for_platform( + GHOST_PID, "ghostty", true + )); + // 默认终端不支持:pid 检测与 herdr 检测都失败(测试进程不存在) + assert!(!session_focus_available_for_platform( + GHOST_PID, "warp", true + )); + // 非 macOS 恒不可聚焦 + assert!(!session_focus_available_for_platform( + GHOST_PID, "terminal", false + )); + assert!(!session_focus_available_for_platform( + GHOST_PID, "ghostty", false + )); + assert!(!session_focus_available_for_platform( + GHOST_PID, "warp", false + )); } /// 回归测试:会话托盘开启时,空 sessions 也必须返回非空占位标题, diff --git a/src/App.test.tsx b/src/App.test.tsx index 432b933..3037128 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1151,6 +1151,12 @@ describe("App", () => { }); resolveOverview?.(overviewFixture); + // 概览加载结果经 startTransition 提交,transition 在调度繁忙时可能延迟落盘; + // 且首帧树可能由模块级缓存概览先行渲染,因此需等待 6 条目状态真正提交后再断言。 + await waitFor(() => { + expect(screen.getByText("已加载 6 个条目")).toBeInTheDocument(); + }); + await waitFor(() => { expect(fileTreeOptionsMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -1183,7 +1189,6 @@ describe("App", () => { expect(invokeMock).not.toHaveBeenCalledWith("get_claude_directory_children", { path: null, }); - expect(screen.getByText("已加载 6 个条目")).toBeInTheDocument(); expect(screen.getByText("已跳过 2 个 node_modules 目录")).toBeInTheDocument(); expect(screen.queryByText("已达到 100000 个条目上限")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "scripts" })); diff --git a/src/components/ClaudeOverviewPage.tsx b/src/components/ClaudeOverviewPage.tsx index 779c806..f827001 100644 --- a/src/components/ClaudeOverviewPage.tsx +++ b/src/components/ClaudeOverviewPage.tsx @@ -9,6 +9,7 @@ import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -434,11 +435,15 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { ? (entryByPath.get(activePreview.path) ?? selectedEntry) : selectedEntry; - useEffect(() => { + // 用 useLayoutEffect 同步 ref:事件处理(目录变更、预览选择)会在 commit 后立刻读取这些 ref, + // 若用被动 useEffect,在调度繁忙时 effect flush 可能滞后于事件回调,读到过期值(例如目录变更 + // 事件到达时 openPreviewsRef 仍是空数组,导致已打开的预览跳过刷新/关闭)。layout effect 在 + // commit 时同步执行,保证 ref 与 state 严格一致。 + useLayoutEffect(() => { openPreviewsRef.current = openPreviews; }, [openPreviews]); - useEffect(() => { + useLayoutEffect(() => { activePreviewPathRef.current = activePreviewPath; }, [activePreviewPath]); @@ -521,7 +526,10 @@ function ClaudeOverviewPage({ active = false }: { active?: boolean }) { ); useEffect(() => { - void loadOverview({ preserveCurrent: cachedOverviewOnMountRef.current !== null }); + // 挂载时统一用 preserveCurrent:true 加载:mount 阶段 selectedPath/openPreviews 等本就处于 + // 初始值,preserveCurrent:false 的全量重置是冗余的;若被动 effect 因调度延迟晚于用户交互才 + // 执行,setOpenPreviews([]) 会误清用户刚打开的预览,导致目录变更事件跳过预览刷新/关闭。 + void loadOverview({ preserveCurrent: true }); }, [loadOverview]); useEffect(() => {