diff --git a/src/app/actions.rs b/src/app/actions.rs index 56d773d..5b2b97b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -30,6 +30,10 @@ gpui_kit::actions!( FocusSearch, /// Connect the session selected in the focused session list. ConnectSelected, + /// Expand every group in the session tree. + ExpandAllGroups, + /// Collapse every group in the session tree. + CollapseAllGroups, /// Close the center tab displayed most recently (the one ⌘W acts on). CloseActiveTab, /// Increase the application base font (interface zoom). @@ -108,6 +112,10 @@ session_action!( DeleteSession ); +group_action!( + /// Open a terminal connection for every session in this group's subtree. + ConnectGroup +); group_action!( /// Open the new-session dialog with this group pre-selected. NewSessionInGroup diff --git a/src/session/database.rs b/src/session/database.rs index 55bb005..3202bb9 100644 --- a/src/session/database.rs +++ b/src/session/database.rs @@ -218,6 +218,13 @@ impl SessionDatabase { Ok(()) } + /// Save an expand-all or collapse-all command in one database write. + pub fn set_all_groups_expanded(&self, expanded: bool) -> rusqlite::Result<()> { + self.connection + .execute("UPDATE groups SET expanded = ?1", params![expanded])?; + Ok(()) + } + /// Deleting a group takes its subgroups and their sessions with it, via /// the `ON DELETE CASCADE` on both foreign keys. pub fn remove_group(&self, id: GroupId) -> rusqlite::Result<()> { diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index 0663140..d1b3525 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -1,9 +1,9 @@ -use std::rc::Rc; +use std::{rc::Rc, time::Duration}; use gpui_kit::component::{ ActiveTheme as _, IndexPath, Sizable as _, WindowExt as _, button::{Button, ButtonVariant, ButtonVariants as _}, - dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter}, + dialog::{Cancel, Confirm, DialogButtonProps, DialogFooter}, form::{Field, Form}, h_flex, input::{Input, InputContentType, InputEvent, InputState}, @@ -12,6 +12,7 @@ use gpui_kit::component::{ }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use zeroize::Zeroizing; use crate::secrets::{SecretRef, SharedSecretStore}; @@ -51,6 +52,8 @@ pub struct SessionForm { /// Parallel to the group select's rows; `None` is the root of the tree. group_ids: Vec>, error: Option, + testing_connection: bool, + connection_test_result: Option<(bool, SharedString)>, editing_connected: bool, /// Where saved secrets are read from. Writes go through the store, which /// owns the one error channel. @@ -179,6 +182,8 @@ impl SessionForm { group, group_ids, error: None, + testing_connection: false, + connection_test_result: None, editing_connected, secrets, password_loaded: false, @@ -278,6 +283,51 @@ impl SessionForm { .detach(); } + /// Probe the endpoint currently shown in the form without saving it. + fn test_connection(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + if self.testing_connection { + return; + } + let host = self.host.read(cx).value().trim().to_string(); + let port = self.port.read(cx).value().trim().parse::(); + let error = if host.is_empty() { + Some("请输入主机") + } else if !matches!(port, Ok(1..=u16::MAX)) { + Some("端口必须是 1 到 65535 之间的数字") + } else { + None + }; + if let Some(error) = error { + self.connection_test_result = Some((false, error.into())); + cx.notify(); + return; + } + let port = port.expect("validated port"); + let endpoint = format!("{host}:{port}"); + self.testing_connection = true; + self.connection_test_result = None; + cx.notify(); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { probe_ssh_service(&host, port) }) + .await; + this.update_in(cx, |this, _, cx| { + this.testing_connection = false; + this.connection_test_result = Some(match result { + Ok(()) => ( + true, + format!("{endpoint}:SSH 服务可达,尚未验证登录认证").into(), + ), + Err(error) => (false, format!("{endpoint}:{error}").into()), + }); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + /// Validate and write to the store. Returns whether the dialog may close. pub fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { let name = self.name.read(cx).value().trim().to_string(); @@ -505,9 +555,67 @@ impl Render for SessionForm { .child(error), ) }) + .when_some( + self.connection_test_result.clone(), + |form, (success, message)| { + form.child( + div() + .id("connection-test-result") + .test_support() + .aria_label(message.clone()) + .text_sm() + .text_color(if success { + cx.theme().success + } else { + cx.theme().danger + }) + .child(message), + ) + }, + ) } } +/// Check that a TCP endpoint speaks SSH. Authentication is intentionally not +/// attempted, so the form can test unsaved hosts without storing credentials +/// or changing the application's trusted-host list. +fn probe_ssh_service(host: &str, port: u16) -> Result<(), String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("无法启动连接测试:{error}"))?; + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(8), async { + let mut stream = tokio::net::TcpStream::connect((host, port)) + .await + .map_err(|error| format!("无法连接:{error}"))?; + stream + .write_all(b"SSH-2.0-shellr-probe\r\n") + .await + .map_err(|error| format!("无法发送 SSH 标识:{error}"))?; + for _ in 0..10 { + let mut line = Vec::new(); + for _ in 0..255 { + let byte = stream + .read_u8() + .await + .map_err(|error| format!("无法读取 SSH 标识:{error}"))?; + if byte == b'\n' { + break; + } + line.push(byte); + } + if line.starts_with(b"SSH-2.0-") || line.starts_with(b"SSH-1.99-") { + return Ok(()); + } + } + Err("目标端口没有返回 SSH 服务标识".to_string()) + }) + .await + .map_err(|_| "连接超时".to_string())? + }) +} + /// Open the new-session (`editing == None`) or edit-session dialog. /// `preselect_group` fills in the group field of a new session, so creating /// one from a group's context menu lands it in that group. @@ -556,16 +664,49 @@ pub fn open_session_dialog( window.open_dialog(cx, { let form = form.clone(); - move |dialog, _, _| { + move |dialog, _, cx| { dialog .title(title.clone()) .child(form.clone()) .footer( DialogFooter::new() - .child(DialogClose::new().trigger(|button| button.label("取消"))) + .w_full() + .justify_between() .child( - DialogAction::new() - .child(Button::new("commit").primary().label(commit_label.clone())), + Button::new("test-connection") + .label("测试连接") + .icon(crate::app::CatalogIcon::Plug) + .small() + .loading(form.read(cx).testing_connection) + .on_click({ + let form = form.clone(); + move |event, window, cx| { + form.update(cx, |form, cx| { + form.test_connection(event, window, cx) + }); + } + }), + ) + .child( + h_flex() + .gap_2() + .child(Button::new("cancel").label("取消").small().on_click( + |_, window, cx| { + window.dispatch_action(Box::new(Cancel), cx); + }, + )) + .child( + Button::new("commit") + .primary() + .label(commit_label.clone()) + .small() + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(Confirm { secondary: false }), + cx, + ); + }), + ), ), ) .on_ok({ diff --git a/src/session/session_panel.rs b/src/session/session_panel.rs index c99600b..fabbcfe 100644 --- a/src/session/session_panel.rs +++ b/src/session/session_panel.rs @@ -21,9 +21,9 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CatalogIcon, ConnectSelected, ConnectSession, DeleteGroup, DeleteSession, DuplicateSession, - EditSession, MoveSessionNode, NewChildGroup, NewGroup, NewSession, NewSessionInGroup, - OpenExplorer, RenameGroup, SESSION_PANEL_CONTEXT, + CatalogIcon, CollapseAllGroups, ConnectGroup, ConnectSelected, ConnectSession, DeleteGroup, + DeleteSession, DuplicateSession, EditSession, ExpandAllGroups, MoveSessionNode, NewChildGroup, + NewGroup, NewSession, NewSessionInGroup, OpenExplorer, RenameGroup, SESSION_PANEL_CONTEXT, }; use crate::shared::HostMark; @@ -271,6 +271,22 @@ impl SessionPanel { self.search.update(cx, |input, cx| input.focus(window, cx)); } + /// Refresh the visible tree after a bulk expansion change in the store. + pub fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.expanded = if expanded { + self.store + .read(cx) + .groups() + .iter() + .map(|group| group.id) + .collect() + } else { + HashSet::new() + }; + self.right_clicked.set(None); + self.rebuild_tree(cx); + } + /// The node currently selected in the tree. pub fn selected_node(&self, cx: &App) -> Option { self.tree_state @@ -789,6 +805,12 @@ fn build_context_menu(node: Option, menu: PopupMenu) -> PopupMenu { Box::new(DeleteSession(id)), ), Some(SessionNode::Group(id)) => menu + .menu_with_icon( + "连接组内主机", + Icon::new(CatalogIcon::Plug), + Box::new(ConnectGroup(id)), + ) + .separator() .menu_with_icon( "新建会话…", Icon::new(IconName::Plus), @@ -800,6 +822,17 @@ fn build_context_menu(node: Option, menu: PopupMenu) -> PopupMenu { Box::new(NewChildGroup(id)), ) .separator() + .menu_with_icon( + "展开全部分组", + Icon::new(IconName::FolderOpen), + Box::new(ExpandAllGroups), + ) + .menu_with_icon( + "折叠全部分组", + Icon::new(IconName::Folder), + Box::new(CollapseAllGroups), + ) + .separator() .menu_with_icon( "重命名分组…", Icon::new(CatalogIcon::Pencil), diff --git a/src/session/store.rs b/src/session/store.rs index e8e3f1f..4253e7a 100644 --- a/src/session/store.rs +++ b/src/session/store.rs @@ -509,6 +509,25 @@ impl SessionStore { true } + /// Apply one expansion choice to the whole tree and persist it together. + pub fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context) -> bool { + if self.groups.iter().all(|group| group.expanded == expanded) { + return false; + } + for group in &mut self.groups { + group.expanded = expanded; + } + if let Some(database) = self.database.as_ref() { + self.report( + database.set_all_groups_expanded(expanded), + "保存所有分组展开状态", + cx, + ); + } + cx.notify(); + true + } + /// Move a group or host in the tree and persist its new location and /// sibling order in one transaction. pub fn move_node( diff --git a/src/workspace/recent_sessions.rs b/src/workspace/recent_sessions.rs index eadd1cc..330e5ea 100644 --- a/src/workspace/recent_sessions.rs +++ b/src/workspace/recent_sessions.rs @@ -50,6 +50,13 @@ impl RecentSessions { } } + /// A newly shown start page begins without a selected host. + pub(super) fn clear_selection(&mut self, cx: &mut Context) { + if self.selected.take().is_some() { + cx.notify(); + } + } + fn rows(&self, cx: &App) -> Vec { let store = self.store.read(cx); store diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index f78604d..e211f22 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -19,12 +19,13 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, ConnectSession, CopyTerminal, - DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, ExplorerAction, - ExplorerCommand, FocusSearch, MoveSessionNode, NewChildGroup, NewGroup, NewLocalTerminal, - NewSession, NewSessionInGroup, OpenExplorer, PasteTerminal, ReconnectTerminal, RenameGroup, - RestartLocalTerminal, SelectAllUploadFiles, ToggleSessionPanel, ToggleTheme, - ToggleUploadSelection, UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset, + CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, CollapseAllGroups, + ConnectGroup, ConnectSession, CopyTerminal, DeleteGroup, DeleteSession, DisconnectSession, + DuplicateSession, EditSession, ExpandAllGroups, ExplorerAction, ExplorerCommand, FocusSearch, + MoveSessionNode, NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, + OpenExplorer, PasteTerminal, ReconnectTerminal, RenameGroup, RestartLocalTerminal, + SelectAllUploadFiles, ToggleSessionPanel, ToggleTheme, ToggleUploadSelection, + UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset, }; use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload}; use crate::session::{ @@ -352,6 +353,8 @@ impl Workspace { let was_empty = self.skin.is_center_empty(); self.skin.set_center_empty(empty, cx); if empty && !was_empty { + self.recent + .update(cx, |recent, cx| recent.clear_selection(cx)); let page = self.recent.read(cx).focus_handle(cx); window.focus(&page, cx); } @@ -751,7 +754,27 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let session_id = action.0; + self.connect_session(action.0, window, cx); + } + + fn on_connect_group( + &mut self, + action: &ConnectGroup, + window: &mut Window, + cx: &mut Context, + ) { + let session_ids = self.store.read(cx).sessions_under(action.0); + for session_id in session_ids { + self.connect_session(session_id, window, cx); + } + } + + fn connect_session( + &mut self, + session_id: SessionId, + window: &mut Window, + cx: &mut Context, + ) { if self.store.read(cx).session(session_id).is_none() { return; } @@ -1153,6 +1176,31 @@ impl Workspace { open_group_dialog(None, None, self.store.clone(), window, cx); } + fn on_expand_all_groups( + &mut self, + _: &ExpandAllGroups, + _: &mut Window, + cx: &mut Context, + ) { + self.set_all_groups_expanded(true, cx); + } + + fn on_collapse_all_groups( + &mut self, + _: &CollapseAllGroups, + _: &mut Window, + cx: &mut Context, + ) { + self.set_all_groups_expanded(false, cx); + } + + fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.store + .update(cx, |store, cx| store.set_all_groups_expanded(expanded, cx)); + self.session_panel + .update(cx, |panel, cx| panel.set_all_groups_expanded(expanded, cx)); + } + fn on_new_child_group( &mut self, action: &NewChildGroup, @@ -1517,10 +1565,13 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_delete_session)) .on_action(cx.listener(Self::on_new_session_in_group)) .on_action(cx.listener(Self::on_new_group)) + .on_action(cx.listener(Self::on_expand_all_groups)) + .on_action(cx.listener(Self::on_collapse_all_groups)) .on_action(cx.listener(Self::on_new_child_group)) .on_action(cx.listener(Self::on_rename_group)) .on_action(cx.listener(Self::on_delete_group)) .on_action(cx.listener(Self::on_connect_session)) + .on_action(cx.listener(Self::on_connect_group)) .on_action(cx.listener(Self::on_disconnect_session)) .on_action(cx.listener(Self::on_reconnect_terminal)) .on_action(cx.listener(Self::on_open_explorer)) diff --git a/tests/ui.rs b/tests/ui.rs index 481fa05..e2ee642 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -1,6 +1,8 @@ //! UI integration tests: the production `Workspace` rendered in a headless //! window, driven through real pointer and keyboard events. +use std::io::Write as _; +use std::net::TcpListener; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; @@ -13,13 +15,13 @@ use gpui_kit::{ }; use shellr::app::{ - ConnectSession, DeleteGroup, DeleteSession, EditSession, NewSessionInGroup, OpenExplorer, - RenameGroup, + CollapseAllGroups, ConnectGroup, ConnectSession, DeleteGroup, DeleteSession, EditSession, + ExpandAllGroups, NewSessionInGroup, OpenExplorer, RenameGroup, }; use shellr::secrets::{InMemorySecretStore, SecretRef, SecretStore as _}; use shellr::session::{ - AuthKind, ConnectionState, GroupId, HostOs, SessionDatabase, SessionDraft, SessionId, - SessionStore, + AuthKind, ConnectionState, GroupDraft, GroupId, HostOs, SessionDatabase, SessionDraft, + SessionId, SessionStore, }; use shellr::sftp::{ DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, @@ -34,8 +36,10 @@ use shellr::workspace::Workspace; /// Seeded session ids, in insertion order (see `SessionStore::seed`). const WEB_01: u64 = 1; +const WEB_02: u64 = 2; const DB_01: u64 = 3; const STAGING_API: u64 = 4; +const DEV_BOX: u64 = 6; const INITIAL_WEB_TERMINAL: u64 = 1; const INITIAL_STAGING_TERMINAL: u64 = 2; const FIRST_NEW_TERMINAL: u64 = 3; @@ -861,6 +865,72 @@ async fn new_session_dialog_validates_then_inserts(cx: &mut TestAppContext) { }); } +#[gpui_kit::test] +async fn session_dialog_tests_ssh_service_without_saving(cx: &mut TestAppContext) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let (reply, resume) = mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + resume.recv_timeout(Duration::from_secs(3)).unwrap(); + stream.write_all(b"SSH-2.0-test-server\r\n").unwrap(); + }); + let (handle, workspace) = open_workspace_with_store(cx, SessionStore::empty()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("new-session", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let test = window.find("test-connection").bounds(); + let cancel = window.find("cancel").bounds(); + let commit = window.find("commit").bounds(); + assert!(test.right() < cancel.left()); + assert!(cancel.right() < commit.left()); + assert!(commit.size.width < window.find("session-host").bounds().size.width); + + window.click("test-connection", cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find("connection-test-result").label(), + Some("请输入主机") + ); + window.click("session-host", cx); + window.input("127.0.0.1", cx); + window.click("session-port", cx); + #[cfg(target_os = "macos")] + window.press("cmd-a", cx); + #[cfg(not(target_os = "macos"))] + window.press("ctrl-a", cx); + window.input(&port.to_string(), cx); + window.click("test-connection", cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("test-connection").visible()); + assert!(window.try_find("connection-test-result").is_none()); + }) + .unwrap(); + reply.send(()).unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { + window.render_frame(cx); + window + .try_find("connection-test-result") + .and_then(|result| result.label().map(str::to_string)) + .is_some_and(|message| message.contains("SSH 服务可达")) + }) + .await; + server.join().unwrap(); + cx.update(|cx| assert!(workspace.read(cx).store().read(cx).sessions().is_empty())); +} + #[gpui_kit::test] fn search_filters_the_tree(cx: &mut TestAppContext) { let (handle, _) = open_workspace(cx); @@ -954,6 +1024,123 @@ fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) { ); } +#[gpui_kit::test] +fn expand_and_collapse_all_groups_include_nested_groups_and_persist(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellr.db"); + let database = SessionDatabase::open(&path).unwrap(); + let mut seed = SessionStore::seed(); + let nested = + seed.insert_group_unnotified(GroupDraft::new("内部服务", Some(GroupId(PRODUCTION)))); + for group in seed.groups() { + database.insert_group(group).unwrap(); + } + for session in seed.sessions() { + database.insert_session(session).unwrap(); + } + let (handle, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find(("group-row", nested.0)).visible()); + assert!(window.find(("session-row", WEB_01)).visible()); + window.dispatch_action(Box::new(CollapseAllGroups), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find(("group-row", PRODUCTION)).visible()); + assert!(window.try_find(("group-row", nested.0)).is_none()); + assert!(window.try_find(("session-row", WEB_01)).is_none()); + }) + .unwrap(); + assert!( + SessionDatabase::open(&path) + .unwrap() + .load() + .unwrap() + .groups + .iter() + .all(|group| !group.expanded) + ); + + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(ExpandAllGroups), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find(("group-row", nested.0)).visible()); + assert!(window.find(("session-row", WEB_01)).visible()); + }) + .unwrap(); + let database = SessionDatabase::open(&path).unwrap(); + assert!( + database + .load() + .unwrap() + .groups + .iter() + .all(|group| group.expanded) + ); + let (reopened, _) = open_workspace_with_store(cx, SessionStore::load(database).unwrap()); + cx.update_window(reopened.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find(("group-row", nested.0)).visible()); + assert!(window.find(("session-row", WEB_01)).visible()); + }) + .unwrap(); +} + +#[gpui_kit::test] +fn connect_group_opens_each_host_in_its_subtree(cx: &mut TestAppContext) { + let mut store = SessionStore::seed(); + let child = + store.insert_group_unnotified(GroupDraft::new("内部服务", Some(GroupId(PRODUCTION)))); + let grandchild = store.insert_group_unnotified(GroupDraft::new("后端", Some(child))); + let nested_host = store.insert_unnotified(SessionDraft::new( + "backend-01", + "10.0.3.8", + 22, + "deploy", + AuthKind::Auto, + Some(grandchild), + )); + let (handle, workspace) = open_workspace_with_store(cx, store); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ConnectGroup(child)), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!(workspace.terminal_count(nested_host, cx), 1); + assert_eq!(workspace.terminal_count(SessionId(WEB_01), cx), 1); + assert_eq!(workspace.terminal_count(SessionId(DB_01), cx), 0); + assert_eq!(workspace.terminal_count(SessionId(STAGING_API), cx), 1); + }); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.dispatch_action(Box::new(ConnectGroup(GroupId(PRODUCTION))), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + let workspace = workspace.read(cx); + assert_eq!(workspace.terminal_count(nested_host, cx), 2); + assert_eq!(workspace.terminal_count(SessionId(WEB_01), cx), 2); + assert_eq!(workspace.terminal_count(SessionId(WEB_02), cx), 1); + assert_eq!(workspace.terminal_count(SessionId(DB_01), cx), 1); + assert_eq!(workspace.terminal_count(SessionId(STAGING_API), cx), 1); + assert_eq!(workspace.terminal_count(SessionId(DEV_BOX), cx), 0); + }); +} + #[gpui_kit::test] fn dragging_a_host_into_a_group_updates_the_session_tree(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); @@ -1312,6 +1499,32 @@ async fn closing_every_tab_shows_the_recent_sessions(cx: &mut TestAppContext) { // Reconnecting moved web-01 to the front. assert_eq!(recent, ["web-01", "staging-api"]); }); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click(("close-terminal", FIRST_NEW_TERMINAL), cx); + }) + .unwrap(); + cx.run_until_parked(); + + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("recent-sessions").visible()); + // Reopening the page must not reconnect the old selection on Enter. + window.press("enter", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("recent-sessions").visible()); + assert!( + window + .try_find(("terminal", FIRST_NEW_TERMINAL + 1)) + .is_none() + ); + }) + .unwrap(); } #[gpui_kit::test]