From d6d052de9eb69a549d953c30e534306a1d01bd5c Mon Sep 17 00:00:00 2001 From: Xuz Date: Thu, 1 Oct 2026 07:45:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=AD=E6=8D=AE=EF=BC=88=E5=AF=86=E7=A0=81?= =?UTF-8?q?=20/=20=E5=AF=86=E9=92=A5=20/=20SSH=20Agent=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E7=9A=84=E8=AE=A4=E8=AF=81=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E5=88=86=E3=80=8C=E6=89=8B=E5=8A=A8=E8=BE=93=E5=85=A5=20/=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=87=AD=E6=8D=AE=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 凭据 - 标题栏「ShellRS」后面加第三个图标「凭据」,侧栏的第三个列表:搜索、 ↑↓ 与回车编辑、双击编辑、右键编辑 / 删除,空列表里有「新建凭据…」 - 三种:密码(用户名 + 密码)、密钥(用户名 + 本机私钥文件路径 + 口令)、 SSH Agent(用户名)。凭据带用户名,改了之后用它的主机一起变;改用户名、 类型或私钥文件会让已连接的主机重连,只改名称不会 - 密码进钥匙串,账户名 credential:<随机 keychain_id>,不用会被复用的数字 id;口令照旧按私钥文件归属 - 删除仍在用的凭据先确认并写明台数,那些主机改成「手动输入 · 自动」、 保留用户名,已打开的连接不断开,凭据的密码随之删掉 - 库升到 v9:新增 credentials 与 session_credentials 两张表(不给 sessions 加列,ALTER TABLE 会破坏「升级后与新库逐字相同」),迁移改成按 STEPS 逐级升级 主机对话框 - 「认证方式」改为「手动输入 / 使用凭据」单选。手动输入原样保留 自动 / 密码 / 私钥文件;使用凭据时选凭据,用户名和秘密都来自凭据, 「测试连接」读钥匙串里凭据保存的密码 - 密码、私钥文件、口令三个字段连同异步预填和 secret_change 抽成 session::SecretFields,主机和凭据两个对话框共用 - 地址与端口同一行的四列布局 登录 - 新的 SessionLogin:store 在 UI 线程查好凭据生成的登录快照,终端、SFTP、 端口转发的 provider 和外部 CLI 的 CliTarget 都改收它;主机和凭据的修改 按前后快照是否相等决定是否重连 - SSH Agent 凭据只走 agent,连不上、没有钥匙、服务器都不接受时明确报错; agent 位置可注入(AgentLocation),Windows 接 OpenSSH Agent 的命名管道, 手动的「自动」也走同一个 connect_agent - LoginTest 分 typed(表单说了算)和 saved(读钥匙串) 测试 - 单元:凭据模型、登录快照、v8→v9 迁移、store 的凭据接口与用户名同步、 删除规则 - 协议:密码凭据只读自己的钥匙串条目;进程内 russh agent 服务端覆盖 Agent 登录成功和几种失败 - UI:侧栏切换、新建 / 编辑 / 删除凭据、类型决定字段、主机使用凭据、 测试连接、终端拿到的登录、改用户名重连;新建主机的校验测试改为等待 错误行出现(对话框滑入时它在最底下一行) 文档:README 新增「凭据」一节;CLAUDE.md 同步架构和不变量,并更正 转发对话框「选完主机移走焦点」的描述(代码里从未有这个处理) --- AGENTS.md | 4 +- CLAUDE.md | 30 +- README.md | 32 +- src/app/actions.rs | 19 +- src/app/assets.rs | 6 +- src/app/mod.rs | 13 + src/cli/backend.rs | 19 +- src/cli/server.rs | 36 +- src/cli/tests.rs | 16 +- src/connection.rs | 88 ++-- src/credential/credential_dialog.rs | 317 ++++++++++++ src/credential/credential_panel.rs | 438 +++++++++++++++++ src/credential/mod.rs | 9 + src/explorer/explorer_panel.rs | 9 +- src/forward/manager.rs | 6 +- src/forward/protocol_tests.rs | 6 +- src/forward/transport.rs | 7 +- src/forward/worker.rs | 6 +- src/lib.rs | 5 +- src/secrets/mod.rs | 20 +- src/session/credential.rs | 328 +++++++++++++ src/session/database.rs | 382 ++++++++++++++- src/session/login.rs | 214 +++++++++ src/session/mod.rs | 6 + src/session/model.rs | 21 + src/session/secret_fields.rs | 306 ++++++++++++ src/session/session_dialog.rs | 720 ++++++++++++++-------------- src/session/store.rs | 466 ++++++++++++++++-- src/sftp/protocol_tests.rs | 4 +- src/sftp/transport.rs | 4 +- src/sftp/worker.rs | 6 +- src/ssh/connection.rs | 286 +++++++---- src/ssh/mod.rs | 4 +- src/ssh/tester.rs | 48 +- src/ssh/transport.rs | 217 ++++++++- src/terminal/terminal_panel.rs | 20 +- src/terminal/transport.rs | 12 +- src/workspace/credentials.rs | 101 ++++ src/workspace/forwards.rs | 7 +- src/workspace/mod.rs | 1 + src/workspace/sidebar.rs | 26 +- src/workspace/title_bar.rs | 16 +- src/workspace/workspace_view.rs | 10 +- tests/cli_bin.rs | 14 +- tests/ui.rs | 633 +++++++++++++++++++++++- 45 files changed, 4206 insertions(+), 732 deletions(-) create mode 100644 src/credential/credential_dialog.rs create mode 100644 src/credential/credential_panel.rs create mode 100644 src/credential/mod.rs create mode 100644 src/session/credential.rs create mode 100644 src/session/login.rs create mode 100644 src/session/secret_fields.rs create mode 100644 src/workspace/credentials.rs diff --git a/AGENTS.md b/AGENTS.md index 33aa794..a67619e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 - `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。 -- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 +- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:`)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 - `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 - `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 @@ -55,7 +55,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 - **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。 -- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。 +- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用 `password_in_use`(按查好凭据之后的登录快照 `SessionLogin` 判断)看还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。 - **id 由内存分配。** `next_session_id` / `next_group_id` 两个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。 - **状态经由 store 流动。** 面板和对话框拿到 `Entity` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 - **Dock 面板**需实现 `EventEmitter + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。 diff --git a/CLAUDE.md b/CLAUDE.md index 96cbbf5..d299af3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP **界面上叫「主机」,代码里叫 `Session`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」;它以前叫「会话」,代码标识符(`Session`、`SessionStore`、`session/` …)没有跟着改,本文件里说的「会话」指的就是它。新写的界面文案一律用「主机」,不要再出现「会话」;填 IP / 域名的那个字段叫「地址」,不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。协议术语「SSH 会话通道」不算。 -会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和会话存在同一个库里。详见 `README.md`。 +会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和会话存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串),会话可以「使用凭据」而不是自己填登录信息。详见 `README.md`。 -已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的两个图标(主机 / 端口转发)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。 +已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」分「手动输入」(原样保留 自动 / 密码 / 私钥文件,手动里不加 Agent)和「使用凭据」;密钥凭据只引用本机私钥文件的路径,不导入私钥内容;删除仍在用的凭据要确认并写明台数,删除后那些会话改成「手动输入 · 自动」、保留用户名,密码和私钥不复制过去,已打开的连接不断开;会话对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,手动的「自动」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。 做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/.md` 为准。 @@ -41,11 +41,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。 -- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 +- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`,`Session.credential` 是 `Option`)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `forward.rs`)、`login.rs`(`SessionLogin` / `LoginMethod`:查好凭据之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:会话对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、SSH、SFTP 和工作区都直接用这些类型。 -- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。 -- **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(表单当前值,秘密用 `Zeroizing`,`Debug` 打码)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 -- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。 +- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。登录参数 `SshConnectionConfig` 由 `SessionLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「自动」遇到这些静默跳过。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。 +- **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是手动输入,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 +- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。 - `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 - `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `forward/` — 端口转发。规则的模型在 `session/forward.rs`,这里是运行和界面。 @@ -55,7 +55,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `manager.rs`:`ForwardManager` 实体,持有每条在跑的规则的工作线程和状态 `ForwardStatus`,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随会话级联)就停,`ForwardSettingsChanged` / `ConnectionSettingsChanged` 就重启在跑的。 - `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 -- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `show-forwards` 两个图标的 `ButtonGroup`,有转发在跑时后者带数量)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 +- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog`:类型是 `RadioGroup::horizontal("credential-kind")`,秘密字段用 `session::SecretFields`;提交时先 `insert_credential` / `update_credential` 再 `save_secret`,顺序同会话对话框)。 +- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 - `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。 @@ -97,12 +98,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。 - **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。 - **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。 -- **侧栏是一个面板、两个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `SessionPanel` 和 `ForwardPanel` 两个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。两个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。 +- **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `SessionPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。 - **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。 - **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。 - **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。 - **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。 -- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 8。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本逐步升级(目前只有 7→8:建 `forwards` 表);已经发出去的库只改 `SCHEMA` 是拿不到新表的。新表的 DDL 写成字面量宏(`forwards_table!`),完整 schema 和迁移步骤共用同一段文字;`a_version_7_database_gains_the_forwards_table_and_keeps_its_rows` 用冻结的 v7 schema 验证升级后的表定义与新库逐字相同。 +- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 9。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本按 `STEPS` 一步接一步升级(7→8 建 `forwards` 表,8→9 建 `credentials` 和 `session_credentials` 表),一个库可以连升几级;已经发出去的库只改 `SCHEMA` 是拿不到新表的。新表的 DDL 写成字面量宏(`forwards_table!`、`credential_tables!`),完整 schema 和迁移步骤共用同一段文字;`a_version_7_database_gains_the_forwards_table_and_keeps_its_rows` 与 `a_version_8_database_gains_the_credential_tables_and_keeps_its_rows` 用冻结的旧 schema 验证升级后的表定义与新库逐字相同。**不要给已有的表 `ALTER TABLE ADD COLUMN`**:SQLite 会把新列拼在原来的右括号前面(`\n, 列定义)`),新库的 `SCHEMA` 只有照抄这种格式才能逐字相同;要加字段就像会话和凭据的关联那样另建一张表。 - **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。 - 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 @@ -127,8 +128,9 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **终端字体走全局量。** 终端模块不能依赖 settings,所以 `settings::apply` 把字体设成 `terminal::TerminalFont` 全局量并 `window.refresh()`,`TerminalView` 在 render(字体族、字号)和 prepaint(行高 `row_height`,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 `None` 表示跟随主题默认,下拉里选默认那一项也存 `None`。文件里的字体族只有装着才用(`is_font_installed`,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 `AppSettings::normalized` 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 `f64::to_string` 显示,步进 0.1 会显示 `1.7000000000000002`,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:`monospace_font_families` 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 `TerminalFontPreview` 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。 - **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。 - **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。会话树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。 -- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`。 -- **id 由内存分配。** `next_session_id` / `next_group_id` / `next_forward_id` 三个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。 +- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `SessionLogin`(`SessionStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键),不是 `Session`。`SessionLogin` 只含影响登录的字段并派生 `PartialEq`,会话 `update` 和 `update_credential` 都按「前后快照不相等」决定发不发 `ConnectionSettingsChanged`,所以只改名称不重连。引用凭据的会话在 `Session.user` 里冗余存一份凭据的用户名(`auth` 存 `Auto`、`key_path` 为空),store 在保存会话(`normalized`)、改凭据(同一个事务里改 `sessions.username`)和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `session.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时会因为缺密码停下。 +- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `SessionStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。会话换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的会话在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。会话对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `SessionDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的会话不占着端点的密码条目,会话从手动切到密码凭据时旧条目会像换了端点一样被清理;它只删端点条目,从不删凭据的。 +- **id 由内存分配。** `next_session_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`SessionStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`SessionStore::seed()` 生产环境不再使用,是 `tests/ui.rs` 的夹具。 - **对外只给 `PublicId`,不给 `SessionId`。** 会话右键「复制 ID」复制的是 `Session.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `sessions.public_id`(可为空,唯一性由索引 `sessions_public_id` 保证)。新建和复制会话时由 `SessionStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建会话时必须带上原来的 `public_id`,`update_session` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。 - **状态经由 store 流动。** 面板和对话框拿到 `Entity` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前会话的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。 - **Dock 面板**需实现 `EventEmitter + Focusable + Render + BasePanel + Panel`,一律用 `panel_handle(...)` 包装;`tab_name` 保持 `None`,由 `title` 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(`ClosableTabTitle` 的「×」只派发面板的关闭动作,见下一条)。`panel_name()` 是持久化键,一经选定不可更改。 @@ -148,8 +150,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **树的语义。** 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 `ListItem` 上的 `ClickEvent::click_count() == 2`。`TreeState::set_items` 会清空选择,`SessionPanel::rebuild_tree` 按 id 恢复选择,展开状态由它自己的 `expanded` 集合维护。对话框新建出来的节点靠 `SessionPanel` 比对 `known_groups` / `known_sessions` 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。 - **会话行的 tooltip 开在行的右侧。** 悬停会话行 500 ms 后,行右侧显示 `user@host:port`(`session_tooltip`,不显示系统),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用 `popover` 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(`Button::tooltip_placement` 那套)是 crate 私有的,普通元素的 `.tooltip()` 只会贴着鼠标弹、盖住下面的行,所以 `SessionPanel` 自己持有一个 `gpui_kit::base::TooltipOverlay`(`row_tooltip`,作为面板的子元素渲染,面板收起就一起消失;动画 `animate_row_tooltip` 仿 gpui-kit 自己的)。行在 `on_hover` 里带着行的边界 `request_show(...placement(Placement::Right))`,任意鼠标按下立即 `hide`,拖动中不显示。**只有 tooltip 的当前主人才能收起它**(`row_tooltip_owner`):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 `request_hide` 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(`the_row_tooltip_follows_the_pointer_down_the_list`)。按键会收起 tooltip 是 GPUI 的 `HoverListenerMode::InputModalityAware`:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 `canvas` 在 prepaint 记下,**必须**带 `top_0().left_0()`:`ListItem` 的内容盒不是 `relative`,不设偏移的 canvas(gpui-base 的 `ElementExt::on_prepaint` 就是这样)会落到内容下方,tooltip 随之偏下将近一行。 - **树的右键菜单挂在容器上,不挂在行上。** gpui-component 的 `ContextMenu` 在 `request_layout` 里给弹出菜单抢焦点,而树行是 `uniform_list` 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(`set_focus called more than once in a single frame`,只在开了辅助功能的调试构建里出现)。所以 `SessionPanel` 把 `.context_menu(..)` 挂在 `#session-tree` 容器上,右键命中的节点由行上的右键处理器写进一个 `Rc>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 `Table` 也是这么挂的。开始页的列表 `recent-session-list` 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 `deferred` 画的,但 `Deferred` 的子元素在主布局阶段就做 `request_layout`,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。 -- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组和端口转发三个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。 -- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」已经绕开:表单订阅 `SelectEvent::Confirm`,选完就把焦点移到监听端口(`ForwardForm::focus_bind_port`;下拉自己在发事件之后才聚焦自己,事件是延后送达的,所以我们的聚焦生效)。主机对话框的「认证方式」「分组」还没有处理(「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这两个下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。 +- **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `window.open_dialog` 都要 `.overlay_closable(false)`(默认是 `true`,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(`a_click_beside_a_dialog_does_not_close_it` 查主机、分组、端口转发和凭据四个)。`open_alert_dialog` 开的确认框本来就不会被点外面关掉。 +- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`DialogAction`)和回车都是 `window.dispatch_action(Confirm)`,先到焦点元素;gpui-base 的 `Select` 在收起状态下收到 `Confirm` 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「认证类型」「凭据」「分组」都没有绕开(`ForwardForm::focus_bind_port` 只在打开新建对话框时聚焦一次,代码里没有订阅 `SelectEvent`;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 `commit`,否则后面第一次点击会被吃掉。 - **对话框关闭后焦点会丢。** 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 `window.dispatch_action` 没有落点,要先点一下面板里的东西。UI 测试里同理(见 `groups_and_sessions_are_read_back_from_the_database`)。 - **通知层要 `deferred` 并高过对话框。** gpui-base 的对话框(连同半透明背景)是用 `deferred`(优先级 0)画的,开始页也是;通知层若作为普通子元素,就会画在对话框背景底下,只能隐约看到。所以工作区把 `Root::render_notification_layer` 包成 `deferred(..).with_priority(NOTIFICATION_PRIORITY)`(= 1,低于菜单等弹出层的 `POPUP_PRIORITY` = 100)。无头测试看不到绘制顺序(被盖住的元素照样能点中),这条只能靠肉眼确认。 - **要彩色就不能用 `Icon`。** GPUI 的 `svg()` 走 `Window::paint_svg`,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 `Icon` 会变成一坨纯色。真要彩色只能走 `img()`(`ImageSource`),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。 @@ -158,4 +160,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(会话面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。SFTP 测试的夹具会 `set_reduce_motion(true)`:对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-session`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-session` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身用不关动效的 `open_workspace_with_store`(`the_forward_diagram_keeps_showing_its_flow` 用 `advance_clock` 推进并比较圆点的位置)。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(会话面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。SFTP 测试的夹具会 `set_reduce_motion(true)`:对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-session`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-session` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身用不关动效的 `open_workspace_with_store`(`the_forward_diagram_keeps_showing_its_flow` 用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new`、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);会话对话框的认证方式在 `within("session-auth-source")` 里按序号 `0usize`(手动输入)/ `1usize`(使用凭据),凭据下拉 `session-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `session-credential-summary`。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `SessionLogin`(重连也记一次,所以能拿它数重连)。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.md b/README.md index e35b8cd..3f19270 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ # ShellRS -基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。 +基于 GPUI Kit 的中文 SSH 主机管理工具,提供持久化的主机与分组、可在多台主机间共用的登录凭据、本地终端、SSH 多终端、对标 WinSCP 的 SFTP 双栏浏览器和 SSH 端口转发。 -标题栏「ShellRS」后面的两个图标切换左侧栏显示的内容:主机列表,或端口转发列表。侧栏隐藏时点其中一个会把它重新展开。 +标题栏「ShellRS」后面的三个图标切换左侧栏显示的内容:主机列表、端口转发列表或凭据列表。侧栏隐藏时点其中一个会把它重新展开。 ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。 ## 主机列表 -左侧栏里保存的每一项是一台主机:地址、端口、用户名和认证方式。同一台机器用不同用户登录,就保存成两台主机。 +左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。 + +主机对话框的「认证方式」有两种: + +- **手动输入**:在对话框里填用户名,选认证类型「自动」「密码」或「私钥文件」。「自动」依次尝试 SSH Agent、`~/.ssh` 下的默认私钥和密码。填的密码和私钥口令保存在系统钥匙串。 +- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。 鼠标停在主机上,行的右侧会显示 `用户@地址:端口`。右键主机可连接、打开 SFTP、编辑、复制、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。 @@ -31,13 +36,30 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **状态**:行尾显示正在连接、运行中(及当前连接数)、正在重连或已停止的原因。某个连接转发不了(例如服务器连不上目标)只在该行提示,转发本身继续运行。 - **登录**:使用主机的配置、钥匙串里保存的密码和 ShellRS 自己的主机信任记录。首次连接的主机、需要输入的密码会弹出和终端一样的询问框,并注明是哪条转发在连接;取消询问等于停止这条转发。 - **断线重连**:连接中断后按 1、3、10 秒自动重连三次,重连期间本机监听端口保留,新进来的连接会被直接关闭。自动重连不弹任何询问:需要重新输入密码、确认主机密钥时停止并写明原因,手动启动即可。三次都失败也会停止,并弹出通知。 -- **修改后**:编辑运行中规则的类型、主机、监听或目标,或编辑它所经由的主机的地址和认证方式,这条转发会按新设置重新启动;只改名称或自动开启不会。 +- **修改后**:编辑运行中规则的类型、主机、监听或目标,或编辑它所经由的主机的地址和认证方式(包括这台主机所用凭据的用户名、类型或私钥文件),这条转发会按新设置重新启动;只改名称或自动开启不会。 - **删除主机**:删除主机(或包含它的分组)会连同经由它的转发规则一起删除,运行中的先停止,确认框里写明数量。 - **动态转发**支持 SOCKS5(无认证,`CONNECT`,IPv4、域名、IPv6)和 SOCKS4 / 4a。域名交给服务器解析。 - **监听地址**默认 `127.0.0.1`。填其他地址时,本地和动态转发会被同一网络中的其他设备使用;远程转发需要服务器的 `sshd_config` 设置 `GatewayPorts clientspecified` 或 `yes`,其他机器才连得上;默认是 `no`,这时转发照常运行、不报错,但只监听服务器自己的 `127.0.0.1`(和手写 `ssh -R` 一样)。对话框在填了非回环地址时会提示这一点。1024 以下的端口需要管理员(服务器上是 root)权限。不支持端口 0(由系统挑选端口)。 **尚未实现:通过外部 CLI 管理转发、规则排序。系统的「减少动态效果」设置目前不会关掉示意图的动画。** +## 凭据 + +点标题栏的凭据(钥匙)图标,左侧栏换成凭据列表。一条凭据是一套可以让多台主机共用的登录信息,包括名称、用户名和登录方式: + +| 类型 | 保存什么 | 登录时 | +| --- | --- | --- | +| 密码 | 用户名和密码 | 用凭据保存的密码;没存或被拒绝时询问 | +| 密钥 | 用户名、本机私钥文件的路径和口令 | 只用这把私钥;口令按私钥文件保存,和手动填私钥的主机共用 | +| SSH Agent | 用户名 | 只用 SSH Agent 中的密钥:macOS / Linux 上是 `SSH_AUTH_SOCK` 指向的 Agent,Windows 上是 OpenSSH Authentication Agent 服务 | + +- **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。私钥文件只保存路径,文件移动或删除后需要重新选择。 +- **主机跟着凭据变**:改凭据的用户名,所有使用它的主机的用户名一起变。改用户名、类型或私钥文件时,使用它的已连接主机会重新连接(只改名称不会);对话框里写明有几台主机在用。 +- **删除**:右键「删除」。有主机在用时确认框写明数量;删除后这些主机改为「手动输入 · 自动」,保留原来的用户名,密码和私钥不会复制过去,下次连接时按「自动」尝试或询问。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。 +- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。手动输入里的「自动」也会尝试同一个 Agent,连不上时静默跳过(Windows 上同样如此)。 + +**尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字。** + ## 设置 侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 @@ -230,7 +252,7 @@ cargo clippy --all-targets -- -D warnings cargo fmt --check ``` -数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机和端口转发规则保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,凭据使用系统钥匙串(服务名 `shellrs`)。 +数据目录默认是系统应用数据目录中的 `shellrs`,可通过 `SHELLRS_DATA_DIR` 覆盖。主机、端口转发规则和凭据(不含密码)保存在 `shellrs.db`,主机信任记录保存在该目录的 `known_hosts`,密码和私钥口令使用系统钥匙串(服务名 `shellrs`;账户名是主机端点的 `password:<用户>@<地址>:<端口>`、私钥的 `passphrase:<路径>` 和密码凭据的 `credential:<随机 ID>`)。 `src/sftp/tests.rs` 使用临时目录与故障注入覆盖上传和下载的文件内容、空目录、链接、覆盖、权限、取消、源文件变化、乱序与短读、续传及替换各阶段恢复,以及删除、重命名、新建和递归改权限不跟随链接。Unix 协议测试使用本机 OpenSSH `sftp-server`(macOS `/usr/libexec/sftp-server`,Linux `/usr/lib/openssh/sftp-server`);工作线程测试仅监听 `127.0.0.1` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。 diff --git a/src/app/actions.rs b/src/app/actions.rs index 66afe55..f5d1598 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -6,7 +6,7 @@ use gpui_kit::*; use crate::{ cli::AgentKind, explorer::{ExplorerId, FileSizeFormat}, - session::{ForwardId, GroupId, NodeDrop, SessionId, SessionNode}, + session::{CredentialId, ForwardId, GroupId, NodeDrop, SessionId, SessionNode}, terminal::{LocalTerminalId, RemoteTerminalId}, }; @@ -49,6 +49,17 @@ gpui_kit::actions!( SelectPreviousForward, /// Move the forward list's selection to the row below. SelectNextForward, + /// Show the credential list in the left dock. + ShowCredentials, + /// Open the new-credential dialog. + NewCredential, + /// Open the edit dialog of the credential selected in the credential + /// list. + EditSelectedCredential, + /// Move the credential list's selection to the row above. + SelectPreviousCredential, + /// Move the credential list's selection to the row below. + SelectNextCredential, /// Switch between the light and dark theme. ToggleTheme, /// Move keyboard focus to the search field of the list the left dock @@ -163,6 +174,12 @@ id_actions! { EditForward(ForwardId); /// Ask for confirmation, then delete a port-forwarding rule. DeleteForward(ForwardId); + + /// Open the edit-credential dialog. + EditCredential(CredentialId); + /// Ask for confirmation, then delete a credential. The hosts using it + /// go back to logging in on their own. + DeleteCredential(CredentialId); } /// Move a session-tree row by dropping it beside a peer or into a group. diff --git a/src/app/assets.rs b/src/app/assets.rs index f51c771..4c3f8e7 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -42,7 +42,11 @@ icon_assets!( Laptop, Lock, CircleAlert, - Target + Target, + KeyRound, + RectangleEllipsis, + FileKey, + UserRoundKey ] ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 85f975c..5baaa0f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -18,6 +18,8 @@ pub const SESSION_PANEL_CONTEXT: &str = "SessionPanel"; pub const RECENT_SESSIONS_CONTEXT: &str = "RecentSessions"; /// Key context of the port-forwarding list. pub const FORWARD_PANEL_CONTEXT: &str = "ForwardPanel"; +/// Key context of the credential list. +pub const CREDENTIAL_PANEL_CONTEXT: &str = "CredentialPanel"; /// Key contexts of the two SFTP file lists. pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList"; pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList"; @@ -77,6 +79,17 @@ fn key_bindings() -> Vec { KeyBinding::new("enter", ToggleSelectedForward, Some(FORWARD_PANEL_CONTEXT)), KeyBinding::new("up", SelectPreviousForward, Some(FORWARD_PANEL_CONTEXT)), KeyBinding::new("down", SelectNextForward, Some(FORWARD_PANEL_CONTEXT)), + KeyBinding::new( + "enter", + EditSelectedCredential, + Some(CREDENTIAL_PANEL_CONTEXT), + ), + KeyBinding::new( + "up", + SelectPreviousCredential, + Some(CREDENTIAL_PANEL_CONTEXT), + ), + KeyBinding::new("down", SelectNextCredential, Some(CREDENTIAL_PANEL_CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("cmd-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)), #[cfg(target_os = "macos")] diff --git a/src/cli/backend.rs b/src/cli/backend.rs index 3541763..1e3d860 100644 --- a/src/cli/backend.rs +++ b/src/cli/backend.rs @@ -8,9 +8,8 @@ use std::{ }; use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary}; -use super::server::CliBackend; +use super::server::{CliBackend, CliTarget}; use crate::connection::{ConnectionPromptKind, ConnectionPromptReply}; -use crate::session::Session; use crate::sftp::{ DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider, TransferAnswer, TransferChoice, TransferPhase, TransferProgress, TransferQuestionKind, @@ -39,11 +38,11 @@ impl SshCliBackend { /// question the way an unattended copy should. fn transfer( &self, - session: &Session, + target: &CliTarget, command: SftpCommand, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, ) -> Result { - let transport = self.sftp.create(session); + let transport = self.sftp.create(target.login()); let (commands, command_receiver) = async_channel::unbounded(); let (event_sender, events) = async_channel::unbounded(); let worker = std::thread::Builder::new() @@ -119,11 +118,11 @@ impl SshCliBackend { impl CliBackend for SshCliBackend { fn exec( &self, - session: &Session, + target: &CliTarget, command: &str, output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>, ) -> Result { - let config = SshConnectionConfig::from(session); + let config = SshConnectionConfig::from(target.login()); match run_command(&self.connector, &config, command, output) { Ok(ExecExit::Code(code)) => Ok(i32::try_from(code).unwrap_or(255)), Ok(ExecExit::Signal(signal)) => { @@ -147,25 +146,25 @@ impl CliBackend for SshCliBackend { fn upload( &self, - session: &Session, + target: &CliTarget, source: &Path, destination: &str, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, ) -> Result { let request = UploadRequest::scp(source.to_path_buf(), remote_path(destination)?); - self.transfer(session, SftpCommand::Upload(request), progress) + self.transfer(target, SftpCommand::Upload(request), progress) } fn download( &self, - session: &Session, + target: &CliTarget, source: &str, destination: &Path, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, ) -> Result { let request = DownloadRequest::scp(remote_path(source)?, destination.to_path_buf()) .map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?; - self.transfer(session, SftpCommand::Download(request), progress) + self.transfer(target, SftpCommand::Download(request), progress) } } diff --git a/src/cli/server.rs b/src/cli/server.rs index c981930..f8f1c6c 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -19,7 +19,7 @@ use super::protocol::{ CliError, Envelope, ErrorCode, FrameKind, PROTOCOL_VERSION, Reply, Request, SessionInfo, TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json, }; -use crate::session::{Session, SessionStore, matches_query}; +use crate::session::{Session, SessionLogin, SessionStore, matches_query}; use crate::ssh::ExecStream; /// Does what a CLI request asks. Every method blocks: each request has a @@ -30,7 +30,7 @@ pub trait CliBackend: Send + Sync + 'static { /// and the command should be abandoned. fn exec( &self, - session: &Session, + target: &CliTarget, command: &str, output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>, ) -> Result; @@ -40,7 +40,7 @@ pub trait CliBackend: Send + Sync + 'static { /// should stop. fn upload( &self, - session: &Session, + target: &CliTarget, source: &Path, destination: &str, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, @@ -50,23 +50,25 @@ pub trait CliBackend: Send + Sync + 'static { /// does. fn download( &self, - session: &Session, + target: &CliTarget, source: &str, destination: &Path, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, ) -> Result; } -/// A saved session as the CLI sees it. +/// A saved session as the CLI sees it, with the login it connects with +/// resolved when the target list was built. #[derive(Clone, Debug)] pub struct CliTarget { info: SessionInfo, session: Session, + login: SessionLogin, } impl CliTarget { /// `group` is the session's full group path, if it has one. - pub fn new(session: &Session, group: Option) -> Self { + pub fn new(session: &Session, login: SessionLogin, group: Option) -> Self { Self { info: SessionInfo { id: session.public_id.to_string(), @@ -78,9 +80,19 @@ impl CliTarget { os: session.os.map(|os| os.as_str().to_string()), }, session: session.clone(), + login, } } + pub fn session(&self) -> &Session { + &self.session + } + + /// How a request logs in to the session's host. + pub fn login(&self) -> &SessionLogin { + &self.login + } + /// Every session in `store`, with its group path. pub fn all(store: &SessionStore) -> Vec { store @@ -91,7 +103,11 @@ impl CliTarget { .group .map(|id| store.group_names(id)) .unwrap_or_default(); - Self::new(session, (!names.is_empty()).then(|| names.join("/"))) + Self::new( + session, + store.login_of(session), + (!names.is_empty()).then(|| names.join("/")), + ) }) .collect() } @@ -440,7 +456,7 @@ fn respond( let target = find(shared, &session)?; let code = shared .backend - .exec(&target.session, &command, &mut |stream, bytes| { + .exec(&target, &command, &mut |stream, bytes| { let kind = match stream { ExecStream::Stdout => FrameKind::Stdout, ExecStream::Stderr => FrameKind::Stderr, @@ -460,7 +476,7 @@ fn respond( } shared .backend - .upload(&target.session, &source, &destination, &mut |counters| { + .upload(&target, &source, &destination, &mut |counters| { write_json(writer, &Reply::Progress(counters)) }) .map(Reply::TransferDone) @@ -476,7 +492,7 @@ fn respond( } shared .backend - .download(&target.session, &source, &destination, &mut |counters| { + .download(&target, &source, &destination, &mut |counters| { write_json(writer, &Reply::Progress(counters)) }) .map(Reply::TransferDone) diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 69b4797..fc34ef1 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -17,7 +17,7 @@ use super::server::{CliBackend, CliServer, CliTarget}; use super::{Cli, Command}; use super::{ConsoleText, normalize_command}; use crate::app::cli_endpoint; -use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId}; +use crate::session::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionLogin}; use crate::ssh::ExecStream; /// Records what it was asked and answers from a script. @@ -29,14 +29,14 @@ struct FakeBackend { impl CliBackend for FakeBackend { fn exec( &self, - session: &Session, + target: &CliTarget, command: &str, output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>, ) -> Result { self.calls .lock() .unwrap() - .push(format!("exec {} {command}", session.name)); + .push(format!("exec {} {command}", target.session().name)); if command == "unreachable" { return Err(CliError::new(ErrorCode::HostKeyUnknown, "尚未信任")); } @@ -47,14 +47,14 @@ impl CliBackend for FakeBackend { fn upload( &self, - session: &Session, + target: &CliTarget, source: &Path, destination: &str, progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>, ) -> Result { self.calls.lock().unwrap().push(format!( "upload {} {} {destination}", - session.name, + target.session().name, source.display() )); progress(TransferCounters { @@ -75,7 +75,7 @@ impl CliBackend for FakeBackend { fn download( &self, - _: &Session, + _: &CliTarget, _: &str, _: &Path, _: &mut dyn FnMut(TransferCounters) -> io::Result<()>, @@ -107,8 +107,8 @@ fn fixture() -> Fixture { let web = session(1, "web-01", "10.0.1.12"); let db = session(2, "db-01", "10.0.2.5"); server.set_targets(vec![ - CliTarget::new(&web, Some("生产".into())), - CliTarget::new(&db, Some("生产/数据库".into())), + CliTarget::new(&web, SessionLogin::of(&web, None), Some("生产".into())), + CliTarget::new(&db, SessionLogin::of(&db, None), Some("生产/数据库".into())), ]); server.set_enabled(true); Fixture { diff --git a/src/connection.rs b/src/connection.rs index 893a53b..f315bc5 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -215,84 +215,86 @@ impl std::fmt::Debug for ConnectionPromptReply { /// What a connection test logs in with: the session form's current values, /// saved or not. Secrets stay zeroized and never print. pub struct LoginTest { - host: String, - port: u16, - user: String, - auth: crate::session::AuthKind, - key_path: Option, + login: crate::session::SessionLogin, + /// The secrets typed into the form, for a login typed there. `None` for + /// a login through a credential, which uses what the credential saved. + typed: Option, +} + +#[derive(Default)] +struct TypedSecrets { password: Option>, passphrase: Option>, } impl LoginTest { - pub fn new( - host: impl Into, - port: u16, - user: impl Into, - auth: crate::session::AuthKind, - ) -> Self { + /// A login typed into the form. The form decides its password and its + /// key's passphrase: an empty field means none, whatever is saved. + pub fn typed(login: crate::session::SessionLogin) -> Self { Self { - host: host.into(), - port, - user: user.into(), - auth, - key_path: None, - password: None, - passphrase: None, + login, + typed: Some(TypedSecrets::default()), } } - pub fn with_key_path(mut self, path: impl Into) -> Self { - self.key_path = Some(path.into()); - self + /// A login through a saved credential, with the secrets saved for it. + pub fn saved(login: crate::session::SessionLogin) -> Self { + Self { login, typed: None } } + /// The password typed into the form. Only a typed login has one. pub fn with_password(mut self, password: impl Into) -> Self { - self.password = Some(Zeroizing::new(password.into())); + if let Some(typed) = &mut self.typed { + typed.password = Some(Zeroizing::new(password.into())); + } self } + /// The passphrase typed into the form. Only a typed login has one. pub fn with_passphrase(mut self, passphrase: impl Into) -> Self { - self.passphrase = Some(Zeroizing::new(passphrase.into())); + if let Some(typed) = &mut self.typed { + typed.passphrase = Some(Zeroizing::new(passphrase.into())); + } self } + pub fn login(&self) -> &crate::session::SessionLogin { + &self.login + } pub fn host(&self) -> &str { - &self.host + &self.login.host } pub fn port(&self) -> u16 { - self.port + self.login.port } pub fn user(&self) -> &str { - &self.user + &self.login.user } - pub fn auth(&self) -> crate::session::AuthKind { - self.auth - } - pub fn key_path(&self) -> Option<&std::path::Path> { - self.key_path.as_deref() + /// Whether the form, not the keychain, decides the secrets. + pub fn is_typed(&self) -> bool { + self.typed.is_some() } pub fn password(&self) -> Option<&str> { - self.password.as_deref().map(String::as_str) + self.typed + .as_ref() + .and_then(|typed| typed.password.as_deref()) + .map(String::as_str) } pub fn passphrase(&self) -> Option<&str> { - self.passphrase.as_deref().map(String::as_str) + self.typed + .as_ref() + .and_then(|typed| typed.passphrase.as_deref()) + .map(String::as_str) } } impl std::fmt::Debug for LoginTest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LoginTest") - .field("host", &self.host) - .field("port", &self.port) - .field("user", &self.user) - .field("auth", &self.auth) - .field("key_path", &self.key_path) - .field("password", &self.password.as_ref().map(|_| "")) - .field( - "passphrase", - &self.passphrase.as_ref().map(|_| ""), - ) + .field("login", &self.login) + .field("typed", &self.is_typed()) + .field("password", &self.password().map(|_| "")) + .field("passphrase", &self.passphrase().map(|_| "")) .finish() } } diff --git a/src/credential/credential_dialog.rs b/src/credential/credential_dialog.rs new file mode 100644 index 0000000..f9a49ee --- /dev/null +++ b/src/credential/credential_dialog.rs @@ -0,0 +1,317 @@ +use gpui_kit::component::{ + ActiveTheme as _, Sizable as _, WindowExt as _, + form::{Field, Form}, + input::{Input, InputState}, + radio::{Radio, RadioGroup}, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::secrets::SecretRef; +use crate::session::{ + CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, SecretFields, SessionStore, +}; +use crate::shared::{commit_footer, form_error}; + +/// Where the SSH agent is found, as the form explains it. +#[cfg(windows)] +const AGENT_NOTE: &str = "登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。"; +#[cfg(not(windows))] +const AGENT_NOTE: &str = "登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。"; + +/// The body of the new/edit credential dialog. +pub struct CredentialForm { + store: Entity, + editing: Option, + kind: CredentialKind, + name: Entity, + user: Entity, + /// The password, key file and passphrase, shared with the host form. + fields: Entity, + /// How many hosts use the credential being edited, and how many of them + /// are connected right now. + hosts: usize, + connected: usize, + error: Option, + _subscriptions: Vec, +} + +impl CredentialForm { + pub fn new( + editing: Option, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let (credential, secrets, hosts, connected) = { + let read = store.read(cx); + let credential = editing.and_then(|id| read.credential(id)).cloned(); + let using: Vec<_> = editing + .map(|id| read.sessions_using(id).collect()) + .unwrap_or_default(); + let connected = using + .iter() + .filter(|session| session.state.is_connected()) + .count(); + (credential, read.secrets(), using.len(), connected) + }; + let draft = credential + .as_ref() + .map(|credential| credential.draft()) + .unwrap_or_else(|| CredentialDraft::new("", CredentialKind::default(), DEFAULT_USER)); + + let name = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("例如 生产环境 root") + .default_value(draft.name.clone()) + }); + let user = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(DEFAULT_USER) + .default_value(draft.user.clone()) + }); + let fields = cx.new(|cx| SecretFields::new(secrets, draft.key_path.clone(), window, cx)); + if let Some(credential) = &credential { + fields.update(cx, |fields, cx| { + fields.load_saved( + (credential.kind == CredentialKind::Password) + .then(|| credential.password_secret()), + credential.key_path.clone(), + cx, + ) + }); + } + let subscriptions = vec![cx.observe(&fields, |_, _, cx| cx.notify())]; + Self { + store, + editing, + kind: draft.kind, + name, + user, + fields, + hosts, + connected, + error: None, + _subscriptions: subscriptions, + } + } + + /// Put the keyboard where a new credential starts: its name. + fn focus_name(&self, window: &mut Window, cx: &mut Context) { + self.name.update(cx, |input, cx| input.focus(window, cx)); + } + + fn set_kind(&mut self, kind: CredentialKind, cx: &mut Context) { + if self.kind != kind { + self.kind = kind; + self.error = None; + cx.notify(); + } + } + + /// Validate and write to the store. Returns whether the dialog may close. + pub fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let fields = self.fields.read(cx); + let draft = CredentialDraft::new( + self.name.read(cx).value().to_string(), + self.kind, + self.user.read(cx).value().to_string(), + ) + .with_key_path(fields.key_path(cx)) + .validated(); + let draft = match draft { + Ok(draft) => draft, + Err(error) => { + self.error = Some(error.to_string().into()); + cx.notify(); + return false; + } + }; + // Secrets never ride along in the draft, which derives `Debug`. + let password_change = (draft.kind == CredentialKind::Password) + .then(|| fields.password_change(cx)) + .flatten(); + let passphrase_change = draft.key_path.as_ref().and_then(|path| { + Some(( + SecretRef::passphrase(path.as_ref()), + fields.passphrase_change(cx)?, + )) + }); + + let editing = self.editing; + self.store.update(cx, |store, cx| { + // The credential first: an edit that reconnects its hosts reads + // the keychain later, on the connection's own thread. + let id = match editing { + Some(id) => store.update_credential(id, draft, cx).then_some(id), + None => Some(store.insert_credential(draft, cx)), + }; + let secret = id + .and_then(|id| store.credential(id)) + .map(|credential| credential.password_secret()); + if let (Some(secret), Some(change)) = (secret, password_change) { + store.save_secret(secret, change, cx); + } + if let Some((secret, change)) = passphrase_change { + store.save_secret(secret, change, cx); + } + }); + // The dialog is about to close and take the form with it; drop the + // plaintext now rather than waiting for the entity. + self.fields + .update(cx, |fields, cx| fields.clear(window, cx)); + self.error = None; + true + } + + /// What editing the credential does to the hosts using it. + fn usage_note(&self) -> Option { + if self.hosts == 0 { + return None; + } + let mut note = format!("有 {} 台主机使用此凭据。", self.hosts); + if self.connected > 0 { + note.push_str("修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。"); + } + Some(note) + } +} + +impl Render for CredentialForm { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let kind = self.kind; + let fields = self.fields.read(cx); + let muted = cx.theme().muted_foreground; + let secret_note = match kind { + CredentialKind::Agent => None, + _ if fields.keychain_available() => { + Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。") + } + _ => Some("系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"), + }; + let form = Form::new() + .child( + Field::new().label("类型").child( + RadioGroup::horizontal("credential-kind") + .selected_index(CredentialKind::ALL.iter().position(|each| *each == kind)) + .on_change(cx.listener(|this, ix: &usize, _, cx| { + if let Some(kind) = CredentialKind::ALL.get(*ix) { + this.set_kind(*kind, cx); + } + })) + .children( + CredentialKind::ALL + .map(|each| Radio::new(each.as_str()).label(each.label()).small()), + ), + ), + ) + .child( + Field::new() + .label("名称") + .required(true) + .child(Input::new(&self.name).id("credential-name").small()), + ) + .child( + Field::new() + .label("用户名") + .child(Input::new(&self.user).id("credential-user").small()), + ); + let form = match kind { + CredentialKind::Password => form.child( + Field::new() + .label("密码") + .child(fields.password_input("credential-password")), + ), + CredentialKind::Key => form + .child(Field::new().label("私钥文件").required(true).child( + SecretFields::key_path_input( + &self.fields, + "credential-key-path", + "choose-credential-key", + cx, + ), + )) + .child( + Field::new() + .label("私钥口令") + .child(fields.passphrase_input("credential-passphrase")), + ), + CredentialKind::Agent => form, + }; + v_flex() + .gap_3() + .w_full() + .child(form) + .when(kind == CredentialKind::Agent, |view| { + view.child( + div() + .id("credential-agent-note") + .test_support() + .aria_label(AGENT_NOTE) + .text_sm() + .text_color(muted) + .child(AGENT_NOTE), + ) + }) + .when_some(secret_note, |view, note| { + view.child(div().text_sm().text_color(muted).child(note)) + }) + .when_some(self.usage_note(), |view, note| { + view.child( + div() + .id("credential-usage") + .test_support() + .aria_label(note.clone()) + .text_sm() + .text_color(muted) + .child(note), + ) + }) + .when_some(self.error.clone(), |view, error| { + view.child(form_error(error.clone(), cx).aria_label(error)) + }) + } +} + +/// Open the new-credential (`editing == None`) or edit-credential dialog. +pub fn open_credential_dialog( + editing: Option, + store: Entity, + window: &mut Window, + cx: &mut App, +) { + let form = cx.new(|cx| CredentialForm::new(editing, store, window, cx)); + let title: SharedString = if editing.is_some() { + "编辑凭据" + } else { + "新建凭据" + } + .into(); + let commit_label: SharedString = if editing.is_some() { + "保存" + } else { + "创建" + } + .into(); + window.open_dialog(cx, { + let form = form.clone(); + move |dialog, _, _| { + dialog + .title(title.clone()) + // Closed by its buttons or Escape, not by a click beside it. + .overlay_closable(false) + .child(form.clone()) + .footer(commit_footer("commit", commit_label.clone())) + .on_ok({ + let form = form.clone(); + move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx)) + }) + } + }); + // Focused in the same update that opened the dialog, which is the one + // that sticks: a later request loses to the dialog's own focus. + if editing.is_none() { + form.update(cx, |form, cx| form.focus_name(window, cx)); + } +} diff --git a/src/credential/credential_panel.rs b/src/credential/credential_panel.rs new file mode 100644 index 0000000..54b87ad --- /dev/null +++ b/src/credential/credential_panel.rs @@ -0,0 +1,438 @@ +use std::{cell::Cell, collections::HashSet, rc::Rc}; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, + button::Button, + h_flex, + input::{Input, InputEvent, InputState}, + list::ListItem, + menu::{ContextMenuExt as _, PopupMenu}, + scroll::ScrollableElement as _, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::app::{ + CREDENTIAL_PANEL_CONTEXT, CatalogIcon, DeleteCredential, EditCredential, + EditSelectedCredential, NewCredential, SelectNextCredential, SelectPreviousCredential, +}; +use crate::session::{CredentialId, CredentialKind, SessionStore, matches_credential_query}; + +/// The credential list the left dock shows in place of the sessions: every +/// saved login and how many hosts use it. +/// +/// The credentials live in the shared `SessionStore`; this panel observes it +/// and owns only its selection and search. +pub struct CredentialPanel { + store: Entity, + /// The workspace's focus handle: actions dispatched on it reach the + /// workspace handlers whatever is focused, which after a dialog closes + /// may be nothing at all. + target: FocusHandle, + search: Entity, + query: String, + selected: Option, + /// The credentials the store held at the last change, which is how one + /// just created in the dialog gets selected without the dialog reporting + /// back. + known: HashSet, + /// The row a right click landed on, for the menu about to open; `None` + /// for the blank space below the rows. + menu_hit: Rc>>, + scroll_handle: ScrollHandle, + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +/// One row of the list: a plain snapshot taken in `render`. +struct CredentialRow { + id: CredentialId, + kind: CredentialKind, + name: SharedString, + /// 「root · 密码 · 3 台主机」. + detail: SharedString, + /// The detail with the whole key path, for the tooltip. + tooltip: SharedString, +} + +impl CredentialPanel { + pub fn new( + store: Entity, + target: FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("搜索凭据") + .clean_on_escape() + }); + let known = store + .read(cx) + .credentials() + .iter() + .map(|credential| credential.id) + .collect(); + let subscriptions = vec![ + cx.observe(&store, |this, _, cx| this.on_store_changed(cx)), + cx.subscribe(&search, |this, state, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.query = state.read(cx).value().to_string(); + cx.notify(); + } + }), + ]; + Self { + store, + target, + search, + query: String::new(), + selected: None, + known, + menu_hit: Rc::new(Cell::new(None)), + scroll_handle: ScrollHandle::new(), + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + /// Select whatever credential the store just gained, and let go of a + /// selection whose credential is gone. + fn on_store_changed(&mut self, cx: &mut Context) { + let current: Vec = self + .store + .read(cx) + .credentials() + .iter() + .map(|credential| credential.id) + .collect(); + if let Some(created) = current.iter().rev().find(|id| !self.known.contains(id)) { + self.selected = Some(*created); + } + if self.selected.is_some_and(|id| !current.contains(&id)) { + self.selected = None; + } + self.known = current.into_iter().collect(); + cx.notify(); + } + + pub fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search.update(cx, |input, cx| input.focus(window, cx)); + } + + /// The credential the list has selected, if it is still there. + pub fn selected(&self) -> Option { + self.selected + } + + /// What the dock's title bar shows while this list is up. + pub fn title(&self) -> impl IntoElement + use<> { + h_flex() + .gap_1() + .child(Icon::new(CatalogIcon::KeyRound).small()) + .child("凭据") + } + + /// The dock's toolbar while this list is up. + pub fn toolbar_buttons(&self) -> Vec