标签栏显示 SSH 连接延迟
This commit is contained in:
1 parent
4428237b25
commit
fa87917d9c
11 files changed
+246
-25
No files matched your search
@@ -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` 初始为已连接并已打开终端标签。
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 标签页
|
||||
|
||||
远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。批量关闭时,正在上传的 SFTP 标签仍会先确认。
|
||||
远程终端标签以主机系统徽章为图标。右键标签可重命名(仅本次运行有效,留空恢复为会话名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑会话,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「编辑会话…」加关闭命令,本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。远程终端运行时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。批量关闭时,正在上传的 SFTP 标签仍会先确认。
|
||||
|
||||
## SFTP 上传
|
||||
|
||||
|
||||
+55
-9
@@ -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
@@ -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>),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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(), "超时");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user