diff --git a/CLAUDE.md b/CLAUDE.md index 12283cc..61aa56c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 会话和分组是**真实持久化**的,存在一个 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)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),会话可以「使用凭据」而不是自己填登录信息。详见 `README.md`。 -已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」分「手动输入」(原样保留 自动 / 密码 / 私钥文件,手动里不加 Agent)和「使用凭据」;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的会话一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些会话改成「手动输入 · 自动」、保留用户名,密码和私钥不复制过去,已打开的连接不断开;会话对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,手动的「自动」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。 +已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 10 的迁移把原来手动选私钥的会话按「用户 + 私钥文件」各建一条密钥凭据,原来的「自动」归为「密码」);密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的会话一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些会话保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;会话对话框里不做「新建凭据…」(设计规范不叠弹层);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` 为准。 @@ -43,8 +43,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `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`,`Session.credential` 是 `Option`)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`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。登录参数 `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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 +- `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`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `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 写锁。 @@ -55,7 +55,7 @@ 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)加本机回显服务,驱动真实的工作线程。 -- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `RadioGroup::horizontal("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `session::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `SessionStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 +- `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `session::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `SessionStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 - `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` 命令,以及它在应用里的服务端。 @@ -65,7 +65,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。 - `server.rs`:`CliServer` 与可注入的 `CliBackend` trait;`backend.rs` 是真实实现 `SshCliBackend`(exec 走 `ssh::run_command`,传输走 `sftp_provider`)。 - `install.rs`:把 `shellrs` 放进 PATH(macOS / Linux 是符号链接)、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 `IntegrationPaths`;`SKILL.md` 是 skill 正文(`include_str!`)。`install_windows.rs`:Windows 的做法(复制 + 用户 PATH),`cfg(any(windows, test))`,所以在 Mac 上也跑它的测试。`integration.rs`:设置页用的状态实体 `CliIntegration`。 -- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`close_tab_items`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 +- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 关键流程与不变量: @@ -103,7 +103,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **`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` 是 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` 只有照抄这种格式才能逐字相同;要加字段就像会话和凭据的关联那样另建一张表。 +- **改 schema 要升版本并加一步迁移。** `SCHEMA_VERSION` 是 10。`migrate` 对版本 0 建完整的 `SCHEMA`,对旧版本按 `STEPS` 一步接一步升级(7→8 建 `forwards` 表,8→9 建 `credentials` 和 `session_credentials` 表,9→10 是只改数据的 `Step::Code`:`keys_into_credentials` 把会话自己的私钥文件变成密钥凭据、`auto` 变成 `password`,表定义不变,`sessions.key_path` 列留着、一律为空,因为删列要重建表),一个库可以连升几级;一步改不了表就用 `Step::Sql`,要在 Rust 里算(随机 `keychain_id`、取文件名)就用 `Step::Code`,两种都在一个事务里,最后写 `user_version`;已经发出去的库只改 `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` 与 `a_version_9_database_moves_host_keys_into_credentials` 用冻结的旧 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 的管道读不能设超时。 @@ -128,9 +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`),因为整行已经有一个,两个会叠在一起。 -- **登录快照在 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` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时会因为缺密码停下。 +- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `SessionLogin`(`SessionStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键),不是 `Session`。`SessionLogin` 只含影响登录的字段并派生 `PartialEq`,会话 `update` 和 `update_credential` 都按「前后快照不相等」决定发不发 `ConnectionSettingsChanged`,所以只改名称不重连。引用凭据的会话在 `Session.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`),store 在保存会话(`normalized`)、改凭据(同一个事务里改 `sessions.username`)和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `session.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。 - **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `SessionStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或会话在用时原地覆盖:路径不变,用它的会话不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。 -- **秘密只进钥匙串,不进数据库。** `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` 按解析后的登录快照判断,所以用密码凭据的会话不占着端点的密码条目,会话从手动切到密码凭据时旧条目会像换了端点一样被清理;它只删端点条目,从不删凭据的。 +- **秘密只进钥匙串,不进数据库。** `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`,工作区据此清理注册表。 @@ -151,8 +151,9 @@ 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,不会触发上面的断言。 +- **表单里的几选一用 `shared::SegmentedControl`。** 用户给的样式:背景色的轨道加 `border` 细边框,选中的一段是 `accent` 底色、比轨道深一级的细边框(`border` 混入 8% 的 `foreground`,浅色主题约 neutral-300,深色主题反过来变亮)和轻阴影的块(截图里的灰太深,`secondary_active` 是 neutral-300,用户要浅一些;`accent` 浅色主题是 neutral-100,深色是 neutral-800),没选中的是 `muted_foreground` 字、悬停时半透明的 `accent` 底,比选中的淡;各段等宽铺满轨道(`flex_1`),高度是中号控件的 `h_8`,选中块的圆角按 2 px 内缩与轨道同心。行为全来自 gpui-base 的 `Radio` / `RadioGroup`(点击、回车 / 空格、每段一个 Tab 停靠点、无障碍角色),这里只管外观;每段的元素 id 是它的序号,和 gpui-component 的 `RadioGroup` 一样,所以测试照旧 `within(组).click(1usize)`。gpui-base 的 `RadioGroup` 是 `RenderOnce`,不能 `test_support()`,测试里找不到轨道本身,只能量各段。主机对话框的认证方式、凭据对话框的类型 / 私钥来源 / 算法都用它;端口转发的三张类型卡片带说明和示意图,仍是 gpui-component 的 `RadioGroup`。 - **对话框点外面一律不关。** 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 `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`,否则后面第一次点击会被吃掉。 +- **下拉框攥着焦点时,提交会把它弹开。** 对话框的提交按钮(`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」,不是一张彩图。 @@ -161,4 +162,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`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `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)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_session_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_sessions_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`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)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;会话对话框的认证方式在 `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` 初始为已连接并已打开终端标签。 +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`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-password`、「无密码」下面的说明 `session-no-password-note`、`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)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_session_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_sessions_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`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)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;会话对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("session-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `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 4840a30..5e1d0ef 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,13 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。 -主机对话框的「认证方式」有两种: +主机对话框的「认证方式」有三种: -- **手动输入**:在对话框里填用户名,选认证类型「自动」「密码」或「私钥文件」。「自动」依次尝试 SSH Agent、`~/.ssh` 下的默认私钥和密码。填的密码和私钥口令保存在系统钥匙串。 -- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。 +- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。 +- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。 +- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。 + +无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。旧版本里「自动」的主机升级后归为「密码」,自己选了私钥文件的主机升级后改用按「用户 + 私钥文件」自动建好的密钥凭据。 鼠标停在主机上,行的右侧会显示 `用户@地址:端口`。右键主机可连接、打开 SFTP、编辑、复制、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。 @@ -50,7 +53,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 | 类型 | 保存什么 | 登录时 | | --- | --- | --- | | 密码 | 用户名和密码 | 用凭据保存的密码;没存或被拒绝时询问 | -| 密钥 | 用户名、私钥和口令 | 只用这把私钥;口令按私钥文件保存,和手动填私钥的主机共用 | +| 密钥 | 用户名、私钥和口令 | 只用这把私钥;口令按私钥文件保存,用同一个文件的凭据共用 | | SSH Agent | 用户名 | 只用 SSH Agent 中的密钥:macOS / Linux 上是 `SSH_AUTH_SOCK` 指向的 Agent,Windows 上是 OpenSSH Authentication Agent 服务 | - **新建和编辑**:列表标题栏的「+」、空列表里的「新建凭据…」或空白处的右键菜单打开对话框;双击凭据、选中后回车或右键「编辑凭据…」编辑。密码和私钥口令存进系统钥匙串,数据库里只有名称、用户名、类型和私钥路径。 @@ -61,8 +64,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - 粘贴和生成的私钥由 ShellRS 写成数据目录下 `keys/` 里的文件(随机文件名,只有自己的账户能读),凭据引用这个文件,编辑时显示为「本机文件」和它的路径。之后再粘贴或生成会直接覆盖这个文件,已连接的主机不会因此重连;改成别的文件或别的类型、或删除凭据时,这个文件连同它的口令一起删除,对话框会先提示。用户自己的私钥文件 ShellRS 从不改动或删除。 - 空列表和空白处的右键菜单里另有「生成密钥…」,打开的是同一个对话框,已经选好「密钥 · 生成新密钥」。密钥凭据的右键菜单有「复制公钥」,用来把公钥加到服务器的 `~/.ssh/authorized_keys`。 - **主机跟着凭据变**:改凭据的用户名,所有使用它的主机的用户名一起变。改用户名、类型或私钥文件时,使用它的已连接主机会重新连接(只改名称不会);对话框里写明有几台主机在用。 -- **删除**:右键「删除」。有主机在用时确认框写明数量;删除后这些主机改为「手动输入 · 自动」,保留原来的用户名,密码和私钥不会复制过去,下次连接时按「自动」尝试或询问。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。 -- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。手动输入里的「自动」也会尝试同一个 Agent,连不上时静默跳过(Windows 上同样如此)。 +- **删除**:右键「删除」。有主机在用时确认框写明数量和它们之后怎么登录:用密码凭据的主机改为「密码」,连接时询问;用密钥或 SSH Agent 凭据的主机改为「无密码」。用户名不变,密码和私钥不会复制过去。已经打开的连接不受影响,凭据的密码随凭据一起从钥匙串删除。 +- **SSH Agent 凭据**连不上 Agent、Agent 里没有密钥或服务器不接受其中任何一把时,会直接说明原因,不会改为询问密码。「无密码」的主机也会尝试同一个 Agent,连不上时静默跳过,接着试默认私钥(Windows 上同样如此)。 **尚未实现:在主机对话框里直接新建凭据、通过外部 CLI 管理凭据、凭据排序、自定义 SSH Agent 的套接字、把公钥自动装到主机上、导出 ShellRS 保存的私钥。** diff --git a/src/cli/tests.rs b/src/cli/tests.rs index fc34ef1..f184344 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -87,7 +87,7 @@ impl CliBackend for FakeBackend { fn session(id: u64, name: &str, host: &str) -> Session { Session::new( SessionId(id), - SessionDraft::new(name, host, 22, "root", AuthKind::Auto, Some(GroupId(1))), + SessionDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))), ) } diff --git a/src/credential/credential_dialog.rs b/src/credential/credential_dialog.rs index f48229a..14c6451 100644 --- a/src/credential/credential_dialog.rs +++ b/src/credential/credential_dialog.rs @@ -7,7 +7,6 @@ use gpui_kit::component::{ form::{Field, Form}, h_flex, input::{Input, InputEvent, InputState, Textarea, TextareaState}, - radio::{Radio, RadioGroup}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; @@ -19,7 +18,7 @@ use crate::session::{ CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, SessionStore, read_public_key, }; -use crate::shared::{commit_footer, form_error}; +use crate::shared::{Segment, SegmentedControl, commit_footer, form_error}; /// Where the SSH agent is found, as the form explains it. #[cfg(windows)] @@ -63,14 +62,6 @@ impl KeySource { KeySource::Generate => "生成新密钥", } } - - fn id(self) -> &'static str { - match self { - KeySource::File => "file", - KeySource::Paste => "paste", - KeySource::Generate => "generate", - } - } } /// The key the form is generating or has generated. @@ -602,22 +593,19 @@ impl CredentialForm { .as_ref() .is_some_and(|kept| fields.key_path(cx) == kept.as_ref()); - let sources = RadioGroup::horizontal("credential-key-source") + let sources = SegmentedControl::new("credential-key-source") .selected_index(KeySource::ALL.iter().position(|each| *each == source)) .on_change(cx.listener(|this, ix: &usize, window, cx| { if let Some(source) = KeySource::ALL.get(*ix) { this.set_source(*source, window, cx); } })) - .children(KeySource::ALL.map(|each| { - Radio::new(each.id()).label(each.label()).small().when( - each != KeySource::File && !can_keep, - |radio| { - radio - .disabled(true) - .tooltip("本地数据库没有打开,这次运行无法保存私钥") - }, - ) + .segments(KeySource::ALL.map(|each| { + Segment::new(each.label()).when(each != KeySource::File && !can_keep, |segment| { + segment + .disabled(true) + .tooltip("本地数据库没有打开,这次运行无法保存私钥") + }) })); // A generated key has nothing to fill in under the choice. let key = match source { @@ -658,7 +646,7 @@ impl CredentialForm { Field::new() .label("算法") .child( - RadioGroup::horizontal("credential-key-algorithm") + SegmentedControl::new("credential-key-algorithm") .selected_index( KeyAlgorithm::ALL .iter() @@ -669,11 +657,7 @@ impl CredentialForm { this.set_algorithm(*algorithm, cx); } })) - .children( - KeyAlgorithm::ALL.map(|each| { - Radio::new(each.label()).label(each.label()).small() - }), - ), + .segments(KeyAlgorithm::ALL.map(|each| Segment::new(each.label()))), ) .when(self.algorithm == KeyAlgorithm::Rsa, |field| { field.description("只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。") @@ -774,17 +758,14 @@ impl Render for CredentialForm { let form = Form::new() .child( Field::new().label("类型").child( - RadioGroup::horizontal("credential-kind") + SegmentedControl::new("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()), - ), + .segments(CredentialKind::ALL.map(|each| Segment::new(each.label()))), ), ) .child( diff --git a/src/session/credential.rs b/src/session/credential.rs index bbadcf9..79e3dd1 100644 --- a/src/session/credential.rs +++ b/src/session/credential.rs @@ -10,8 +10,8 @@ use gpui_kit::SharedString; use crate::secrets::SecretRef; -use super::PublicId; use super::private_key::is_kept_in; +use super::{AuthKind, PublicId}; /// Stable identity of a credential. Never reused within a process. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -67,6 +67,17 @@ impl CredentialKind { _ => None, } } + + /// How a host that used a credential of this kind logs in once the + /// credential is deleted: the nearest it gets on its own. A password + /// credential's host is asked for its password; a key's or the agent's + /// tries the agent and the default keys. + pub fn without_credential(self) -> AuthKind { + match self { + CredentialKind::Password => AuthKind::Password, + CredentialKind::Key | CredentialKind::Agent => AuthKind::NoPassword, + } + } } /// Why a draft cannot be saved. `Display` is the text the form shows. diff --git a/src/session/database.rs b/src/session/database.rs index ba3df9a..7914932 100644 --- a/src/session/database.rs +++ b/src/session/database.rs @@ -5,7 +5,10 @@ //! The store keeps memory as the source of truth and mirrors each change //! here, so a failed write costs the user persistence, never the edit. -use std::{collections::HashSet, path::Path}; +use std::{ + collections::{HashMap, HashSet}, + path::Path, +}; use rusqlite::{Connection, params}; @@ -31,7 +34,7 @@ fn from_sql(id: i64) -> u64 { /// /// The steps start at 7 rather than 1 because the databases of development /// builds were already at 7 when the older steps were folded into `SCHEMA`. -const SCHEMA_VERSION: i64 = 9; +const SCHEMA_VERSION: i64 = 10; /// The port-forwarding rules, added in version 8. A macro rather than a /// constant so that `SCHEMA` and the step from version 7 are built from the @@ -90,25 +93,34 @@ CREATE INDEX session_credentials_credential_id ON session_credentials(credential }; } -/// The steps from each older version to the next, in order. Each is one -/// transaction that ends by recording the version it reached. -const STEPS: [(i64, &str); 2] = [ +/// One step from a version to the next: one transaction that ends by +/// recording the version it reached. +enum Step { + /// Statements that make their own transaction. + Sql(&'static str), + /// A change of rows SQL alone does not express well. + Code(fn(&Connection) -> rusqlite::Result<()>), +} + +/// The steps from each older version to the next, in order. +const STEPS: [(i64, Step); 3] = [ ( 7, - concat!( + Step::Sql(concat!( "BEGIN;\n", forwards_table!(), "\nPRAGMA user_version = 8;\nCOMMIT;" - ), + )), ), ( 8, - concat!( + Step::Sql(concat!( "BEGIN;\n", credential_tables!(), "\nPRAGMA user_version = 9;\nCOMMIT;" - ), + )), ), + (9, Step::Code(keys_into_credentials)), ]; /// The whole schema, as a new database gets it. @@ -222,7 +234,7 @@ impl SessionDatabase { .connection .prepare( "SELECT s.id, s.name, s.host, s.port, s.username, s.auth, s.group_id, \ - s.key_path, s.os, s.sort_order, s.public_id, c.credential_id \ + s.os, s.sort_order, s.public_id, c.credential_id \ FROM sessions s LEFT JOIN session_credentials c ON c.session_id = s.id \ ORDER BY s.id", )? @@ -234,9 +246,8 @@ impl SessionDatabase { let user: String = row.get(4)?; let auth: String = row.get(5)?; let group: Option = row.get(6)?; - let key_path: Option = row.get(7)?; - let os: Option = row.get(8)?; - let credential: Option = row.get(11)?; + let os: Option = row.get(7)?; + let credential: Option = row.get(10)?; let mut session = Session::new( SessionId(from_sql(id)), SessionDraft::new( @@ -246,12 +257,11 @@ impl SessionDatabase { user, AuthKind::from_stored(&auth), group.map(|id| GroupId(from_sql(id))), - ) - .with_optional_key_path(key_path), + ), ); session.os = os.as_deref().and_then(HostOs::from_stored); - session.sort_order = row.get(9)?; - session.public_id = PublicId::from_stored(row.get(10)?); + session.sort_order = row.get(8)?; + session.public_id = PublicId::from_stored(row.get(9)?); session.credential = credential.map(|id| CredentialId(from_sql(id))); Ok(session) })? @@ -421,8 +431,8 @@ impl SessionDatabase { pub fn insert_session(&self, session: &Session) -> rusqlite::Result<()> { let transaction = self.connection.unchecked_transaction()?; transaction.execute( - "INSERT INTO sessions (id, name, host, port, username, auth, group_id, key_path, os, sort_order, public_id) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO sessions (id, name, host, port, username, auth, group_id, os, sort_order, public_id) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ to_sql(session.id.0), session.name.as_ref(), @@ -431,7 +441,6 @@ impl SessionDatabase { session.user.as_ref(), session.auth.as_str(), session.group.map(|group| to_sql(group.0)), - session.key_path.as_deref(), session.os.map(HostOs::as_str), session.sort_order, session.public_id.as_str(), @@ -449,7 +458,7 @@ impl SessionDatabase { let transaction = self.connection.unchecked_transaction()?; transaction.execute( "UPDATE sessions SET name = ?2, host = ?3, port = ?4, username = ?5, \ - auth = ?6, group_id = ?7, key_path = ?8, sort_order = ?9 WHERE id = ?1", + auth = ?6, group_id = ?7, sort_order = ?8 WHERE id = ?1", params![ to_sql(session.id.0), session.name.as_ref(), @@ -458,7 +467,6 @@ impl SessionDatabase { session.user.as_ref(), session.auth.as_str(), session.group.map(|group| to_sql(group.0)), - session.key_path.as_deref(), session.sort_order, ], )?; @@ -645,14 +653,14 @@ impl SessionDatabase { } /// Delete a credential. The sessions using it go back to logging in on - /// their own, automatically, keeping the user name they had; the links - /// go through `ON DELETE CASCADE`. - pub fn remove_credential(&self, id: CredentialId) -> rusqlite::Result<()> { + /// their own with `auth`, keeping the user name they had; the links go + /// through `ON DELETE CASCADE`. + pub fn remove_credential(&self, id: CredentialId, auth: AuthKind) -> rusqlite::Result<()> { let transaction = self.connection.unchecked_transaction()?; transaction.execute( - "UPDATE sessions SET auth = ?2, key_path = NULL WHERE id IN \ + "UPDATE sessions SET auth = ?2 WHERE id IN \ (SELECT session_id FROM session_credentials WHERE credential_id = ?1)", - params![to_sql(id.0), AuthKind::Auto.as_str()], + params![to_sql(id.0), auth.as_str()], )?; transaction.execute( "DELETE FROM credentials WHERE id = ?1", @@ -706,13 +714,86 @@ fn migrate(connection: &Connection) -> rusqlite::Result<()> { } for (from, step) in STEPS { if version == from { - connection.execute_batch(step)?; + match step { + Step::Sql(sql) => connection.execute_batch(sql)?, + Step::Code(change) => change(connection)?, + } version = from + 1; } } Ok(()) } +/// The step from version 9: a host logs in on its own with a password or +/// with nothing typed, and a private key is always a credential's. +/// +/// A host that picked a key file of its own now uses a key credential for +/// that file and user, one for each pair, named after the file. `auto` +/// hosts become password hosts, asked for one when none is saved. The +/// `key_path` column stays, empty: dropping a column rebuilds the table. +fn keys_into_credentials(connection: &Connection) -> rusqlite::Result<()> { + let transaction = connection.unchecked_transaction()?; + let keyed = transaction + .prepare( + "SELECT id, username, key_path FROM sessions s \ + WHERE auth = 'key' AND key_path IS NOT NULL AND trim(key_path) <> '' \ + AND NOT EXISTS (SELECT 1 FROM session_credentials c WHERE c.session_id = s.id) \ + ORDER BY id", + )? + .query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()?; + let mut keychain_ids = transaction + .prepare("SELECT keychain_id FROM credentials")? + .query_map([], |row| row.get::<_, String>(0).map(PublicId::from_stored))? + .collect::>>()?; + let mut sort_order: i64 = transaction.query_row( + "SELECT COALESCE(MAX(sort_order), -1) FROM credentials", + [], + |row| row.get(0), + )?; + let mut made: HashMap<(String, String), i64> = HashMap::new(); + for (session, user, key_path) in keyed { + let pair = (user, key_path); + let credential = match made.get(&pair) { + Some(credential) => *credential, + None => { + let (user, key_path) = &pair; + let name = Path::new(key_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(key_path); + let keychain_id = PublicId::generate_unused(|id| keychain_ids.contains(id)); + sort_order += 1; + transaction.execute( + "INSERT INTO credentials (keychain_id, name, kind, username, key_path, sort_order) \ + VALUES (?1, ?2, 'key', ?3, ?4, ?5)", + params![keychain_id.as_str(), name, user, key_path, sort_order], + )?; + keychain_ids.insert(keychain_id); + let credential = transaction.last_insert_rowid(); + made.insert(pair.clone(), credential); + credential + } + }; + transaction.execute( + "INSERT INTO session_credentials (session_id, credential_id) VALUES (?1, ?2)", + params![session, credential], + )?; + } + transaction.execute_batch( + "UPDATE sessions SET auth = 'password' WHERE auth <> 'no-password';\n\ + UPDATE sessions SET key_path = NULL;\n\ + PRAGMA user_version = 10;", + )?; + transaction.commit() +} + /// Give every session without a [`PublicId`] one, which is any row added by /// hand with `sqlite3`. Runs at every open; once every session has an id it /// is one query that finds nothing. @@ -916,10 +997,10 @@ PRAGMA user_version = 8;"; assert_eq!(version, SCHEMA_VERSION); let data = db.load().unwrap(); assert_eq!(data.sessions[0].user.as_ref(), "deploy"); - assert_eq!(data.sessions[0].auth, AuthKind::Key); - assert_eq!(data.sessions[0].credential, None); assert_eq!(data.forwards.len(), 1); - assert!(data.credentials.is_empty()); + // On through version 10, its key file became a credential. + assert_eq!(data.credentials.len(), 1); + assert_eq!(data.sessions[0].credential, Some(data.credentials[0].id)); let fresh = SessionDatabase::in_memory().unwrap(); assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); @@ -927,6 +1008,103 @@ PRAGMA user_version = 8;"; assert!(SessionDatabase::open(&path).is_ok()); } + /// The credential tables version 9 added, frozen like `SCHEMA_V7`. + const SCHEMA_V9_CREDENTIALS: &str = " +CREATE TABLE credentials ( + id INTEGER PRIMARY KEY, + keychain_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('password', 'key', 'agent')), + username TEXT NOT NULL, + key_path TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + CHECK ((kind = 'key') = (key_path IS NOT NULL)) +); +CREATE TABLE session_credentials ( + session_id INTEGER PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + credential_id INTEGER NOT NULL REFERENCES credentials(id) ON DELETE CASCADE +); +CREATE INDEX session_credentials_credential_id ON session_credentials(credential_id); +PRAGMA user_version = 9;"; + + #[test] + fn a_version_9_database_moves_host_keys_into_credentials() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + { + let old = Connection::open(&path).unwrap(); + old.execute_batch(SCHEMA_V7).unwrap(); + old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); + old.execute_batch(SCHEMA_V9_CREDENTIALS).unwrap(); + old.execute_batch( + "INSERT INTO credentials (id, keychain_id, name, kind, username, sort_order) \ + VALUES (1, 'kept0000kept0000', 'agent', 'agent', 'me', 0); + INSERT INTO sessions (id, name, host, port, username, auth, key_path, public_id) VALUES + (1, 'auto', 'a.test', 22, 'root', 'auto', NULL, 'p000000000000001'), + (2, 'web', 'b.test', 22, 'deploy', 'key', '/home/me/.ssh/id_deploy', 'p000000000000002'), + (3, 'api', 'c.test', 22, 'deploy', 'key', '/home/me/.ssh/id_deploy', 'p000000000000003'), + (4, 'ops', 'd.test', 22, 'root', 'key', '/home/me/.ssh/id_deploy', 'p000000000000004'), + (5, 'db', 'e.test', 22, 'postgres', 'password', NULL, 'p000000000000005'), + (6, 'jump', 'f.test', 22, 'me', 'auto', NULL, 'p000000000000006'); + INSERT INTO session_credentials (session_id, credential_id) VALUES (6, 1);", + ) + .unwrap(); + } + + let db = SessionDatabase::open(&path).unwrap(); + let data = db.load().unwrap(); + // One key credential for each user and key file, named after the + // file, after the credential that was there. + let names: Vec<_> = data + .credentials + .iter() + .map(|credential| (credential.name.as_ref(), credential.user.as_ref())) + .collect(); + assert_eq!( + names, + [ + ("agent", "me"), + ("id_deploy", "deploy"), + ("id_deploy", "root") + ] + ); + let deploy = &data.credentials[1]; + assert_eq!(deploy.kind, CredentialKind::Key); + assert_eq!(deploy.key_path.as_deref(), Some("/home/me/.ssh/id_deploy")); + assert_ne!(deploy.keychain_id, data.credentials[2].keychain_id); + let uses: Vec<_> = data.sessions.iter().map(|s| s.credential).collect(); + assert_eq!( + uses, + [ + None, + Some(deploy.id), + Some(deploy.id), + Some(data.credentials[2].id), + None, + Some(CredentialId(1)), + ] + ); + // Everything left logs in with a password, asked for when none is + // saved; no host keeps a key file of its own. + assert!(data.sessions.iter().all(|s| s.auth == AuthKind::Password)); + let key_files: i64 = db + .connection + .query_row( + "SELECT COUNT(*) FROM sessions WHERE key_path IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(key_files, 0); + let fresh = SessionDatabase::in_memory().unwrap(); + assert_eq!(schema_of(&db.connection), schema_of(&fresh.connection)); + + // Opening it again finds nothing left to do. + drop(db); + let again = SessionDatabase::open(&path).unwrap().load().unwrap(); + assert_eq!(again.credentials.len(), 3); + } + fn credential(id: u64, name: &str, kind: CredentialKind) -> Credential { let draft = CredentialDraft::new(name, kind, "deploy"); let draft = match kind { @@ -939,7 +1117,7 @@ PRAGMA user_version = 8;"; fn using(mut session: Session, credential: &Credential) -> Session { session.credential = Some(credential.id); session.user = credential.user.clone(); - session.auth = AuthKind::Auto; + session.auth = AuthKind::Password; session } @@ -993,23 +1171,19 @@ PRAGMA user_version = 8;"; } #[test] - fn removing_a_credential_turns_its_hosts_into_auto_and_drops_the_link() { + fn removing_a_credential_leaves_its_hosts_on_their_own_and_drops_the_link() { let db = SessionDatabase::in_memory().unwrap(); let key = credential(1, "部署", CredentialKind::Key); db.insert_credential(&key).unwrap(); - let mut web = using(session(1, "web", None), &key); - // Against the invariant, to show the delete does not rely on it. - web.auth = AuthKind::Key; - web.key_path = Some("/tmp/id_other".into()); - db.insert_session(&web).unwrap(); + db.insert_session(&using(session(1, "web", None), &key)) + .unwrap(); db.insert_session(&session(2, "db", None)).unwrap(); - db.remove_credential(key.id).unwrap(); + db.remove_credential(key.id, AuthKind::NoPassword).unwrap(); let data = db.load().unwrap(); assert!(data.credentials.is_empty()); assert_eq!(data.sessions[0].credential, None); - assert_eq!(data.sessions[0].auth, AuthKind::Auto); - assert_eq!(data.sessions[0].key_path, None); + assert_eq!(data.sessions[0].auth, AuthKind::NoPassword); assert_eq!(data.sessions[0].user.as_ref(), "deploy"); // A host that never used it is untouched. assert_eq!(data.sessions[1].auth, AuthKind::Password); @@ -1141,15 +1315,13 @@ PRAGMA user_version = 8;"; let mut moved = session(1, "web-01", Some(2)); moved.port = 2222; - moved.auth = AuthKind::Key; - moved.key_path = Some("/tmp/test-key".into()); + moved.auth = AuthKind::NoPassword; db.update_session(&moved).unwrap(); db.update_group(&group(2, "预发", None)).unwrap(); let data = db.load().unwrap(); assert_eq!(data.sessions[0].port, 2222); - assert_eq!(data.sessions[0].auth, AuthKind::Key); - assert_eq!(data.sessions[0].key_path.as_deref(), Some("/tmp/test-key")); + assert_eq!(data.sessions[0].auth, AuthKind::NoPassword); assert_eq!(data.sessions[0].group, Some(GroupId(2))); assert_eq!(data.groups[1].name.as_ref(), "预发"); } @@ -1304,7 +1476,7 @@ PRAGMA user_version = 8;"; let db = SessionDatabase::in_memory().unwrap(); let session = Session::new( SessionId(1), - SessionDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Auto, None), + SessionDraft::new("web", "10.0.0.1", 22, "root", AuthKind::Password, None), ); db.insert_session(&session).unwrap(); assert_eq!(db.load().unwrap().sessions[0].os, None); diff --git a/src/session/login.rs b/src/session/login.rs index ea323e2..668e127 100644 --- a/src/session/login.rs +++ b/src/session/login.rs @@ -9,12 +9,16 @@ use crate::secrets::SecretRef; use super::{AuthKind, Credential, CredentialKind, Session}; -/// Which authentication methods a login tries. +/// Which authentication methods a login tries. Each one starts by asking +/// the server to let the user in with nothing at all, which a server without +/// authentication grants. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LoginMethod { - /// The SSH agent, then the default keys in `~/.ssh`, then a password. - Auto, + /// A password, saved or asked for. Password, + /// Whatever needs nothing typed: the SSH agent, then the default keys in + /// `~/.ssh`. A server that wants a password is refused, not asked. + NoPassword, /// One private key file. Key, /// Only the SSH agent's keys. @@ -35,26 +39,24 @@ pub struct SessionLogin { /// method. pub key_path: Option, /// The keychain entry the password step reads: the endpoint's own, or a - /// password credential's. Key and agent logins keep the endpoint's for a + /// password credential's. Every other login keeps the endpoint's for a /// server that asks for a password after the key. pub password: SecretRef, } impl SessionLogin { - /// A login typed into the host form rather than taken from a credential. + /// A login of the host's own rather than taken from a credential. pub fn manual( host: impl Into, port: u16, user: impl Into, auth: AuthKind, - key_path: Option, ) -> Self { let host = host.into(); let user = user.into(); - let (method, key_path) = match auth { - AuthKind::Auto => (LoginMethod::Auto, None), - AuthKind::Password => (LoginMethod::Password, None), - AuthKind::Key => (LoginMethod::Key, key_path), + let method = match auth { + AuthKind::Password => LoginMethod::Password, + AuthKind::NoPassword => LoginMethod::NoPassword, }; Self { password: SecretRef::password(&user, &host, port), @@ -62,7 +64,7 @@ impl SessionLogin { port, user, method, - key_path, + key_path: None, } } @@ -106,7 +108,6 @@ impl SessionLogin { session.port, session.user.as_ref(), session.auth, - session.key_path.as_deref().map(PathBuf::from), ), } } @@ -140,7 +141,7 @@ mod tests { fn using(kind: CredentialKind) -> (Session, Credential) { let credential = credential(kind); - let mut session = session(AuthKind::Auto); + let mut session = session(AuthKind::Password); session.credential = Some(credential.id); (session, credential) } @@ -155,16 +156,11 @@ mod tests { } #[test] - fn auto_ignores_a_leftover_key_path() { - let mut host = session(AuthKind::Auto); - host.key_path = Some("/tmp/id_old".into()); - assert_eq!(SessionLogin::of(&host, None).key_path, None); - - host.auth = AuthKind::Key; - assert_eq!( - SessionLogin::of(&host, None).key_path, - Some(PathBuf::from("/tmp/id_old")) - ); + fn a_host_without_a_password_keeps_its_endpoint_for_a_partial_success() { + let login = SessionLogin::of(&session(AuthKind::NoPassword), None); + assert_eq!(login.method, LoginMethod::NoPassword); + assert_eq!(login.key_path, None); + assert_eq!(login.password, SecretRef::password("root", "10.0.0.1", 22)); } #[test] @@ -197,8 +193,8 @@ mod tests { #[test] fn a_credential_the_host_does_not_name_is_not_used() { let credential = credential(CredentialKind::Password); - let login = SessionLogin::of(&session(AuthKind::Auto), Some(&credential)); - assert_eq!(login.method, LoginMethod::Auto); + let login = SessionLogin::of(&session(AuthKind::NoPassword), Some(&credential)); + assert_eq!(login.method, LoginMethod::NoPassword); assert_eq!(login.user, "root"); } diff --git a/src/session/model.rs b/src/session/model.rs index 9dbdb77..7501367 100644 --- a/src/session/model.rs +++ b/src/session/model.rs @@ -65,45 +65,37 @@ impl std::fmt::Display for PublicId { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct GroupId(pub u64); -/// How a session authenticates. +/// How a session logs in on its own, without a credential. A key, or a +/// password shared by several hosts, is a credential. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum AuthKind { + /// A password typed into the host form, or asked for at each connection + /// when none was saved. #[default] - Auto, Password, - Key, + /// Nothing typed: the server lets the user in as they are, or the SSH + /// agent or a default key in `~/.ssh` does. A server that wants a + /// password is refused rather than asked. + NoPassword, } impl AuthKind { - /// Every kind, in the order the session form lists them. - pub const ALL: [AuthKind; 3] = [AuthKind::Auto, AuthKind::Password, AuthKind::Key]; - - pub fn label(self) -> &'static str { - match self { - AuthKind::Auto => "自动", - AuthKind::Password => "密码", - AuthKind::Key => "私钥文件", - } - } - - /// The stored spelling. Kept separate from `label` so translating the UI - /// cannot rewrite what is already in the database. + /// The stored spelling. Kept separate from any label so translating the + /// UI cannot rewrite what is already in the database. pub fn as_str(self) -> &'static str { match self { - AuthKind::Auto => "auto", AuthKind::Password => "password", - AuthKind::Key => "key", + AuthKind::NoPassword => "no-password", } } - /// Parse a stored spelling, falling back to the default for anything a - /// newer version might have written. + /// Parse a stored spelling. Anything else, including what versions + /// before 10 wrote (`auto`, `key`), is a password: version 10's + /// migration turned those rows into passwords and key credentials. pub fn from_stored(value: &str) -> Self { match value { - "auto" => AuthKind::Auto, - "password" => AuthKind::Password, - "key" => AuthKind::Key, - _ => AuthKind::Auto, + "no-password" => AuthKind::NoPassword, + _ => AuthKind::Password, } } } @@ -295,10 +287,9 @@ pub struct Session { /// The user it logs in as. For a session using a credential this is the /// credential's user, which the store keeps in step. pub user: SharedString, - /// How a session typed into the form logs in. A session using a - /// credential keeps [`AuthKind::Auto`] and no key file here. + /// How a session logs in on its own. A session using a credential keeps + /// the default here. pub auth: AuthKind, - pub key_path: Option, /// The credential it logs in with instead of `auth`, if any. pub credential: Option, pub group: Option, @@ -323,7 +314,6 @@ impl Session { port: draft.port, user: draft.user, auth: draft.auth, - key_path: draft.key_path, credential: draft.credential, group: draft.group, sort_order: 0, @@ -358,7 +348,6 @@ impl Session { port: self.port, user: self.user.clone(), auth: self.auth, - key_path: self.key_path.clone(), credential: self.credential, group: self.group, } @@ -419,7 +408,6 @@ pub struct SessionDraft { pub port: u16, pub user: SharedString, pub auth: AuthKind, - pub key_path: Option, pub credential: Option, pub group: Option, } @@ -439,7 +427,6 @@ impl SessionDraft { port, user: user.into(), auth, - key_path: None, credential: None, group, } @@ -457,21 +444,6 @@ impl SessionDraft { pub fn password_secret(&self) -> SecretRef { SecretRef::password(self.user.as_ref(), self.host.as_ref(), self.port) } - - /// Set the private key used by [`AuthKind::Key`]. Keeping this as a - /// builder preserves the existing six-argument constructor for callers. - pub fn with_key_path(mut self, path: impl Into) -> Self { - let path = path.into(); - self.key_path = (!path.trim().is_empty()).then_some(path); - self - } - - pub(crate) fn with_optional_key_path(mut self, path: Option) -> Self { - self.key_path = path - .filter(|path| !path.trim().is_empty()) - .map(SharedString::from); - self - } } #[cfg(test)] @@ -544,17 +516,20 @@ mod tests { fn a_new_session_has_not_been_probed_yet() { let session = Session::new( SessionId(1), - SessionDraft::new("s", "h", 22, "root", AuthKind::Auto, None), + SessionDraft::new("s", "h", 22, "root", AuthKind::Password, None), ); assert_eq!(session.os, None); } #[test] - fn auto_is_the_default_and_key_path_is_opt_in() { - assert_eq!(AuthKind::default(), AuthKind::Auto); - let draft = SessionDraft::new("server", "host", 22, "me", AuthKind::Key, None) - .with_key_path("/tmp/id_ed25519"); - assert_eq!(draft.key_path.as_deref(), Some("/tmp/id_ed25519")); + fn a_password_is_the_default_and_old_spellings_read_as_one() { + assert_eq!(AuthKind::default(), AuthKind::Password); + for auth in [AuthKind::Password, AuthKind::NoPassword] { + assert_eq!(AuthKind::from_stored(auth.as_str()), auth); + } + // What versions before 10 wrote. + assert_eq!(AuthKind::from_stored("auto"), AuthKind::Password); + assert_eq!(AuthKind::from_stored("key"), AuthKind::Password); } } diff --git a/src/session/outline.rs b/src/session/outline.rs index 70f877b..e4698d7 100644 --- a/src/session/outline.rs +++ b/src/session/outline.rs @@ -268,7 +268,7 @@ mod tests { "10.0.0.1", 22, "root", - AuthKind::Key, + AuthKind::NoPassword, group, )); } diff --git a/src/session/secret_fields.rs b/src/session/secret_fields.rs index 0b56a99..35bac3e 100644 --- a/src/session/secret_fields.rs +++ b/src/session/secret_fields.rs @@ -1,8 +1,8 @@ -//! The password, private key and passphrase fields a login form shares: the -//! host form for a login typed there, the credential form for a saved one. -//! Each form lays the fields out in its own grid under its own ids; this owns -//! their state, reads back what the keychain saved for them, and says what a -//! commit should write. +//! The password, private key and passphrase fields of a login form: the +//! host form's password, and the credential form's password or key file and +//! passphrase. Each form lays the fields out in its own grid under its own +//! ids; this owns their state, reads back what the keychain saved for them, +//! and says what a commit should write. use gpui_kit::component::{ Sizable as _, diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index 1447610..81e931a 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -9,7 +9,6 @@ use gpui_kit::component::{ h_flex, input::{Input, InputState}, notification::Notification, - radio::{Radio, RadioGroup}, select::{Select, SelectState}, v_flex, }; @@ -17,7 +16,6 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt}; -use crate::secrets::SecretRef; use super::secret_fields::SecretFields; use super::{ @@ -25,32 +23,54 @@ use super::{ SessionDraft, SessionId, SessionLogin, SessionStore, group_options, }; pub use crate::shared::DeleteHandler; -use crate::shared::{confirm_delete, form_error, parse_port}; +use crate::shared::{Segment, SegmentedControl, confirm_delete, form_error, parse_port}; /// The label of the row that puts a session at the root of the tree. pub const NO_GROUP_LABEL: &str = "(无分组)"; -/// Where a host's login comes from. +/// How a host logs in, as the form's 「认证方式」 offers it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AuthSource { - /// Typed into this form. - Manual, - /// A saved credential. + /// A password typed into this form, or asked for each time. + Password, + /// A saved credential: a password several hosts share, a key, the agent. Credential, + /// Nothing typed: the server lets the user in, or the SSH agent or a + /// default key does. + NoPassword, } impl AuthSource { - /// Both, in the order the form lists them. - const ALL: [AuthSource; 2] = [AuthSource::Manual, AuthSource::Credential]; + /// Every way, in the order the form lists them. + const ALL: [AuthSource; 3] = [ + AuthSource::Password, + AuthSource::Credential, + AuthSource::NoPassword, + ]; fn label(self) -> &'static str { match self { - AuthSource::Manual => "手动输入", + AuthSource::Password => "密码", AuthSource::Credential => "使用凭据", + AuthSource::NoPassword => "无密码", + } + } + + /// How a host of its own logs in, for the two ways that are not a + /// credential. + fn auth(self) -> Option { + match self { + AuthSource::Password => Some(AuthKind::Password), + AuthSource::NoPassword => Some(AuthKind::NoPassword), + AuthSource::Credential => None, } } } +/// What 「无密码」 tries, under the choice. +const NO_PASSWORD_NOTE: &str = + "依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥;服务器要求密码时连接失败,不会询问。"; + /// The body of the new/edit session dialog. Owns the field states and /// validates on commit; the store is only touched when validation passes. pub struct SessionForm { @@ -61,8 +81,7 @@ pub struct SessionForm { port: Entity, source: AuthSource, user: Entity, - auth: Entity>>, - /// The password, key file and passphrase of a login typed here. + /// The password of a login typed here. fields: Entity, credential: Entity>>, /// The credentials as they were when the form opened, parallel to the @@ -114,7 +133,14 @@ impl SessionForm { group_names.push(path); } let draft = draft.unwrap_or_else(|| { - SessionDraft::new("", "", 22, DEFAULT_USER, AuthKind::Auto, preselect_group) + SessionDraft::new( + "", + "", + 22, + DEFAULT_USER, + AuthKind::default(), + preselect_group, + ) }); let name = cx.new(|cx| { @@ -137,22 +163,7 @@ impl SessionForm { .placeholder(DEFAULT_USER) .default_value(draft.user.clone()) }); - let auth_ix = AuthKind::ALL - .iter() - .position(|kind| *kind == draft.auth) - .unwrap_or(0); - let auth = cx.new(|cx| { - SelectState::new( - AuthKind::ALL - .iter() - .map(|kind| kind.label()) - .collect::>(), - Some(IndexPath::new(auth_ix)), - window, - cx, - ) - }); - let fields = cx.new(|cx| SecretFields::new(secrets, draft.key_path.clone(), window, cx)); + let fields = cx.new(|cx| SecretFields::new(secrets, None, window, cx)); let credential_ix = draft.credential.and_then(|id| { credentials .iter() @@ -177,19 +188,15 @@ impl SessionForm { let group = cx.new(|cx| SelectState::new(group_names, Some(IndexPath::new(group_ix)), window, cx)); let subscriptions = vec![ - cx.observe(&auth, |_, _, cx| cx.notify()), cx.observe(&credential, |_, _, cx| cx.notify()), cx.observe(&fields, |_, _, cx| cx.notify()), ]; if editing.is_some() { - // The typed login's secrets, even for a host that uses a - // credential now: they are what switching back would show. + // The host's own password, even for a host that does not use one + // now: it is what switching back would show. fields.update(cx, |fields, cx| { - let key_path = (draft.auth == AuthKind::Key) - .then(|| draft.key_path.clone()) - .flatten(); - fields.load_saved(Some(draft.password_secret()), key_path, cx) + fields.load_saved(Some(draft.password_secret()), None, cx) }); } Self { @@ -198,13 +205,12 @@ impl SessionForm { name, host, port, - source: if credential_ix.is_some() { - AuthSource::Credential - } else { - AuthSource::Manual + source: match (credential_ix, draft.auth) { + (Some(_), _) => AuthSource::Credential, + (None, AuthKind::Password) => AuthSource::Password, + (None, AuthKind::NoPassword) => AuthSource::NoPassword, }, user, - auth, fields, credential, credentials, @@ -227,15 +233,6 @@ impl SessionForm { } } - /// The authentication method the form has selected. - fn auth(&self, cx: &App) -> AuthKind { - self.auth - .read(cx) - .selected_index(cx) - .and_then(|ix| AuthKind::ALL.get(ix.row).copied()) - .unwrap_or_default() - } - /// The credential the form has selected. fn selected_credential(&self, cx: &App) -> Option<&Credential> { self.credential @@ -259,36 +256,21 @@ impl SessionForm { /// there is nothing to test yet. fn login_test(&self, cx: &App) -> Result { let (host, port) = self.endpoint(cx)?; - if self.source == AuthSource::Credential { + let Some(auth) = self.source.auth() else { let credential = self.selected_credential(cx).ok_or("请选择凭据")?; return Ok(LoginTest::saved(SessionLogin::with_credential( host, port, credential, ))); - } + }; let user = self.user.read(cx).value().trim().to_string(); if user.is_empty() { return Err("请输入用户名"); } - let auth = self.auth(cx); - let fields = self.fields.read(cx); - let key_path = fields.key_path(cx); - if auth == AuthKind::Key && key_path.is_empty() { - return Err("私钥认证需要选择私钥文件"); - } - let key_path = (auth == AuthKind::Key).then(|| key_path.into()); - let mut request = LoginTest::typed(SessionLogin::manual(host, port, user, auth, key_path)); - // Only what the chosen method uses, which is also what the form shows. - if uses_password(auth) { - let password = fields.password(cx); - if !password.is_empty() { - request = request.with_password(password); - } - } - if auth == AuthKind::Key { - let passphrase = fields.passphrase(cx); - if !passphrase.is_empty() { - request = request.with_passphrase(passphrase); - } + let mut request = LoginTest::typed(SessionLogin::manual(host, port, user, auth)); + // Only what the chosen way uses, which is also what the form shows. + let password = self.fields.read(cx).password(cx); + if auth == AuthKind::Password && !password.is_empty() { + request = request.with_password(password); } Ok(request) } @@ -371,16 +353,9 @@ impl SessionForm { /// How the form says the host logs in, or what is missing. fn committed_login(&self, cx: &App) -> Result { - match self.source { - AuthSource::Manual => { - let auth = self.auth(cx); - let key_path = self.fields.read(cx).key_path(cx); - if auth == AuthKind::Key && key_path.is_empty() { - return Err("私钥认证需要选择私钥文件"); - } - Ok(CommittedLogin::Typed { auth, key_path }) - } - AuthSource::Credential => self + match self.source.auth() { + Some(auth) => Ok(CommittedLogin::Own(auth)), + None => self .selected_credential(cx) .map(|credential| CommittedLogin::Saved { credential: credential.id, @@ -414,34 +389,27 @@ impl SessionForm { .selected_index(cx) .and_then(|ix| self.group_ids.get(ix.row).copied()) .unwrap_or(None); - // Secrets never ride along in the draft, which derives `Debug`. They - // go to the keychain separately, under the endpoint the draft names; - // a login through a credential leaves the host's own entries alone. - let mut secret_changes: Vec<(SecretRef, Option)> = Vec::new(); + // The password never rides along in the draft, which derives + // `Debug`. It goes to the keychain separately, under the endpoint the + // draft names; any other way of logging in leaves that entry alone. + let mut password_change = None; let draft = match login { CommittedLogin::Saved { credential, user } => { - SessionDraft::new(name, host, port, user, AuthKind::Auto, group) + SessionDraft::new(name, host, port, user, AuthKind::default(), group) .with_credential(credential) } - CommittedLogin::Typed { auth, key_path } => { + CommittedLogin::Own(auth) => { let user = self.user.read(cx).value().trim().to_string(); let user = if user.is_empty() { DEFAULT_USER.to_string() } else { user }; - let mut draft = SessionDraft::new(name, host, port, user, auth, group); - let fields = self.fields.read(cx); - if uses_password(auth) - && let Some(change) = fields.password_change(cx) + let draft = SessionDraft::new(name, host, port, user, auth, group); + if auth == AuthKind::Password + && let Some(change) = self.fields.read(cx).password_change(cx) { - secret_changes.push((draft.password_secret(), change)); - } - if auth == AuthKind::Key { - if let Some(change) = fields.passphrase_change(cx) { - secret_changes.push((SecretRef::passphrase(&key_path), change)); - } - draft = draft.with_key_path(key_path); + password_change = Some((draft.password_secret(), change)); } draft } @@ -459,7 +427,7 @@ impl SessionForm { } // `update` above may have dropped the entry for the endpoint the // session just left; this writes the one it moved to. - for (secret, change) in secret_changes { + if let Some((secret, change)) = password_change { store.save_secret(secret, change, cx); } }); @@ -471,48 +439,21 @@ impl SessionForm { true } - /// The fields of a login typed into the form. - fn manual_fields(&self, form: Form, cx: &mut Context) -> Form { - let auth = self.auth(cx); - let fields = self.fields.read(cx); + /// The fields of a host that logs in on its own: its user, and its + /// password when it has one. + fn own_fields(&self, form: Form, auth: AuthKind, cx: &App) -> Form { form.child( Field::new() .label("用户名") .col_span(4) .child(Input::new(&self.user).id("session-user").small()), ) - .child( - Field::new() - .label("认证类型") - .col_span(4) - .child(Select::new(&self.auth).id("session-auth").small()), - ) - .when(uses_password(auth), |form| { + .when(auth == AuthKind::Password, |form| { form.child( Field::new() .label("密码") .col_span(4) - .child(fields.password_input("session-password")), - ) - }) - .when(auth == AuthKind::Key, |form| { - form.child( - Field::new() - .label("私钥文件") - .required(true) - .col_span(4) - .child(SecretFields::key_path_input( - &self.fields, - "session-key-path", - "choose-key", - cx, - )), - ) - .child( - Field::new() - .label("私钥口令") - .col_span(4) - .child(fields.passphrase_input("session-passphrase")), + .child(self.fields.read(cx).password_input("session-password")), ) }) } @@ -590,23 +531,32 @@ impl Render for SessionForm { .child(Input::new(&self.port).id("session-port").small()), ) .child( - Field::new().label("认证方式").col_span(4).child( - RadioGroup::horizontal("session-auth-source") - .selected_index(AuthSource::ALL.iter().position(|each| *each == source)) - .on_change(cx.listener(|this, ix: &usize, _, cx| { - if let Some(source) = AuthSource::ALL.get(*ix) { - this.set_source(*source, cx); - } - })) - .children( - AuthSource::ALL - .map(|each| Radio::new(each.label()).label(each.label()).small()), - ), - ), + Field::new() + .label("认证方式") + .col_span(4) + .child( + SegmentedControl::new("session-auth-source") + .selected_index(AuthSource::ALL.iter().position(|each| *each == source)) + .on_change(cx.listener(|this, ix: &usize, _, cx| { + if let Some(source) = AuthSource::ALL.get(*ix) { + this.set_source(*source, cx); + } + })) + .segments(AuthSource::ALL.map(|each| Segment::new(each.label()))), + ) + .when(source == AuthSource::NoPassword, |field| { + field.description_fn(|_, _| { + div() + .id("session-no-password-note") + .test_support() + .aria_label(NO_PASSWORD_NOTE) + .child(NO_PASSWORD_NOTE) + }) + }), ); - let form = match source { - AuthSource::Manual => self.manual_fields(form, cx), - AuthSource::Credential => form.child(self.credential_field(cx)), + let form = match source.auth() { + Some(auth) => self.own_fields(form, auth, cx), + None => form.child(self.credential_field(cx)), }; v_flex() .gap_3() @@ -619,7 +569,7 @@ impl Render for SessionForm { .child(Select::new(&self.group).small()), ), ) - .when(source == AuthSource::Manual, |form| { + .when(source == AuthSource::Password, |form| { form.child( div() .text_sm() @@ -643,10 +593,8 @@ impl Render for SessionForm { /// How a host the form commits logs in. enum CommittedLogin { - Typed { - auth: AuthKind, - key_path: String, - }, + /// On its own. + Own(AuthKind), Saved { credential: CredentialId, user: SharedString, @@ -726,10 +674,6 @@ fn connection_test_notification(result: Result<(), String>) -> Notification { /// Whether this authentication kind can end up asking for a password. /// `Auto` walks agent, then keys, then password, so it can. -fn uses_password(auth: AuthKind) -> bool { - matches!(auth, AuthKind::Auto | AuthKind::Password) -} - /// Open the new-session (`editing == None`) or edit-session dialog. /// `preselect_group` fills in the group field of a new session, so creating /// one from a group's context menu lands it in that group. `tester` backs the @@ -856,7 +800,7 @@ fn describe_session_delete( #[cfg(test)] mod tests { - use super::{AuthKind, describe_session_delete, uses_password}; + use super::describe_session_delete; #[test] fn deleting_a_session_says_what_goes_with_it() { @@ -878,11 +822,4 @@ mod tests { Some("会一并关闭该主机已打开的终端和 SFTP 标签。将同时删除经由该主机的 1 条端口转发。") ); } - - #[test] - fn auto_and_password_can_reach_a_password_prompt() { - assert!(uses_password(AuthKind::Auto)); - assert!(uses_password(AuthKind::Password)); - assert!(!uses_password(AuthKind::Key)); - } } diff --git a/src/session/session_panel.rs b/src/session/session_panel.rs index ea9a5ea..d933fe9 100644 --- a/src/session/session_panel.rs +++ b/src/session/session_panel.rs @@ -362,15 +362,16 @@ impl Panel for SessionPanel { } fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { + // A new host first: it is what the list is mostly added to. Some(vec![ - Button::new("new-group") - .icon(Icon::new(CatalogIcon::FolderPlus)) - .tooltip("新建分组…") - .on_click(|_, window, cx| window.dispatch_action(Box::new(NewGroup), cx)), Button::new("new-session-panel") .icon(IconName::Plus) .tooltip("新建主机…") .on_click(|_, window, cx| window.dispatch_action(Box::new(NewSession), cx)), + Button::new("new-group") + .icon(Icon::new(CatalogIcon::FolderPlus)) + .tooltip("新建分组…") + .on_click(|_, window, cx| window.dispatch_action(Box::new(NewGroup), cx)), ]) } diff --git a/src/session/store.rs b/src/session/store.rs index 2f32167..4e9a720 100644 --- a/src/session/store.rs +++ b/src/session/store.rs @@ -143,7 +143,6 @@ impl SessionStore { let draft = normalized(&credentials, session.draft()); session.user = draft.user; session.auth = draft.auth; - session.key_path = draft.key_path; session.credential = draft.credential; } Ok(Self { @@ -178,7 +177,7 @@ impl SessionStore { "10.0.1.12", 22, "root", - AuthKind::Auto, + AuthKind::Password, Some(production), ), SessionDraft::new( @@ -186,7 +185,7 @@ impl SessionStore { "10.0.1.13", 22, "root", - AuthKind::Auto, + AuthKind::Password, Some(production), ), SessionDraft::new( @@ -202,7 +201,7 @@ impl SessionStore { "10.0.9.20", 2222, "deploy", - AuthKind::Auto, + AuthKind::Password, Some(staging), ), SessionDraft::new( @@ -218,7 +217,7 @@ impl SessionStore { "192.168.1.20", 22, "xuz", - AuthKind::Auto, + AuthKind::Password, Some(development), ), ]; @@ -876,7 +875,8 @@ impl SessionStore { return Vec::new(); }; let released = self.remove_credential_unnotified(id); - self.persist("删除凭据", cx, |db| db.remove_credential(id)); + let auth = credential.kind.without_credential(); + self.persist("删除凭据", cx, |db| db.remove_credential(id, auth)); if credential.kind == CredentialKind::Password { self.save_secret(credential.password_secret(), None, cx); } @@ -888,17 +888,19 @@ impl SessionStore { } pub fn remove_credential_unnotified(&mut self, id: CredentialId) -> Vec { - let before = self.credentials.len(); - self.credentials.retain(|credential| credential.id != id); - if self.credentials.len() == before { + let Some(index) = self + .credentials + .iter() + .position(|credential| credential.id == id) + else { return Vec::new(); - } + }; + let auth = self.credentials.remove(index).kind.without_credential(); let mut released = Vec::new(); for session in &mut self.sessions { if session.credential == Some(id) { session.credential = None; - session.auth = AuthKind::Auto; - session.key_path = None; + session.auth = auth; released.push(session.id); } } @@ -1183,20 +1185,13 @@ impl SessionStore { .is_some_and(|dir| is_kept_in(dir, Path::new(path))) } - /// How many credentials and hosts log in with the key file at `path`. - /// A host using a credential has no key file of its own. + /// How many credentials log in with the key file at `path`. Hosts have + /// no key files of their own. fn key_file_users(&self, path: &str) -> usize { - let credentials = self - .credentials + self.credentials .iter() .filter(|credential| credential.key_path.as_deref() == Some(path)) - .count(); - let sessions = self - .sessions - .iter() - .filter(|session| session.key_path.as_deref() == Some(path)) - .count(); - credentials + sessions + .count() } /// Delete a key file ShellRS keeps, with its saved passphrase, once @@ -1373,8 +1368,7 @@ fn normalized(credentials: &[Credential], mut draft: SessionDraft) -> SessionDra match credential { Some(credential) => { draft.user = credential.user.clone(); - draft.auth = AuthKind::Auto; - draft.key_path = None; + draft.auth = AuthKind::default(); } None => draft.credential = None, } @@ -1387,7 +1381,7 @@ mod tests { use super::*; fn draft(name: &str, group: Option) -> SessionDraft { - SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Auto, group) + SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Password, group) } #[test] @@ -1521,15 +1515,13 @@ mod tests { let credential = password_credential(&mut store, "deploy"); let mut form = draft("web", None).with_credential(credential); // Whatever the form still held of its own login is dropped. - form.auth = AuthKind::Key; - form.key_path = Some("/tmp/id".into()); + form.auth = AuthKind::NoPassword; let web = store.insert_unnotified(form); let session = store.session(web).unwrap(); assert_eq!(session.credential, Some(credential)); assert_eq!(session.user.as_ref(), "deploy"); - assert_eq!(session.auth, AuthKind::Auto); - assert_eq!(session.key_path, None); + assert_eq!(session.auth, AuthKind::Password); let login = store.login(web).unwrap(); assert_eq!(login.user, "deploy"); assert_eq!( @@ -1587,7 +1579,7 @@ mod tests { } #[test] - fn deleting_a_credential_leaves_its_hosts_manual_auto_with_the_user() { + fn deleting_a_credential_leaves_its_hosts_on_their_own_with_the_user() { let mut store = SessionStore::empty(); let credential = store.insert_credential_unnotified( CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"), @@ -1599,11 +1591,17 @@ mod tests { assert!(store.credentials().is_empty()); let session = store.session(web).unwrap(); assert_eq!(session.credential, None); - assert_eq!(session.auth, AuthKind::Auto); - assert_eq!(session.key_path, None); + // Without its key, it tries the agent and the default keys. + assert_eq!(session.auth, AuthKind::NoPassword); assert_eq!(session.user.as_ref(), "deploy"); assert_eq!(store.session(db).unwrap().user.as_ref(), "root"); assert!(store.remove_credential_unnotified(credential).is_empty()); + + // Without its password, it asks for one. + let credential = password_credential(&mut store, "deploy"); + let api = store.insert_unnotified(draft("api", None).with_credential(credential)); + store.remove_credential_unnotified(credential); + assert_eq!(store.session(api).unwrap().auth, AuthKind::Password); } #[test] @@ -1642,13 +1640,13 @@ mod tests { database.insert_credential(&credential).unwrap(); let mut stale = Session::new(SessionId(1), draft("web", None)); stale.credential = Some(credential.id); - stale.auth = AuthKind::Password; + stale.auth = AuthKind::NoPassword; database.insert_session(&stale).unwrap(); let mut store = SessionStore::load(database).unwrap(); let session = store.session(SessionId(1)).unwrap(); assert_eq!(session.user.as_ref(), "deploy"); - assert_eq!(session.auth, AuthKind::Auto); + assert_eq!(session.auth, AuthKind::Password); assert_eq!( store.credential(CredentialId(4)).unwrap().keychain_id, credential.keychain_id @@ -1698,11 +1696,8 @@ mod tests { ); assert_eq!(read(&path), "two"); - // Not when a host logs in with that file as well. - let mut host = draft("web", None); - host.auth = AuthKind::Key; - host.key_path = Some(path.clone()); - store.insert_unnotified(host); + // Not when another credential logs in with that file as well. + key_credential(&mut store, &path); let moved = store.save_private_key(Some(credential), "three").unwrap(); assert_ne!(moved, path); assert_eq!(read(&path), "two"); @@ -1821,7 +1816,7 @@ mod tests { let mut store = SessionStore::empty(); let id = store.insert_unnotified(draft("web", None)); let endpoint = store.session(id).unwrap().password_secret(); - let moved = SessionDraft::new("web", "10.0.0.1", 2222, "root", AuthKind::Auto, None); + let moved = SessionDraft::new("web", "10.0.0.1", 2222, "root", AuthKind::Password, None); store.update_unnotified(id, moved); assert!(!store.password_in_use(&endpoint)); } @@ -1832,7 +1827,14 @@ mod tests { let group = store.insert_group_unnotified(GroupDraft::new("生产", None)); store.insert_unnotified(draft("web-01", Some(group))); store.insert_unnotified(draft("web-02", Some(group))); - let other = SessionDraft::new("db", "10.0.0.2", 22, "root", AuthKind::Auto, Some(group)); + let other = SessionDraft::new( + "db", + "10.0.0.2", + 22, + "root", + AuthKind::Password, + Some(group), + ); store.insert_unnotified(other); let endpoints = store.endpoints_of(&store.sessions_under(group)); @@ -1881,16 +1883,12 @@ mod tests { let mut store = SessionStore::seed(); let web01 = store.sessions()[0].id; let mut draft = store.session(web01).unwrap().draft(); - draft.auth = AuthKind::Key; - draft.key_path = Some("/tmp/id_ed25519".into()); + draft.auth = AuthKind::NoPassword; assert!(store.update_unnotified(web01, draft)); let copy = store.duplicate_unnotified(web01).unwrap(); assert_eq!(store.sessions()[1].id, copy); assert_eq!(store.sessions()[1].name.as_ref(), "web-01 副本"); - assert_eq!( - store.sessions()[1].key_path.as_deref(), - Some("/tmp/id_ed25519") - ); + assert_eq!(store.sessions()[1].auth, AuthKind::NoPassword); assert_eq!(store.sessions()[1].state, ConnectionState::Disconnected); assert_ne!( store.sessions()[1].public_id, diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 9b14905..50b04c3 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -4,10 +4,12 @@ mod closable_tab; mod dialog; mod host_mark; mod rename_tab_dialog; +mod segmented_control; mod tab_menu; pub use closable_tab::ClosableTabTitle; pub use dialog::{DeleteHandler, commit_footer, confirm_delete, form_error, parse_port}; pub use host_mark::HostMark; pub use rename_tab_dialog::{RenamableTab, open_rename_tab_dialog}; +pub use segmented_control::{Segment, SegmentedControl}; pub use tab_menu::close_tab_items; diff --git a/src/shared/segmented_control.rs b/src/shared/segmented_control.rs new file mode 100644 index 0000000..e79d4fc --- /dev/null +++ b/src/shared/segmented_control.rs @@ -0,0 +1,170 @@ +use std::rc::Rc; + +use gpui_kit::base::{Radio, RadioGroup}; +use gpui_kit::component::{ActiveTheme as _, tooltip::Tooltip}; +use gpui_kit::prelude::FluentBuilder; +use gpui_kit::*; + +type ChangeHandler = Rc; + +/// One choice out of a few, side by side in one track, the chosen one +/// standing out as a raised block: a segmented control, for a form's choice +/// between ways of doing the same thing (密码 / 使用凭据 / 无密码). +/// +/// Behavior comes from gpui-base's radio group: activation by pointer, Enter +/// or Space, a tab stop per segment, and the radio group's semantics for +/// assistive technology. This owns only the look. Each segment's element id +/// is its position, as with gpui-component's `RadioGroup`, so tests reach +/// one with `within(group).click(1usize)`. +#[derive(IntoElement)] +pub struct SegmentedControl { + id: ElementId, + segments: Vec, + selected: Option, + on_change: Option, +} + +/// One segment of a [`SegmentedControl`]. +pub struct Segment { + label: SharedString, + disabled: bool, + /// Why a disabled segment cannot be chosen. + tooltip: Option, +} + +impl Segment { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + disabled: false, + tooltip: None, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } +} + +impl FluentBuilder for Segment {} + +impl SegmentedControl { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + segments: Vec::new(), + selected: None, + on_change: None, + } + } + + pub fn selected_index(mut self, index: Option) -> Self { + self.selected = index; + self + } + + /// Handle a request to choose the segment at an index. The value is + /// controlled: the owner stores it and renders it back. + pub fn on_change(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + pub fn segments(mut self, segments: impl IntoIterator) -> Self { + self.segments.extend(segments); + self + } +} + +impl RenderOnce for SegmentedControl { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + // The chosen block sits this far inside the track, its corners + // rounded on the same centre as the track's. + let inset = rems(0.125); + let inner_radius = (theme.radius - inset.to_pixels(window.rem_size())).max(px(0.)); + // The chosen block's edge, a step stronger than the track's: its + // fill is barely darker than the track, so the edge carries it. Mixed + // from the theme rather than picked, so it is a step darker on a light + // theme and a step lighter on a dark one. 8% of the text colour takes + // the default light border from neutral-200 to about neutral-300. + let edge = theme.border.blend(theme.foreground.opacity(0.08)); + let total = self.segments.len(); + let selected = self.selected; + let on_change = self.on_change; + + RadioGroup::new(self.id) + .axis(Axis::Horizontal) + .flex() + .w_full() + .h_8() + .p(inset) + .gap(inset) + .rounded(theme.radius) + .border_1() + .border_color(theme.border) + .bg(theme.background) + .children(self.segments.into_iter().enumerate().map(|(ix, segment)| { + let checked = selected == Some(ix); + let hoverable = !checked && !segment.disabled; + Radio::new(ix) + .checked(checked) + .disabled(segment.disabled) + .accessibility_label(segment.label.clone()) + .set_position(ix + 1, total) + // Equal shares of the track, whatever the labels. + .flex_1() + .min_w_0() + .h_full() + .flex() + .items_center() + .justify_center() + .px_2() + .rounded(inner_radius) + // Every segment has the border, so choosing one + // moves nothing. + .border_1() + .border_color(theme.transparent) + .text_sm() + .text_color(theme.muted_foreground) + .styles(|styles| { + styles + .checked(|style| { + style + .bg(theme.accent) + .border_color(edge) + .text_color(theme.foreground) + .shadow_xs() + }) + .disabled(|style| style.opacity(0.5)) + }) + // Half of what choosing it would show. + .when(hoverable, |radio| { + radio.hover(|style| { + style + .bg(theme.accent.opacity(0.5)) + .text_color(theme.foreground) + }) + }) + .focus_visible(|style| style.border_color(theme.ring)) + // A click chooses without taking the keyboard from + // where it was, as gpui-component's radios do. + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(on_change.clone(), |radio, on_change| { + radio.on_change(move |_, _, window, cx| on_change(&ix, window, cx)) + }) + .when_some(segment.tooltip, |radio, tooltip| { + radio.tooltip(move |window, cx| { + Tooltip::new(tooltip.clone()).build(window, cx) + }) + }) + .child(div().truncate().child(segment.label)) + })) + } +} diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index 7c90ba1..a27eb78 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -548,9 +548,9 @@ async fn authenticate( let mut methods = remaining_methods(first); let mut partial = false; - // Automatic logins try the agent quietly and move on; a login that is - // only the agent says what went wrong with it. - if matches!(method, LoginMethod::Auto | LoginMethod::Agent) { + // A login without a password tries the agent quietly and moves on; a + // login that is only the agent says what went wrong with it. + if matches!(method, LoginMethod::NoPassword | LoginMethod::Agent) { if methods.contains(&MethodKind::PublicKey) { match try_agent(handle, user, agent).await? { Ok(result) => { @@ -571,7 +571,7 @@ async fn authenticate( } } - if matches!(method, LoginMethod::Auto | LoginMethod::Key) + if matches!(method, LoginMethod::NoPassword | LoginMethod::Key) && methods.contains(&MethodKind::PublicKey) { let paths = key_paths(login)?; @@ -602,7 +602,19 @@ async fn authenticate( } } - if matches!(method, LoginMethod::Auto | LoginMethod::Password) || partial { + // Nothing typed is all a login without a password has. A server that + // wants more is told no here, not answered with a question; a code it + // asks for after a key is still asked, below. + if method == LoginMethod::NoPassword && !partial { + if methods.contains(&MethodKind::Password) + || methods.contains(&MethodKind::KeyboardInteractive) + { + bail!("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」"); + } + bail!("服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥"); + } + + if method == LoginMethod::Password || partial { if methods.contains(&MethodKind::Password) { // Try what the session has saved before bothering anyone. let mut saved_rejected = false; diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index c7bb93b..d16e959 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -655,7 +655,13 @@ mod tests { known_hosts: &Path, answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply, ) { - connect(session, known_hosts, Arc::new(NoSecretStore), false, answer); + connect( + SessionLogin::of(&session, None), + known_hosts, + Arc::new(NoSecretStore), + false, + answer, + ); } fn connect_with_secrets( @@ -664,7 +670,13 @@ mod tests { secrets: SharedSecretStore, answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply, ) -> ConnectionReport { - connect(session, known_hosts, secrets, false, answer) + connect( + SessionLogin::of(&session, None), + known_hosts, + secrets, + false, + answer, + ) } /// Same, but waits for the host-operating-system probe and the first @@ -674,7 +686,13 @@ mod tests { known_hosts: &Path, answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply, ) -> ConnectionReport { - connect(session, known_hosts, Arc::new(NoSecretStore), true, answer) + connect( + SessionLogin::of(&session, None), + known_hosts, + Arc::new(NoSecretStore), + true, + answer, + ) } /// What one connection told the UI about itself. @@ -688,7 +706,7 @@ mod tests { /// Connect, answer whatever is asked, then shut down. The report says what /// was raised along the way, so a test can assert that nothing was. fn connect( - session: Session, + login: SessionLogin, known_hosts: &Path, secrets: SharedSecretStore, wait_for_host_os: bool, @@ -697,7 +715,7 @@ mod tests { let mut report = ConnectionReport::default(); let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(known_hosts, secrets)); - let factory = provider.factory_for(&SessionLogin::of(&session, None)); + let factory = provider.factory_for(&login); let (command_tx, command_rx) = mpsc::channel(); let (event_tx, event_rx) = async_channel::unbounded(); let (done_tx, done_rx) = mpsc::channel(); @@ -949,7 +967,6 @@ mod tests { port, "tester", AuthKind::Password, - None, )) } @@ -1179,7 +1196,7 @@ mod tests { #[cfg(unix)] #[test] - fn automatic_login_tries_the_same_agent() { + fn a_login_without_a_password_tries_the_same_agent() { let key = random_key(); let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else { eprintln!("loopback sockets are unavailable in this sandbox; skipping"); @@ -1187,7 +1204,7 @@ mod tests { }; let directory = tempfile::tempdir().unwrap(); let agent = start_agent(&[key]); - let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::Auto, None); + let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword); let connector = SshConnector::new( directory.path().join("known_hosts"), Arc::new(NoSecretStore), @@ -1239,6 +1256,26 @@ mod tests { ); } + #[test] + fn a_login_without_a_password_refuses_a_server_that_wants_one() { + let Some(server) = start_server(TestAuth::Password) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let connector = SshConnector::new( + directory.path().join("known_hosts"), + Arc::new(NoSecretStore), + ); + let login = SessionLogin::manual("127.0.0.1", server.port, "tester", AuthKind::NoPassword); + // Refused outright: the connection test cannot ask, and would say a + // password was missing if this login tried to. + assert_eq!( + test_through(connector, crate::connection::LoginTest::typed(login)), + Err("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」".to_string()) + ); + } + #[test] fn exec_without_a_saved_password_says_what_is_missing() { let Some(server) = start_server(TestAuth::Password) else { @@ -1602,21 +1639,20 @@ mod tests { client_key .write_openssh_file(&key_path, russh::keys::ssh_key::LineEnding::LF) .unwrap(); - let key_session = Session::new( - crate::session::SessionId(1), - crate::session::SessionDraft::new( + let key_credential = crate::session::Credential::new( + crate::session::CredentialId(1), + crate::session::CredentialDraft::new( "key-test", - "127.0.0.1", - key_server.port, + crate::session::CredentialKind::Key, "tester", - AuthKind::Key, - None, ) .with_key_path(key_path.to_string_lossy().into_owned()), ); - connect_then_shutdown( - key_session, + connect( + SessionLogin::with_credential("127.0.0.1", key_server.port, &key_credential), &directory.path().join("key-known-hosts"), + Arc::new(NoSecretStore), + false, |prompt| match prompt { ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave, other => panic!("unexpected key-auth prompt: {other:?}"), diff --git a/src/workspace/credentials.rs b/src/workspace/credentials.rs index eded7c9..f81600c 100644 --- a/src/workspace/credentials.rs +++ b/src/workspace/credentials.rs @@ -12,7 +12,7 @@ use crate::app::{ NewCredential, ShowCredentials, }; use crate::credential::{CredentialDialog, open_credential_dialog}; -use crate::session::read_public_key; +use crate::session::{AuthKind, CredentialKind, read_public_key}; use crate::shared::confirm_delete; use super::{sidebar::SidebarMode, workspace_view::Workspace}; @@ -111,9 +111,9 @@ impl Workspace { ) { let id = action.0; let store = self.store.read(cx); - let Some(name) = store + let Some((name, kind)) = store .credential(id) - .map(|credential| credential.name.clone()) + .map(|credential| (credential.name.clone(), credential.kind)) else { return; }; @@ -121,7 +121,7 @@ impl Workspace { let store = self.store.clone(); confirm_delete( &name, - describe_credential_delete(hosts), + describe_credential_delete(hosts, kind), Rc::new(move |_, cx| { store.update(cx, |store, cx| { store.remove_credential(id, cx); @@ -135,26 +135,34 @@ impl Workspace { /// What the delete dialog says happens to the hosts using the credential. /// `None` when no host uses it. -fn describe_credential_delete(hosts: usize) -> Option { - (hosts > 0).then(|| { - format!( - "有 {hosts} 台主机正在使用此凭据。删除后它们改为手动输入,认证类型为「自动」,用户名不变。" - ) - .into() - }) +fn describe_credential_delete(hosts: usize, kind: CredentialKind) -> Option { + let after = match kind.without_credential() { + AuthKind::Password => "改为「密码」,连接时询问密码", + AuthKind::NoPassword => "改为「无密码」,用 SSH Agent 和 ~/.ssh 中的私钥登录", + }; + (hosts > 0) + .then(|| format!("有 {hosts} 台主机正在使用此凭据。删除后它们{after},用户名不变。").into()) } #[cfg(test)] mod tests { use super::describe_credential_delete; + use crate::session::CredentialKind; #[test] fn deleting_a_credential_says_how_many_hosts_and_what_they_become() { - assert_eq!(describe_credential_delete(0), None); assert_eq!( - describe_credential_delete(3).as_deref(), + describe_credential_delete(0, CredentialKind::Password), + None + ); + assert_eq!( + describe_credential_delete(3, CredentialKind::Password).as_deref(), + Some("有 3 台主机正在使用此凭据。删除后它们改为「密码」,连接时询问密码,用户名不变。") + ); + assert_eq!( + describe_credential_delete(1, CredentialKind::Agent).as_deref(), Some( - "有 3 台主机正在使用此凭据。删除后它们改为手动输入,认证类型为「自动」,用户名不变。" + "有 1 台主机正在使用此凭据。删除后它们改为「无密码」,用 SSH Agent 和 ~/.ssh 中的私钥登录,用户名不变。" ) ); } diff --git a/tests/cli_bin.rs b/tests/cli_bin.rs index f1ef266..c14f242 100644 --- a/tests/cli_bin.rs +++ b/tests/cli_bin.rs @@ -104,7 +104,7 @@ fn the_command_finds_the_app_through_its_data_directory() { let server = CliServer::start(cli_endpoint(data_dir.path()), backend.clone()).unwrap(); let web = Session::new( SessionId(1), - SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Auto, None), + SessionDraft::new("web-01", "10.0.1.12", 22, "root", AuthKind::Password, None), ); server.set_targets(vec![CliTarget::new( &web, diff --git a/tests/ui.rs b/tests/ui.rs index a1b4942..815da3f 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -344,6 +344,8 @@ fn open_workspace_with_factory( factory: Arc, ) -> (WindowHandle, Entity) { cx.update(shellrs::init); + // Dialogs still, as in every fixture: see `open_workspace_with_tester`. + cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| SessionStore::seed()); @@ -376,6 +378,8 @@ fn open_workspace_with_remote_factory( factory: Arc, ) -> (WindowHandle, Entity) { cx.update(shellrs::init); + // Dialogs still, as in every fixture: see `open_workspace_with_tester`. + cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| store); @@ -517,50 +521,46 @@ fn one_session_store(auth: AuthKind) -> (SessionStore, SessionId) { } #[gpui_kit::test] -async fn session_form_switches_to_key_and_uses_native_path_picker(cx: &mut TestAppContext) { - let (handle, _) = open_workspace_with_store(cx, SessionStore::empty()); - - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - window.click("new-session", cx); - }) - .unwrap(); - cx.run_until_parked(); - - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - assert!(window.try_find("session-key-path").is_none()); - window.within("session-auth").click("input", cx); - window.within("session-auth").press("down", cx); - window.within("session-auth").press("down", cx); - window.within("session-auth").press("enter", cx); - }) - .unwrap(); - cx.run_until_parked(); - - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - assert!(window.find("session-key-path").visible()); - window.click("choose-key", cx); - }) - .unwrap(); - assert!(cx.did_prompt_for_paths()); - cx.simulate_path_prompt_response(|options| { - assert!(options.files); - assert!(!options.directories); - assert!(!options.multiple); - Some(vec!["/tmp/id_ed25519".into()]) +async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such( + cx: &mut TestAppContext, +) { + let (handle, workspace) = open_workspace_with_store(cx, SessionStore::empty()); + in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| { + // A new host logs in with a password. + let sources = window.within("session-auth-source"); + assert_eq!(sources.find(0usize).selected(), Some(true)); + assert!(window.find("session-password").visible()); + assert!(window.try_find("session-no-password-note").is_none()); + window.click("session-name", cx); + window.input("box", cx); + window.click("session-host", cx); + window.input("10.0.0.9", cx); + window.within("session-auth-source").click(2usize, cx); }); - cx.run_until_parked(); - - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); + in_frame(cx, handle, |window, cx| { + assert!(window.find("session-user").visible()); + assert!(window.try_find("session-password").is_none()); + assert!(window.try_find("session-credential").is_none()); assert_eq!( - window.find("session-key-path").value(), - Some("/tmp/id_ed25519") + window.find("session-no-password-note").label(), + Some( + "依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥;服务器要求密码时连接失败,不会询问。" + ) ); - }) - .unwrap(); + window.click("commit", cx); + }); + wait_for_dialog_to_close(cx, handle).await; + cx.update(|cx| { + let store = workspace.read(cx).store().read(cx); + let session = &store.sessions()[0]; + assert_eq!(session.auth, AuthKind::NoPassword); + assert_eq!(session.credential, None); + assert_eq!( + store.login(session.id).unwrap().method, + LoginMethod::NoPassword + ); + }); } #[gpui_kit::test] @@ -630,7 +630,7 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes #[gpui_kit::test] async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Auto); + let (store, id) = one_session_store(AuthKind::Password); let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::UnknownHost)); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); @@ -732,7 +732,7 @@ async fn canceling_unknown_host_prompt_keeps_session_disconnected(cx: &mut TestA #[gpui_kit::test] async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Auto); + let (store, id) = one_session_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::default()); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); @@ -780,7 +780,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA #[gpui_kit::test] async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Auto); + let (store, id) = one_session_store(AuthKind::Password); let (handle, workspace) = open_workspace_with_store(cx, store); cx.update_window(handle.into(), |_, window, cx| { @@ -859,7 +859,7 @@ async fn double_click_on_session_opens_terminal_and_updates_status(cx: &mut Test #[gpui_kit::test] async fn connected_session_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Auto); + let (store, id) = one_session_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::default()); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); @@ -1381,7 +1381,7 @@ fn connect_group_opens_each_host_in_its_subtree(cx: &mut TestAppContext) { "10.0.3.8", 22, "deploy", - AuthKind::Auto, + AuthKind::Password, Some(grandchild), )); let (handle, workspace) = open_workspace_with_store(cx, store); @@ -3915,7 +3915,7 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut "10.0.1.12", 22, "root", - AuthKind::Auto, + AuthKind::Password, None, )); let fresh = store.insert_unnotified(SessionDraft::new( @@ -3923,7 +3923,7 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut "10.0.2.5", 22, "root", - AuthKind::Auto, + AuthKind::Password, None, )); store.set_host_os_unnotified(probed, Some(HostOs::Debian)); @@ -3957,7 +3957,7 @@ async fn the_tree_falls_back_to_the_first_letter_until_a_host_is_probed(cx: &mut #[gpui_kit::test] async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Auto); + let (store, id) = one_session_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::reports_os(HostOs::Fedora)); let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory); @@ -3988,7 +3988,7 @@ async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut Te #[gpui_kit::test] async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestAppContext) { - let (store, id) = one_session_store(AuthKind::Auto); + let (store, id) = one_session_store(AuthKind::Password); let factory = Arc::new(FakeTerminalFactory::reports_latency(Latency::Measured( Duration::from_millis(32), ))); @@ -4034,7 +4034,7 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut "10.0.1.12", 22, "root", - AuthKind::Auto, + AuthKind::Password, None, )); // Connecting once puts it on the start page; disconnecting keeps it there @@ -7129,6 +7129,10 @@ async fn the_title_bar_switches_the_sidebar_between_sessions_and_forwards(cx: &m in_frame(cx, handle, |window, _| { assert_eq!(window.find("show-sessions").checked(), Some(true)); assert_eq!(window.find("show-forwards").checked(), Some(false)); + // A new host, the usual addition, comes before a new group. + let host = window.find("new-session-panel").bounds(); + let group = window.find("new-group").bounds(); + assert!(host.right() <= group.left(), "{host:?} {group:?}"); }); show_forwards(cx, handle).await; @@ -7801,7 +7805,14 @@ async fn forwards_are_read_back_from_the_database(cx: &mut TestAppContext) { let path = directory.path().join("shellrs.db"); let store = SessionStore::load(SessionDatabase::open(&path).expect("database opened")) .expect("store loaded"); - let session = SessionDraft::new("db-01", "10.0.2.5", 22, "postgres", AuthKind::Auto, None); + let session = SessionDraft::new( + "db-01", + "10.0.2.5", + 22, + "postgres", + AuthKind::Password, + None, + ); let (handle, workspace) = open_workspace_with_forwards(cx, store, Arc::new(FakeForwardProvider::default())); cx.run_until_parked(); @@ -8241,7 +8252,7 @@ fn store_with_credential( ) .unwrap(); let session = store.insert_unnotified( - SessionDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Auto, None) + SessionDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None) .with_credential(credential), ); (store.with_secrets(secrets), credential, session) @@ -8503,7 +8514,7 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC .expect("web-01 inserted"); assert_eq!(created.credential, Some(credential)); assert_eq!(created.user.as_ref(), "deploy"); - assert_eq!(created.auth, AuthKind::Auto); + assert_eq!(created.auth, AuthKind::Password); }); // Nothing of the host's own went to the keychain. assert_eq!(secrets.len(), 1); @@ -8576,7 +8587,7 @@ async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestA "10.0.1.12", 22, "root", - AuthKind::Auto, + AuthKind::Password, None, )); let remote = Arc::new(RecordingRemoteProvider::default()); @@ -8682,7 +8693,7 @@ async fn deleting_a_used_credential_leaves_its_hosts_connected_and_logging_in_on assert!(store.credentials().is_empty()); let host = store.session(session).unwrap(); assert_eq!(host.credential, None); - assert_eq!(host.auth, AuthKind::Auto); + assert_eq!(host.auth, AuthKind::Password); assert_eq!(host.user.as_ref(), "deploy"); }); // The working connection was left alone, and the password went with the @@ -8956,3 +8967,66 @@ async fn a_key_credentials_public_key_is_copied_from_its_file(cx: &mut TestAppCo Some(key.public_key_line("deploy@laptop")) ); } + +#[gpui_kit::test] +async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppContext) { + let (handle, _) = open_workspace_with_credentials( + cx, + SessionStore::seed(), + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + in_frame(cx, handle, |window, cx| window.click("new-session", cx)); + in_frame(cx, handle, |window, cx| { + let name = window.find("session-name").bounds(); + let mut group = window.within("session-auth-source"); + let segments = [0usize, 1, 2].map(|ix| group.find(ix)); + let selected: Vec<_> = segments.iter().map(|segment| segment.selected()).collect(); + assert_eq!(selected, [Some(true), Some(false), Some(false)]); + let [password, saved, keyless] = segments.map(|segment| segment.bounds()); + // Side by side in one row, sharing the track equally (to the + // layout's rounding). The track spans the form like the name field + // above it, and the segments fill it but for its inset. + for segment in [saved, keyless] { + assert_eq!(segment.top(), password.top()); + assert_eq!(segment.size.height, password.size.height); + assert!((segment.size.width - password.size.width).abs() <= px(1.)); + } + assert!(password.right() <= saved.left() && saved.right() <= keyless.left()); + assert!(name.left() < password.left() && keyless.right() < name.right()); + assert!(password.size.width * 3. > name.size.width * 0.95); + group.click(1usize, cx); + }); + in_frame(cx, handle, |window, _| { + let group = window.within("session-auth-source"); + assert_eq!(group.find(1usize).selected(), Some(true)); + assert_eq!(group.find(0usize).selected(), Some(false)); + assert!(window.find("session-credential").visible()); + }); +} + +#[gpui_kit::test] +async fn a_segment_that_cannot_be_chosen_stays_unchosen(cx: &mut TestAppContext) { + // No key directory: pasted and generated keys have nowhere to go. + let (handle, _) = open_workspace_with_credentials( + cx, + SessionStore::empty(), + Arc::new(RecordingRemoteProvider::default()), + Arc::new(FakeConnectionTester::default()), + ); + show_credentials(cx, handle).await; + in_frame(cx, handle, |window, cx| window.click("new-credential", cx)); + in_frame(cx, handle, |window, cx| { + window.within("credential-kind").click(1usize, cx); + }); + in_frame(cx, handle, |window, cx| { + window.within("credential-key-source").click(1usize, cx); + }); + in_frame(cx, handle, |window, _| { + let sources = window.within("credential-key-source"); + assert_eq!(sources.find(0usize).selected(), Some(true)); + assert_eq!(sources.find(1usize).selected(), Some(false)); + assert!(window.find("credential-key-path").visible()); + assert!(window.try_find("credential-key-text").is_none()); + }); +}