新建、编辑会话增加测试连接功能
This commit is contained in:
1 parent
4b9dac8fb8
commit
1af8d0df97
8 files changed
+499
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user