新建、编辑会话增加测试连接功能

This commit is contained in:
Xuz committed 2026-09-24 20:55:38 +08:00
1 parent 4b9dac8fb8
commit 1af8d0df97
8 files changed
+499 -20

No files matched your search

+8
View File
@@ -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
+7
View File
@@ -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<()> {
+147 -6
View File
@@ -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<Option<GroupId>>,
error: Option<SharedString>,
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<Self>) {
if self.testing_connection {
return;
}
let host = self.host.read(cx).value().trim().to_string();
let port = self.port.read(cx).value().trim().parse::<u16>();
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<Self>) -> 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({
+36 -3
View File
@@ -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>) {
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<SessionNode> {
self.tree_state
@@ -789,6 +805,12 @@ fn build_context_menu(node: Option<SessionNode>, 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<SessionNode>, 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),
+19
View File
@@ -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<Self>) -> 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(
+7
View File
@@ -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<Self>) {
if self.selected.take().is_some() {
cx.notify();
}
}
fn rows(&self, cx: &App) -> Vec<RecentRow> {
let store = self.store.read(cx);
store
+58 -7
View File
@@ -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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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>,
) {
self.set_all_groups_expanded(true, cx);
}
fn on_collapse_all_groups(
&mut self,
_: &CollapseAllGroups,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.set_all_groups_expanded(false, cx);
}
fn set_all_groups_expanded(&mut self, expanded: bool, cx: &mut Context<Self>) {
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))
+217 -4
View File
@@ -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]