SFTP 标签栏右侧显示连接延迟,和终端标签一样

- SFTP 工作线程另起一个任务测往返:新连接建好就测,之后每 5 秒一次,传输进行中也照测;
  只在一次 ping 期间持有连接,客户端被换掉或断开就放手,「断开」照样立刻关掉连接
- 新事件 SftpEvent::Latency;SFTP 标签在已连接时于标签栏右侧显示,断开、重连时清掉
- Latency / LatencyLevel 从终端模块移到 connection.rs;测往返的函数提到 ssh/latency.rs,
  终端和 SFTP 共用;标签栏上的延迟文字抽成 shared::LatencyLabel
- 新增协议测试(连上后很快报出实测延迟)和 SFTP 标签栏的界面测试
This commit is contained in:
Xuz committed 2026-10-01 15:32:24 +08:00
1 parent 388a1aa68a
commit 82fcdd4296
21 files changed
+319 -126

No files matched your search

+4 -4
View File
@@ -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<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 线程解析」)、`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`、`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 校验写 `<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 秒用 `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 校验写 `<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`,服务器回成功或失败都算回复)量一次往返,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<SftpClient>`,并且每 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>`;数字 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<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`,状态行的 `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`Line 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`(标签页和关闭、开始页、标题栏、对话框的共性)、`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<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`,状态行的 `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`、`commitLine truncated
+1 -1
View File
@@ -122,7 +122,7 @@ shellrs download <ID> <远程路径> <本地路径>
## 标签页
远程终端标签和 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 标签仍会先确认。
## 终端
+67 -1
View File
@@ -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<dyn ConnectionTester>;
/// 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(), "超时");
}
}
+17 -2
View File
@@ -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<SftpCommand>,
state: ConnectionState,
/// The connection's latest round trip; none until the first one after
/// connecting comes back.
latency: Option<Latency>,
/// 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<Self>) -> Option<impl IntoElement> {
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<Self>) -> Option<Vec<Button>> {
let (id, host_id) = (self.id, self.host_id);
+8 -2
View File
@@ -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<SshHandle>,
ssh: Option<SshHandle>,
/// Turns true once the server side of the SFTP stream has ended.
closed: watch::Receiver<bool>,
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<Latency> {
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.
+31 -1
View File
@@ -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 {
+4 -1
View File
@@ -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 {
+54 -3
View File
@@ -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<RwLock<Option<Arc<SftpClient>>>>, events: Sender<SftpEvent>) {
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<Option<Arc<SftpClient>>>, connected: &Arc<SftpClient>) {
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<Option<Arc<SftpClient>>>,
+45
View File
@@ -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<ElementId>, 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)
}
}
+2
View File
@@ -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;
+25
View File
@@ -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,
}
}
+2
View File
@@ -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;
+5 -14
View File
@@ -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;
+2 -2
View File
@@ -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,
};
+1 -2
View File
@@ -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};
-64
View File
@@ -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(), "超时");
}
}
+5 -23
View File
@@ -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<Self>) -> Option<Vec<Button>> {
+3 -3
View File
@@ -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";
+2 -1
View File
@@ -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};
+39
View File
@@ -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,
+2 -2
View File
@@ -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,