From 2de318a4e6047492d33daf1e02ac232083cc7bb8 Mon Sep 17 00:00:00 2001 From: Xuz Date: Sun, 4 Oct 2026 19:20:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=A7=8B=E9=A1=B5=E5=8A=A0=E3=80=8C?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E8=BF=9E=E6=8E=A5=E3=80=8D=EF=BC=9A=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E4=BF=9D=E5=AD=98=E7=9A=84=E4=B8=BB=E6=9C=BA=EF=BC=8C?= =?UTF-8?q?=E5=9B=9E=E8=BD=A6=E6=88=96=E5=8D=95=E5=87=BB=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「最近连接」标题右边,「新建主机…」左边加一个小号的「快速连接」按钮(两个按钮都改成小号)。 打开的对话框列出所有保存的主机,连接过的按最近连接排在前面,其余按名称排;每行是系统徽章、 名称和「分组 · 用户@地址:端口」。名称、地址、用户名、备注和分组都能搜,几个词都要匹配。 打开时不高亮任何一台,输入后高亮第一个匹配的,回车或单击连接。 多选(Tab 选中、⌘A 全选、⇧↵ 批量连接)已经做好但先关掉,常量 MULTIPLE 打开即可。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- docs/manual.md | 2 + src/app/actions.rs | 3 + src/host/mod.rs | 2 + src/host/quick_connect.rs | 545 ++++++++++++++++++++++++++++++++ src/workspace/recent_hosts.rs | 41 ++- src/workspace/workspace_view.rs | 15 +- tests/ui/workspace.rs | 63 ++++ 8 files changed, 662 insertions(+), 15 deletions(-) create mode 100644 src/host/quick_connect.rs diff --git a/CLAUDE.md b/CLAUDE.md index 6dff783..ae44f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`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())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 -- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `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`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`(`HostForm`;`HostForm::temporary` / `open_temporary_connection_dialog` 是临时连接的对话框)、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。 +- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `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`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`(`HostForm`;`HostForm::temporary` / `open_temporary_connection_dialog` 是临时连接的对话框)、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`quick_connect.rs`(开始页的「快速连接」,用户给了参考图:gpui-kit 的 `List`(搜索框、↑↓、回车和单击确认)列出保存的主机,`quick_hosts` 排序(最近连接的在前,其余按名称),`QuickHost::matches` 几个词都要在名称、地址、用户名、备注或分组路径里,打开时不高亮任何一台(用户嫌预先选中一台别扭),搜索后由 `List` 自己高亮第一个匹配的、回车就连它。`QuickChoice` 是不依赖窗口的选择状态(高亮行、Tab / ⌘A 选中的、⇧↵ 连哪些),都有单元测试。**多选先关掉了**(用户定的,常量 `MULTIPLE` 为假):开着时搜索框自己要的 Tab(`IndentInline`)、⌘A(`SelectAll`)和 ⇧↵(`Enter { shift }`)由外层容器在捕获阶段 `capture_action` 截下再 `stop_propagation`,⌘ 单击也是选中,页脚列出这三个键;关着时这些都不挂,Tab、⌘A 照搜索框的本意。普通回车照常经输入框传给 `List` 的确认。连接是关掉对话框后 `window.defer` 在工作区的 focus handle 上逐台派发 `ConnectHost`,同临时连接)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。 - `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`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、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` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`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 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`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`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。 - **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。选「密码」又没填密码时不拦着(用户定的:不强制填密码,连不上就连不上):`LoginTest::password_left_empty` 为真,测试按「无密码」登录(免认证、SSH Agent、默认私钥),服务器要密码时(`PasswordWanted`,「无密码」那句报错的类型)改报「未填写密码」,不说「请改用…」(`a_test_with_the_password_left_empty_goes_without_one`)。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 @@ -64,7 +64,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。 - `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。 - `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。 -- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 +- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 - `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。 @@ -208,4 +208,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-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`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `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)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host-panel`(主机列表标题栏的「+」,打开新建主机对话框;标题栏右上角的按钮是临时连接 `temporary-connection`,它的对话框字段 id 同主机对话框,另有说明 `temporary-note`)、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-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`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `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)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`recent-quick-connect`(快速连接:列表容器 `quick-connect`,每行 `("quick-connect-host", 主机 id)`(`aria_label` 形如「db-01 · 数据库 · root@10.0.0.2:22」,`aria_selected` 是被 Tab / ⌘A 选中,多选关着时总是假),搜不到或没有主机时 `quick-connect-empty`;打开后搜索框已有焦点,直接 `input`、`press`)、`host-search`、`new-host-panel`(主机列表标题栏的「+」,打开新建主机对话框;标题栏右上角的按钮是临时连接 `temporary-connection`,它的对话框字段 id 同主机对话框,另有说明 `temporary-note`)、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/docs/manual.md b/docs/manual.md index 898beba..86a948e 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -36,6 +36,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 鼠标停在主机上,行的右侧会显示 `用户@地址:端口`,主机有备注时下面接着显示备注(太长的只显示前 6 行)。右键主机可连接、打开 SFTP、编辑、复制、复制 IP 地址(地址是域名时叫「复制主机名」)、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。 +列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。 + 拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。 ## 临时连接 diff --git a/src/app/actions.rs b/src/app/actions.rs index f115036..76a5282 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -23,6 +23,9 @@ gpui_kit::actions!( NewHost, /// Open the 临时连接 dialog: connect to a host without saving it. NewTemporaryConnection, + /// Open 快速连接: search the saved hosts and connect to one or + /// several. + QuickConnect, /// Open the new-group dialog for a top-level group. NewGroup, /// Open a new local login-shell terminal. diff --git a/src/host/mod.rs b/src/host/mod.rs index f8b799b..fb3417f 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -11,6 +11,7 @@ mod login; mod model; mod outline; mod private_key; +mod quick_connect; mod secret_fields; mod snippet; mod ssh_link; @@ -29,6 +30,7 @@ pub use login::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin}; pub use model::*; pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query}; pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key}; +pub use quick_connect::{QuickChoice, QuickHost, open_quick_connect_dialog, quick_hosts}; pub use secret_fields::SecretFields; pub use snippet::*; pub use ssh_link::{LinkKind, SshLink}; diff --git a/src/host/quick_connect.rs b/src/host/quick_connect.rs new file mode 100644 index 0000000..f1d48b6 --- /dev/null +++ b/src/host/quick_connect.rs @@ -0,0 +1,545 @@ +//! 快速连接: the start page's palette for reaching saved hosts by typing a +//! few letters of them. +//! +//! The list is gpui-kit's `List` (its search box, ↑↓, Enter and a click +//! connect the highlighted host). Choosing several and connecting them +//! together is built but switched off, see [`MULTIPLE`]: the search box +//! would keep Tab, ⌘A and ⇧↵ for itself, so the palette takes those actions +//! on their way to it, in the capture phase: Tab chooses the highlighted +//! host, ⌘A every host shown, ⇧↵ connects the chosen ones. + +use std::collections::HashSet; + +use gpui_kit::component::{ + ActiveTheme as _, IconName, IndexPath, Sizable as _, WindowExt as _, + dialog::DialogFooter, + h_flex, + input::{Enter, IndentInline, SelectAll}, + kbd::Kbd, + list::{List, ListDelegate, ListItem, ListState}, + v_flex, +}; +use gpui_kit::*; + +use gpui_kit::prelude::FluentBuilder as _; + +use crate::app::ConnectHost; +use crate::shared::HostMark; + +use super::{HostId, HostOs, HostStore}; + +/// Whether several hosts can be chosen (Tab, ⌘A, ⌘-click) and connected to +/// together (⇧↵), with the keys in the footer. Off for now (the user's +/// call); turning it on brings all of that back. +const MULTIPLE: bool = false; + +/// A host as the palette lists it, as it was when the palette opened: the +/// dialog is modal, so nothing changes under it. +#[derive(Clone, Debug, PartialEq)] +pub struct QuickHost { + pub id: HostId, + pub name: SharedString, + pub user: SharedString, + pub address: SharedString, + /// `user@address:port`. + pub endpoint: SharedString, + /// The full group path (`公司 / MySQL 集群`), when it has a group. + pub group: Option, + pub notes: SharedString, + pub os: Option, +} + +impl QuickHost { + /// Whether every word of `query` is somewhere in the name, address, + /// user, notes or group path, whatever the case. + pub fn matches(&self, query: &str) -> bool { + let fields = [ + &self.name, + &self.address, + &self.user, + &self.notes, + self.group.as_ref().unwrap_or(&self.name), + ] + .map(|field| field.to_lowercase()); + query + .to_lowercase() + .split_whitespace() + .all(|word| fields.iter().any(|field| field.contains(word))) + } + + /// The line under the name: the group, then where it logs in. + pub fn detail(&self) -> String { + match &self.group { + Some(group) => format!("{group} · {}", self.endpoint), + None => self.endpoint.to_string(), + } + } +} + +/// The saved hosts, those connected to lately first (the latest first), then +/// the rest by name. +pub fn quick_hosts(store: &HostStore) -> Vec { + let of = |host: &super::Host| QuickHost { + id: host.id, + name: host.name.clone(), + user: host.user.clone(), + address: host.address.clone(), + endpoint: host.endpoint().into(), + group: host + .group + .map(|group| SharedString::from(store.group_path(group))), + notes: host.notes.clone(), + os: host.os, + }; + let mut hosts: Vec = store + .recent_hosts() + .filter(|host| !store.is_temporary(host.id)) + .map(of) + .collect(); + let recent: HashSet = hosts.iter().map(|host| host.id).collect(); + let mut rest: Vec = store + .hosts() + .iter() + .filter(|host| !recent.contains(&host.id)) + .map(of) + .collect(); + rest.sort_by_key(|host| host.name.to_lowercase()); + hosts.extend(rest); + hosts +} + +/// What the palette shows, which row is highlighted and which hosts have +/// been chosen: the list's state, apart from the list. +pub struct QuickChoice { + hosts: Vec, + /// Indices into `hosts` of the ones the query matches, in order. + shown: Vec, + /// Chosen with Tab or ⌘A, for ⇧↵ to connect together. Kept while the + /// query changes, so hosts found by different searches go together. + chosen: HashSet, + /// The highlighted row of `shown`. + selected: Option, +} + +impl QuickChoice { + pub fn new(hosts: Vec) -> Self { + Self { + shown: (0..hosts.len()).collect(), + hosts, + chosen: HashSet::new(), + selected: None, + } + } + + /// Show the hosts `query` matches. + pub fn search(&mut self, query: &str) { + self.shown = (0..self.hosts.len()) + .filter(|ix| self.hosts[*ix].matches(query)) + .collect(); + } + + /// Highlight a row of what is shown. + pub fn highlight(&mut self, row: Option) { + self.selected = row; + } + + /// The host on a row of what is shown. + pub fn shown(&self, row: usize) -> Option<&QuickHost> { + self.shown.get(row).map(|ix| &self.hosts[*ix]) + } + + pub fn shown_count(&self) -> usize { + self.shown.len() + } + + pub fn is_chosen(&self, id: HostId) -> bool { + self.chosen.contains(&id) + } + + fn highlighted(&self) -> Option { + self.selected + .and_then(|row| self.shown(row)) + .map(|host| host.id) + } + + /// Tab: choose the highlighted host, or take it back. + pub fn toggle_highlighted(&mut self) { + if let Some(id) = self.highlighted() + && !self.chosen.remove(&id) + { + self.chosen.insert(id); + } + } + + /// ⌘A: choose every host shown; when they all are already, take them + /// back. + pub fn choose_all(&mut self) { + let shown: Vec = self.shown.iter().map(|ix| self.hosts[*ix].id).collect(); + if shown.iter().all(|id| self.chosen.contains(id)) { + for id in &shown { + self.chosen.remove(id); + } + } else { + self.chosen.extend(shown); + } + } + + /// What Enter connects: the highlighted host. + pub fn to_connect_one(&self) -> Vec { + self.highlighted().into_iter().collect() + } + + /// What ⇧↵ connects: the chosen hosts in the list's order, or the + /// highlighted one when none is chosen. + pub fn to_connect(&self) -> Vec { + let chosen: Vec = self + .hosts + .iter() + .map(|host| host.id) + .filter(|id| self.chosen.contains(id)) + .collect(); + if chosen.is_empty() { + self.to_connect_one() + } else { + chosen + } + } + + fn has_hosts(&self) -> bool { + !self.hosts.is_empty() + } +} + +/// The palette's list: its choice, and where to ask for connections. +pub struct QuickConnectList { + choice: QuickChoice, + /// The workspace's focus handle, which connecting is asked of: once the + /// dialog has closed, the focus is nowhere. + dispatch: FocusHandle, +} + +impl QuickConnectList { + pub fn new(hosts: Vec, dispatch: FocusHandle) -> Self { + Self { + choice: QuickChoice::new(hosts), + dispatch, + } + } + + pub fn choice(&self) -> &QuickChoice { + &self.choice + } + + pub fn choice_mut(&mut self) -> &mut QuickChoice { + &mut self.choice + } + + /// Close the palette and connect to `hosts`, each in a terminal tab. + pub fn connect(&self, hosts: Vec, window: &mut Window, cx: &mut App) { + if hosts.is_empty() { + return; + } + window.close_dialog(cx); + let dispatch = self.dispatch.clone(); + window.defer(cx, move |window, cx| { + for host in hosts { + dispatch.dispatch_action(&ConnectHost(host), window, cx); + } + }); + } +} + +impl ListDelegate for QuickConnectList { + type Item = ListItem; + + fn perform_search( + &mut self, + query: &str, + _: &mut Window, + _: &mut Context>, + ) -> Task<()> { + self.choice.search(query); + Task::ready(()) + } + + fn items_count(&self, _: usize, _: &App) -> usize { + self.choice.shown_count() + } + + fn render_item( + &mut self, + ix: IndexPath, + _: &mut Window, + cx: &mut Context>, + ) -> Option { + let host = self.choice.shown(ix.row)?; + let chosen = self.choice.is_chosen(host.id); + let detail = host.detail(); + let muted = cx.theme().muted_foreground; + Some( + ListItem::new(("quick-connect-row", host.id.0)) + .py_1p5() + .rounded(cx.theme().radius) + .when(MULTIPLE, |item| { + item.check_icon(IconName::Check).confirmed(chosen) + }) + .child( + h_flex() + .id(("quick-connect-host", host.id.0)) + .test_support() + .aria_label(SharedString::from(format!("{} · {detail}", host.name))) + .aria_selected(chosen) + .gap_3() + .child( + HostMark::new( + ("quick-connect-os", host.id.0), + host.name.clone(), + host.os, + ) + .without_tooltip() + .small(), + ) + .child( + v_flex() + .min_w_0() + .child(div().truncate().child(host.name.clone())) + .child(div().text_sm().text_color(muted).truncate().child(detail)), + ), + ), + ) + } + + fn set_selected_index( + &mut self, + ix: Option, + _: &mut Window, + _: &mut Context>, + ) { + self.choice.highlight(ix.map(|ix| ix.row)); + } + + /// Enter or a click connects the highlighted host; with ⌘ (Ctrl) and + /// [`MULTIPLE`] on, it is chosen instead, as Tab does. + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + if secondary && MULTIPLE { + self.choice.toggle_highlighted(); + cx.notify(); + } else { + self.connect(self.choice.to_connect_one(), window, cx); + } + } + + fn render_empty( + &mut self, + _: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + let text = if !self.choice.has_hosts() { + "还没有保存的主机" + } else { + "没有匹配的主机" + }; + h_flex() + .id("quick-connect-empty") + .test_support() + .aria_label(text) + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text) + } +} + +/// One key and what it does, for the footer. +fn key_hint(keys: &str, label: &'static str) -> impl IntoElement { + h_flex() + .gap_1p5() + .children(Keystroke::parse(keys).ok().map(Kbd::new)) + .child(label) +} + +/// Open 快速连接: search the saved hosts and connect to one, or choose +/// several and connect to them together. `dispatch` is the workspace's +/// focus handle. +pub fn open_quick_connect_dialog( + store: Entity, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut App, +) { + let hosts = quick_hosts(store.read(cx)); + // Nothing is highlighted until there is something to go by (the user's + // call: a host picked in advance read as chosen for them). Searching + // highlights the first match, as `List` does, and ↓ the first host. + let list = cx.new(|cx| { + ListState::new(QuickConnectList::new(hosts, dispatch), window, cx).searchable(true) + }); + let select_all = if cfg!(target_os = "macos") { + "cmd-a" + } else { + "ctrl-a" + }; + window.open_dialog(cx, { + let list = list.clone(); + move |dialog, _, cx| { + let theme = cx.theme(); + dialog + .title("快速连接") + .overlay_closable(false) + .child( + div() + .id("quick-connect") + .test_support() + .w_full() + .h(rems(26.)) + .border_1() + .border_color(theme.border) + .rounded(theme.radius) + .overflow_hidden() + // The search box's own Tab, ⌘A and ⇧↵, taken on the + // way to it. + .when(MULTIPLE, |palette| { + palette + .capture_action({ + let list = list.clone(); + move |_: &IndentInline, _, cx| { + list.update(cx, |list, cx| { + list.delegate_mut().choice_mut().toggle_highlighted(); + cx.notify(); + }); + cx.stop_propagation(); + } + }) + .capture_action({ + let list = list.clone(); + move |_: &SelectAll, _, cx| { + list.update(cx, |list, cx| { + list.delegate_mut().choice_mut().choose_all(); + cx.notify(); + }); + cx.stop_propagation(); + } + }) + .capture_action({ + let list = list.clone(); + move |enter: &Enter, window, cx| { + if enter.shift { + list.update(cx, |list, cx| { + let hosts = list.delegate().choice().to_connect(); + list.delegate().connect(hosts, window, cx); + }); + cx.stop_propagation(); + } + } + }) + }) + .child( + List::new(&list) + .search_placeholder("搜索主机名称、地址、用户名、备注或分组"), + ), + ) + .when(MULTIPLE, |dialog| { + dialog.footer( + DialogFooter::new() + .w_full() + .justify_end() + .gap_4() + .text_sm() + .text_color(theme.muted_foreground) + .child(key_hint("tab", "选中")) + .child(key_hint(select_all, "全选")) + .child(key_hint("shift-enter", "批量连接")), + ) + }) + } + }); + // The search box takes the keys at once: focusing it later in another + // update would lose to the dialog's own focus. + list.update(cx, |list, cx| list.focus(window, cx)); +} + +#[cfg(test)] +mod tests { + use super::super::{ + AuthKind, ConnectionState, GroupDraft, GroupId, HostDraft, HostId, HostStore, + }; + use super::{QuickChoice, QuickHost, quick_hosts}; + + fn host(name: &str, address: &str, group: Option) -> HostDraft { + HostDraft::new(name, address, 22, "root", AuthKind::Password, group) + } + + #[test] + fn the_hosts_connected_to_lately_come_first_then_the_rest_by_name() { + let mut store = HostStore::empty(); + let group = store.insert_group_unnotified(GroupDraft::new("公司", None)); + let zen = store.insert_unnotified(host("禅道", "8.138.95.125", None)); + let web = store.insert_unnotified(host("web", "10.0.0.1", Some(group))); + let db = store.insert_unnotified(host("DB", "10.0.0.2", None)); + let api = store.insert_unnotified(host("api", "10.0.0.3", None)); + store.set_state_unnotified(web, ConnectionState::Connected); + store.set_state_unnotified(zen, ConnectionState::Connected); + + let hosts = quick_hosts(&store); + let ids: Vec = hosts.iter().map(|host| host.id).collect(); + assert_eq!(ids, [zen, web, api, db]); + assert_eq!(hosts[0].detail(), "root@8.138.95.125:22"); + assert_eq!(hosts[1].detail(), "公司 · root@10.0.0.1:22"); + } + + fn quick(id: u64, name: &str) -> QuickHost { + QuickHost { + id: HostId(id), + name: name.into(), + user: "deploy".into(), + address: "172.16.1.166".into(), + endpoint: "deploy@172.16.1.166:22".into(), + group: Some("公司 / CI".into()), + notes: "构建服务器".into(), + os: None, + } + } + + #[test] + fn every_word_is_looked_for_in_the_name_address_user_notes_and_group() { + let host = quick(1, "teamcity"); + for query in ["", "TEAM", "172.16", "deploy", "构建", "公司 ci", "ci 166"] { + assert!(host.matches(query), "{query:?}"); + } + for query in ["jenkins", "公司 jenkins"] { + assert!(!host.matches(query), "{query:?}"); + } + } + + #[test] + fn tab_and_select_all_choose_what_shift_enter_connects() { + let mut choice = + QuickChoice::new(vec![quick(1, "alpha"), quick(2, "beta"), quick(3, "gamma")]); + // Nothing chosen: the highlighted host, Enter's as well. + choice.highlight(Some(1)); + assert_eq!(choice.to_connect(), [HostId(2)]); + assert_eq!(choice.to_connect_one(), [HostId(2)]); + + // Tab chooses and takes back; the list's order, not the choosing's. + choice.toggle_highlighted(); + choice.highlight(Some(0)); + choice.toggle_highlighted(); + assert_eq!(choice.to_connect(), [HostId(1), HostId(2)]); + choice.toggle_highlighted(); + assert_eq!(choice.to_connect(), [HostId(2)]); + + // ⌘A chooses what is shown, then takes it all back. A choice made + // under another search stays. + choice.search("gamma"); + assert_eq!(choice.shown_count(), 1); + choice.choose_all(); + assert_eq!(choice.to_connect(), [HostId(2), HostId(3)]); + choice.search(""); + choice.choose_all(); + assert_eq!(choice.to_connect(), [HostId(1), HostId(2), HostId(3)]); + choice.choose_all(); + choice.highlight(Some(2)); + assert_eq!(choice.to_connect(), [HostId(3)]); + } +} diff --git a/src/workspace/recent_hosts.rs b/src/workspace/recent_hosts.rs index b889fc4..c5cd8d9 100644 --- a/src/workspace/recent_hosts.rs +++ b/src/workspace/recent_hosts.rs @@ -1,13 +1,20 @@ use std::{cell::Cell, rc::Rc}; use gpui_kit::component::{ - ActiveTheme as _, Icon, IconName, Sizable as _, button::Button, h_flex, list::ListItem, - menu::ContextMenuExt as _, scroll::ScrollableElement as _, v_flex, + ActiveTheme as _, Icon, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + list::ListItem, + menu::ContextMenuExt as _, + scroll::ScrollableElement as _, + v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; -use crate::app::{CatalogIcon, ConnectHost, ConnectSelected, NewHost, RECENT_HOSTS_CONTEXT}; +use crate::app::{ + CatalogIcon, ConnectHost, ConnectSelected, NewHost, QuickConnect, RECENT_HOSTS_CONTEXT, +}; use crate::host::{HostId, HostOs, HostStore, host_menu}; use crate::shared::HostMark; @@ -103,10 +110,30 @@ impl RecentHosts { ), ) .child( - Button::new("recent-new-host") - .icon(IconName::Plus) - .label("新建主机…") - .on_click(move |_, window, cx| target.dispatch_action(&NewHost, window, cx)), + h_flex() + .gap_2() + .child( + Button::new("recent-quick-connect") + .primary() + .small() + .icon(IconName::Search) + .label("快速连接") + .on_click({ + let target = target.clone(); + move |_, window, cx| { + target.dispatch_action(&QuickConnect, window, cx) + } + }), + ) + .child( + Button::new("recent-new-host") + .small() + .icon(IconName::Plus) + .label("新建主机…") + .on_click(move |_, window, cx| { + target.dispatch_action(&NewHost, window, cx) + }), + ), ) } diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index f56e522..89d106c 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -27,10 +27,10 @@ use crate::app::{ ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveHostNode, NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, NewTemporaryConnection, - OpenExplorer, OpenSettings, PasteTerminal, ReconnectTerminal, RefreshCliIntegration, - RemoveAgentSkill, RemoveCliCommand, RenameExplorer, RenameGroup, RenameTerminal, - RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, - ZoomReset, + OpenExplorer, OpenSettings, PasteTerminal, QuickConnect, ReconnectTerminal, + RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer, RenameGroup, + RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel, ToggleTheme, ZoomIn, + ZoomOut, ZoomReset, }; use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend}; use crate::connection::{ @@ -46,7 +46,7 @@ use crate::forward::{ use crate::host::{ ConnectionState, Dependents, ForwardId, GroupId, HostId, HostNode, HostPanel, HostStore, HostStoreEvent, confirm_delete_group, confirm_delete_host, open_group_dialog, open_host_dialog, - open_temporary_connection_dialog, + open_quick_connect_dialog, open_temporary_connection_dialog, }; use crate::settings::{ Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent, @@ -1699,6 +1699,10 @@ impl Workspace { ); } + fn on_quick_connect(&mut self, _: &QuickConnect, window: &mut Window, cx: &mut Context) { + open_quick_connect_dialog(self.store.clone(), self.focus_handle.clone(), window, cx); + } + fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context) { // A host opened from a link is not saved, so there is nothing to // edit. @@ -2233,6 +2237,7 @@ impl Render for Workspace { .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_new_host)) .on_action(cx.listener(Self::on_new_temporary_connection)) + .on_action(cx.listener(Self::on_quick_connect)) .on_action(cx.listener(Self::on_new_local_terminal)) .on_action(cx.listener(Self::on_edit_host)) .on_action(cx.listener(Self::on_duplicate_host)) diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index e6a7cf0..bbfeb24 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -196,6 +196,69 @@ async fn enter_connects_the_selected_recent_host(cx: &mut TestAppContext) { .await; } +/// 快速连接 on the start page: search the saved hosts and connect to one +/// with Enter. (Choosing several is switched off for now.) +#[gpui_kit::test] +async fn quick_connect_finds_saved_hosts_and_connects_one(cx: &mut TestAppContext) { + use shellrs::host::GroupDraft; + + let mut store = HostStore::empty(); + let group = store.insert_group_unnotified(GroupDraft::new("数据库", None)); + let [web, db, cache] = [ + ("web-01", "10.0.0.1", None), + ("db-01", "10.0.0.2", Some(group)), + ("cache", "10.0.0.3", None), + ] + .map(|(name, address, group)| { + store.insert_unnotified(HostDraft::new( + name, + address, + 22, + "root", + AuthKind::Password, + group, + )) + }); + let (handle, _) = open_workspace_with_store(cx, store); + let row = |id: HostId| ("quick-connect-host", id.0); + + in_frame(cx, handle, |window, cx| { + window.click("recent-quick-connect", cx) + }); + in_frame(cx, handle, |window, cx| { + // Every saved host, by name while none has been connected to. + let tops: Vec = [cache, db, web] + .map(|id| window.find(row(id)).bounds().origin.y.into()) + .to_vec(); + assert!(tops.is_sorted(), "{tops:?}"); + assert_eq!( + window.find(row(db)).label(), + Some("db-01 · 数据库 · root@10.0.0.2:22") + ); + // The search box has the keys: a group's name finds its hosts. + window.input("数据", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find(row(web)).is_none() + }) + .await; + in_frame(cx, handle, |window, cx| { + assert!(window.try_find(row(db)).is_some()); + window.press("enter", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find(("terminal-tab", 1u64)) + .is_some_and(|tab| tab.label() == Some("db-01")) + }) + .await; + in_frame(cx, handle, |window, _| { + assert!(window.try_find("quick-connect").is_none()); + }); +} + /// The start page's row menu is the host tree's menu. Menus are not /// driven here, so this dispatches what its items dispatch, from the page: /// the page is drawn deferred over the dock, and its actions must still