标签栏显示 SSH 连接延迟

This commit is contained in:
Xuz committed 2026-09-24 22:37:18 +08:00
1 parent 4428237b25
commit fa87917d9c
11 files changed
+246 -25

No files matched your search

+2 -2
View File
@@ -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<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`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 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。
- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/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<SessionStore>`:生产环境由 `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<SessionStore>`:生产环境由 `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` 初始为已连接并已打开终端标签。
+1 -1
View File
@@ -8,7 +8,7 @@
## 标签页
远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。批量关闭时,正在上传的 SFTP 标签仍会先确认。
远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。
## SFTP 上传
+55 -9
View File
@@ -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<Pin<Box<dyn Future<Output = Latency> + 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<TerminalPromptKind>,
host_os: Option<HostOs>,
latency: Option<Latency>,
}
/// 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]
+22 -2
View File
@@ -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<TerminalEventProxy>;
@@ -68,6 +69,8 @@ pub struct TerminalEngine {
runtime: TerminalRuntime,
lifecycle: TerminalLifecycle,
title: Option<String>,
/// The connection's latest round trip, while one is running.
latency: Option<Latency>,
generation: u64,
size: TerminalSize,
event_sender: mpsc::Sender<TerminalUiEvent>,
@@ -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<Latency> {
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<dyn Fn(Rgb) -> String + Send + Sync>),
}
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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};
+64
View File
@@ -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(), "超时");
}
}
+32 -3
View File
@@ -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<Self>) -> Option<impl IntoElement> {
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<Self>) -> Option<Vec<Button>> {
let session_id = self.session_id;
let terminal_id = self.id;
+7 -2
View File
@@ -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<Latency> {
self.engine.read(cx).latency()
}
pub fn screen_text(&self, cx: &App) -> String {
self.engine.read(cx).snapshot().visible_text()
}
+4 -1
View File
@@ -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<String>,
+55 -3
View File
@@ -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();