主机认证方式改为「密码 / 使用凭据 / 无密码」,对话框单选改用分段控件
- 主机对话框去掉「认证类型」和私钥字段:「密码」只走密码(留空则每次询问), 「无密码」依次试服务器免认证、SSH Agent 和 ~/.ssh 默认私钥,服务器要密码时直接报错不询问; 私钥只能通过密钥凭据使用 - 数据库升到第 10 版:自己选了私钥文件的主机按「用户 + 私钥文件」转成密钥凭据, 原来的「自动」归为「密码」 - 删除凭据后,密码凭据的主机改为「密码」,密钥和 Agent 凭据的主机改为「无密码」 - 新增基于 gpui-base Radio 的 SegmentedControl,用于主机和凭据对话框里的几选一 - 主机列表标题栏的「新建主机」移到「新建分组」前面 - 测试夹具一律关闭动画,避免对话框滑入导致的偶发失败
This commit is contained in:
1 parent
ee488309f3
commit
9ec2b46234
20 files changed
+865
-488
No files matched your search
@@ -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/<name>.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<GroupId>`,`Session.os` 是 `Option<HostOs>`,`Session.credential` 是 `Option<CredentialId>`)、`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:<id>` / `s:<id>`;`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 校验写 `<data_dir>/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 校验写 `<data_dir>/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>`;数字 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`,写 `<data_dir>/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<SessionStore>` 的克隆,通过它的方法修改(方法内部会 `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<Cell<Option<SessionNode>>>`(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。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<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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 个文件到 …」)与来源清单Line truncated
|
||||
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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 个文件到 …Line truncated
|
||||
@@ -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 保存的私钥。**
|
||||
|
||||
|
||||
+1
-1
@@ -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))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
+218
-46
@@ -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<i64> = row.get(6)?;
|
||||
let key_path: Option<String> = row.get(7)?;
|
||||
let os: Option<String> = row.get(8)?;
|
||||
let credential: Option<i64> = row.get(11)?;
|
||||
let os: Option<String> = row.get(7)?;
|
||||
let credential: Option<i64> = 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::<rusqlite::Result<Vec<_>>>()?;
|
||||
let mut keychain_ids = transaction
|
||||
.prepare("SELECT keychain_id FROM credentials")?
|
||||
.query_map([], |row| row.get::<_, String>(0).map(PublicId::from_stored))?
|
||||
.collect::<rusqlite::Result<HashSet<_>>>()?;
|
||||
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);
|
||||
|
||||
+21
-25
@@ -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<PathBuf>,
|
||||
/// 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<String>,
|
||||
port: u16,
|
||||
user: impl Into<String>,
|
||||
auth: AuthKind,
|
||||
key_path: Option<PathBuf>,
|
||||
) -> 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");
|
||||
}
|
||||
|
||||
|
||||
+27
-52
@@ -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<SharedString>,
|
||||
/// The credential it logs in with instead of `auth`, if any.
|
||||
pub credential: Option<CredentialId>,
|
||||
pub group: Option<GroupId>,
|
||||
@@ -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<SharedString>,
|
||||
pub credential: Option<CredentialId>,
|
||||
pub group: Option<GroupId>,
|
||||
}
|
||||
@@ -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<SharedString>) -> 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<String>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ mod tests {
|
||||
"10.0.0.1",
|
||||
22,
|
||||
"root",
|
||||
AuthKind::Key,
|
||||
AuthKind::NoPassword,
|
||||
group,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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 _,
|
||||
|
||||
+102
-165
@@ -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<AuthKind> {
|
||||
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<InputState>,
|
||||
source: AuthSource,
|
||||
user: Entity<InputState>,
|
||||
auth: Entity<SelectState<Vec<&'static str>>>,
|
||||
/// The password, key file and passphrase of a login typed here.
|
||||
/// The password of a login typed here.
|
||||
fields: Entity<SecretFields>,
|
||||
credential: Entity<SelectState<Vec<SharedString>>>,
|
||||
/// 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::<Vec<_>>(),
|
||||
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<LoginTest, &'static str> {
|
||||
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<CommittedLogin, &'static str> {
|
||||
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<String>)> = 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<Self>) -> 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));
|
||||
}
|
||||
}
|
||||
@@ -362,15 +362,16 @@ impl Panel for SessionPanel {
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
|
||||
// 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)),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+46
-48
@@ -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<SessionId> {
|
||||
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<GroupId>) -> 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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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<dyn Fn(&usize, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<Segment>,
|
||||
selected: Option<usize>,
|
||||
on_change: Option<ChangeHandler>,
|
||||
}
|
||||
|
||||
/// One segment of a [`SegmentedControl`].
|
||||
pub struct Segment {
|
||||
label: SharedString,
|
||||
disabled: bool,
|
||||
/// Why a disabled segment cannot be chosen.
|
||||
tooltip: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
pub fn new(label: impl Into<SharedString>) -> 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<SharedString>) -> Self {
|
||||
self.tooltip = Some(tooltip.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl FluentBuilder for Segment {}
|
||||
|
||||
impl SegmentedControl {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
segments: Vec::new(),
|
||||
selected: None,
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_index(mut self, index: Option<usize>) -> 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<Item = Segment>) -> 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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -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;
|
||||
|
||||
+53
-17
@@ -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:?}"),
|
||||
|
||||
@@ -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<SharedString> {
|
||||
(hosts > 0).then(|| {
|
||||
format!(
|
||||
"有 {hosts} 台主机正在使用此凭据。删除后它们改为手动输入,认证类型为「自动」,用户名不变。"
|
||||
)
|
||||
.into()
|
||||
})
|
||||
fn describe_credential_delete(hosts: usize, kind: CredentialKind) -> Option<SharedString> {
|
||||
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 中的私钥登录,用户名不变。"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+130
-56
@@ -344,6 +344,8 @@ fn open_workspace_with_factory(
|
||||
factory: Arc<FakeTerminalFactory>,
|
||||
) -> (WindowHandle<Root>, Entity<Workspace>) {
|
||||
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<dyn TerminalTransportFactory>,
|
||||
) -> (WindowHandle<Root>, Entity<Workspace>) {
|
||||
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());
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user