1230 lines
86 KiB
Markdown
1230 lines
86 KiB
Markdown
# 终端模块规划(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)**:P0–P2 全部落地(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 依赖,交叉编译友好 | 需 libssh2;Windows 下常走 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 本地 Shell(P0)
|
||
|
||
- **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 7;bash 用 `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(推荐默认)/ RSA(2048/3072/4096)/ ECDSA(P-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) 快速面板搜索里出现「打开 SSH:prod-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 # TerminalManager(Tauri State)+ 设置读写
|
||
├── settings.rs # 设置数据模型(#[serde(default)] 容器级默认)
|
||
├── commands.rs # Tauri 命令层(薄:参数整形 / 校验 / 错误归类)
|
||
├── session.rs # Session trait + SessionRegistry(DashMap)
|
||
├── 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}` |
|
||
| 设置字段 | camelCase(serde rename_all) | `maxScrollback` |
|
||
|
||
---
|
||
|
||
## 9-11. 实施记录(P0–P2 精编)
|
||
|
||
> 本节为 2026-09-18 全链路审查时按「精简」要求压缩的版本:保留全部**架构决策、
|
||
> 坑记录与语义备忘**,省略逐轮的过程性叙述与重复的验证表。按阶段分节的原始
|
||
> 详版(P0 §9 / P1 §10 / P2 §11,共 8 轮)记录在 git 历史与当日工作日志中。
|
||
|
||
### 交付总览
|
||
|
||
| 阶段 | 交付 | 状态 |
|
||
|---|---|---|
|
||
| P0 | 本地 Shell(ConPTY)、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(完整能力)**
|
||
- SFTP:russh 不含 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 落盘。
|
||
- ProxyJump:russh 无内置 → 逐跳手搭(`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 trait(E0225) | 合并 trait + blanket impl |
|
||
| 34 | russh 对 forwarded-tcpip 默认全收 | Handler 白名单覆写 |
|
||
| 35 | 连接复用后 -R 入站按 session_id 路由永不命中 | 全局端口匹配 + 唯一性 |
|
||
| 36 | **[审查轮]** 切标签销毁 xterm 实例、隐藏期输出被丢弃 | renderPanes 常驻全部会话 + v-show |
|
||
| 37 | **[审查轮]** store.write 字符串分支未编码 → 键盘输入完全失效 | TextEncoder 后 base64 |
|
||
|
||
### 全链路审查(2026-09-18,P2 收官)
|
||
|
||
探查代理 + 人工复核,确认并修复 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.00s;TerminalModule 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 只认 8–40 → 静默改回 14 | UI 收敛到 8–40 |
|
||
| 快捷键表两侧不一致 | `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-8,UTF-16 转换由 ConPTY 负责)。
|
||
- 历史面板 `Ctrl+Shift+H`;单击**填入**、双击 / `Enter` **填入并执行**(默认不执行是刻意的取舍,理由见 §10.12)。
|