Files
Thing/TERMINAL_MODULE_PLAN.md
T
2026-09-22 19:10:16 +08:00

1230 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 终端模块规划(Terminal Module Plan
> 状态:**P0 骨架已落地;P1 进行中**(Rust 侧:cwd 跟踪 / 状态事件 / GBK 编码 / SFTP 后端已完成并编译通过)
> 定位:Thing 工具集的第 11 个模块,`id = terminal``category = 'tool'`
> 作者:砚 | 日期:2026-09-17
> 关联文档:`AI_DEV_GUIDE.md`(模块注册机制 / IPC / 进程管理范式)
>
> **实施进度与踩坑记录见文末 §9(P0)与 §10(P1)**(含与本规划不一致之处,以 §9 / §10 为准)
---
## 0. 结论先行
三个关键判断,先摆在这里,后文展开论证:
1. **终端模块不应复用 `ProcessManager`**。它是为「单例常驻守护进程(mihomo)」设计的:一个模块 ID 对应一个进程,崩溃即重启。而终端要的是「N 个会话、每个会话生命周期独立、能挂起能重连、能写 stdin」——语义不同,硬套会把这套抽象撑坏。正确做法是**新建独立的 `TerminalManager`**,与 `ClipboardManager` / `MusicManager` / `TranslateManager` 平级,`manage()` 进 Tauri State。
2. **会话进程必须跑在 Rust 侧,不能是前端 shell**。这是 Windows 上的硬约束,且与已有基建同构:`ProcessManager``download_engine` 都在 Rust 侧管进程。理由有三——(a) WebView2 无 PTY 访问;(b) 前端持有的子进程会在页面重载时变孤儿;(c) 多标签、后台保活、断线重连都需要一个独立于 UI 生命周期的宿主。
3. **SSH 走「自研客户端 + 真实 PTY」,而不是「拼接 ssh.exe + ConPTY」**。后者实现快但天花板低:无法做 SFTP 复用连接、无法读主机密钥指纹、无法做跳板机链、无法统一错误模型、`ssh.exe` 的输出会与 ConPTY 的 ANSI 处理打架。代价是 Ruffles/ssh2 的移植与 ConPTY 绑定要自己写,收益是整个能力面没有上限。
**总工作量估算**:P0 骨架(本地 Shell + 多会话 + 密钥管理)约 8~12 个工作日;P1(SSH/SFTP 完整能力)约 15~20 个工作日;P2(高级能力)按需。**建议按 P0 先行落地可用版本,再迭代。**
> **收官状态(2026-09-18**P0P2 全部落地(ZMODEM 经评估放弃,见实施记录),
> 并完成一轮全链路审查(修复键盘输入失效、切标签丢缓冲、连接期关闭竞态等 7 项)。
> 单测 91/91、`cargo check` 与 `vue-tsc` 零错误。实施记录见 §9-11(精编版)。
---
## 1. 需求解构
用户提出的四条主干,拆成可执行的规格:
| 用户原话 | 解构为 | 落点 |
|---|---|---|
| 「主要是 ssh」 | SSH2 客户端、主机密钥校验、认证(密钥/密码/Agent/键盘交互/2FA)、跳板机、端口转发、连接复用 | §4.2 / §5.1 |
| 「多会话」 | 多标签 + 分屏、会话持久化(切页不断连)、状态栏、会话恢复、会话模板 | §4.3 |
| 「密钥管理」 | SSH 密钥生成/导入/列举、passphrase 托管、known_hosts 管理、ssh-agent 集成、私钥不进明文 | §4.4 |
| 「文件快捷管理」 | SFTP 双栏文件管理器、拖拽上传下载、跟随终端 cwd、内联 `rz/sz`、文件编辑器 | §4.5 |
| 「方便的快捷键」 | 终端键盘映射(复制粘贴/搜索/新建标签/分屏/跳转)、可配置、与全局面板联动 | §4.6 |
| (我补充) | **本地 Shell**PowerShell/cmd/WSL/Git-Bash)、**命令补全与历史**、**命令片段库**、**AI 命令助手**、**日志与审计**、**快速面板联动** | §4.1 / §4.7 / §4.8 |
---
## 2. 现状勘察(论证依据)
以下为 2026-09-17 从仓库实际读取的结果,作为设计约束的来源。
### 2.1 已具备的基建
| 能力 | 现有实现 | 终端模块可复用的部分 |
|---|---|---|
| 模块注册 | `src/modules/registry.ts` + `src/modules/index.ts` 静态导入 | 直接沿用,新增一行导入 + 图标映射 |
| 类型绑定 | `tauri-specta` 自动生成 `src/lib/bindings.ts`debug 构建时导出 | **必须复用**,终端命令量较大,手写 `invoke` 类型不可接受 |
| 凭据存储 | `src-tauri/src/secrets.rs``keyring` + Windows 凭据管理器(DPAPI),服务名固定 `"Thing"` | **直接复用**,见 §4.4 |
| 全局快捷键 | `src-tauri/src/shortcut.rs`:原子化注册 + 应用内冲突检测 + 占用表 | **直接复用**,见 §4.6 |
| 托盘 | `src-tauri/src/tray_menu.rs` | 可挂「新建会话」入口(P2) |
| 日志 | `src-tauri/src/logger.rs``log_info` / `log_warn` / `log_error`) | 继承统一日志,日志页可过滤 |
| 窗口常量 | `src-tauri/src/constants.rs``windows` / `events` | 需新增窗口与事件常量 |
| 弹窗范式 | `translate-popup` 的 NOACTIVATE 预创建窗口 + `capabilities/translate-popup.json` | 终端「快速会话/命令补全」浮层可参照 |
### 2.2 关键缺口(需要新增依赖)
| 缺口 | 现状 | 方案 |
|---|---|---|
| ConPTY 绑定 | 无。`windows-sys` 未开启 `Win32_System_Console` | 开启该 feature;或引入 `portable-pty`(见 §3.1 取舍) |
| SSH 客户端 | 无 | 引入 `russh`(纯 Rust)或 `ssh2`libssh2 绑定) |
| SFTP | 无 | 随 SSH 库一并引入 |
| 终端渲染 | 无。`node_modules` 中**不存在** `@xterm/*` | 引入 `@xterm/xterm` + `@xterm/addon-fit` + `@xterm/addon-webgl` + `@xterm/addon-search` + `@xterm/addon-web-links` |
| 前端代码编辑器 | 无 | 按需引入 `codemirror` 或复用纯 `<textarea>`(见 §4.5 |
| 密码短语输入 | 无安全输入通道 | 用 Tauri 原生窗口 + 一次性输入,不经 IPC 明文回传 |
> **注意**:仓库 `Cargo.toml` 存在**编码损坏**(多处注释已是乱码,如第 64、70、120、121、127 行)。新增依赖时建议顺带修复该文件编码,否则后续 diff 会持续污染。这是一个独立的清理项,不阻塞终端模块。
---
## 3. 技术选型
### 3.1 终端进程层:ConPTY
Windows 10 1809+ 提供 **ConPTY**`CreatePseudoConsole`),是 Windows Terminal 的底层机制。三条路径:
| 方案 | 优势 | 代价 | 判断 |
|---|---|---|---|
| `portable-pty`(wezterm 提取库) | 跨平台、API 干净、久经考验 | 引入一个非 Tauri 生态的大依赖;其 Windows 后端同样走 ConPTY,出问题时要下钻 | 可接受 |
| **直接绑 `windows-sys` 的 ConPTY** | 零额外依赖、完全可控、与项目已有 `windows-sys` 姿态一致 | 需自行处理 pseudo console handle 生命周期、read/write 线程、resize 时序 | **推荐** |
| `conpty` 窄封装 crate | 上手快 | 维护活跃度不确定 | 备选 |
**推荐直接绑定 `windows-sys`**,理由:项目已有大量原生 Win32 调用(`win32_util.rs``screenshot/wgc_capture.rs``translate/capture/uia_capture.rs`),团队对该路径熟悉;且 ConPTY 的坑(下述)无论如何都要踩,多一层封装只增加定位难度。
ConPTY 的三个已知陷阱,必须在设计阶段规避:
1. **`ClosePseudoConsole` 会阻塞**,直到所有引用该 PTY 的句柄关闭。必须在独立线程调用,且先取消挂起的 `ReadFile`
2. **`ResizePseudoConsole` 有竞态**:进程刚创建、还没开始读 stdout 时 resize 可能被吞掉。需要在首帧输出后再应用队列中的尺寸。
3. **进程退出不等于 PTY 关闭**:要等 `ReadFile` 返回 0 或 `ERROR_BROKEN_PIPE`,才算真正结束,否则会漏掉尾部输出。
### 3.2 SSH 层:`russh` vs `ssh2`
| 维度 | `russh`(纯 Rust,基于 `thrussh` | `ssh2`libssh2 绑定) |
|---|---|---|
| 构建 | 纯 Rust,无 C 依赖,交叉编译友好 | 需 libssh2Windows 下常走 vendored 编译 |
| async | 原生 async,与现有 `tokio` 运行时契合 | 同步阻塞,需 `spawn_blocking` 包装 |
| 算法覆盖 | 新算法跟进快(如 `chacha20-poly1305``sntrup761x25519` | 受 libssh2 版本限制 |
| 稳定性 | API 演进较快,偶有破坏性变更 | 老牌稳定,几乎不再变化 |
| 与 `tokio` 集成 | 直接 | 需额外线程池,与 `ProcessManager` 的线程模型并存会增加心智负担 |
**推荐 `russh`**。决定性理由是 **async 契合度**Cargo.toml 已启用 `tokio``rt-multi-thread` / `sync` / `net` / `fs`,而终端会话本质是「一个长连接 + 多个并发数据流(shell channel、SFTP channel、port forward)」,用 async 表达最自然;`ssh2` 的同步模型会迫使每个会话占一个 OS 线程,多会话场景下线程数线性增长。
`russh` 在实际接入中出现阻塞性问题,回落方案是 `ssh2` + `spawn_blocking`,本规划的结构(`SessionHandle` 抽象)可容纳这次替换。
### 3.3 前端渲染:xterm.js
`@xterm/xterm` 是事实标准(VS Code 终端同源)。必须装的插件:
| 包 | 用途 |
|---|---|
| `@xterm/xterm` | 核心 VT 解析与渲染 |
| `@xterm/addon-fit` | 容器尺寸 → 行列数,配合 ConPTY resize |
| `@xterm/addon-webgl` | GPU 渲染,大量输出时的性能关键(无它时大 `tail` 会卡) |
| `@xterm/addon-search` | 终端内搜索 |
| `@xterm/addon-web-links` | 链接可点击 |
| `@xterm/addon-unicode11` | 宽字符 / emoji 正确宽度(中文场景重要) |
| `@xterm/addon-serialize`(P1) | 会话快照序列化,用于恢复 |
### 3.4 数据流架构
```
┌──────────────────────── WebView (Vue 3) ────────────────────────┐
│ TerminalModule.vue │
│ ├── SessionSidebar.vue 会话/标签/分组 │
│ ├── TerminalTabs.vue 多标签 + 分屏容器 │
│ │ └── TerminalPane.vue xterm 实例(每个会话一个) │
│ ├── SftpPanel.vue 文件管理器(P1) │
│ ├── KeyManagerPanel.vue 密钥管理 │
│ └── SnippetsPanel.vue 命令片段库 │
│ stores/terminal.ts Pinia:会话元数据 / 布局 / 设置 │
└───────────────┬─────────────────────────────────────────────────┘
│ invoke(命令,请求-响应)
│ listen(事件,流式输出)
┌───────────────▼──────────────── Rust ───────────────────────────┐
│ TerminalManager (Tauri State, manage()) │
│ ├── sessions: DashMap<SessionId, Arc<Mutex<Session>>> │
│ ├── local: ConPtyBackend 本地 Shell 后端 │
│ ├── remote: SshBackend SSH 后端(russh
│ │ ├── shell channel → 终端 I/O │
│ │ ├── sftp subsystem → 文件管理 │
│ │ └── port forward → 隧道(P2
│ └── known_hosts: HostKeyStore 主机密钥校验 │
│ secrets.rs ← 复用:passphrase / 密码 / 代理凭据 │
│ shortcut.rs ← 复用:全局快捷键 │
└─────────────────────────────────────────────────────────────────┘
```
**关键设计**`Session` 是一层 trait 抽象,`ConPtyBackend``SshBackend` 都实现它(`write` / `resize` / `kill` / `subscribe_output`)。这样上层命令层(`terminal_write``terminal_resize`)无需区分本地与远程,多会话管理逻辑只需写一遍。
---
## 4. 功能规格
### 4.1 本地 ShellP0
- **Shell 探测**:启动时枚举可用 Shell,按顺序探测——
- PowerShell 7+`pwsh.exe`,优先)
- Windows PowerShell`powershell.exe`
- cmd`cmd.exe`
- Git Bash`bash.exe`,从 `git --exec-path` 反推)
- WSL 发行版(`wsl.exe -l -q` 枚举)
- **Shell 配置**:每个 Shell 可配可执行路径、启动参数、工作目录、环境变量覆盖、启动时执行命令(如 `cd /d/project && claude`)。
- **默认工作目录**:记住上次 cwd;新建会话时可选「跟随当前项目目录」。
- **注意 cwd 同步**ConPTY 拿不到子进程的真实 cwd(`GetCurrentDirectory` 只反映父进程)。需要**注入 shell hook**PowerShell 用 `$PROMPT` 包装输出 OSC 7bash 用 `PS1` 输出 OSC 7)来跟踪 `cwd`。这是 SFTP「跟随终端目录」的前提,P0 就要做进去。
### 4.2 SSH 连接(P0 骨架 / P1 完整)
**连接管理**
- 主机条目 CRUD:别名、host、port、user、认证方式、私钥、跳板机、分组、备注、标签色。
- **从 `~/.ssh/config` 导入**(P0 就做——用户已有配置不该被要求重录)。
- 连接超时、keep-alive 间隔、重试次数可配。
- 连接状态机:`idle → connecting → auth → established → degraded → closed`,每态可观测。
**认证方式**P0 覆盖前两项,P1 补齐)
1. **公钥认证**P0):支持 RSA / ECDSA / Ed25519,私钥来自文件或导入的存储。
2. **密码认证**P0):密码存 `secrets.rs`,键名 `terminal-ssh-password-{hostId}`
3. **ssh-agent 集成**P1):Windows OpenSSH Agent 命名管道 `\\.\pipe\openssh-ssh-agent`
4. **键盘交互 / 2FA**(P1):需要前端弹窗接收一次性输入,走「弹窗 → 回传 → 继续握手」的异步流程,不能阻塞握手线程。
5. **证书认证**P2)。
**主机密钥校验(安全基线,P0 必须做)**
- 首次连接展示指纹,要求用户显式确认(**不允许 TOFU 静默接受**)。
- 维护 `known_hosts`(放在 `{app_data_dir}/terminal/known_hosts.json`),格式与 OpenSSH 兼容以便导出。
- 指纹变更时**红色告警 + 阻断连接**,要求用户明确选择「接受新指纹」或「中止」。这是防 MITM 的核心开关,不能省。
- 支持 SHA256 / MD5 双格式展示(SHA256 为主,MD5 兼容老文档)。
**高级能力(P2**
- 跳板机链(ProxyJump,多级)。
- 端口转发:本地转发 `-L`、远程转发 `-R`、动态转发 `-D`SOCKS5)。
- 连接复用(ControlMaster 式):同主机多会话共享 TCP 连接,第二次开标签秒开。
### 4.3 多会话(P0
**组织形态**
- **左侧会话侧栏**:树形结构,支持「收藏 / 按主机分组 / 按项目分组」,支持拖拽排序(复用 `vue-draggable-plus`)。
- **标签页**:会话标签可关闭、可拖动重排、可重命名、可固定(pin)。
- **分屏**:水平/垂直切分,最多 2×2(4 格)。**每个格子是独立会话**,而非同一会话的两视图(后者需要 SSH 多 channel,复杂度高收益低)。
- **会话持久化**:切换到别的模块时**会话不断开**(进程在 Rust 侧活着),回来时重新 attach,用 `@xterm/addon-serialize` 恢复可视区快照。
**会话状态可视化**
- 侧栏与标签上显示状态点:绿=已连接、黄=连接中、灰=已断开、红=异常。
- 状态栏展示:会话类型(Local/SSH)、用户@主机、cwd、编码、终端尺寸、连接延迟。
**会话恢复(P2**
- 应用重启后,提供「恢复上次会话」——本地会话重建 shell 并 `cd` 到原目录;SSH 会话重连(**不恢复进程态**,这点要在 UI 上说明,避免误解)。
- 会话模板:把「一组会话 + 布局」存为模板(如「后端开发环境」= 3 个 SSH 会话横向分屏),一键拉起。
### 4.4 密钥管理(P0
**密钥生命周期**
- **生成**Ed25519(推荐默认)/ RSA2048/3072/4096/ ECDSAP-256/P-384/P-521)。可设注释、可设 passphrase。
- **导入**:支持 OpenSSH 格式、PEM、PKCS#8;支持带 passphrase 的私钥;**支持 PuTTY `.ppk`**Windows 用户存量多,P1)。
- **导出**:导出公钥到剪贴板(一键复制 `ssh-ed25519 AAAA... comment`,配合用户自己贴到服务器)。
- **删除**:二次确认 + 提示「该密钥还关联 N 个主机」。
**存储策略(安全姿态必须与本项目既有约定对齐)**
参照 `secrets.rs` 头部注释里明确批判过的历史问题——「同样是可冒充身份的凭据,不该区别对待」。据此定:
| 数据 | 存放位置 | 理由 |
|---|---|---|
| 私钥**文件本身** | `{app_data_dir}/terminal/keys/` 目录,文件权限收紧 | 私钥可能几 KB,塞进凭据管理器(单条上限约 2.5KB)不可靠;且用户需要用其他工具引用该路径 |
| 私钥 **passphrase** | `secrets.rs` → 系统凭据管理器 | 是「可冒充身份的凭据」,必须 DPAPI 保护,键名 `terminal-key-passphrase-{keyId}` |
| SSH 密码 | `secrets.rs` | 同上,键名 `terminal-ssh-password-{hostId}` |
| 代理密码(P2 | `secrets.rs` | 同上 |
| 主机密钥指纹 / known_hosts | JSON 文件 | 非机密,需要人可读、可导出 |
| 主机配置 / 会话元数据 / 设置 | `{app_data_dir}/terminal/settings.json` | 非机密;`#[serde(default)]` 容器级默认,保证向后兼容 |
**明文禁令**(写入代码注释与评审清单):
- 私钥明文**只允许**存在于内存与 `keys/` 目录,禁止回写 `settings.json`
- 私钥 passphrase / SSH 密码禁止进入 localStorage、禁止进入任何日志行。
- 前端**不存在读取凭据明文的命令**——参照 `translate` 模块的姿态:列表接口只回传 `hasPassphrase: bool` + 掩码串。
**ssh-agent 集成(P1**
- 检测 Windows OpenSSH Agent 服务是否运行。
- 「添加到 agent」/「从 agent 移除」操作。
- 指明哪些密钥由 agent 托管(UI 上区分展示)。
**known_hosts 管理(P1**
- 列表查看所有已知主机,支持搜索、删除单条、批量导入导出。
- 变更告警历史留档。
### 4.5 文件快捷管理(P1
**SFTP 双栏文件管理器**
- 左侧本地、右侧远程(或双远程,支持拖拽跨栏传输)。
- 列视图:名称 / 大小 / 类型 / 权限 / 修改时间 / 所有者。支持排序、多选、框选。
- 路径面包屑 + 可直接编辑路径 + 前进后退历史。
- 权限可视化与编辑(`rwxr-xr-x``755` 双向互转)。
**文件操作**
- 新建目录 / 新建文件 / 重命名 / 删除(二次确认)/ 复制 / 移动。
- 上传 / 下载:目录递归、进度显示、**并发分片**(多小文件并行,大文件单流)、断点续传、失败重试、队列管理。
- 拖拽:从 Windows 资源管理器拖入上传;从远程栏拖出到本地栏下载。
- 编辑远程文件:双击打开内置编辑器,保存时上传(P1 用 `<textarea>`P2 换 CodeMirror 带语法高亮)。
**与终端的联动(这是本模块区别于普通 SFTP 客户端的核心)**
- **跟随 cwd**:终端里 `cd` 后,SFTP 面板自动跟随(依赖 §4.1 的 OSC 7 hook)。
- **`rz` / `sz` 内联传输**:拦截终端里的 `sz <file>`,自动弹出「保存到本地」对话框;拦截 `rz`,弹出「选择本地文件上传」。需要实现 ZMODEM 协议或调用 `lrzsz`P2,但价值高)。
- **选中即操作**:终端里双击路径(如 `/var/log/nginx/error.log`)→ 右键菜单「用 SFTP 打开所在目录」。
**本地文件管理(附带)**
- 「本地 Shell」会话同样挂载文件面板,可当轻量双栏文件管理器用(与快速面板的文件能力形成互补,不重复:快速面板面向「搜索定位」,这里面向「浏览操作」)。
### 4.6 快捷键体系(P0
分三层,边界清晰:
**第一层:全局快捷键**(走 `shortcut.rs`,与系统级冲突检测)
| 功能 | 默认值 | 说明 |
|---|---|---|
| 唤起快速会话菜单 | `Ctrl+Alt+T` | 类「新建终端」语义,弹浮层选主机/Shell |
| 打开终端模块 | 无(不抢占) | 建议不设,避免与用户既有习惯冲突 |
> 注意:`shortcut.rs` 的应用内冲突检测会拒绝「已被其他模块占用」的组合。截图默认 `Ctrl+Alt+A`、翻译面板默认 `Ctrl+2`。终端默认值需与此避让。
**第二层:终端内快捷键**xterm `attachCustomKeyEventHandler` 拦截,仅在终端聚焦时生效)
| 功能 | Windows 键位 | 说明 |
|---|---|---|
| 复制 | `Ctrl+Shift+C` | Windows Terminal 惯例。**不拦 `Ctrl+C`**(必走 SIGINT |
| 粘贴 | `Ctrl+Shift+V` | |
| 选中即复制 | 可开关 | 习惯问题,默认关 |
| 新建标签 | `Ctrl+Shift+T` | |
| 关闭标签 | `Ctrl+Shift+W` | 有活动进程时二次确认 |
| 下一个/上一个标签 | `Ctrl+Tab` / `Ctrl+Shift+Tab` | |
| 跳转到第 N 标签 | `Alt+1..9` | |
| 垂直/水平分屏 | `Ctrl+Shift+D` / `Ctrl+Shift+E` | |
| 关闭分屏 | `Ctrl+Shift+Q` | |
| 终端内搜索 | `Ctrl+Shift+F` | 走 `addon-search` |
| 清屏 | `Ctrl+Shift+K` | 发送 `clear``cls`(按 shell 判断) |
| 字体放大/缩小/复位 | `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | |
| 打开 SFTP 面板 | `Ctrl+Shift+P` | |
| 命令片段库 | `Ctrl+Shift+S` | |
| 重命名标签 | `F2` | |
| 会话切换器(快速跳转) | `Ctrl+Shift+O` | 模糊搜索所有会话 |
**第三层:Shell 内快捷键**(终端原生,不改)
- `Ctrl+L``Ctrl+R``Ctrl+A/E/U/K` 等一律透传给 shell,终端不拦截。
**可配置性**
- 第二层全部可自定义,配置存 `terminal/settings.json`
- 冲突检测:同一组合被两个动作占用时高亮提示。
- 提供「重置为默认」。
### 4.7 命令增强(P1,体现「全能」)
- **命令历史搜索**:跨会话聚合历史(本地 shell 从 PowerShell 历史文件读,SSH 会话抓取输出流),`Ctrl+R` 增强版,模糊搜索 + 频次排序。
- **命令片段库(Snippets)**:保存常用命令模板,支持 `{{变量}}` 占位符,选择时弹窗填参;支持分类与搜索;支持一键发送到当前会话。
- **命令补全**(P1):基于历史 + 片段做行内补全(类似 fish 的灰字建议),在 xterm 上叠加一层浮层实现。
- **AI 命令助手(P2)**:复用 `translate` 模块已配置的 AI 引擎(`translate/settings.rs` 里的 `TranslateEngineConfig`),把自然语言转成命令。「复用引擎配置而非另配一套」是关键——用户在翻译模块填过的 API Key 不该再填一遍。
### 4.8 与既有模块联动(P1/P2
| 联动对象 | 联动方式 |
|---|---|
| **快速面板** | (a) 快速面板搜索里出现「打开 SSHprod-web-01」条目;(b) 快速面板输入 `> ssh prod` 直接建会话 |
| **剪贴板模块** | 终端内复制的内容进入剪贴板历史,可回溯找回;剪贴板历史的「粘贴到目标」支持终端 |
| **翻译模块** | 终端选中文本 → `Ctrl+Alt+T` 之类触发划词翻译(**注意**:需把终端进程加进 `SelectionSettings.blacklist` 的思考——实际上终端不在黑名单里,因为终端内 `Ctrl+C` 是复制语义由 xterm 处理,不会误触发;但需实测确认) |
| **代理模块** | SSH 连接可走 mihomo 代理(读 `proxy/settings.json``mixedPort`,参照 `translate/mod.rs::read_mixed_port` 的写法:**只读文件不依赖 Manager 状态** |
| **日志模块** | 连接失败、认证失败、主机密钥变更等关键事件写统一日志 |
| **下载器** | SFTP 传输是否复用下载器的队列/进度 UI?(**建议不复用**——传输语义与 HTTP 下载差异大,共享 UI 会两边受限) |
### 4.9 其他工程能力(补充项)
- **终端外观**:主题(跟随应用亮/暗 + 内置若干配色)、字体族与字号、行高、光标样式(块/竖线/下划线 + 闪烁)、滚动缓冲区行数(默认 10000)、背景透明度。
- **编码**:默认 UTF-8;SSH 老服务器可能是 GBK,需支持按会话指定编码(`encoding_rs` crate)。中文环境下这是刚需,不是可选项。
- **日志与审计(P2)**:可开启「记录会话输入输出到文件」(合规场景),提供脱敏正则。
- **安全基线**
- 禁止在日志中出现私钥、passphrase、密码。
- 会话命令回显中若匹配到疑似密钥(如 `-----BEGIN`),提示用户。
- 危险命令(`rm -rf /``dd`)不做拦截(越权),但可做**高亮提示**(可选功能)。
---
## 5. 工程实现
### 5.1 Rust 侧目录结构
```
src-tauri/src/terminal/
├── mod.rs # TerminalManagerTauri State+ 设置读写
├── settings.rs # 设置数据模型(#[serde(default)] 容器级默认)
├── commands.rs # Tauri 命令层(薄:参数整形 / 校验 / 错误归类)
├── session.rs # Session trait + SessionRegistryDashMap
├── pty/
│ ├── mod.rs
│ └── conpty.rs # ConPTY 绑定、read/write 线程、resize 时序处理
├── shell.rs # 本地 Shell 探测与启动参数组装
├── ssh/
│ ├── mod.rs # SshBackend(实现 Session
│ ├── auth.rs # 认证方式(公钥/密码/agent/键盘交互)
│ ├── hostkey.rs # known_hosts 与指纹校验
│ ├── sftp.rs # SFTP 客户端与传输队列
│ ├── forward.rs # 端口转发(P2)
│ └── config.rs # ~/.ssh/config 解析
├── keys.rs # 密钥生成/导入/列举(含 passphrase 走 secrets.rs
├── snippets.rs # 命令片段库
├── history.rs # 命令历史(SQLite,参照 translate/history.rs
└── encoding.rs # 编码转换(UTF-8 / GBK 等)
```
**命令命名**`terminal_*` 前缀,snake_case。预计 P0 约 30 个、P1 约 45 个命令。
**注册顺序**(严格按此,缺一不可):
1. `lib.rs` `mod terminal;` + `use terminal::{...}` 导入命令
2. `lib.rs` `manage(TerminalManager::new(...))``setup.rs` 中构造,与 `TranslateManager` 同法)
3. `lib.rs` `invoke_handler![...]` 追加命令
4. `lib.rs` `export_bindings()``collect_commands![...]` 追加同名命令 —— **漏掉这步前端就没有 `commands.terminalXxx` 类型**
5. `RunEvent::ExitRequested` 中追加 `terminal.cleanup_on_exit()`(关闭所有会话与 PTY
6. `constants.rs` 新增 `windows::TERMINAL_*``events::TERMINAL_*`
### 5.2 前端目录结构
```
src/modules/terminal/
├── index.ts # ModuleConfig(含 searchItems / lifecycle / order
├── TerminalModule.vue # 主组件(布局容器)
├── components/
│ ├── SessionSidebar.vue # 会话树(拖拽排序)
│ ├── TerminalTabs.vue # 标签 + 分屏管理
│ ├── TerminalPane.vue # xterm 实例宿主(单个会话)
│ ├── TerminalToolbar.vue # 顶部工具条
│ ├── TerminalStatusBar.vue # 底部状态栏
│ ├── HostEditorDialog.vue # 主机编辑
│ ├── KeyManagerPanel.vue # 密钥管理
│ ├── SftpPanel.vue # 文件管理器(P1)
│ ├── SnippetsPanel.vue # 命令片段
│ └── QuickSessionPopup.vue # 全局快捷键唤起的快速会话浮层
├── composables/
│ ├── useXterm.ts # xterm 实例创建 / 插件装配 / 尺寸同步
│ ├── useSessionStream.ts # 事件订阅 → 写入 xterm(含背压处理)
│ └── useTerminalKeys.ts # 快捷键拦截与分发
└── settings/TerminalSettings.vue # 设置页(挂进 settings 模块)
```
**store**`src/stores/terminal.ts` — 会话元数据(不持有 xterm 实例)、布局树、当前激活会话、设置缓存。
**事件常量**`constants.ts` 对应前端 `src/lib/constants.ts`):
| 事件名 | 负载 | 触发时机 |
|---|---|---|
| `terminal-output` | `{ sessionId, data: Vec<u8>base64 , seq }` | 会话有输出 |
| `terminal-exit` | `{ sessionId, code, signal }` | 会话进程/连接结束 |
| `terminal-state` | `{ sessionId, state }` | 状态机变更 |
| `terminal-cwd` | `{ sessionId, cwd }` | OSC 7 报告目录变化 |
| `terminal-sftp-progress` | `{ taskId, transferred, total, speed }` | 传输进度 |
> **背压是重点**:大量输出(如 `cat` 大文件)时,事件频率会压垮 WebView。设计上用**批次聚合**——Rust 侧 8~16ms 窗口聚合一次,前端按 `seq` 校验无丢包;xterm 侧用 `write(data, callback)` 的回调控制写入节奏,配合 `addon-webgl` 提升渲染吞吐。
### 5.3 设置模型(`terminal/settings.json`
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase", default)] // 容器级默认:字段增减向后兼容
pub struct TerminalSettings {
pub version: u32, // 结构版本,用于迁移判断(参照 translate 的 heal 模式)
pub shells: Vec<ShellProfile>, // 本地 Shell 配置
pub hosts: Vec<SshHost>, // SSH 主机条目
pub layout: LayoutSettings, // 标签/分屏默认行为
pub appearance: AppearanceSettings, // 主题/字体/光标/缓冲区
pub shortcuts: Vec<ShortcutBinding>, // 可自定义快捷键
pub selection: SelectionSettings, // 终端内选中行为(复制/粘贴策略)
pub sftp: SftpSettings, // 传输并发、覆盖策略、时间戳保留
pub history: HistorySettings, // 命令历史开关与条数
pub security: SecuritySettings, // 主机密钥策略、代理开关、编码默认值
}
```
**`version` + `heal()` 模式必须沿用**:参照 `translate/settings.rs::heal` ——老配置缺字段取默认值、失效引用自动回落、版本号推进。这是本项目已确立的向后兼容约定。
### 5.4 权限与窗口
- 主窗口已具备 `core:default` 等权限,终端模块**无需新增 capabilities**(全部通过自定义命令走 IPC)。若做独立的快速会话浮层窗口,则需新增 `capabilities/terminal-quick.json`,参照 `translate-popup.json`NOACTIVATE + 预创建)。
- 若后续要做「终端独立窗口」(P2),同样需要独立 capability。
### 5.5 依赖清单
**Rust`Cargo.toml`** —— 以下为**实施后的实际形态**(本节的规划值已被 §9/§10 修正,以这里为准)
```toml
# ===== SSH =====
russh = "0.63" # 规划写 0.5;实际落地版本 0.63.3(见 §9.2 偏差表)
russh-sftp = "3.0" # 【规划遗漏】russh 不含 SFTP,协议在独立 crate 里
# 版本配套:russh-sftp 3.0.0 依赖 `russh ^0.63.2`,与上面同源。
# 这一点是硬约束——SFTP 通道必须从**已认证的同一个
# Session** 上开,跨 patch 版本的类型不互通。
# ===== 编码 =====
encoding_rs = "0.8" # GBK/GB18030/Big5/Shift_JIS/EUC-KR/latin1
# 依赖树里已存在(reqwest → encoding_rs 0.8.35),
# 提升为直接依赖**不新增编译单元**
# ===== 已存在可直接用 =====
# tokio / serde / serde_json / base64 / sha2 / rand / rusqlite / dirs / keyring / dashmap
# ===== 需开启 feature =====
windows-sys = { version = "0.52", features = ["Win32_System_Console", "Win32_System_Pipes",
"Win32_System_Threading", "Win32_Foundation", ...] }
# 注意:InitializeProcThreadAttributeList / UpdateProcThreadAttribute /
# DeleteProcThreadAttributeList 虽属 Win32_System_Threading,但 0.52 未随 feature 导出,
# 用 `unsafe extern "system"` 自行声明(见 conpty.rs 尾部),
# 避免为三个函数开启一个大 feature 而显著拖长编译时间。
```
**前端(`package.json`**
```
@xterm/xterm
@xterm/addon-fit
@xterm/addon-webgl
@xterm/addon-search
@xterm/addon-web-links
@xterm/addon-unicode11
@xterm/addon-serialize # P1
```
### 5.5.1 依赖数量校验(实施后)
`cargo metadata --no-deps` 结果:**50 个直接依赖,0 重复**。重点确认了两件事:
1. `russh` 只有一份(`russh-sftp` 3.0.0 把 `russh` 列为 dev-dependency,不会重复引入)。
2. `encoding_rs` 不会引入第二个 `iconv` 类 C 依赖——它是纯 Rust 实现。
### 5.6 模块注册
```typescript
// src/modules/terminal/index.ts
export const moduleConfig: ModuleConfig = {
id: 'terminal',
name: '终端',
icon: 'terminal', // 需在 icons.ts 加映射 → lucide 的 SquareTerminal
description: 'SSH 与本地 Shell 多会话终端,含密钥管理与文件传输',
category: 'tool',
defaultEnabled: true,
loader: () => import('./TerminalModule.vue'),
searchItems, // 见下方搜索项设计
order: 18 // 建议:proxy=10 / music=15 / terminal=18 / clipboard=20 / translate=25
}
```
`src/modules/icons.ts` 新增:
```typescript
import { SquareTerminal } from '@lucide/vue'
// moduleIconMap 中追加
terminal: SquareTerminal
```
**全局搜索项**`searchItems`)建议覆盖:终端、本地 Shell、SSH 主机(动态)、密钥管理、known_hosts、命令片段、终端设置、外观、编码、快捷键。
---
## 6. 分期路线图
### P0 — 骨架可跑(目标:本地 Shell + SSH 基本连得上 + 密钥管理)
**Rust**
- [ ] `TerminalManager` 骨架 + `Session` trait + `SessionRegistry`
- [ ] ConPTY 绑定(含 resize 时序、阻塞关闭、EOF 处理三个坑)
- [ ] 本地 Shell 探测(PowerShell 7 / Windows PowerShell / cmd / Git Bash / WSL
- [ ] 输出读线程 + 事件聚合(8~16ms 批处理)
- [ ] OSC 7 cwd hook 注入与解析
- [ ] `secrets.rs` 复用:密码 / passphrase 存取
- [ ] 密钥生成(Ed25519 / RSA / ECDSA)、导入、列举、删除
- [ ] SSH 连接(russh):公钥认证 + 密码认证
- [ ] 主机密钥校验 + known_hosts 存储 + 指纹变更阻断
- [ ] 设置模型 + `heal()` 迁移骨架
- [ ] `lib.rs` 六处注册(含 `collect_commands!`+ `cleanup_on_exit`
- [ ] `constants.rs` 窗口/事件常量
**前端**
- [ ] 模块注册(`index.ts` / `modules/index.ts` / `icons.ts`
- [ ] `useXterm.ts`:实例装配(fit + webgl + unicode11 + search + web-links
- [ ] `TerminalPane.vue`I/O 绑定、尺寸同步、焦点管理
- [ ] `TerminalTabs.vue`:多标签 + 关闭确认
- [ ] `SessionSidebar.vue`:会话列表 + 状态点
- [ ] 主机编辑对话框 + 密码短语输入
- [ ] `KeyManagerPanel.vue`
- [ ] 三层快捷键(第二层可配置)
- [ ] 设置页(外观 / Shell / 快捷键)
- [ ] `stores/terminal.ts`
**P0 验收标准**:能开 3 个本地 PowerShell 标签 + 2 个 SSH 会话(一个公钥、一个密码),秒级切换不卡顿,切到其他模块再回来会话仍在,`Ctrl+Shift+C/V` 可复制粘贴,关闭应用无残留进程。
### P1 — 完整能力
- [ ] SFTP 双栏文件管理器(浏览 / 上传 / 下载 / 目录递归 / 并发分片 / 断点续传)
- [ ] 分屏(2×2
- [ ] ssh-agent 集成
- [ ] 键盘交互认证 / 2FA
- [ ] `~/.ssh/config` 导入
- [ ] 命令历史聚合 + 增强搜索
- [ ] 命令片段库
- [ ] 会话侧栏拖拽分组、收藏
- [ ] 会话快照序列化与恢复
- [ ] 全局快捷键「快速会话浮层」
- [ ] 快速面板联动(搜索项 + `> ssh` 语法)
- [ ] SFTP 跟随 cwd
- [ ] 编码支持(GBK
- [ ] 终端内搜索、链接点击
### P2 — 高级与生态
- [x] 端口转发(`-L` / `-R`)(2026-09-18 第五轮,见 §11.3`-D` SOCKS5 留作后续)
- [x] 跳板机链(ProxyJump)(2026-09-18 第四轮,见 §11.2
- [x] 连接复用(2026-09-18 第八轮,见 §11.8
- [x] AI 命令助手(复用 translate 引擎配置)(2026-09-18 第七轮,见 §11.7
- [x] `rz` / `sz` ZMODEM 内联传输 —— **已放弃**2026-09-18 评审:SFTP 已覆盖主场景,
协议成本 600–800 行且无法单测主流程;详见实施记录 §11.9 评估)
- [x] 会话模板(一键拉起一组会话 + 布局)(2026-09-18 第六轮,见 §11.6
- [x] 终端独立窗口(P1 已交付 detach/attach;「拖出标签成窗」手势留后续)
- [x] 会话日志与审计(2026-09-18 第五轮,见 §11.4
- [x] PuTTY `.ppk` 导入(2026-09-18 第四轮,见 §11.1
- [x] 主机分组同步(导入导出配置)(2026-09-18 第六轮,见 §11.5
---
## 7. 风险清单
| # | 风险 | 影响 | 缓解 |
|---|---|---|---|
| 1 | ConPTY resize 竞态导致 TUI 程序(vim/htop)花屏 | 中 | 首帧后延迟应用尺寸;监听 `WINDOW_BUFFER_SIZE_EVENT` 校正;实测 vim/top/less |
| 2 | `ClosePseudoConsole` 阻塞导致退出卡死 | **高** | 独立线程 + 先取消 `ReadFile``cleanup_on_exit` 带超时(参照 `MonitorKernel` 的 3s `recv_timeout` 写法) |
| 3 | 输出洪流压垮 WebView`cat` 大文件) | **高** | Rust 侧 8~16ms 批次聚合 + `seq` 校验;xterm `write` 回调节流;`addon-webgl` |
| 4 | `russh` API 破坏性变更 | 中 | 锁定小版本;`Session` trait 隔离,必要时可换 `ssh2` |
| 5 | 主机密钥校验被用户习惯性点过(TOFU 疲劳) | **高(安全)** | 首次连接突出展示指纹;变更时红色阻断而非黄色提示;提供「仅本次接受」与「永久接受」区分 |
| 6 | 多会话内存占用(每个 xterm 实例 + 滚动缓冲) | 中 | 默认缓冲 10000 行;会话数量上限提示;非激活标签暂停渲染 |
| 7 | 中文宽字符对齐错乱 | 中 | 必装 `addon-unicode11`;实测 `ls -l` 中文文件名的列对齐 |
| 8 | SSH 老服务器 GBK 编码乱码 | 中 | `encoding_rs` 按会话转码;默认 UTF-8 可选 GBK |
| 9 | 私钥文件被其他进程读取 | 中(安全) | `keys/` 目录权限收紧;passphrase 存凭据管理器;UI 提示用户优先使用带 passphrase 的密钥 |
| 10 | `Cargo.toml` 编码损坏影响新增依赖的 diff | 低 | 独立清理项,建议在动工前修复 |
| 11 | 分屏 × 标签 × 会话的组合复杂度爆炸 | 中 | 分屏上限 2×2;布局用树结构表达并单测 |
| 12 | 全局快捷键与应用内快捷键语义混淆 | 低 | 明确三层边界,UI 上一处分开展示;不做「全局拦截 Ctrl+C」这类危险映射 |
---
## 8. 待确认决策项
动工前需要拍板的四项,我给出倾向但需要用户确认:
1. **SSH 库**:倾向 `russh`(async 契合)。若用户更看重稳定性与既有经验,可改 `ssh2`
2. **ConPTY**:倾向直接绑定 `windows-sys`(与项目现有原生态一致)。若更看重开发速度,可用 `portable-pty`
3. **P0 范围**:本规划把「SSH + 密钥管理 + 多会话 + 快捷键」全放进 P0,工作量偏大(8~12 天)。若希望更快见到可用版本,可将 SSH 拆到 P0.5,先交付「本地 Shell + 多会话 + 快捷键」。
4. **是否需要终端独立窗口**:影响窗口与 capability 设计,早定早省事。
---
## 附录 A:与既有模块的范式对照
| 范式 | 既有实现 | 终端模块对应 |
|---|---|---|
| 模块 ID / 分类 | `translate``category: 'tool'` | 同 |
| 设置持久化 | `{app_data_dir}/<module>/settings.json` | `{app_data_dir}/terminal/settings.json` |
| 设置兼容 | 容器级 `#[serde(default)]` + `heal()` + `version` | 完全沿用 |
| 凭据存储 | `secrets.rs` + 服务名 `"Thing"` | 完全复用,仅新增键名约定 |
| 命令层姿态 | `translate/commands.rs`「薄」:整形/校验/归类 | 同 |
| 类型绑定 | `tauri-specta``src/lib/bindings.ts` | 必须复用 |
| 事件命名 | `kebab-case``translate-stream-chunk` | `terminal-output` / `terminal-exit` / ... |
| 快捷键 | `shortcut.rs` 原子注册 + 冲突检测 | 完全复用 |
| 退出清理 | `RunEvent::ExitRequested` 逐个 `cleanup_on_exit` | 追加 `TerminalManager` |
| 历史存储 | `translate/history.rs`SQLite | `terminal/history.rs` 同法 |
| 原生浮层窗口 | `translate-popup`NOACTIVATE 预创建) | 快速会话浮层参照 |
## 附录 B:命名规范落点
| 类型 | 规范 | 示例 |
|---|---|---|
| 前端组件 | PascalCase | `TerminalPane.vue` |
| 前端文件 | kebab-case | `use-session-stream.ts`composable 目录内用 camelCase 前缀 `use` |
| Pinia store | camelCase 文件 | `src/stores/terminal.ts` |
| Rust 模块 | snake_case | `terminal/pty/conpty.rs` |
| Tauri 命令 | `terminal_` + snake_case | `terminal_open_session` |
| Tauri 事件 | kebab-case | `terminal-output` |
| 凭据键名 | `terminal-<用途>-<id>` | `terminal-key-passphrase-{keyId}` |
| 设置字段 | camelCaseserde rename_all | `maxScrollback` |
---
## 9-11. 实施记录(P0P2 精编)
> 本节为 2026-09-18 全链路审查时按「精简」要求压缩的版本:保留全部**架构决策、
> 坑记录与语义备忘**,省略逐轮的过程性叙述与重复的验证表。按阶段分节的原始
> 详版(P0 §9 / P1 §10 / P2 §11,共 8 轮)记录在 git 历史与当日工作日志中。
### 交付总览
| 阶段 | 交付 | 状态 |
|---|---|---|
| P0 | 本地 ShellConPTY)、SSH 连接、多标签、密钥管理、快捷键骨架 | ✅ |
| P1 | SFTP 双栏、分屏 2×2、命令片段库、命令历史(OSC 133)、编码切换、Cargo.toml 修复 | ✅ |
| P2 | `.ppk` 导入、ProxyJump 跳板链、端口转发 -L/-R、会话日志与审计、主机导入导出、会话模板、AI 命令助手、连接复用 | ✅ |
| P2 | 终端独立窗口 | ✅(P1 交付 detach/attach |
| P2 | ZMODEM | ❌ 已放弃(评估见下) |
| 审查 | 全链路审查:修复 2 个 P0 级前端缺陷 + 1 个后端竞态 + 4 个中低问题 | ✅ |
### 分阶段决策摘要
**P0(骨架)**
- ConPTY 直接用 `windows-sys``CreatePseudoConsole` 三函数自行声明(避免拖入大 feature)。
- 会话抽象 `Session` trait:本地/SSH 双后端共用命令层;`ProcessManager` 不适用(N 会话 + 双向流 + 退出不重启)。
- `SessionId` 用短序号(`s1`…),会出现在窗口 label 与日志。
- 密码/密钥 passphrase 分离存储:密码进系统凭据管理器(按 id 键名),私钥本体落 `keys/` 目录。
**P1(完整能力)**
- SFTPrussh 不含 SFTP → 引入 `russh-sftp`;通道挂在会话连接上(非独立连接)。
- 分屏 = 新建会话 + 并排渲染(tmux 语义),上限 4(WebGL 上下文约束);CSS Grid 布局。
- 命令片段:占位符 `${name}` 语法只在 Rust 侧实现一份(前端自己写正则必分叉);两步执行(填入 vs 执行)。
- 命令历史:OSC 133 + 1337 提取命令边界;本地用 shell hook 上报 cwd;不做 DROP 重建式迁移。
- 编码切换:解码在前端(用户可切编码重看历史),读写两侧都从会话状态现取。
**P2(高级与生态,共 8 轮)**
- `.ppk` 导入:`ssh-key``ppk` feature(russh 不转发 → 自己声明同版本号 `=0.7.0-rc.11`);PPK 解析后统一转 OpenSSH 落盘。
- ProxyJumprussh 无内置 → 逐跳手搭(`direct-tcpip` 通道流 + `connect_stream`);跳板与直连同权校验。
- 端口转发 -L/-R`direct-tcpip` + `copy_bidirectional` / `tcpip_forward` + Handler 白名单回调;规则挂会话不持久化。
- 会话日志:双后端 `flush_output` 单点挂钩;记原始字节含 ANSI;只记输出不记输入(密码安全)。
- 主机同步:JSON 备份只含配置不含密码/私钥;导入重编 id(凭据键名冲突)+ 重写跳板链 + 三元组去重。
- 会话模板:捕获当前可见面板集合;拉起 = 逐条开会话 + addPane;只存 target 引用。
- AI 助手:复用翻译模块引擎配置(`chat_once` 通用补全出口);三层解析防御;默认填入不执行。
- 连接复用:连接池按「用户名|host:port|auth|材料指纹」共享 SSH 连接;引用计数归零才断开。
### 关键架构语义备忘(跨模块契约)
1. **`ssh-key 0.7``decrypt()`/`encrypt()` 都是 `&self → Result<Self>` 转换语义**——返回值必须接住;
丢返回值 = 仍在加密态(坑 31,曾导致加密私钥导入从未成功过)。
2. **`encrypt()` 会清空内存对象的注释**(重建 public_key),但加密载荷里含注释(decrypt 可读回);
`set_comment` 必须在 encrypt 之后调用。
3. **`collect_commands!`(导出绑定)与 `generate_handler!`(运行时注册)是两份独立清单**——
新增命令必须双清单登记;前端用原生 `invoke` + 手写镜像类型(translate 先例,terminal 跟随)。
4. **`export_bindings()` 失败是运行时的**:specta 类型注册表全局按名索引,
跨模块同名 `Type` 派生类型会让应用启动即 panic`cargo check` 完全看不见)。
5. **`tauri-specta` derive 路径无法重命名类型**(`#[specta(rename)]` 只对函数宏生效)——
通用词(Settings/HistoryPage/Item…)一律加模块前缀。
6. **`Write` 契约**:前端 `store.write(string)` 必须 TextEncoder 编码后 base64
(后端严格解码);xterm onData / 粘贴走字符串分支。
7. **`vue-draggable-plus``target` 是跨容器专用 prop**,且 `querySelector` 不匹配元素自身——
单容器排序禁止传 target。
8. **连接池槽位是 tokio Mutex**(连接建立期跨 `.await` 持锁,天然串行化同主机并发连接);
sftp/转发的同步访问走 `spawn_blocking + block_on`,锁在 block_on 内获取。
9. **跳板 Handle 挂池条目**而非首建会话——否则首建会话关闭剪断他人隧道。
10. **`-R` 入站路由按端口全局匹配**:连接级 Handler 的 session_id 属于首建会话;
远程监听端口全局唯一(add 时强制)。
11. **`chat_once`translate 根 re-export)是终端 AI 助手的唯一 API 配置源**——
终端不持有任何引擎配置副本。
12. **面板常驻挂载**`renderPanes` 含全部会话,v-show 切可见性——
切标签/分屏绝不销毁 xterm 实例(缓冲与隐藏期输出不丢)。
### 坑记录(35 条精编)
| # | 一句话 | 修复/规避 |
|---|---|---|
| 1 | `ssh-key` 双版本分叉(0.6 vs russh 钉的 0.7 | 只用 russh re-export;例外须同版本号声明 |
| 2 | ssh-key 的 getrandom feature 门控(rand_core 0.10 | 直接依赖 getrandom 0.4 + UnwrapErr(SysRng) |
| 3 | `#[specta::specta]``#[tauri::command]` 必须成对 | 漏一个 = 绑定缺失或运行时不可调 |
| 4 | windows-sys 0.52 的 HANDLE/HPCON 是 isize | 注意类型转换 |
| 5 | ConPTY 三个时序陷阱(先建管道再建 PTY 等) | 见 pty::conpty 注释 |
| 6 | `create_pipe()` 已返回 File,不要再转一次 | — |
| 7 | xterm 无 selectWordAt(自实现选择词语) | 右键菜单自定义 |
| 8 | PowerShell 写文件产出 UTF-16LE | 让程序自己写或 Python 落盘 |
| 9 | 密码与配置分离存储(凭据管理器 vs settings.json | 永不明文落盘 |
| 10 | 新建主机先向后端要 id(密码按 id 存取) | id 规则单点 |
| 11 | russh 无 SFTP → 引入 russh-sftp | 通道复用连接 |
| 12 | SFTP 通道借用 Handle 需 spawn_blocking+block_on | Handle 不可 Clone、不能跨 await 持锁 |
| 13 | WebGL 上下文上限 4 个(黑屏风险) | maxPanes 封顶 + onContextLoss 回退 |
| 14 | 分屏容器是标签级的,切标签要重置 | resetPanesTo(见坑 36 修正) |
| 15 | 本机 Bash 缺 coreutils,管道全部失真 | 验证命令重定向到文件后用 Python 读 |
| 16 | `npx` 触发 wsl.exe 黑名单拦截 | 直接调 JS 入口 |
| 17 | PowerShell 重定向产出 UTF-16LE | 同 8 |
| 18 | `vite build` 重定向+后台 = 假死(非 OOM) | 构建一律前台跑 |
| 19 | impl 块放错位置 → trait 方法「已实现却报未实现」 | — |
| 20 | Session trait 未引入时报错指不到成因 | 显式 use |
| 21 | OSC 133 命令文本与结束标记是两个独立序列 | 必须累积 |
| 22 | `1337``133` 共享前缀,判断顺序错了静默失效 | 先判长前缀 |
| 23 | `${x#"$y"}` 类语法在 Rust 字符串里写不出 | 换等价写法 |
| 24 | 两份 `scan_control_sequences` 拷贝按后端分支出诡异 bug | 收敛到一处 |
| 25 | 命令历史遵守 `HISTCONTROL=ignorespace` 惯例 | 前导空格不记录 |
| 26 | FTS 与 LIKE 双路径查询需一致性测试 | — |
| 27 | `export_bindings()` 失败是运行时的(编译全绿 ≠ 能启动) | 新增 Type 必须实际跑二进制 |
| 28 | 两份命令清单不自动同步 | 双清单登记 + 交叉注释 |
| 29 | 重定向/后台让验证命令本身不可信 | 前台对照实验 |
| 30 | `vue-draggable-plus``target` 是跨容器专用(querySelector 不搜自身) | 单容器禁用 target |
| 31 | `decrypt()/encrypt()` 是转换语义,丢返回值 = 加密私钥导入从未成功 | 接住 Result\<Self\> |
| 32 | `encrypt()` 清空内存对象注释(载荷里有) | set_comment 在 encrypt 后 |
| 33 | trait object 不能挂两个非 auto traitE0225 | 合并 trait + blanket impl |
| 34 | russh 对 forwarded-tcpip 默认全收 | Handler 白名单覆写 |
| 35 | 连接复用后 -R 入站按 session_id 路由永不命中 | 全局端口匹配 + 唯一性 |
| 36 | **[审查轮]** 切标签销毁 xterm 实例、隐藏期输出被丢弃 | renderPanes 常驻全部会话 + v-show |
| 37 | **[审查轮]** store.write 字符串分支未编码 → 键盘输入完全失效 | TextEncoder 后 base64 |
### 全链路审查(2026-09-18P2 收官)
探查代理 + 人工复核,确认并修复 7 项(另排除 2 项误报):
| # | 级别 | 问题 | 修复 |
|---|---|---|---|
| 1 | **P0** | `store.write` 字符串分支未 base64 编码——xterm 键盘输入/粘贴全部被后端拒绝,**终端无法打字**(坑 37) | TextEncoder 编码后再 base64 |
| 2 | **P0** | 切标签卸载其他会话的 TerminalPane:xterm 缓冲丢失、隐藏期输出被丢弃(坑 36) | renderPanes 常驻全部会话 + v-show |
| 3 | 高 | 连接期间关闭标签的竞态:do_connect 复活会话(Established 覆盖 Closed)、连接写进已拆除的池条目永不断开 | do_connect 三处 closed 检查点,命中则断开新连接并放弃 |
| 4 | 高 | `closeTab` 分屏组误判:分屏激活时关后台标签会误关分屏组而非目标 | 仅当目标在分屏组内才按组关闭 |
| 5 | 中 | SFTP 面板在 SSH 会话间切换不关旧通道(泄漏) | `<SftpPanel :key="sessionId">` 强制重建 |
| 6 | 低 | HistoryPanel 防抖定时器卸载不清理 | onBeforeUnmount clearTimeout |
| 7 | 低 | Alt+1..9 要求焦点在 `.xterm` 内(侧栏/对话框下失效) | 只在输入控件聚焦时让路 |
已排除的误报:「兜底 watch 只看 sessions.length」(实际有 activeSessionId 有效性校验)等。
**性能结论**:输出管线(8ms 聚合窗口 + base64 + 事件)与渲染(WebGL + 回退)无热点;
面板常驻化后 xterm 实例数 = 会话数,WebGL 超限已有回退兜底。无需要改动的热路径。
### ZMODEM 评估(已放弃)
完整协议(帧结构 / CRC-16+32 / 转义编码 / 滑动窗口重同步 / 双向状态机)约 600–800 行,
调试依赖真实 rz/sz 对端,无法用单测覆盖主流程。SFTP 已覆盖绝大多数文件传输场景,
ZMODEM 剩余价值主要在串口/老旧嵌入式设备。成本收益不成立,正式放弃;
若未来出现需求,建议独立一轮且优先做 sz 下载方向。
### 验证汇总(收官状态)
| 检查 | 结果 |
|---|---|
| `cargo check` | exit 0(警告数与 P0 基线一致) |
| `cargo test --lib` | **91/91 通过**keys 17 + commands 9 + assistant 6 + audit 5 + pool 5 + 既有 49 |
| `vue-tsc --noEmit` | exit 0 |
| 类型重名扫描 / 池引用计数 / 解析防御 | 单测覆盖 |
### 待用户真机验证清单
1. 终端键盘输入与粘贴(审查轮修复 #1——此前从未被测出)。
2. 多标签切换不丢缓冲、隐藏期输出不丢(修复 #2)。
3. 连接复用:同主机双标签秒连、关一个另一个不受影响、全关后连接断开。
4. ProxyJump 跳板链、端口转发 -L/-R、`.ppk` 导入、会话模板拉起、AI 助手(需翻译引擎配置)。
### 界面重构与标题口径(2026-09-20)
用户反馈两条:「本地 Shell 的标题显示成了全路径」「终端页像内嵌了一个独立终端页面」。
两条都成立,根因不同,分别处理。
#### 一、会话标题为什么曾经是全路径
标题有两个来源,后者会覆盖前者:
1. 创建时写入的标题(`terminal_open_local``ConPTYSession::spawn`);
2. shell 通过 OSC 0/2 上报的「自定义标题」。
`cmd.exe``powershell.exe` **默认就把自己的可执行文件全路径设成控制台标题**
cmd 运行命令时还会追加 ` - <命令>`),经 PTY 原样到达 → 标签栏与状态栏被顶成
`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`。Windows Terminal /
VS Code 同样要过滤这类标题,属于同类问题。
**提权会话还多一层坑**:以管理员身份运行时标题前面会带一个本地化前缀
(简体中文下是 `管理员: `,英文是 `Administrator: `)。首版过滤只判「以盘符开头」,
日常(非提权)会话表现完全正确,只有提权运行时才漏 —— 因此
`osc_title_is_executable_path` 现在对「原串」与「剥掉前缀后的串」各判一次,
并有单测覆盖 `管理员: ` / `Administrator: ` / 全角冒号三种形态。
修复分四处:
| 改动 | 位置 | 理由 |
|---|---|---|
| 过滤「绝对路径 + `.exe`/`.com`」形态的 OSC 标题(含提权前缀) | `shell.rs::osc_title_is_executable_path` + `session.rs` | 判据收紧到绝对路径,WSL/Git Bash 的 `user@host: ~/dir``/usr/bin/htop` 必须保留 |
| **标题改用 `shell::session_label()`**(按可执行文件名取短名:`pwsh.exe``PowerShell 7``powershell.exe``PowerShell``cmd.exe``cmd``bash.exe``Git Bash`,认不出则退回 `name` | `shell.rs::session_label` | 标签要短、稳定、一行放下多个会话;设置页的 `name` 可编辑、可很长,两者诉求不同 |
| 设置页的探测名**保持不变**`PowerShell 7` / `Windows PowerShell` / `命令提示符` / `Git Bash` | `shell.rs::detect_shells` | 用户明确要求:命名问题在标签位置,不动设置里的命名 |
| 历史默认名迁移同时覆盖两代取值 | `shell.rs::legacy_default_names` | 合并规则是「保留用户设置的名字」,不迁移则默认名对老用户**永远不生效**;列表里含上一版误当作展示名的短名,用于把它们收回描述性默认名 |
状态栏随之调整:本地会话不再显示从 `ShellProfile::name` 拼出的副标题 ——
标题已经是短名(`PowerShell`),再并排一个 `Windows PowerShell` 会让人以为是两个东西。
SSH 不受影响(标题是主机名、副标题是 `user@host:port`,后者标题给不出)。
顺带修掉一个相邻缺陷:`terminal_rename_session` 设的标题会被下一条提示符的 OSC 标题
覆盖(表现为「重命名无效」)。现在 `set_title_by_user` 会置 `title_locked`
一旦用户显式命名,OSC 标题一律忽略;trait 方法名从 `set_title` 改为带 `_by_user`
后缀,从命名上堵住「程序自动改标题」这条误用路径。
#### 二、终端页为什么像内嵌独立应用
| 症状 | 处理 |
|---|---|
| 工具栏一行 + 会话标签一行,两套横向导航 | 合并为单行头部:左侧会话标签,右侧操作组(省 36px 垂直空间) |
| 工具栏写原始 `sessionId` | 从界面移除,改放标签的悬停提示(排障时仍可见) |
| 十来个别扭的图标按钮铺满一行 | 高频动作(搜索/字号/清屏/独立窗口)外露,其余入「更多」下拉 |
| 「+」只能新建上次用过的 shell,连主机要去主机页 | 改为「+▾」下拉:本地 Shell 列表 + 已保存主机 + 管理主机 |
| 七个页面头部高度/内边距/计数写法各不相同 | 新增 `TerminalPageHeader`h-11、icon size-4、`共 N …`),六页统一 |
| 父层与页面各套一层圆角边框 | 容器下沉到各页自身,历史页的双边框随之消失 |
| 「返回终端」「关闭」按钮与模块 Tabs 重复 | 移除;`close` 意图事件保留,由父层映射为切 tab |
| 唯一未接入 `useModuleTabs` 的模块 | 接入(标题栏浮动切换器 + tab 记忆 + **全局搜索跳转到指定页** |
#### 三、Tabs 由 7 项收敛为 5 项
同类模块(proxy / monitor / music / downloader / clipboard)的 TabsList 都不超过
5 项、统一 `max-w-md`;终端原有 7 项只能靠 `max-w-2xl` 撑开,每项被压得很窄,
横向比别处宽出半屏 —— 是「不像是同一套导航」的直接来源之一。
| tab | 组成 | 说明 |
|---|---|---|
| 终端 | 会话(xterm + SFTP + 状态栏) | forceMount,切走不卸载 |
| 主机 | SSH 主机管理 | 不变 |
| 凭据 | 密钥 + 已知主机 | 都是「SSH 信任材料」,同一类列表 |
| 记录 | 命令历史 + 命令片段 | 都是「命令资产」,同一类用途 |
| 设置 | 外观 / 布局 / 选择 / 安全 / 快捷键 / Shell | 不变 |
合并的前提是**子分区仍能被直接到达**,否则用户要在一页里找两块内容,比多一个 tab 更糟。
因此新增 `TerminalSectionSwitch`(按钮组实现,视觉规格对齐 `TabsList`/`TabsTrigger`
不是嵌套 reka Tabs —— 那会带来两层键盘导航语义)。分区状态由 `TerminalModule`
持有(`credSection` / `recordSection`),面板卸载重挂不丢「上次看的是哪一半」。
**已知取舍**`SearchIndexItem.tab` 只能表达「哪个 tab」,无法表达「tab 里的哪个分区」,
所以全局搜索「已知主机」会落在「凭据」页的密钥分区,需再点一次页内开关。
要精确落位需给 `moduleTabsStore` / `useModuleTabs` / `TitleBar` 三处共享基建加字段,
收益不抵成本,故不做(已在 `index.ts` 的注释里写明)。
未改动的部分及原因:底部状态栏保留(终端习惯,且它是唯一承载编码/尺寸/cwd 的位置);
会话标签仍可拖动重排;会话面板仍常驻挂载用 `v-show` 切换(见模块文件头)。
`useModuleTabs` 的浮动切换器在终端模块**实际不会出现** —— 模块根是
`overflow-hidden`,页面不滚动,TabsList 不会滚出可视区。接入的实际收益是
tab 记忆与全局搜索跳转(此前搜索「SSH 密钥」只切模块、停在终端页)。
#### 验证
| 检查 | 结果 |
|---|---|
| `cargo check` | exit 0 |
| `cargo test --lib terminal::` | shell.rs 新增 5 个单测(提权前缀 / 有效标题保留 / 短名取值 / 兜底 / 历史默认名) |
| `vue-tsc --noEmit` | exit 0 |
| `vite build` | 成功 |
| 待真机确认 | 提权 PowerShell 标签显示为 `PowerShell`;重命名后不再被覆盖;窄窗口下头部不打架;5 个 tab 与页内分区开关的可达性 |
### 容器口径统一与终端页拆分(2026-09-20 第二轮)
峰提出:「终端模块的结构完全是单独的内部窗口,各 tabs 的内部窗口用同一个框架,
与其他模块 UI 模式差别很大」。勘察确认成立,并定位到三处偏离。
#### 一、偏离的取证(改动前)
| # | 偏离 | 事实 |
|---|---|---|
| 1 | 每页多一层**内嵌窗口框** | `h-full flex flex-col rounded-lg border border-border bg-background overflow-hidden`,全项目**仅终端使用**,共 7 处(终端页 + 6 个子面板) |
| 2 | 每页多一条**页中页标题栏** | `TerminalPageHeader`h-11 + border-b + px-4);6 个子面板各自手写同一套外框再内嵌该头部 |
| 3 | 滚动实现不统一 | 子面板用原生 `overflow-y-auto`,其它模块统一用 `ScrollArea`,滚动条外观不同 |
对照口径(proxy / monitor / music / clipboard / downloader 一致遵守):
模块根 `h-full p-6` → TabsList 包 `div[ref=tabsListRef]``max-w-md !bg-transparent`
`TabsContent flex-1 min-h-0 mt-4 tab-animate` → 内容**直接铺开**
`ScrollArea` + Card 流 / 列表项),**没有任何容器边框**。
#### 二、改动
| 项 | 内容 |
|---|---|
| 新增 `components/layout/PageToolbar.vue` | 漂浮工具条(无边框、无底边、`mb-3`),API 与原 `TerminalPageHeader` 一致(icon / title / meta / 默认槽=动作 / info 槽);提升到 layout 层,其它模块可复用 |
| 6 个子面板 | 去外框(保留 `flex flex-col h-full overflow-hidden`);`TerminalPageHeader``PageToolbar``overflow-y-auto``ScrollArea`padding 移到 ScrollArea 上(沿用 proxy/monitor 的 `pr-3` 写法) |
| 删除 `components/TerminalPageHeader.vue` | 已被 `PageToolbar` 取代,避免留两份近似组件 |
| 新增 `components/TerminalWorkspace.vue` | 终端页整体:工具条 + 终端屏幕(画布 / SFTP / 状态栏)+ 右键菜单 + 4 个对话框 |
| `TerminalModule.vue` 1197 → 383 行 | 只剩模块级编排:5 个 tab、tab 记忆、快捷键分发、初始化、主机密钥弹窗 |
#### 三、两个关键取舍
**1. 终端屏幕保留边框,但边界从「整页」收缩到「画布 + 状态栏」。**
xterm 是画布而不是内容流,需要明确的视觉边界;但此前边框包住的是
「标签行 + 主体 + 状态栏」整体,框内还有一条 h-11 标题栏 —— 那正是
「内嵌窗口」的观感来源。现在会话标签与操作组移到框外成为页面级工具条,
边框只包画布与状态栏。
**2. `useTerminalStream` 全模块只创建一次。**
该 composable 的 `tabOrder` / `panes` / `activeSessionId` 是**每次调用各创建一份**的
局部状态。拆分后若模块根与终端页各调一次,会出现两套互不相干的标签顺序与分屏布局
(表现为「点击标签没反应」)。因此实例在模块根创建,经 prop 传给 `TerminalWorkspace`
终端页里由模块级快捷键触发的动作(复制/粘贴/搜索/清屏/分屏/独立窗口/SFTP/重命名)
通过 `defineExpose` 的显式清单暴露,由模块根做一次转发。
#### 四、约束(后续改动须遵守)
1. **`h-9` 是卡片内的横向分隔线高度**:控制栏 / 状态栏 / `SftpPanel` 头部同为 h-9
改其一必须同步(原契约是「SftpPanel 头部对齐卡片外的工具条行 h-11」,2026-09-21
控制栏收进卡片并降为 h-9 后该层已不存在)。
2. **xterm 尺寸链不能断**`TabsContent → 工具条 + 屏幕容器 → 画布` 全靠 `flex-1 min-h-0`
3. `ModuleContainer.vue``[data-main-scroll] … > div { height: 100% }` 是给全高模块(终端/翻译)补高度链的补丁,勿删。
4. `TerminalWindow.vue`(独立窗口)与主窗口共用 `TerminalPane` / `TerminalStatusBar`,共享逻辑留在 `useSessionStream` 与 store,不下移到页面组件。
#### 五、非终端页与其它模块的口径对齐(2026-09-21)
第二轮对齐只动**观感与组件选型**,不改终端页(画布 + 状态栏)的结构:
1. **外层 padding 归模块根**:根容器 `p-6``p-5`,各页不再自带 `p-3/p-5`
—— 此前「根 24px + 页内 12/20px」双重内缩,列表左边缘比其它模块多缩 16px。
分栏页(片段、设置)的沟槽也由模块根提供:`UI_DESIGN_SYSTEM.md` §3.4 的
「split 无 padding」指**分栏容器自身**不加 padding,不是让页面顶到窗口边缘。
2. **列表一律用 `Card` 承载**(与 proxy「订阅列表」同口径):`Card` + `CardContent`
内部行保持 `rounded-md border`;页名与计数仍由 `PageToolbar` 承担,不再加 `CardTitle`
3. **表单控件走 `@/components/ui`**`Input` / `Select` / `Textarea` / `Checkbox`),
不再出现裸 `<input>` / `<select>` / `<textarea>``SftpPanel` 的远程文件编辑器是
文档显式豁免,保持原生 `<textarea>`
4. **四态与徽章用 `@/components/common`**:空/加载用 `StateBlock`(注意它在 ScrollArea 内
不会垂直居中,需显式 `min-height`),状态类徽章用 `StatusBadge`
纯计数的内联标记(历史 `×N`、Shell kind)保持 `text-[11px]` 文本,
避免 StatusBadge 的 20px 最小高度把行高抬起来。
5. **设置页用 `SettingGroup` + `SettingRow`**(本仓库首批真实消费者):分组自带卡片与
`divide-y`,行内不要再加边框/分隔;下拉/滑杆/输入这类表单项按 §3.4 的
「label 在上 + 控件全宽」纵排,不塞进 `SettingRow` 的值区。
控制列保留 `max-w-3xl`(§3.3 的例外:`SettingRow``justify-between`
不限宽会把标签与控件拉到 900px 之外)。
6. **分栏页保留页内滚动**:设置页/片段页的左右栏各自滚动(§3.4 split 模式),
不改外部滚动 —— 模块根有 `overflow-hidden`,改外部滚动会让左栏导航跟着滚走
`sticky` 会被祖先的 `overflow-hidden` 杀死)。
7. **历史页的键盘守卫**`onKeydown` 挂在外层,对 Enter/↑↓ 无条件 `preventDefault`
焦点落在按钮/下拉触发器/文本域上时先让路(`closest('button,[data-slot="select-trigger"],select,textarea')`),
否则换成 `Select` 后一次 Enter 会「先展开下拉、再把选中命令填进终端」。
搜索框不在让路之列:在那里 ↑↓ 选条目、Enter 填入是刻意保留的操作方式。
#### 六、验证
| 检查 | 结果 |
|---|---|
| `vue-tsc --noEmit` | exit 0 |
| `vite build` | 成功(28.00sTerminalModule chunk 160.34 kB |
| 待真机确认 | 6 个子页去框后与其它模块观感是否一致;终端屏幕的边界感;SFTP 面板与工具条行的对齐;分屏 / 右键菜单 / 搜索 / 会话模板 / AI 助手行为未变 |
| 第二轮待真机确认 | 列表卡片化与 20px 沟槽;历史页在搜索框/来源下拉里按 Enter 的行为;设置页 8 个分区的行对齐;片段页右栏滚动条为自绘 |
### 控制栏收进终端卡片与控件规范化(2026-09-21 第三轮)
峰提出:「控制栏放在卡片外面不对」、「按钮 hover 应是统一的主题色字 + 半透明主题色底,
看来有遗漏」、「字号三连图标改成『字体』按钮 + popover,图标按钮用 tooltip 而不是 title」、
「状态栏调到与控制栏同高、字号调大、编码换成 popover」。
#### 改动
| 项 | 前 | 后 |
|---|---|---|
| 控制栏位置 | 卡片**外**的页面级工具条(h-11,`mt-1` 与卡片分隔) | 卡片**内**顶部(h-9 + `border-b`),与画布、状态栏同属一张「终端窗口」 |
| 图标按钮 | 自定义 `.icon-btn`hover = `--accent` + `--foreground` | `Button variant="ghost"` —— 全项目唯一口径 `--primary-soft` + `--primary-soft-text` |
| 按钮提示 | 原生 `title` | `Tooltip` + `TooltipContent`(菜单触发器用 `:disabled="open"`,避免提示压在菜单上) |
| 字号 | 三个并排图标(缩小 / 放大 / 重置) | 「字体」按钮 + popover:滑块(6–48+ 当前值 + 重置 |
| 状态栏 | h-6 / 10px / 图标 `size-3` | h-9 / 12px / 图标 `size-3.5` |
| 编码 | 原生 `<select>` | `Popover` 选项列表(当前项打勾 + 非默认编码提示) |
| `SftpPanel` 头部 | h-11(对齐卡片外的工具条行) | h-9(对齐卡片内的两条栏) |
#### 取舍
1. **字号滑块复用 `bumpFont(delta)`**,不新增 `setFont` 接口:`bumpFont` 内部以
「临时覆盖值 ?? 设置值」为基准并收敛到 6–48,与状态栏展示的 `fontSize` 同源,
因此「基准 + 增量 = 目标值」恒成立。
2. **编码的浮层反而是更稳的选择**2026-09 早期用原生 select 的理由是
「shadcn 的浮层会被终端容器裁剪」,但 `PopoverContent``PopoverPortal`(挂 body),
不受卡片 `overflow-hidden` 影响 —— 该顾虑不成立,原生 select 的样式代价则一直存在。
3. **高亮态加在 svg 上**`Button.ghost` 已给按钮设 `text-fg-secondary`
同元素再写 `text-primary` 的胜负取决于 Tailwind 输出顺序;`[&_svg]:text-primary`
落在子元素上确定覆盖继承色(「日志记录中」的 ⋯ 用它)。
#### 验证
| 检查 | 结果 |
|---|---|
| `eslint src/modules/terminal` | exit 0 |
| `vue-tsc --noEmit` | exit 0 |
| 待真机确认 | tooltip 与下拉菜单不叠加;滑块拖动跟手且「重置」回落;编码浮层在卡片/独立窗口内均不被裁剪;SFTP 头部与上下栏等高 |
### 独立窗口与 Ctrl+3 快捷新建(2026-09-21 第四轮)
峰提出:「控制栏的 tooltip 似乎和点击事件冲突,新建会话/更多操作点不动」、
「独立窗口复用终端页内部窗口,再加最小化/最大化/关闭」、「加一个 Ctrl+3 直接开独立窗口,
打开时列出 SSH + 本地终端 + 新建的选择」。
#### 一、修 tooltip 与菜单触发器冲突
现象:控制栏的「新建会话」「更多操作」点击后毫无反应(菜单不出现)。
三轮定位过程:① `TooltipTrigger as-child` 直接包 `DropdownMenuTrigger as-child`(同时给
`DropdownMenu` 绑了 `v-model:open` 以便展开时收起提示);② 中间夹一层真实 `<span>` 作锚点
(仍带着 `v-model:open`);③ 去掉 Tooltip 与 `v-model:open` —— 菜单恢复可用。
结论:**受控 `open` 是元凶**。给 `DropdownMenu`(本项目对 `DropdownMenuRoot` 的封装)
`v-model:open` 后菜单点不开;Tooltip 只要锚在**包裹元素**上(而不是把菜单触发器当作
它的 as-child 子节点)就没有副作用。最终形态:
```
Tooltip > TooltipTrigger as-child > div(包裹元素)
└─ DropdownMenu @update:open(仅监听,不绑 open
└─ DropdownMenuTrigger as-child > Button
```
两个菜单触发器(本组件的「更多操作」、标签栏的「新建会话」)现在都有 Tooltip,
文案统一 `side="bottom"`(控制栏在卡片/窗口最顶部,向上弹会被裁掉)。
**约束**:① 本项目不给 `DropdownMenu``v-model:open`(需要开合状态时只监听
`@update:open`);② Tooltip 的锚点用普通元素,不要直接套在菜单/弹层触发器上。
#### 二、独立窗口 = 单会话的终端卡片
| 项 | 前 | 后 |
|---|---|---|
| 结构 | h-8 标题栏(菜单图标 + 会话名 + 窗口按钮)+ 画布 + 状态栏 | 与终端页同构:控制栏(h-9)+ 画布 + 状态栏 |
| 控制栏左端 | — | 会话身份(图标 + 名称 + 目标),**兼窗口拖拽区**(`data-tauri-drag-region` + 子元素 `pointer-events-none` |
| 控制栏右侧 | — | 复用的 `TerminalToolbar``standalone` 模式)+ 最小化/最大化/关闭 |
| Tooltip | 无(窗口是独立入口,没有 TooltipProvider | 窗口根加 `TooltipProvider` |
| 主题 | **不跟随主窗口**(窗口不加载 appStore,也没有各自的 applyTheme 副本) | 补上独立窗口的 `applyTheme`:读同一份 `localStorage` 设置 → `win.setTheme` + `dark` class + `applyStoredAccent`,并监听 `storage` / `prefers-color-scheme` 跟随主窗口改动(与快速面板、剪贴板弹窗、下载窗口同一套做法);**不应用 mica/acrylic**(本窗口是不透明窗口,见 `terminal/window.rs` |
| 提示条 | 无 Toaster`toast.*` 静默失效) | 自带 `Toaster`Toaster 是窗口级的) |
`standalone` 模式隐藏三类只对主窗口终端页成立的动作:独立窗口按钮(自己就是那个窗口)、
文件面板 / 端口转发 / 会话模板(它们开的是终端页的面板与分屏);保留搜索、字体、清屏、
重命名(文案改「重命名会话」)、AI 助手、会话日志。
**取舍**:窗口仍是「一个会话 ↔ 一个窗口」,不渲染标签栏 —— 主窗口那侧的「已在独立窗口打开」
提示条与标签上的「独立」徽标以此为准。控制栏动作与 TerminalWorkspace 有**少量有意重复**
(搜索/清屏/重命名/日志):那边还要管分屏与 SFTP,抽共享 composable 会把状态搅在一起。
#### 三、Ctrl+3:全局快捷键 + 选择框
**最终形态(2026-09-21 第二轮调整后)**
- **先开窗、再问**:快捷键触发 → 立刻创建「选择窗口」(`#new-terminal-window`
`NewTerminalWindow.vue`label 前缀 `terminal-window-new-` 以命中同名 capability
→ 用户在**这个窗口里**选本地 Shell / SSH 主机 → 建会话 →
`terminal_detach_session` 交给 Rust 用规范 label`terminal-window-<id>`)开真正的会话窗口
→ 选择窗口关闭(两者同尺寸同居中,视觉上是「原地换成了会话窗口」)。
**不再经过主窗口弹窗**,也不再把主窗口拉到前台 —— 会话窗口由 Rust 创建且此时还没有
sessionId,窗口 label 在 Tauri 里不可改,所以「选择窗口」与「会话窗口」只能是两个窗口。
想要零切换需要新增 Rust 命令(把已有窗口认领为某会话的窗口),当前未做。
- **键位可自定义**`newDetachedWindow` 作为一条动作放进 `TERMINAL_ACTIONS`
于是它跟着终端设置一起存、一起改(设置 → 快捷键 → 会话 → 新建独立窗口,默认 `Ctrl+3`
与其它键位一样支持录制与开关);Rust `default_shortcuts()` 同步加一条,`heal()` 会为老配置补齐。
`TerminalQuickLaunch`(常驻 App 层)读该键位并用 `plugin-global-shortcut` 注册,
键位变化即重新注册;关掉该行开关 = 不注册。
- **终端内分发不处理它**:全局注册在应用前台时同样生效,两处都处理会开出两个窗口,
因此 `TerminalModule``onAction` 里是一个带注释的空分支。
#### 四、窗口首帧不再闪白(2026-09-21 第二轮追加)
现象:深色模式下打开独立窗口 / 选择窗口,会先出现一个纯白窗口,随后才变深色。
原因:两个窗口都是**可见创建**的 —— WebView 初始底色是白的,而深浅色主题要等前端
读到 `localStorage` 设置后才应用。窗口在主题生效之前就已经显示出来了。
做法(沿用本项目的既有范式,见 `translate/popup.rs` 的预创建):
| 窗口 | 创建方式 | 显示时机 |
|---|---|---|
| 会话窗口(Rust `terminal/window.rs` | `visible(false)` + `focused(false)`(新增) | `TerminalWindow.vue``onMounted``applyTheme()``show()` + `setFocus()` |
| 选择窗口(前端 `openWindow.ts` | `visible: false` + `focus: false` | `NewTerminalWindow.vue` 同上 |
同时两个窗口的 `applyTheme()` 里补了 `win.setBackgroundColor(--background 的值)`
`show()` 那一刻 DOM 可能还没绘制完,窗口底色是主题色才不会露出白底。
capability 因此多了 `core:window:allow-show``terminal-window.json`)。
`show()` 放在主题之后、会话校验之前 —— 即使后面初始化失败(会话已结束等),
窗口也已可见,错误提示才看得见。
#### 五、设置跨窗口同步与「跟随应用主题」修复(2026-09-21 第三轮追加)
峰提出三件事:独立窗口不跟随主窗口改的终端配色;其它设置要不要/能不能同步;
「跟随应用主题」开关像没生效(改应用深浅时终端不变)。
**1. 跨窗口同步(原来完全没有)**
终端设置存在 Rust 侧,而每个窗口各有一份前端副本 —— 主窗口改完,已打开的独立窗口
永远用旧值。现在:Rust 在 6 个保存命令(settings / appearance / layout / selection /
security / shortcuts)后广播 `terminal-settings`(无负载;`save_settings` 上没有 AppHandle
故在命令层发);前端 `terminalStore` 监听该事件 → `loadSettingsInner()` 重读 →
`effectiveAppearance` / 状态栏显隐等随之更新。**能同步的就是这些**:外观、布局、选择、
安全、快捷键;主机 / 密钥 / 片段等列表数据在各自面板打开时重读,不需要推送。
**2. 「跟随应用主题」此前是死开关**
`followAppTheme` 从来没被任何代码读过(主题只按 `resolveTheme(appearance.theme)` 解析),
`resolveTheme('system')` 读的是**非响应式**的 DOM class —— 应用切深浅时没人重算。
现在按 Rust 字段说明的原始意图实现:**开关打开时,所选主题只作亮/暗取色基准**
`vscode-dark↔vscode-light``solarized-dark↔solarized-light`Dracula / One Dark 只有
深色,浅色下回落 VS Code 浅色);关闭时用所选主题本身(`system` 按当时深浅解析一次)。
实现要点:新增响应式 `appIsDark``terminalThemes.ts`),由 appStore 与各独立窗口的
`applyTheme` 写入;`useSessionStream.effectiveAppearance``effectiveThemeName()` 解析出
**具体主题名**后再交给面板,于是应用改深浅 → computed 重算 → xterm 换皮肤。
**3. 顺带修掉陈旧默认值**Rust 默认 `theme``thing-dark`(一套从未实现的主题,
解析时静默回落到 vscode-dark,用户会以为「选了主题不生效」)。默认改为 `system`
`heal()` 里把不认识的 key 一律收敛到 `system`
#### 六、设置项逐个核对与修复(2026-09-21 第四轮追加)
峰报了两个具体问题(背景不透明度无效、独立窗口右键无反应),并要求核对其余设置。
逐字段核对后确认了**一批「设置了但没有任何代码读它」的死设置**,以及若干只在终端页
生效、独立窗口漏掉的行为。
**已修**
| 问题 | 原因 | 修法 |
|---|---|---|
| 背景不透明度无效 | `appearance.opacity` 从没被读过 | `allowTransparency: true`(构造时固定)+ 主题背景写成带 alpha 的 8 位 hex`themeWithOpacity`);不透明度 < 100 时**主动丢弃 WebGL**WebGL 渲染器不认 `allowTransparency`,否则看起来仍是没反应);面板容器底色同带 alpha,避免四周取整边缘颜色不一致 |
| 独立窗口右键无反应 | 右键菜单只是终端页里的一段内联标记;`main.ts` 又全局 `preventDefault` 了原生菜单 | 菜单抽成共用组件 `TerminalContextMenu.vue`,两个宿主都接 `@contextmenu`menu / paste / select-word 三种行为一致) |
| 独立窗口不尊重「显示状态栏」 | 窗口无条件渲染状态栏 | 加 `showStatusBar !== false` 守卫 |
| 独立窗口复制忽略「去掉末尾换行」 | 窗口内联硬编码 `replace(/\n+$/,'')` | 键盘与菜单统一走 `doCopy()` |
| 「面板数量上限」下拉改不动 | `<Select :model-value>` 没有 `@update:model-value` | 补上处理 |
| 字号滑杆可拖到后端不认的值 | UI 6–48,后端 heal 只认 840 → 静默改回 14 | UI 收敛到 840 |
| 快捷键表两侧不一致 | `history` 只在 Rust 有(设置页看不到,保存时会被抹掉);`detachWindow` 只在设置页有(Rust `heal()` 会剔除) | 两侧都补齐;`heal()` 只保留默认表内 action,这条约束写进了注释 |
**死设置:全部实现(同日追加)**
上一版列出的 8 项「设置了但没有代码读它」的字段,按「让设置真的驱动行为」逐项落地。
原则:**不删除用户能看见的开关**(除确实无宿主的两项),把开关接到已有链路上。
| 设置 | 修法 |
|---|---|
| `layout.confirmCloseRunning` | `closeTab()` 加守卫:关闭时**连 `sessionAlive` 查询都不发**(那是一次 IPC + 后端进程探测),直接关 |
| `layout.inheritCwd` | `newLocalTab()` 在调用方未显式传 cwd 时取**当前激活会话**的 cwd;只对**本地**会话生效 —— SSH 的 cwd 是远端路径,本地 shell 起不来(Windows 上 `/home/ops` 会被判非法目录) |
| `layout.sidebarOpen` / `sidebarWidth` | 无宿主(终端模块的侧栏是 host 面板的 tab,不是可折叠轨道)→ 从 Rust `LayoutSettings`、前端类型、设置页草稿三处**删除**;容器级 `#[serde(default)]` 让旧配置里的多余字段被忽略,无需迁移 |
| `selection.copyOnSelect` | 在 `TerminalPane`**mouseup**(仅左键)写剪贴板,复用 `trimTrailingNewline`。刻意不放 `onSelectionChange`:拖动过程中它每次变化都触发,会把一次拖选切成几十次剪贴板写入 |
| `selection.middleClickPaste` | `TerminalPane``@mousedown.capture.middle``preventDefault` 挡掉 Chromium/Windows 的「中键自动滚动」,捕获阶段 `stopPropagation` 让 xterm 的鼠标状态机完全看不到这次中键;因事件被吞,焦点由处理函数自己 emit |
| `security.hostKeyPolicy` | `check_server_key` **显式读取**该字段(此前写死走 ask)。唯一合法取值仍是 `"ask"`,其它取值按 `"ask"` 处理并记日志——安全开关的失败方向必须朝更严一侧;`heal()` 同时把非法值收敛回 `"ask"`(修数据 + 防空两道保险) |
| `security.blockOnFingerprintChange` | 关闭时「与记录不符」**不再弹确认**,改为自动 `hostkey::accept`(旧指纹进变更历史,事后仍可查出「密钥曾被悄悄换过」)+ 记一条 warn。开着时行为不变(红框阻断式确认) |
| `security.auditLog` | 原来描述与实现是两件事:字段注释写的是「输入输出落盘」,而 UI 写的是「主机密钥确认 / 指纹变更等事件写日志」。按 **UI 的口径**实现(会话输出落盘由工具栏的 `terminal_toggle_logging` 按会话显式开启,`audit` 模块的取舍说明里已论证过「不自动记录屏幕内容」)。`SshHandler::audit()` 单入口收敛五类事件(已信任 / 变更 / 变更被接受 / 首次接受 / 自动接受),命中前先判开关;`terminal_confirm_host_key` 也记一条用户的接受/拒绝(该命令新增 `app` 参数,specta 会把 `AppHandle` 从 JS 签名剔除,前端调用不变)。**默认值 `false` 改 `true`**:只写事件行、不含屏幕内容,没有隐私代价,默认开才能保证事后可查 |
顺带修掉一处陈旧文案:布局页「面板数量上限」的说明还写着「分屏功能在后续版本提供」,
而分屏早已实现,改为说明真实含义与封顶 4 的原因。
#### 验证
| 检查 | 结果 |
|---|---|
| `eslint src` | exit 0 |
| `vue-tsc --noEmit` | exit 0 |
| `cargo check`src-tauri | exit 0 |
| 待真机确认(第四轮 · 死设置) | 关闭「关闭运行中的会话前确认」后关标签不再弹窗;开启「新会话继承当前目录」后新建本地标签落在当前 cwd(SSH 会话下不继承);「选中即复制」选中后剪贴板有内容、「中键粘贴」中键一次只粘一次;关掉「指纹变更时阻断连接」后换过密钥的主机直接连上且日志有 warn;关掉「记录安全事件」后日志不再出现主机密钥事件行;布局页不再有陈旧文案 |
| 待真机确认(第三轮) | 新建/更多菜单可点开、tooltip 正常;独立窗口拖拽/最小化/最大化/关闭;Ctrl+3 从其它应用按下能唤到前台并建出窗口;终端模块禁用后 Ctrl+3 不再占用;深色模式下开窗不再闪白;主窗口改终端配色/字号后独立窗口即时同步;应用切深浅时终端画布一起变 |
## 附:P1 新增命令清单(供前端对接与后续维护)
| 命令 | 参数 | 返回 |
|---|---|---|
| `terminal_sftp_is_open` | `sessionId` | `bool` |
| `terminal_sftp_open` | `sessionId` | `ActionOutcome`(幂等) |
| `terminal_sftp_close` | `sessionId` | `()` |
| `terminal_sftp_list` | `sessionId`, `path` | `RemoteDir` |
| `terminal_sftp_parent` | `path` | `string` |
| `terminal_sftp_read_link` | `sessionId`, `path` | `string` |
| `terminal_sftp_mkdir` | `sessionId`, `path` | `()` |
| `terminal_sftp_delete` | `sessionId`, `path`, `isDir` | `ActionOutcome` |
| `terminal_sftp_rename` | `sessionId`, `from`, `to` | `()` |
| `terminal_sftp_upload` | `sessionId`, `localPath`, `remotePath` | `ActionOutcome` |
| `terminal_sftp_download` | `sessionId`, `remotePath`, `localPath` | `ActionOutcome` |
| `terminal_session_cwd_value` | `sessionId` | `string` |
| `terminal_open_local_path` | `path` | `()` |
| `terminal_reveal_local_path` | `path` | `()` |
| `terminal_list_snippets` | — | `SnippetView[]` |
| `terminal_save_snippet` | `snippet` | `SnippetView[]` |
| `terminal_delete_snippet` | `snippetId` | `SnippetView[]` |
| `terminal_render_snippet` | `snippetId`, `values` | `string` |
| `terminal_run_snippet` | `sessionId`, `snippetId`, `values`, `submit` | `ActionOutcome` |
| `terminal_restore_default_snippets` | — | `SnippetView[]` |
**新增事件**`terminal-transfer-progress`(负载 `TransferProgress`200ms 节流)。
### 追加:P1 第四 / 第五批(字符编码 + 命令历史)
| 命令 | 参数 | 返回 | 备注 |
|---|---|---|---|
| `terminal_set_encoding` | `sessionId`, `encoding` | `string` | 返回**后端 normalize 后的规范名**,前端须以此回写;内部已 emit `TERMINAL_STATE`,前端不要重复刷新 |
| `terminal_history_query` | `query: HistoryQuery` | `HistoryPage` | `keyword` 短于 3 字符自动走 `LIKE` 回退(trigram 索引对 `ls`/`cd` 无效) |
| `terminal_history_sources` | — | `HistorySource[]` | 只返回**有历史记录**的来源,不是全部主机列表 |
| `terminal_history_toggle_favorite` | `id` | `bool` | 返回切换后的状态 |
| `terminal_history_delete` | `id` | `ActionOutcome` | |
| `terminal_history_clear` | `keepFavorites: Option<bool>` | `ActionOutcome` | 省略时默认 `true`(保留收藏);`ActionOutcome` 带删除条数 |
| `terminal_history_run` | `sessionId`, `command`, `submit: Option<bool>` | `ActionOutcome` | `submit` 省略即 `false`(只填入不执行);命令内 `\r`/`\n` **折叠为空格** |
**前端配套约定**
- `SessionInfo.encoding` 是唯一编码事实源;状态栏下拉改值走 `terminal_set_encoding`,成功后回写 store 里的 `session.encoding`
- 输出侧解码在 `useXterm``decoderFor(encoding)` 缓存 `TextDecoder`);**Rust 侧永不转码**,只发原始字节的 base64。
- 输入侧重编码在 `SshSession::write`(本地会话不需要 —— Windows 控制台收的是 UTF-8UTF-16 转换由 ConPTY 负责)。
- 历史面板 `Ctrl+Shift+H`;单击**填入**、双击 / `Enter` **填入并执行**(默认不执行是刻意的取舍,理由见 §10.12)。