diff --git a/docs/manual.en.md b/docs/manual.en.md index 05d4c69..2758c6d 100644 --- a/docs/manual.en.md +++ b/docs/manual.en.md @@ -192,6 +192,8 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon ( ## Terminal +Remote terminal and SFTP tabs show their own connection state with a status dot and text tooltip. After a remote connection fails, exits, is disconnected or has authentication cancelled, press an unmodified Enter with the terminal focused to reconnect that tab using the current host settings. Held keys, pasted input, search fields and authentication fields do not trigger reconnection. Local terminals retain their restart action. + The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected. Settings › Terminal › Interaction can make right-click paste or do nothing instead, and can turn on “Copy on select”. - **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar. diff --git a/docs/manual.md b/docs/manual.md index 10d6cd9..5b5d29c 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -199,6 +199,8 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] ## 终端 +远程终端和 SFTP 标签的状态点分别表示各自的连接状态,并提供文字提示。远程终端连接失败、退出、手动断开或取消认证后,显示「按 Enter 重新连接」;聚焦终端后按无修饰键的 Enter,在原标签使用主机最新配置重连。按住按键、粘贴、查找或认证输入框不会触发重连。本地终端仍使用重新启动。 + 右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。「设置 › 终端 › 终端交互」可以把右键改成直接粘贴或不做任何事,也可以打开「选中复制」。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。 - **主机密钥**:第一次连接某台主机时要确认它的主机密钥(「信任并连接」),记在 ShellRS 自己的 `known_hosts` 里,之后不再问。服务器的密钥和记下的不一致时(重装了系统、换了密钥,也可能是有人冒充),弹框列出记下的和现在的指纹:「更新并连接」删掉这台主机记下的旧密钥、换成现在的再继续连接;「取消」不连接。测试连接、外部 CLI 和端口转发的自动重连遇到这种情况只报错、不询问,在 ShellRS 里连接一次这台主机更新即可。 diff --git a/locales/terminal.yml b/locales/terminal.yml index 0609a02..4f426eb 100644 --- a/locales/terminal.yml +++ b/locales/terminal.yml @@ -198,3 +198,7 @@ terminal.pty.resize_error: terminal.pty.exit_status: zh-CN: 无法读取 shell 退出状态 en: Couldn’t read the shell’s exit status + +terminal.notice.enter_reconnect: + zh-CN: 按 Enter 重新连接 + en: Press Enter to reconnect diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index 5c35638..f5593e1 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -905,6 +905,7 @@ impl Panel for ExplorerPanel { // The host's mark, as on the host's terminal tabs. let mark = HostMark::new(("explorer-tab-os", id.0), name, os).small(); ClosableTabTitle::new(("explorer-tab", id.0), mark, self.tab_title(cx)) + .connection_state(self.connection_state()) .closable(("close-explorer", id.0), Box::new(CloseExplorer(id))) .context_menu(move |popup, _, cx| menu.build(popup, cx)) } diff --git a/src/shared/closable_tab.rs b/src/shared/closable_tab.rs index b99adfb..ec108be 100644 --- a/src/shared/closable_tab.rs +++ b/src/shared/closable_tab.rs @@ -5,11 +5,13 @@ use gpui_kit::component::{ button::{Button, ButtonVariants as _}, h_flex, menu::{ContextMenuExt as _, PopupMenu}, + tooltip::Tooltip, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::ToggleHostPanel; +use crate::host::ConnectionState; use crate::i18n::t; type MenuBuilder = Rc) -> PopupMenu>; @@ -37,6 +39,7 @@ pub struct ClosableTabTitle { close: Option<(ElementId, Box)>, menu: Option, modified: bool, + connection_state: Option, } impl ClosableTabTitle { @@ -54,9 +57,17 @@ impl ClosableTabTitle { close: None, menu: None, modified: false, + connection_state: None, } } + /// Show this tab's own connection state, never its host's aggregate state. + /// Tabs that do not own a remote connection leave this unset. + pub fn connection_state(mut self, state: ConnectionState) -> Self { + self.connection_state = Some(state); + self + } + /// Mark the tab as holding changes not saved yet: a dot after the label, /// as editors do. pub fn modified(mut self, modified: bool) -> Self { @@ -83,10 +94,48 @@ impl ClosableTabTitle { impl RenderOnce for ClosableTabTitle { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + // The badge must not change the mark's layout box or baseline. Put + // its center at the circular mark's lower-right corner: the two + // circles remain separate without shifting the icon or title text. + let leading = match self.connection_state { + Some(state) => { + let color = match state { + ConnectionState::Connected => cx.theme().success, + ConnectionState::Connecting => cx.theme().warning, + ConnectionState::Disconnected => cx.theme().danger, + }; + let label = state.label(); + div() + .relative() + .flex_shrink_0() + .child(self.leading) + .child( + div() + .id("connection-status") + .test_support() + .role(Role::Status) + .aria_label(label.clone()) + .absolute() + // Half the size_1p5 diameter, in the same rem + // units, centers the dot on the mark's corner. + .right(-rems(0.1875)) + .bottom(-rems(0.1875)) + .size_1p5() + .flex_shrink_0() + .rounded_full() + .bg(color) + .tooltip(move |window, cx| { + Tooltip::new(label.clone()).build(window, cx) + }), + ) + .into_any_element() + } + None => self.leading, + }; let row = h_flex() .id("title") .gap_1() - .child(self.leading) + .child(leading) .child(self.label.clone()) .when(self.modified, |row| { row.child( diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index ff27da3..953219c 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -103,6 +103,7 @@ pub struct TerminalEngine { /// lines against them; swapped when the settings change them. highlights: SharedHighlights, _highlights_observer: gpui_kit::Subscription, + reconnect_on_enter: bool, generation: u64, size: TerminalSize, event_sender: mpsc::Sender, @@ -142,6 +143,7 @@ impl TerminalEngine { url_search: RefCell::new(url_search()), highlights, _highlights_observer: highlights_observer, + reconnect_on_enter: false, generation, size, event_sender, @@ -201,10 +203,12 @@ impl TerminalEngine { TerminalUiEventKind::Title(title) => self.title = sanitize_title(&title), TerminalUiEventKind::ResetTitle => self.title = None, TerminalUiEventKind::Exited { code, signal } => { + self.append_reconnect_hint(); self.lifecycle = TerminalLifecycle::Exited { code, signal }; self.latency = None; } TerminalUiEventKind::Failed(error) => { + self.append_reconnect_hint(); self.lifecycle = TerminalLifecycle::Failed(error); self.latency = None; } @@ -240,6 +244,16 @@ impl TerminalEngine { None } + pub(super) fn enable_reconnect_on_enter(&mut self) { + self.reconnect_on_enter = true; + } + + fn append_reconnect_hint(&self) { + if self.reconnect_on_enter { + append_message(&self.runtime.term, &t!("terminal.notice.enter_reconnect")); + } + } + pub fn restart(&mut self, cx: &mut gpui_kit::Context) { self.generation = self.generation.saturating_add(1); self.runtime.shutdown(); @@ -282,6 +296,7 @@ impl TerminalEngine { self.generation = self.generation.saturating_add(1); self.runtime.shutdown(); append_message(&self.runtime.term, message); + self.append_reconnect_hint(); self.lifecycle = TerminalLifecycle::Exited { code: 0, signal: None, diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index a4f48a0..768a1e9 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -13,7 +13,7 @@ use crate::app::{ EditHost, OpenExplorer, ReconnectTerminal, RenameTerminal, }; use crate::connection::{ConnectionPrompt, ConnectionPromptReply}; -use crate::host::{HostId, HostOs, HostStore}; +use crate::host::{ConnectionState, HostId, HostOs, HostStore}; use crate::i18n::t; use crate::shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, close_tab_items}; @@ -77,6 +77,7 @@ impl TerminalPanel { cx, ); terminal.set_menu_items(connection_menu_items(id, host_id)); + terminal.set_reconnect_action(ReconnectTerminal(id), cx); terminal }); let subscriptions = vec![ @@ -246,7 +247,15 @@ impl Panel for TerminalPanel { .and_then(|host| host.os); let mark = HostMark::new(("terminal-tab-os", id.0), self.default_title(cx), os).small(); let tab_menu = self.tab_menu(cx); + let state = match self.lifecycle(cx) { + TerminalLifecycle::Starting => ConnectionState::Connecting, + TerminalLifecycle::Running => ConnectionState::Connected, + TerminalLifecycle::Exited { .. } + | TerminalLifecycle::Failed(_) + | TerminalLifecycle::Closing => ConnectionState::Disconnected, + }; ClosableTabTitle::new(("terminal-tab", id.0), mark, self.tab_title(cx)) + .connection_state(state) .closable(("close-terminal", id.0), Box::new(CloseTerminal(id))) .context_menu(move |menu, _, cx| tab_menu.build(menu, cx)) } @@ -262,9 +271,10 @@ impl Panel for TerminalPanel { Some(LatencyLabel::new(("terminal-latency", self.id.0), latency)) } - fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { + fn toolbar_buttons(&mut self, _: &mut Window, cx: &mut Context) -> Option> { let host_id = self.host_id; let terminal_id = self.id; + let reconnect_dispatch = self.focus_handle(cx); Some(vec![ Button::new(("sftp", terminal_id.0)) .icon(Icon::new(CatalogIcon::FolderTree)) @@ -277,7 +287,7 @@ impl Panel for TerminalPanel { .icon(Icon::new(CatalogIcon::RefreshCw)) .tooltip(t!("terminal.remote.reconnect")) .on_click(move |_, window, cx| { - window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx) + reconnect_dispatch.dispatch_action(&ReconnectTerminal(terminal_id), window, cx) }), ]) } diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 93ece53..ad7df7a 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -66,6 +66,17 @@ fn opens_links(modifiers: &Modifiers) -> bool { } } +fn reconnect_keystroke(event: &KeyDownEvent, lifecycle: &TerminalLifecycle) -> bool { + event.keystroke.key == "enter" + && event.keystroke.modifiers == Modifiers::default() + && !event.is_held + && !event.prefer_character_input + && matches!( + lifecycle, + TerminalLifecycle::Exited { .. } | TerminalLifecycle::Failed(_) + ) +} + /// What a terminal is called for screen readers. Asked for each time it is /// drawn, so it follows the interface language. type TerminalLabel = Rc SharedString>; @@ -215,6 +226,8 @@ pub struct TerminalView { context_menu: Option<(Entity, gpui_kit::Point)>, context_menu_subscription: Option, menu_items: Option, + reconnect_action: Option>, + reconnect_pending: bool, find: Option, cursor_visible: bool, focused: bool, @@ -305,6 +318,8 @@ impl TerminalView { context_menu: None, context_menu_subscription: None, menu_items: None, + reconnect_action: None, + reconnect_pending: false, find: None, cursor_visible: true, focused: false, @@ -326,6 +341,13 @@ impl TerminalView { self.focus_handle.clone() } + /// Only remote panels opt in, with their own terminal-scoped action. + pub(super) fn set_reconnect_action(&mut self, action: impl Action, cx: &mut Context) { + self.reconnect_action = Some(Box::new(action)); + self.engine + .update(cx, |engine, _| engine.enable_reconnect_on_enter()); + } + pub fn restart(&mut self, cx: &mut Context) { self.reset_interaction(); self.engine.update(cx, |engine, cx| engine.restart(cx)); @@ -348,6 +370,7 @@ impl TerminalView { /// Forget everything in progress on the screen that is going away: IME /// composition, a selection drag, the context menu and the find bar. fn reset_interaction(&mut self) { + self.reconnect_pending = false; self.marked_text.clear(); self.marked_selection = 0..0; self.ime_cursor_bounds = None; @@ -564,7 +587,19 @@ impl TerminalView { .update(cx, |engine, cx| engine.set_search_query(&query, cx)); } - fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { + fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + if self.focus_handle.is_focused(window) + && !self.reconnect_pending + && reconnect_keystroke(event, &self.lifecycle(cx)) + && let Some(action) = &self.reconnect_action + { + // Dispatch is deferred. Latch immediately so queued key events cannot + // restart this connection twice before its lifecycle becomes Starting. + self.reconnect_pending = true; + window.dispatch_action(action.boxed_clone(), cx); + cx.stop_propagation(); + return; + } let mode = self.engine.read(cx).mode(); if let Some(bytes) = encode_key(&event.keystroke, mode) { self.send_user_input(bytes, cx); @@ -2097,6 +2132,42 @@ mod tests { Keystroke::parse(source).unwrap() } + #[test] + fn reconnect_enter_requires_a_fresh_plain_key_and_stopped_lifecycle() { + let stopped = super::TerminalLifecycle::Exited { + code: 0, + signal: None, + }; + let event = gpui_kit::KeyDownEvent { + keystroke: key("enter"), + is_held: false, + prefer_character_input: false, + }; + assert!(super::reconnect_keystroke(&event, &stopped)); + assert!(super::reconnect_keystroke( + &event, + &super::TerminalLifecycle::Failed("offline".into()) + )); + for state in [ + super::TerminalLifecycle::Starting, + super::TerminalLifecycle::Running, + super::TerminalLifecycle::Closing, + ] { + assert!(!super::reconnect_keystroke(&event, &state)); + } + for name in ["shift-enter", "ctrl-enter", "alt-enter", "cmd-enter", "a"] { + let mut modified = event.clone(); + modified.keystroke = key(name); + assert!(!super::reconnect_keystroke(&modified, &stopped)); + } + let mut held = event.clone(); + held.is_held = true; + assert!(!super::reconnect_keystroke(&held, &stopped)); + let mut character_input = event; + character_input.prefer_character_input = true; + assert!(!super::reconnect_keystroke(&character_input, &stopped)); + } + #[test] fn encodes_control_and_cursor_keys() { assert_eq!( diff --git a/tests/ui/connection.rs b/tests/ui/connection.rs index e1af629..80a4d59 100644 --- a/tests/ui/connection.rs +++ b/tests/ui/connection.rs @@ -153,6 +153,13 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); + assert_eq!( + window + .within(("terminal-tab", 1u64)) + .find("connection-status") + .label(), + Some("连接中") + ); let password = window.find(("ssh-auth-answer", 0usize)); assert_eq!( password.value(), @@ -167,6 +174,11 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); window.find("status-connection").label() == Some("已连接 prompt-host") + && window + .within(("terminal-tab", 1u64)) + .find("connection-status") + .label() + == Some("已连接") }) .await; @@ -259,6 +271,11 @@ async fn canceling_unknown_host_prompt_keeps_host_disconnected(cx: &mut TestAppC cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); window.find("status-connection").label() == Some("未连接 prompt-host") + && window + .within(("terminal-tab", 1u64)) + .find("connection-status") + .label() + == Some("未连接") && factory.canceled.load(Ordering::SeqCst) == 1 }) .await; @@ -494,6 +511,26 @@ async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(cx: &mut TestAp cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); + for id in [INITIAL_WEB_TERMINAL, second.0] { + let tab = window.within(("terminal-tab", id)); + let mark = tab.find(("terminal-tab-os", id)).bounds(); + let dot = tab.find("connection-status").bounds(); + let title = window.find(("terminal-tab", id)).bounds(); + let close = window.find(("close-terminal", id)).bounds(); + assert_eq!(mark.center().y, close.center().y); + let centers = dot.center() - mark.center(); + let distance = (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt(); + assert!(distance > f32::from(mark.size.width + dot.size.width) / 2.); + assert!(dot.right() < close.left()); + assert_eq!(mark.center().y, title.center().y); + assert_eq!( + window + .within(("terminal-tab", id)) + .find("connection-status") + .label(), + Some("已连接") + ); + } window.dispatch_action(Box::new(DisconnectTerminal(second)), cx); }) .unwrap(); @@ -523,11 +560,68 @@ async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(cx: &mut TestAp cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action(Box::new(ReconnectTerminal(second)), cx); + assert_eq!( + window + .within(("terminal-tab", INITIAL_WEB_TERMINAL)) + .find("connection-status") + .label(), + Some("已连接") + ); + assert_eq!( + window + .within(("terminal-tab", second.0)) + .find("connection-status") + .label(), + Some("未连接") + ); + window.click(("terminal", second.0), cx); + window.press("enter", cx); }) .unwrap(); - cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running + && window + .within(("terminal-tab", second.0)) + .find("connection-status") + .label() + == Some("已连接") + }) + .await; +} + +#[gpui_kit::test] +async fn failed_connection_tab_reports_disconnected_then_recovers(cx: &mut TestAppContext) { + let (store, host_id) = one_host_store(AuthKind::Password); + let factory = Arc::new(FakeTerminalFactory::fail_first()); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory); + let terminal_id = RemoteTerminalId(1); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(ConnectHost(host_id)), cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + matches!( + remote_lifecycle(&workspace, terminal_id, cx), + TerminalLifecycle::Failed(_) + ) && window + .within(("terminal-tab", terminal_id.0)) + .find("connection-status") + .label() + == Some("未连接") + }) + .await; + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + remote_lifecycle(&workspace, terminal_id, cx) == TerminalLifecycle::Running + && window + .within(("terminal-tab", terminal_id.0)) + .find("connection-status") + .label() + == Some("已连接") }) .await; } @@ -958,3 +1052,311 @@ async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppC assert!(workspace.store().read(cx).host(LINK_HOST).is_none()); }); } + +/// Initial errors and later exits both retain their tab and retry through +/// that tab's regular reconnect action, without leaking Enter to the shell. +async fn enter_reconnects_stopped_remote( + cx: &mut TestAppContext, + factory: Arc, +) { + let (store, host) = one_host_store(AuthKind::Password); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + let terminal = RemoteTerminalId(1); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("host-tree") + .double_click(("host-row", host.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + matches!( + remote_lifecycle(&workspace, terminal, cx), + TerminalLifecycle::Failed(_) | TerminalLifecycle::Exited { .. } + ) + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let screen = workspace + .read(cx) + .remote_terminal(terminal) + .unwrap() + .read(cx) + .terminal() + .read(cx) + .screen_text(cx); + assert!(screen.contains("按 Enter 重新连接"), "{screen}"); + window.click(("terminal", terminal.0), cx); + for key in ["shift-enter", "ctrl-enter", "alt-enter"] { + window.press(key, cx); + } + cx.write_to_clipboard(ClipboardItem::new_string("\n".into())); + window.dispatch_action(Box::new(shellrs::app::PasteTerminal), cx); + window.dispatch_action(Box::new(FindInTerminal), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("terminal-find", cx); + window.press("enter", cx); + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!( + factory.starts(), + 1, + "modifiers, paste and find must not reconnect" + ); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("terminal", terminal.0), cx); + window.press("enter", cx); + window.press("enter", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + remote_lifecycle(&workspace, terminal, cx) == TerminalLifecycle::Running + }) + .await; + assert_eq!(factory.starts(), 2); + assert_eq!( + factory.written_text(), + "", + "reconnect Enter must not reach the new shell" + ); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find(("terminal", terminal.0)).focused(), Some(true)); + window.press("enter", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| { + factory.written_text() == "\r" + }) + .await; + assert_eq!(factory.starts(), 2, "connected Enter is ordinary input"); +} + +#[gpui_kit::test] +async fn enter_reconnects_failed_remote(cx: &mut TestAppContext) { + enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::fail_first())).await; +} + +#[gpui_kit::test] +async fn enter_reconnects_exited_remote(cx: &mut TestAppContext) { + enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::exit_first())).await; +} + +#[gpui_kit::test] +async fn enter_retries_canceled_authentication_in_the_same_tab(cx: &mut TestAppContext) { + let (store, host) = one_host_store(AuthKind::Password); + let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication)); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within("host-tree") + .double_click(("host-row", host.0), cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("ssh-auth-submit").is_some() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("escape", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + matches!( + remote_lifecycle(&workspace, RemoteTerminalId(1), cx), + TerminalLifecycle::Exited { .. } + ) && window.try_find("ssh-auth-submit").is_none() + }) + .await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("terminal", 1_u64), cx); + window.press("enter", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("ssh-auth-submit").is_some() + }) + .await; + cx.update(|cx| { + assert_eq!( + remote_lifecycle(&workspace, RemoteTerminalId(1), cx), + TerminalLifecycle::Starting + ); + assert!( + workspace + .read(cx) + .remote_terminal(RemoteTerminalId(2)) + .is_none() + ); + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press("escape", cx); + }) + .unwrap(); + cx.run_until_parked(); +} + +/// Exercise the production title in the same TabBar used by the dock, with +/// and without its badge. Geometry must be invariant under state/selection. +struct TabStatusLayout { + state: ConnectionState, + selected: usize, + rem: f32, +} + +impl gpui_kit::Render for TabStatusLayout { + fn render( + &mut self, + window: &mut gpui_kit::Window, + _: &mut gpui_kit::Context, + ) -> impl gpui_kit::IntoElement { + use gpui_kit::component::{ + Sizable as _, + tab::{Tab, TabBar}, + }; + use gpui_kit::{InteractiveElement as _, ParentElement as _}; + use shellrs::shared::{ClosableTabTitle, HostMark}; + window.set_rem_size(px(self.rem)); + let title = |id| { + ClosableTabTitle::new( + ("layout-title", id), + HostMark::new(("layout-mark", id), "host", Some(HostOs::Ubuntu)).small(), + "host", + ) + .closable(("layout-close", id), Box::new(shellrs::app::CloseActiveTab)) + }; + TabBar::new("layout-tabs") + .selected_index(self.selected) + .child( + Tab::new() + .debug_selector(|| "plain-tab".into()) + .child(title(0u64)), + ) + .child( + Tab::new() + .debug_selector(|| "status-tab".into()) + .child(title(1u64).connection_state(self.state)), + ) + } +} + +#[gpui_kit::test] +fn connection_badge_preserves_icon_alignment_and_fits_the_tab(cx: &mut TestAppContext) { + init_app(cx); + let (view, cx) = cx.add_window_view(|_, _| TabStatusLayout { + state: ConnectionState::Connected, + selected: 0, + rem: 16., + }); + // The headless platform fixes device scaling at 2x; vary rem sizing too. + for rem in [12., 14., 16., 18.] { + for selected in [0, 1] { + for state in [ + ConnectionState::Connected, + ConnectionState::Connecting, + ConnectionState::Disconnected, + ] { + view.update(cx, |view, cx| { + view.rem = rem; + view.selected = selected; + view.state = state; + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.render_frame(cx)); + let clipped_tab = cx.debug_bounds("status-tab").unwrap(); + cx.update(|window, _| { + let plain = window.find(("layout-title", 0u64)).bounds(); + let title = window.find(("layout-title", 1u64)).bounds(); + let plain_mark = window.find(("layout-mark", 0u64)).bounds(); + let mark = window.find(("layout-mark", 1u64)).bounds(); + let close = window.find(("layout-close", 1u64)).bounds(); + let dot = window + .within(("layout-title", 1u64)) + .find("connection-status") + .bounds(); + assert_eq!(title.size, plain.size, "badge must not affect title layout"); + assert_eq!(mark.size, plain_mark.size, "badge must not shrink the icon"); + assert_eq!( + mark.center() - title.origin, + plain_mark.center() - plain.origin + ); + assert_eq!(mark.center().y, close.center().y); + assert_eq!(mark.center().y, title.center().y); + let centers = dot.center() - mark.center(); + let distance = + (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt(); + assert!( + distance > f32::from(mark.size.width + dot.size.width) / 2., + "circles must not overlap" + ); + assert!( + dot.bottom() <= clipped_tab.bottom() - px(1.), + "badge must fit the dock tab" + ); + assert!( + dot.right() < mark.right() + px(rem / 4.), + "badge must stop before the label" + ); + assert!(dot.right() < close.left()); + }); + } + } + } +} + +#[gpui_kit::test] +async fn reconnect_toolbar_works_without_window_focus(cx: &mut TestAppContext) { + let (store, host) = one_host_store(AuthKind::Password); + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone()); + in_frame(cx, handle, |window, cx| { + window + .within("host-tree") + .double_click(("host-row", host.0), cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + workspace + .read(cx) + .remote_terminal(RemoteTerminalId(1)) + .unwrap() + .read(cx) + .lifecycle(cx) + == TerminalLifecycle::Running + }) + .await; + let starts = factory.starts(); + in_frame(cx, handle, |window, cx| window.blur(cx)); + in_frame(cx, handle, |window, cx| { + assert!(window.focused(cx).is_none()); + window.click(("reconnect", 1u64), cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + factory.starts() > starts + && workspace + .read(cx) + .remote_terminal(RemoteTerminalId(1)) + .unwrap() + .read(cx) + .lifecycle(cx) + == TerminalLifecycle::Running + }) + .await; + assert_eq!(factory.starts(), starts + 1); +} diff --git a/tests/ui/sftp.rs b/tests/ui/sftp.rs index cd799dc..c54b5fe 100644 --- a/tests/ui/sftp.rs +++ b/tests/ui/sftp.rs @@ -1199,6 +1199,15 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( move |window: &mut gpui_kit::Window, cx: &mut App| { window.render_frame(cx); window.find("status-connection").label() == Some(expected) + && window + .within(("explorer-tab", SFTP_TAB)) + .find("connection-status") + .label() + == Some(if expected.starts_with("已连接") { + "已连接" + } else { + "未连接" + }) } }; let table = cx @@ -1208,6 +1217,24 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left( window.find("status-connection").label(), Some("已连接 db-01") ); + assert_eq!( + window + .within(("explorer-tab", SFTP_TAB)) + .find("connection-status") + .label(), + Some("已连接") + ); + let tab = window.within(("explorer-tab", SFTP_TAB)); + let mark = tab.find(("explorer-tab-os", SFTP_TAB)).bounds(); + let dot = tab.find("connection-status").bounds(); + let title = window.find(("explorer-tab", SFTP_TAB)).bounds(); + let close = window.find(("close-explorer", SFTP_TAB)).bounds(); + assert_eq!(mark.center().y, close.center().y); + assert_eq!(mark.center().y, title.center().y); + let centers = dot.center() - mark.center(); + let distance = (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt(); + assert!(distance > f32::from(mark.size.width + dot.size.width) / 2.); + assert!(dot.right() < close.left()); window .within(("remote-pane", SFTP_TAB)) .find("table") diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 7f94bc4..09e04e1 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -74,6 +74,12 @@ async fn creates_independent_local_terminals_from_button_and_shortcut(cx: &mut T window.render_frame(cx); assert!(window.find(("local-terminal", 1_u64)).visible()); assert_eq!(window.find(("local-terminal", 1_u64)).focused(), Some(true)); + assert!( + window + .within(("local-terminal-tab", 1_u64)) + .try_find("connection-status") + .is_none() + ); #[cfg(target_os = "macos")] window.press("cmd-t", cx); #[cfg(not(target_os = "macos"))] @@ -249,6 +255,14 @@ async fn exited_local_terminal_keeps_its_tab_and_restarts_fresh(cx: &mut TestApp window.render_frame(cx); assert!(window.find(("local-terminal", 1_u64)).visible()); assert!(window.find(("local-terminal-exit", 1_u64)).visible()); + window.click(("local-terminal", 1_u64), cx); + window.press("enter", cx); + assert_eq!( + factory.starts(), + 1, + "local shells do not opt into SSH reconnect" + ); + assert!(!local_screen(&workspace, cx).contains("按 Enter 重新连接")); cx.write_to_clipboard(ClipboardItem::new_string("退出后不可粘贴".into())); #[cfg(target_os = "macos")] window.press("cmd-v", cx);