开始页加「快速连接」:搜索保存的主机,回车或单击连接

「最近连接」标题右边,「新建主机…」左边加一个小号的「快速连接」按钮(两个按钮都改成小号)。
打开的对话框列出所有保存的主机,连接过的按最近连接排在前面,其余按名称排;每行是系统徽章、
名称和「分组 · 用户@地址:端口」。名称、地址、用户名、备注和分组都能搜,几个词都要匹配。
打开时不高亮任何一台,输入后高亮第一个匹配的,回车或单击连接。

多选(Tab 选中、⌘A 全选、⇧↵ 批量连接)已经做好但先关掉,常量 MULTIPLE 打开即可。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-04 19:20:57 +08:00
1 parent 9474c9e789
commit 2de318a4e6
8 files changed
+662 -15

No files matched your search

+3 -3
View File
@@ -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<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), 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:<id>` / `s:<id>`;`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<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), 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:<id>` / `s:<id>`;`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 校验写 `<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` 由 `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`,服务器回成功或失败都算Line truncated
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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`,写 `<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` 命令,以及它在应用里的服务端。
- `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<HostStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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-tooltLine truncated
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<HostStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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-tooltLine truncated
+2
View File
@@ -36,6 +36,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
鼠标停在主机上,行的右侧会显示 `用户@地址:端口`,主机有备注时下面接着显示备注(太长的只显示前 6 行)。右键主机可连接、打开 SFTP、编辑、复制、复制 IP 地址(地址是域名时叫「复制主机名」)、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
## 临时连接
+3
View File
@@ -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.
+2
View File
@@ -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};
+545
View File
@@ -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<SharedString>,
pub notes: SharedString,
pub os: Option<HostOs>,
}
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<QuickHost> {
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<QuickHost> = store
.recent_hosts()
.filter(|host| !store.is_temporary(host.id))
.map(of)
.collect();
let recent: HashSet<HostId> = hosts.iter().map(|host| host.id).collect();
let mut rest: Vec<QuickHost> = 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<QuickHost>,
/// Indices into `hosts` of the ones the query matches, in order.
shown: Vec<usize>,
/// Chosen with Tab or ⌘A, for ⇧↵ to connect together. Kept while the
/// query changes, so hosts found by different searches go together.
chosen: HashSet<HostId>,
/// The highlighted row of `shown`.
selected: Option<usize>,
}
impl QuickChoice {
pub fn new(hosts: Vec<QuickHost>) -> 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<usize>) {
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<HostId> {
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<HostId> = 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<HostId> {
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<HostId> {
let chosen: Vec<HostId> = 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<QuickHost>, 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<HostId>, 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<ListState<Self>>,
) -> 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<ListState<Self>>,
) -> Option<ListItem> {
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<IndexPath>,
_: &mut Window,
_: &mut Context<ListState<Self>>,
) {
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<ListState<Self>>) {
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<ListState<Self>>,
) -> 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<HostStore>,
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<GroupId>) -> 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<HostId> = 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)]);
}
}
+34 -7
View File
@@ -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)
}),
),
)
}
+10 -5
View File
@@ -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<Self>) {
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<Self>) {
// 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))
+63
View File
@@ -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<f32> = [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