diff --git a/CLAUDE.md b/CLAUDE.md index 975236f..73ebe5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`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 线程解析」)、`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`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 -- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、SSH、SFTP 和工作区都直接用这些类型。 -- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`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` 一样,解析不了的名字也算网络错误。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 秒用 `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`。 +- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `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` 一样,解析不了的名字也算网络错误。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` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。 - `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 @@ -74,7 +74,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。 - `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。 - `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用、设置页和对话框共用的下一步 `UpdateStep`(`UpdateSnapshot::step`),以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。 -- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 +- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 关键流程与不变量: @@ -175,4 +175,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`(标签页和关闭、开始页、标题栏、对话框的共性)、`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`)。**不要在 `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`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、主机行的 tooltip `host-tooltip`(`aria_label` 是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的备注 `host-tooltip-notes`(`aria_label` 是备注全文,没有备注时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`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-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `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`(标签页和关闭、开始页、标题栏、对话框的共性)、`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`)。**不要在 `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`,状态行的 `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`(`aria_label` 是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的备注 `host-tooltip-notes`(`aria_label` 是备注全文,没有备注时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`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-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `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/README.md b/README.md index 36aec8d..bc5314d 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ shellrs download <远程路径> <本地路径> ## 标签页 -远程终端标签和 SFTP 标签都以主机系统徽章为图标(与主机列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为主机的名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑主机,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「主机的名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的侧栏(同 ⌘B 和标题栏的按钮)。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 +远程终端标签和 SFTP 标签都以主机系统徽章为图标(与主机列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为主机的名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑主机,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「主机的名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的侧栏(同 ⌘B 和标题栏的按钮)。远程终端运行时和 SFTP 标签已连接时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。SFTP 传输进行中也照常测量,读数会随传输占满带宽而升高。批量关闭时,正在上传的 SFTP 标签仍会先确认。 ## 终端 diff --git a/src/connection.rs b/src/connection.rs index 02b57ab..dab0826 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -1,4 +1,7 @@ -//! Shared connection questions. Secrets are never serialized or printed. +//! Shared connection questions, and the round trips measured on a live +//! connection. Secrets are never serialized or printed. +use std::time::Duration; + use zeroize::Zeroizing; /// One field requested by an SSH keyboard-interactive challenge. @@ -374,3 +377,66 @@ pub trait ConnectionTester: Send + Sync + 'static { } pub type SharedConnectionTester = std::sync::Arc; + +/// The last round trip measured on a remote connection: how long the server +/// took to answer, or that it did not answer in time. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Latency { + Measured(Duration), + TimedOut, +} + +/// How a latency reads to someone typing: echo is instant below 100 ms, +/// noticeable up to 200 ms, and sluggish beyond. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LatencyLevel { + Good, + Fair, + Poor, +} + +impl Latency { + pub fn level(self) -> LatencyLevel { + match self { + Latency::Measured(rtt) if rtt < Duration::from_millis(100) => LatencyLevel::Good, + Latency::Measured(rtt) if rtt <= Duration::from_millis(200) => LatencyLevel::Fair, + Latency::Measured(_) | Latency::TimedOut => LatencyLevel::Poor, + } + } + + pub fn label(self) -> String { + match self { + Latency::Measured(rtt) => format!("{} ms", rtt.as_millis()), + Latency::TimedOut => "超时".into(), + } + } +} + +#[cfg(test)] +mod latency_tests { + use std::time::Duration; + + use super::{Latency, LatencyLevel}; + + fn ms(millis: u64) -> Latency { + Latency::Measured(Duration::from_millis(millis)) + } + + #[test] + fn latency_levels_split_at_100_and_200_ms() { + assert_eq!(ms(99).level(), LatencyLevel::Good); + assert_eq!(ms(100).level(), LatencyLevel::Fair); + assert_eq!(ms(200).level(), LatencyLevel::Fair); + assert_eq!(ms(201).level(), LatencyLevel::Poor); + assert_eq!(Latency::TimedOut.level(), LatencyLevel::Poor); + } + + #[test] + fn latency_labels_show_whole_milliseconds() { + assert_eq!( + Latency::Measured(Duration::from_micros(32_900)).label(), + "32 ms" + ); + assert_eq!(Latency::TimedOut.label(), "超时"); + } +} diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 268b437..7c22d9a 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -8,14 +8,14 @@ use crate::{ CatalogIcon, CenterTab, CloseExplorer, CopyHostAddress, ExplorerAction, ExplorerCommand, OpenExplorer, RenameExplorer, }, - connection::{ConnectionPrompt, ConnectionPromptReply}, + connection::{ConnectionPrompt, ConnectionPromptReply, Latency}, host::{BookmarkSide, ConnectionState, HostId, HostStore}, sftp::{ DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider, SharedSftpTransportProvider, TransferDirection, TransferPhase, TransferQuestion, UploadRequest, }, - shared::{ClosableTabTitle, HostMark, RenamableTab, close_tab_items}, + shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, close_tab_items}, }; use gpui_kit::component::{ Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, @@ -58,6 +58,9 @@ pub struct ExplorerPanel { pub(super) next_operation: u64, commands: async_channel::Sender, state: ConnectionState, + /// The connection's latest round trip; none until the first one after + /// connecting comes back. + latency: Option, /// Batches waiting, running and ended, WinSCP's queue. The engine runs /// one at a time and the panel hands it the next. pub(super) queue: TransferQueue, @@ -211,6 +214,7 @@ impl ExplorerPanel { next_operation: 0, commands, state: ConnectionState::Connecting, + latency: None, queue: TransferQueue::default(), engine_busy: false, question: None, @@ -445,6 +449,7 @@ impl ExplorerPanel { SftpEvent::Connecting => { self.generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); self.state = ConnectionState::Connecting; + self.latency = None; cx.emit(ExplorerPanelEvent::StateChanged(self.id, self.host_id)); } SftpEvent::Connected { home } => { @@ -456,6 +461,7 @@ impl ExplorerPanel { } SftpEvent::Disconnected(message) => { self.state = ConnectionState::Disconnected; + self.latency = None; self.abandon_remote_operations(cx); self.remote .update(cx, |pane, cx| pane.disconnected(message, cx)); @@ -528,6 +534,7 @@ impl ExplorerPanel { SftpEvent::Notice(message) => { window.push_notification(Notification::error(message), cx); } + SftpEvent::Latency(latency) => self.latency = Some(latency), } self.sync_available(cx); cx.notify(); @@ -849,6 +856,14 @@ impl Panel for ExplorerPanel { ) -> PopupMenu { self.tab_menu(cx).build(menu, cx) } + /// The connection's latest round trip, as on a terminal tab. Shown only + /// while connected, so a dropped connection leaves no stale number. + fn title_suffix(&mut self, _: &mut Window, _: &mut Context) -> Option { + let latency = self + .latency + .filter(|_| self.state == ConnectionState::Connected)?; + Some(LatencyLabel::new(("sftp-latency", self.id.0), latency)) + } /// A terminal tab's buttons that apply here too. fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { let (id, host_id) = (self.id, self.host_id); diff --git a/src/sftp/client.rs b/src/sftp/client.rs index 5245300..8ef66bb 100644 --- a/src/sftp/client.rs +++ b/src/sftp/client.rs @@ -1,4 +1,5 @@ use super::{DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, RemotePath}; +use crate::connection::Latency; use crate::ssh::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts}; use anyhow::{Result, anyhow, bail}; use futures::StreamExt as _; @@ -52,7 +53,7 @@ pub(crate) trait RemoteFs { pub(crate) struct SftpClient { raw: RawSftpSession, - _ssh: Option, + ssh: Option, /// Turns true once the server side of the SFTP stream has ended. closed: watch::Receiver, fingerprint: String, @@ -97,7 +98,7 @@ impl SftpClient { .is_some_and(|v| v == "1"); Ok(Self { raw, - _ssh: ssh, + ssh, closed, fingerprint, atomic_replace, @@ -127,6 +128,11 @@ impl SftpClient { pub fn fingerprint(&self) -> &str { &self.fingerprint } + /// The SSH connection's round trip, measured as a terminal's is. `None` + /// for the tests' local sftp-server, which has no SSH connection. + pub async fn round_trip(&self) -> Option { + Some(crate::ssh::round_trip(self.ssh.as_ref()?).await) + } /// Resolves once the server side of the SFTP session has gone, which /// russh-sftp otherwise only tells the next request. It does not hold on /// to the client, so dropping the client still closes the connection. diff --git a/src/sftp/protocol_tests.rs b/src/sftp/protocol_tests.rs index 4a7ab24..a66bdfd 100644 --- a/src/sftp/protocol_tests.rs +++ b/src/sftp/protocol_tests.rs @@ -1,7 +1,7 @@ //! Loopback-only SSH/SFTP worker tests. No user hosts, credentials or shell. use super::*; use crate::{ - connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret}, + connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret, Latency}, host::{AuthKind, Host, HostDraft, HostId, HostLogin}, secrets::InMemorySecretStore, ssh::SshConnector, @@ -405,6 +405,36 @@ fn an_idle_connection_that_drops_is_reported_without_a_request() { }); } +/// Like a terminal's, the connection's round trip is reported right after +/// connecting, with nothing asked of it. +#[test] +fn a_connection_reports_its_round_trip_right_after_connecting() { + runtime().block_on(async { + let temp = tempfile::tempdir().unwrap(); + let Some(server) = server(temp.path().into(), 0, false).await else { + return; + }; + let worker = worker(server.port, temp.path()); + loop { + match next(&worker).await { + SftpEvent::Connected { .. } => break, + SftpEvent::Disconnected(e) => panic!("connect failed: {e}"), + _ => {} + } + } + let latency = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let SftpEvent::Latency(latency) = next(&worker).await { + break latency; + } + } + }) + .await + .expect("no round trip after connecting"); + assert!(matches!(latency, Latency::Measured(_)), "{latency:?}"); + }); +} + #[test] fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() { runtime().block_on(async { diff --git a/src/sftp/transport.rs b/src/sftp/transport.rs index 1037a66..36dcf82 100644 --- a/src/sftp/transport.rs +++ b/src/sftp/transport.rs @@ -3,7 +3,7 @@ use super::{ TransferProgress, TransferQuestion, UploadRequest, }; use crate::{ - connection::{ConnectionPrompt, ConnectionPromptReply}, + connection::{ConnectionPrompt, ConnectionPromptReply, Latency}, host::HostLogin, }; use anyhow::Result; @@ -86,6 +86,9 @@ pub enum SftpEvent { Question(TransferQuestion), Notice(String), Idle, + /// The connection's latest round trip: every few seconds while connected, + /// and right after connecting, transfers or not. + Latency(Latency), } /// One worker lifetime; the caller runs it on its own thread. pub trait SftpTransport: Send + 'static { diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index 8cc6a0c..ed85b26 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -13,20 +13,24 @@ use super::{ }; use crate::{ host::HostLogin, - ssh::{SshConnectionConfig, SshConnector, SshPrompts}, + ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts}, }; use anyhow::{Result, anyhow}; use async_channel::{Receiver, Sender}; use std::{ path::PathBuf, sync::{ - Arc, + Arc, Weak, atomic::{AtomicBool, Ordering}, }, - time::Duration, + time::{Duration, Instant}, }; use tokio::sync::{RwLock, watch}; +/// How often the latency task looks for a connection to measure, and for +/// the one it is measuring to be dropped. +const LATENCY_POLL: Duration = Duration::from_millis(250); + pub struct SshSftpTransportProvider { connector: SshConnector, resume_dir: PathBuf, @@ -243,6 +247,7 @@ impl SshSftpTransport { prompts.cancel_all(); }) }; + let latency = tokio::spawn(measure_latency(client.clone(), events.clone())); let control = TransferControl { events: events.clone(), cancel, @@ -536,11 +541,57 @@ impl SshSftpTransport { events.send(SftpEvent::Idle).await?; } router.abort(); + latency.abort(); *client.write().await = None; Ok(()) } } +/// Measure the connection in use every [`LATENCY_INTERVAL`], and a new one +/// at once, transfers or not. The client is held only for a ping, and let go +/// as soon as it is no longer the one in use, so 断开 still closes the +/// connection at once. +async fn measure_latency(shared: Arc>>>, events: Sender) { + let mut measured = Weak::new(); + let mut last = Instant::now(); + loop { + tokio::time::sleep(LATENCY_POLL).await; + let Some(connected) = shared.read().await.clone() else { + continue; + }; + let fresh = !Weak::ptr_eq(&measured, &Arc::downgrade(&connected)); + if !fresh && last.elapsed() < LATENCY_INTERVAL { + continue; + } + measured = Arc::downgrade(&connected); + last = Instant::now(); + let latency = tokio::select! { + latency = connected.round_trip() => latency, + () = replaced(&shared, &connected) => None, + }; + drop(connected); + if let Some(latency) = latency + && events.send(SftpEvent::Latency(latency)).await.is_err() + { + break; + } + } +} + +/// Resolves once `connected` is no longer the client in use. +async fn replaced(shared: &RwLock>>, connected: &Arc) { + loop { + tokio::time::sleep(LATENCY_POLL).await; + let current = shared.read().await; + if !current + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, connected)) + { + return; + } + } +} + /// A request failed: if the connection broke, `forget` it. async fn drop_broken( shared: &RwLock>>, diff --git a/src/shared/latency_label.rs b/src/shared/latency_label.rs new file mode 100644 index 0000000..882d25c --- /dev/null +++ b/src/shared/latency_label.rs @@ -0,0 +1,45 @@ +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::*; + +use crate::connection::{Latency, LatencyLevel}; + +/// A connection's latest round trip, beside a tab's buttons: green below +/// 100 ms, amber up to 200 ms, red beyond or when the ping timed out. +/// +/// Shared so a host's terminal tabs and SFTP tabs read the same way. +#[derive(IntoElement)] +pub struct LatencyLabel { + id: ElementId, + latency: Latency, +} + +impl LatencyLabel { + pub fn new(id: impl Into, latency: Latency) -> Self { + Self { + id: id.into(), + latency, + } + } +} + +impl RenderOnce for LatencyLabel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let color = match self.latency.level() { + LatencyLevel::Good => cx.theme().success, + LatencyLevel::Fair => cx.theme().warning, + LatencyLevel::Poor => cx.theme().danger, + }; + let label = self.latency.label(); + div() + .id(self.id) + .test_support() + .aria_label(label.clone()) + // Keeps the buttons beside it still as the digits change. + .min_w_12() + .text_xs() + .font_weight(FontWeight::BOLD) + .text_right() + .text_color(color) + .child(label) + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 50b04c3..63b8f0c 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -3,6 +3,7 @@ mod closable_tab; mod dialog; mod host_mark; +mod latency_label; mod rename_tab_dialog; mod segmented_control; mod tab_menu; @@ -10,6 +11,7 @@ mod tab_menu; pub use closable_tab::ClosableTabTitle; pub use dialog::{DeleteHandler, commit_footer, confirm_delete, form_error, parse_port}; pub use host_mark::HostMark; +pub use latency_label::LatencyLabel; pub use rename_tab_dialog::{RenamableTab, open_rename_tab_dialog}; pub use segmented_control::{Segment, SegmentedControl}; pub use tab_menu::close_tab_items; diff --git a/src/ssh/latency.rs b/src/ssh/latency.rs new file mode 100644 index 0000000..271e989 --- /dev/null +++ b/src/ssh/latency.rs @@ -0,0 +1,25 @@ +//! The round trip of a live SSH connection, as the terminal and SFTP tabs +//! show it beside their buttons. + +use std::time::{Duration, Instant}; + +use crate::connection::Latency; + +use super::SshHandle; + +/// How often a live connection's round trip is measured. +pub const LATENCY_INTERVAL: Duration = Duration::from_secs(5); +/// A ping unanswered for this long reads as timed out. +const LATENCY_TIMEOUT: Duration = Duration::from_secs(5); + +/// One round trip on `handle`, measured with `keepalive@openssh.com`, which +/// every server answers (a refusal is an answer too). The ping queues behind +/// whatever the connection is sending, so during a flood of output or a +/// transfer the reading rises: that is the responsiveness the user gets. +pub async fn round_trip(handle: &SshHandle) -> Latency { + let sent = Instant::now(); + match tokio::time::timeout(LATENCY_TIMEOUT, handle.send_ping()).await { + Ok(Ok(())) => Latency::Measured(sent.elapsed()), + _ => Latency::TimedOut, + } +} diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs index 89c0e67..4761ce4 100644 --- a/src/ssh/mod.rs +++ b/src/ssh/mod.rs @@ -1,6 +1,7 @@ //! Shared SSH connection service and remote terminal adapter. mod connection; mod exec; +mod latency; mod probe; mod proxy; #[cfg(test)] @@ -12,5 +13,6 @@ pub use connection::{ SshPrompts, is_network_error, }; pub use exec::{ExecError, ExecErrorKind, ExecExit, ExecStream, run_command}; +pub use latency::{LATENCY_INTERVAL, round_trip}; pub use tester::{SshConnectionTester, describe_login_error}; pub use transport::SshTerminalTransportProvider; diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index 9fa4e91..405be17 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -1,11 +1,13 @@ use super::{ connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts}, + latency::{LATENCY_INTERVAL, round_trip}, probe::{HostOsProbe, ProbeOutcome}, }; use crate::{ + connection::Latency, host::HostLogin, terminal::{ - Latency, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, + RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, }, @@ -22,15 +24,11 @@ use std::{ mpsc, }, thread, - time::{Duration, Instant}, + time::Duration, }; use tokio::sync::{mpsc as tokio_mpsc, watch}; use tokio::time::MissedTickBehavior; const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); -/// How often the connection's round trip is measured while the shell runs. -const LATENCY_INTERVAL: Duration = Duration::from_secs(5); -/// A ping unanswered for this long reads as timed out. -const LATENCY_TIMEOUT: Duration = Duration::from_secs(5); /// Production remote-terminal adapter for the shared SSH connector. pub struct SshTerminalTransportProvider { @@ -249,14 +247,7 @@ impl SshTerminalTransport { } }, _ = latency_ticker.tick(), if ping.is_none() => { - let handle = &handle; - ping = Some(Box::pin(async move { - let sent = Instant::now(); - match tokio::time::timeout(LATENCY_TIMEOUT, handle.send_ping()).await { - Ok(Ok(())) => Latency::Measured(sent.elapsed()), - _ => Latency::TimedOut, - } - })); + ping = Some(Box::pin(round_trip(&handle))); }, latency = async { ping.as_mut().expect("guarded").await }, if ping.is_some() => { ping = None; diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 29383e3..28fc254 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -15,12 +15,12 @@ use alacritty_terminal::term::color::COUNT; use alacritty_terminal::term::{Config, TermMode}; use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb}; -use crate::connection::{ConnectionPrompt, ConnectionPromptReply}; +use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency}; use crate::host::HostOs; use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch}; use super::{ - Latency, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, TerminalStatus, + SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, TerminalStatus, TerminalTransportCommand, TerminalTransportEvent, }; diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index ba3120e..41e9713 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -18,8 +18,7 @@ pub use font::{ pub use local_pty::LocalPtyTransportFactory; pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent}; pub use model::{ - Latency, LatencyLevel, LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, - TerminalStatus, + LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus, }; pub use search::{SearchDirection, SearchMark, SearchPosition}; pub use terminal_panel::{TerminalPanel, TerminalPanelEvent}; diff --git a/src/terminal/model.rs b/src/terminal/model.rs index 1c2161b..abd4651 100644 --- a/src/terminal/model.rs +++ b/src/terminal/model.rs @@ -1,5 +1,4 @@ use std::fmt; -use std::time::Duration; use alacritty_terminal::grid::Dimensions; @@ -11,40 +10,6 @@ pub struct LocalTerminalId(pub u64); #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RemoteTerminalId(pub u64); -/// The last round trip measured on a remote connection: how long the server -/// took to answer, or that it did not answer in time. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Latency { - Measured(Duration), - TimedOut, -} - -/// How a latency reads to someone typing: echo is instant below 100 ms, -/// noticeable up to 200 ms, and sluggish beyond. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LatencyLevel { - Good, - Fair, - Poor, -} - -impl Latency { - pub fn level(self) -> LatencyLevel { - match self { - Latency::Measured(rtt) if rtt < Duration::from_millis(100) => LatencyLevel::Good, - Latency::Measured(rtt) if rtt <= Duration::from_millis(200) => LatencyLevel::Fair, - Latency::Measured(_) | Latency::TimedOut => LatencyLevel::Poor, - } - } - - pub fn label(self) -> String { - match self { - Latency::Measured(rtt) => format!("{} ms", rtt.as_millis()), - Latency::TimedOut => "超时".into(), - } - } -} - /// The dimensions shared by the emulator and a terminal transport. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TerminalSize { @@ -167,32 +132,3 @@ impl TerminalStatus { self.rows } } - -#[cfg(test)] -mod latency_tests { - use std::time::Duration; - - use super::{Latency, LatencyLevel}; - - fn ms(millis: u64) -> Latency { - Latency::Measured(Duration::from_millis(millis)) - } - - #[test] - fn latency_levels_split_at_100_and_200_ms() { - assert_eq!(ms(99).level(), LatencyLevel::Good); - assert_eq!(ms(100).level(), LatencyLevel::Fair); - assert_eq!(ms(200).level(), LatencyLevel::Fair); - assert_eq!(ms(201).level(), LatencyLevel::Poor); - assert_eq!(Latency::TimedOut.level(), LatencyLevel::Poor); - } - - #[test] - fn latency_labels_show_whole_milliseconds() { - assert_eq!( - Latency::Measured(Duration::from_micros(32_900)).label(), - "32 ms" - ); - assert_eq!(Latency::TimedOut.label(), "超时"); - } -} diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index 79bb9a7..5c3d277 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use gpui_kit::component::{ - ActiveTheme as _, Icon, IconName, Sizable as _, + Icon, IconName, Sizable as _, button::Button, dock::{BasePanel, Panel, PanelEvent, TabGroup}, menu::PopupMenu, @@ -14,11 +14,11 @@ use crate::app::{ }; use crate::connection::{ConnectionPrompt, ConnectionPromptReply}; use crate::host::{HostId, HostOs, HostStore}; -use crate::shared::{ClosableTabTitle, HostMark, RenamableTab, close_tab_items}; +use crate::shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, close_tab_items}; use super::{ - LatencyLevel, RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalEvent, - TerminalLifecycle, TerminalMenuItems, TerminalStatus, TerminalView, + RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalEvent, TerminalLifecycle, + TerminalMenuItems, TerminalStatus, TerminalView, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -246,25 +246,7 @@ impl Panel for TerminalPanel { return None; } let latency = terminal.latency(cx)?; - let color = match latency.level() { - LatencyLevel::Good => cx.theme().success, - LatencyLevel::Fair => cx.theme().warning, - LatencyLevel::Poor => cx.theme().danger, - }; - let label = latency.label(); - Some( - div() - .id(("terminal-latency", self.id.0)) - .test_support() - .aria_label(label.clone()) - // Keeps the buttons beside it still as the digits change. - .min_w_12() - .text_xs() - .font_weight(FontWeight::BOLD) - .text_right() - .text_color(color) - .child(label), - ) + Some(LatencyLabel::new(("terminal-latency", self.id.0), latency)) } fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 491edf9..1acb372 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -23,12 +23,12 @@ use crate::app::{ CatalogIcon, ClearTerminal, CopyTerminal, DismissTerminalFind, FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, PasteTerminal, }; -use crate::connection::ConnectionPromptReply; +use crate::connection::{ConnectionPromptReply, Latency}; use super::search::SearchMark; use super::{ - Latency, SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEvent, - TerminalFont, TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, + SearchDirection, SharedTerminalTransportFactory, TerminalEngine, TerminalEvent, TerminalFont, + TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, }; pub const TERMINAL_KEY_CONTEXT: &str = "Terminal"; diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index 0e485ae..001be7d 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -3,7 +3,8 @@ use std::sync::{Arc, mpsc}; use anyhow::Result; use async_channel::Sender; -use super::{Latency, TerminalSize}; +use super::TerminalSize; +use crate::connection::Latency; use crate::connection::{ConnectionPrompt, ConnectionPromptReply}; use crate::host::{HostLogin, HostOs}; diff --git a/tests/ui/sftp.rs b/tests/ui/sftp.rs index f2505ee..35197b8 100644 --- a/tests/ui/sftp.rs +++ b/tests/ui/sftp.rs @@ -937,6 +937,45 @@ async fn sftp_remote_commands_while_disconnected_offer_to_reconnect(cx: &mut Tes assert_eq!(*provider.reconnects.lock().unwrap(), 1); } +/// Like a terminal tab, an SFTP tab shows its connection's round trip beside +/// its buttons, and only while it is connected. +#[gpui_kit::test] +async fn the_sftp_tab_bar_shows_the_connection_latency_while_connected(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider.clone()); + open_test_explorer(cx, handle).await; + let latency = ("sftp-latency", SFTP_TAB); + in_frame(cx, handle, |window, _| { + assert!(window.try_find(latency).is_none(), "nothing measured yet"); + }); + + let events = provider.events.lock().unwrap()[0].clone(); + for millis in [32, 180] { + events + .send_blocking(SftpEvent::Latency(Latency::Measured( + Duration::from_millis(millis), + ))) + .unwrap(); + let shown = format!("{millis} ms"); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find(latency) + .is_some_and(|element| element.label() == Some(shown.as_str())) + }) + .await; + } + + events + .send_blocking(SftpEvent::Disconnected("SFTP 连接中断,请重新连接".into())) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find(latency).is_none() + }) + .await; +} + #[gpui_kit::test] async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( cx: &mut TestAppContext, diff --git a/tests/ui/support/mod.rs b/tests/ui/support/mod.rs index f7bfad1..5bc9e15 100644 --- a/tests/ui/support/mod.rs +++ b/tests/ui/support/mod.rs @@ -22,7 +22,7 @@ pub use shellrs::app::{ pub use shellrs::cli::{AgentKind, IntegrationPaths}; pub use shellrs::connection::{ ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply, - ConnectionTester, LoginTest, TrustCallback, + ConnectionTester, Latency, LoginTest, TrustCallback, }; pub use shellrs::explorer::ExplorerId; pub use shellrs::forward::{ @@ -41,7 +41,7 @@ pub use shellrs::sftp::{ SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest, }; pub use shellrs::terminal::{ - FixedRemoteTerminalTransportProvider, Latency, LocalTerminalId, RemoteTerminalId, + FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalFont, TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,