diff --git a/CLAUDE.md b/CLAUDE.md index 3afa6bb..ed9bcc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`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())` 渲染。 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_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`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。 -- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。 +- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `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`。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。 - `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 - `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 @@ -71,4 +71,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.md b/README.md index dcc39e7..2a0bb5a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## 标签页 -远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。批量关闭时,正在上传的 SFTP 标签仍会先确认。 +远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。 ## SFTP 上传 diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index 17ce9ac..8dbde6e 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -6,7 +6,7 @@ use crate::{ secrets::SharedSecretStore, session::Session, terminal::{ - RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, + Latency, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, }, @@ -15,17 +15,24 @@ use anyhow::{Context as _, Result, anyhow, bail}; use async_channel::Sender; use russh::{ChannelMsg, client}; use std::{ + future::Future, path::PathBuf, + pin::Pin, sync::{ Arc, atomic::{AtomicBool, Ordering}, mpsc, }, thread, - time::Duration, + time::{Duration, Instant}, }; 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 { @@ -181,6 +188,17 @@ impl SshTerminalTransport { let mut probe = HostOsProbe::new(); let mut probe_channel = open_probe(&handle, probe.command()).await; + // Round trips are measured with `keepalive@openssh.com`, which every + // server answers (a refusal is an answer too), so this is the SSH-level + // delay a keystroke's echo sees. The ping queues behind output on the + // same connection: during a flood of output the reading rises, which is + // the responsiveness the user actually gets. It doubles as a keepalive. + // The first tick fires at once, so a reading shows up right after + // connecting; a new ping only goes out once the last one has settled. + let mut latency_ticker = tokio::time::interval(LATENCY_INTERVAL); + latency_ticker.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut ping: Option + Send + '_>>> = None; + let mut exit_code = 0; let mut exit_signal = None; loop { @@ -237,6 +255,20 @@ 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, + } + })); + }, + latency = async { ping.as_mut().expect("guarded").await }, if ping.is_some() => { + ping = None; + let _ = events.send(TerminalTransportEvent::Latency(latency)).await; + }, message = channel.wait() => match message { Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { if events.send(TerminalTransportEvent::Output(data.to_vec())).await.is_err() { @@ -629,8 +661,8 @@ mod tests { connect(session, known_hosts, secrets, false, answer) } - /// Same, but waits for the host-operating-system probe to report before - /// shutting the connection down. + /// Same, but waits for the host-operating-system probe and the first + /// round-trip measurement to report before shutting the connection down. fn connect_and_probe( session: Session, known_hosts: &Path, @@ -644,6 +676,7 @@ mod tests { struct ConnectionReport { prompts: Vec, host_os: Option, + latency: Option, } /// Connect, answer whatever is asked, then shut down. The report says what @@ -684,18 +717,25 @@ mod tests { TerminalTransportEvent::Started => break, TerminalTransportEvent::Failed(error) => panic!("SSH failed: {error}"), TerminalTransportEvent::Exited { .. } => panic!("unexpected early exit"), - TerminalTransportEvent::HostOsDetected(_) | TerminalTransportEvent::Output(_) => {} + TerminalTransportEvent::HostOsDetected(_) + | TerminalTransportEvent::Latency(_) + | TerminalTransportEvent::Output(_) => {} } } - // The probe opens its channel after the shell is up, so its answer - // lands after `Started`. Only the tests that care pay the wait. + // The probe and the first ping start after the shell is up, so their + // answers land after `Started`. Only the tests that care pay the wait. if wait_for_host_os { // Loopback answers in single-digit milliseconds; this only runs // out for a host that never answers at all. let deadline = std::time::Instant::now() + Duration::from_secs(1); - while report.host_os.is_none() && std::time::Instant::now() < deadline { + while (report.host_os.is_none() || report.latency.is_none()) + && std::time::Instant::now() < deadline + { match event_rx.try_recv() { Ok(TerminalTransportEvent::HostOsDetected(os)) => report.host_os = Some(os), + Ok(TerminalTransportEvent::Latency(latency)) => { + report.latency.get_or_insert(latency); + } Ok(_) => {} Err(async_channel::TryRecvError::Empty) => { thread::sleep(Duration::from_millis(5)) @@ -768,7 +808,7 @@ mod tests { TerminalPromptKind::HostKeyChanged(_) => panic!("unexpected changed key"), }, TerminalTransportEvent::Started => started = true, - TerminalTransportEvent::HostOsDetected(_) => {} + TerminalTransportEvent::HostOsDetected(_) | TerminalTransportEvent::Latency(_) => {} TerminalTransportEvent::Output(bytes) => { ready |= String::from_utf8_lossy(&bytes).contains("ready") } @@ -983,6 +1023,12 @@ mod tests { vec![PROBE_COMMAND.to_string()], "认出来了就不该再问第二遍" ); + // The server answered the keepalive ping, so there is a real reading. + assert!( + matches!(report.latency, Some(Latency::Measured(_))), + "连上之后应当马上测到一次往返延迟:{:?}", + report.latency + ); } #[test] diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 80a070e..029c41e 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -17,8 +17,9 @@ use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, use crate::session::HostOs; use super::{ - SharedTerminalTransportFactory, TerminalLifecycle, TerminalPrompt, TerminalPromptReply, - TerminalSize, TerminalStatus, TerminalTransportCommand, TerminalTransportEvent, + Latency, SharedTerminalTransportFactory, TerminalLifecycle, TerminalPrompt, + TerminalPromptReply, TerminalSize, TerminalStatus, TerminalTransportCommand, + TerminalTransportEvent, }; pub type AlacrittyTerm = Term; @@ -68,6 +69,8 @@ pub struct TerminalEngine { runtime: TerminalRuntime, lifecycle: TerminalLifecycle, title: Option, + /// The connection's latest round trip, while one is running. + latency: Option, generation: u64, size: TerminalSize, event_sender: mpsc::Sender, @@ -89,6 +92,7 @@ impl TerminalEngine { runtime, lifecycle: TerminalLifecycle::Starting, title: None, + latency: None, generation, size, event_sender, @@ -143,10 +147,13 @@ impl TerminalEngine { TerminalUiEventKind::ResetTitle => self.title = None, TerminalUiEventKind::Exited { code, signal } => { self.lifecycle = TerminalLifecycle::Exited { code, signal }; + self.latency = None; } TerminalUiEventKind::Failed(error) => { self.lifecycle = TerminalLifecycle::Failed(error); + self.latency = None; } + TerminalUiEventKind::Latency(latency) => self.latency = Some(latency), TerminalUiEventKind::Prompt(prompt) => { return Some(TerminalEngineEvent::PromptRequested(prompt)); } @@ -167,6 +174,7 @@ impl TerminalEngine { self.runtime.shutdown(); self.lifecycle = TerminalLifecycle::Starting; self.title = None; + self.latency = None; self.runtime = TerminalRuntime::start( self.generation, self.size, @@ -332,6 +340,10 @@ impl TerminalEngine { self.title.as_deref() } + pub fn latency(&self) -> Option { + self.latency + } + pub fn status(&self) -> TerminalStatus { let term = self.runtime.term.lock(); let cursor = term.grid().cursor.point; @@ -476,6 +488,13 @@ impl TerminalRuntime { TerminalUiEventKind::HostOs(os), ); } + TerminalTransportEvent::Latency(latency) => { + send_ui( + &parser_ui_events, + generation, + TerminalUiEventKind::Latency(latency), + ); + } TerminalTransportEvent::Prompt(prompt) => { send_ui( &parser_ui_events, @@ -661,6 +680,7 @@ enum TerminalUiEventKind { Failed(String), Prompt(TerminalPrompt), HostOs(HostOs), + Latency(Latency), ColorRequest(usize, Arc String + Send + Sync>), } diff --git a/src/terminal/local_pty.rs b/src/terminal/local_pty.rs index 39bb8ac..277b8b6 100644 --- a/src/terminal/local_pty.rs +++ b/src/terminal/local_pty.rs @@ -171,7 +171,8 @@ mod tests { TerminalTransportEvent::Started | TerminalTransportEvent::Failed(_) | TerminalTransportEvent::Prompt(_) - | TerminalTransportEvent::HostOsDetected(_), + | TerminalTransportEvent::HostOsDetected(_) + | TerminalTransportEvent::Latency(_), ) => {} Err(async_channel::TryRecvError::Empty) => thread::sleep(Duration::from_millis(5)), Err(async_channel::TryRecvError::Closed) => break, diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 87ad492..29a9ea5 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -13,7 +13,8 @@ pub use engine::{TerminalCell, TerminalEngine, TerminalEngineEvent, TerminalSnap pub use local_pty::LocalPtyTransportFactory; pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent}; pub use model::{ - LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus, + Latency, LatencyLevel, LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, + TerminalStatus, }; pub use rename_tab_dialog::open_rename_tab_dialog; pub use terminal_panel::{TerminalPanel, TerminalPanelEvent}; diff --git a/src/terminal/model.rs b/src/terminal/model.rs index 00cfa7c..4c04d5e 100644 --- a/src/terminal/model.rs +++ b/src/terminal/model.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::time::Duration; use alacritty_terminal::grid::Dimensions; use serde::Deserialize; @@ -11,6 +12,40 @@ pub struct LocalTerminalId(pub u64); #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)] 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 { @@ -142,3 +177,32 @@ impl TerminalStatus { self.title.as_deref() } } + +#[cfg(test)] +mod latency_tests { + use std::time::Duration; + + use super::{Latency, LatencyLevel}; + + fn ms(millis: u64) -> Latency { + Latency::Measured(Duration::from_millis(millis)) + } + + #[test] + fn latency_levels_split_at_100_and_200_ms() { + assert_eq!(ms(99).level(), LatencyLevel::Good); + assert_eq!(ms(100).level(), LatencyLevel::Fair); + assert_eq!(ms(200).level(), LatencyLevel::Fair); + assert_eq!(ms(201).level(), LatencyLevel::Poor); + assert_eq!(Latency::TimedOut.level(), LatencyLevel::Poor); + } + + #[test] + fn latency_labels_show_whole_milliseconds() { + assert_eq!( + Latency::Measured(Duration::from_micros(32_900)).label(), + "32 ms" + ); + assert_eq!(Latency::TimedOut.label(), "超时"); + } +} diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index 7aa37a7..863d0c8 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -1,5 +1,5 @@ use gpui_kit::component::{ - Icon, IconName, Sizable as _, + ActiveTheme as _, Icon, IconName, Sizable as _, button::Button, dock::{BasePanel, Panel, PanelEvent, TabGroup}, menu::PopupMenu, @@ -14,8 +14,8 @@ use crate::session::{HostOs, SessionId, SessionStore}; use crate::shared::{ClosableTabTitle, HostMark, close_tab_items}; use super::{ - RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalLifecycle, TerminalPrompt, - TerminalPromptReply, TerminalView, TerminalViewEvent, + LatencyLevel, RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalLifecycle, + TerminalPrompt, TerminalPromptReply, TerminalView, TerminalViewEvent, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -253,6 +253,35 @@ impl Panel for TerminalPanel { .context_menu(move |menu, _, cx| tab_menu.build(menu, cx)) } + /// The connection's latest round trip, beside the toolbar. Shown only + /// while the shell runs, so a dropped connection leaves no stale number. + fn title_suffix(&mut self, _: &mut Window, cx: &mut Context) -> Option { + let terminal = self.terminal.read(cx); + if terminal.lifecycle(cx) != TerminalLifecycle::Running { + 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), + ) + } + fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { let session_id = self.session_id; let terminal_id = self.id; diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index baa08f9..8bafe2b 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -16,8 +16,9 @@ use crate::app::{CopyTerminal, PasteTerminal}; use crate::session::HostOs; use super::{ - SharedTerminalTransportFactory, TerminalEngine, TerminalEngineEvent, TerminalLifecycle, - TerminalPrompt, TerminalPromptReply, TerminalSize, TerminalSnapshot, TerminalStatus, + Latency, SharedTerminalTransportFactory, TerminalEngine, TerminalEngineEvent, + TerminalLifecycle, TerminalPrompt, TerminalPromptReply, TerminalSize, TerminalSnapshot, + TerminalStatus, }; pub const TERMINAL_KEY_CONTEXT: &str = "Terminal"; @@ -309,6 +310,10 @@ impl TerminalView { self.engine.read(cx).title().map(str::to_owned) } + pub fn latency(&self, cx: &App) -> Option { + self.engine.read(cx).latency() + } + pub fn screen_text(&self, cx: &App) -> String { self.engine.read(cx).snapshot().visible_text() } diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index b6193ef..12d7280 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, mpsc}; use anyhow::Result; use async_channel::Sender; -use super::TerminalSize; +use super::{Latency, TerminalSize}; use crate::session::{HostOs, Session}; pub use crate::connection::{ @@ -48,6 +48,9 @@ pub enum TerminalTransportEvent { /// What the transport found running on the host. Sent once per successful /// connection, after the shell is up. HostOsDetected(HostOs), + /// A round trip measured on the live connection. Sent periodically while + /// the shell runs; transports without a network hop never send it. + Latency(Latency), Exited { code: u32, signal: Option, diff --git a/tests/ui.rs b/tests/ui.rs index 7234b8b..cbb0238 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -29,9 +29,10 @@ use shellr::sftp::{ SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest, }; use shellr::terminal::{ - FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, TerminalLifecycle, - TerminalPrompt, TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize, - TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, + FixedRemoteTerminalTransportProvider, Latency, LocalTerminalId, RemoteTerminalId, + TerminalLifecycle, TerminalPrompt, TerminalPromptField, TerminalPromptKind, + TerminalPromptReply, TerminalSize, TerminalTransport, TerminalTransportCommand, + TerminalTransportEvent, TerminalTransportFactory, }; use shellr::workspace::Workspace; @@ -89,6 +90,7 @@ enum FakeBehavior { ExitFirst, FailFirst, ReportsOs(HostOs), + ReportsLatency(Latency), } #[derive(Default)] @@ -121,6 +123,13 @@ impl FakeTerminalFactory { } } + fn reports_latency(latency: Latency) -> Self { + Self { + behavior: FakeBehavior::ReportsLatency(latency), + ..Self::default() + } + } + fn written_text(&self) -> String { let bytes: Vec<_> = self .writes @@ -171,6 +180,9 @@ impl TerminalTransport for FakeTerminalTransport { if let FakeBehavior::ReportsOs(os) = self.behavior { events.send_blocking(TerminalTransportEvent::HostOsDetected(os))?; } + if let FakeBehavior::ReportsLatency(latency) = self.behavior { + events.send_blocking(TerminalTransportEvent::Latency(latency))?; + } events.send_blocking(TerminalTransportEvent::Output( format!( "\x1b]0;测试终端 {}\x07run:{}$ alpha.txt 会议纪要.md\r\n", @@ -2796,6 +2808,46 @@ async fn connecting_marks_the_session_with_the_host_operating_system(cx: &mut Te }); } +#[gpui_kit::test] +async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestAppContext) { + let (store, id) = one_session_store(AuthKind::Auto); + let factory = Arc::new(FakeTerminalFactory::reports_latency(Latency::Measured( + Duration::from_millis(32), + ))); + let (handle, _) = open_workspace_with_remote_factory(cx, store, factory); + // The first terminal of a store with nothing connected. + let terminal = 1_u64; + let latency = ("terminal-latency", terminal); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("session-tree") + .double_click(("session-row", id.0), cx); + }) + .unwrap(); + // Transport events reach the engine on its 16ms batching timer. + 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("32 ms")) + }) + .await; + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("close-terminal", terminal), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.try_find(latency).is_none()); + }) + .unwrap(); +} + #[gpui_kit::test] async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut TestAppContext) { let mut store = SessionStore::empty();