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