临时连接:标题栏「临时连接…」打开对话框,连一台主机而不保存

标题栏右上角的「新建主机…」换成「临时连接…」(新建主机还在主机列表的「+」、
右键菜单和 ⌘N)。对话框沿用主机对话框,去掉分组、连接方式和备注,名称可不填;
密码只留在内存里,不进钥匙串。连上后功能齐全(不像外部连接只开一个通道),
不进主机列表和最近连接,标签都关掉就没了;可以使用凭据,凭据改动和删除也
照顾到它。外部 CLI 不区分临时连接和外部连接,表格写「(未保存)」。

测试连接选「密码」却没填时,按「无密码」那样试(免认证、SSH Agent、默认私钥),
服务器要密码时报「未填写密码」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-04 15:52:29 +08:00
1 parent 835894625f
commit 9474c9e789
20 files changed
+596 -126

No files matched your search

+123
View File
@@ -768,6 +768,129 @@ async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContex
store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none()));
}
/// The title bar's 临时连接: a host's login without its group, route or
/// notes, connected to with every feature and saved nowhere.
#[gpui_kit::test]
async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut TestAppContext) {
use shellrs::app::CloseTerminal;
let data = tempfile::tempdir().unwrap();
let path = data.path().join("shellrs.db");
let keychain = Arc::new(InMemorySecretStore::default());
let store = HostStore::load(HostDatabase::open(&path).unwrap())
.unwrap()
.with_secrets(keychain.clone());
let remote = Arc::new(RecordingRemoteProvider::default());
let tester = Arc::new(FakeConnectionTester::default());
let (handle, workspace) =
open_workspace_with_credentials(cx, store, remote.clone(), tester.clone());
in_frame(cx, handle, |window, cx| {
window.click("temporary-connection", cx)
});
in_frame(cx, handle, |window, cx| {
// Only what a login needs.
assert!(window.try_find("host-route").is_none());
assert!(window.try_find("host-notes").is_none());
assert!(window.try_find("test-connection").is_some());
assert_eq!(
window.find("temporary-note").label(),
Some("仅当前使用的临时会话,不会保存到主机列表。")
);
// The name may be left out.
window.click("host-address", cx);
window.input("10.0.0.9", cx);
window.click("host-port", cx);
window.press("cmd-a", cx);
window.input("2222", cx);
window.click("host-user", cx);
window.press("cmd-a", cx);
window.input("deploy", cx);
window.click("host-password", cx);
window.input("p@ss", cx);
window.click("test-connection", cx);
});
// 测试连接 tries it as it would connect: directly.
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
window.render_frame(cx);
window.notifications(cx).len() == 1
})
.await;
assert_eq!(tester.routes(), [(LoginRoute::Direct, None)]);
in_frame(cx, handle, |window, cx| window.click("commit", cx));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 10.0.0.9")
})
.await;
let login = remote.logins().pop().expect("connected");
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "deploy")
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
// Every channel it likes, unlike a bastion host's link.
assert!(!login.shell_only);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("terminal-tab", LINK_TERMINAL)).label(),
Some("10.0.0.9")
);
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
// The hosts stay where they were.
assert_eq!(window.find("show-hosts").checked(), Some(true));
for tool in [
"tool-snippets",
"tool-history",
"tool-docker",
"tool-monitor",
] {
assert!(window.try_find(tool).is_some(), "{tool}");
}
})
.unwrap();
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_temporary(LINK_HOST));
assert!(!store.is_external(LINK_HOST));
assert!(store.hosts().is_empty());
assert!(store.recent_hosts().next().is_none());
assert_eq!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.as_deref()
.map(String::as_str),
Some("p@ss")
);
});
assert!(keychain.is_empty());
// It goes with its tab, password and all.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
})
.unwrap();
cx.run_until_parked();
store.read_with(cx, |store, _| {
assert!(store.host(LINK_HOST).is_none());
assert!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.is_none()
);
});
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
assert!(saved.hosts().is_empty());
}
#[gpui_kit::test]
async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_credentials(
+1 -1
View File
@@ -232,7 +232,7 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("web-01", cx);
+10 -10
View File
@@ -8,7 +8,7 @@ async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such(
cx: &mut TestAppContext,
) {
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
// A new host logs in with a password.
let sources = window.within("host-auth-source");
@@ -84,7 +84,7 @@ fn chain(window: &mut gpui_kit::Window) -> Option<String> {
async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppContext) {
let (store, [aliyun, zentao, _]) = store_with_jump_hosts();
let (handle, workspace) = open_workspace_with_store(cx, store);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
// A new host connects directly, with nothing more to fill in.
let routes = window.within("host-route");
@@ -203,7 +203,7 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA
let (store, _) = store_with_jump_hosts();
let tester = Arc::new(FakeConnectionTester::default());
let (handle, _) = open_workspace_with_tester(cx, store, tester.clone());
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("db", cx);
@@ -238,7 +238,7 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA
#[gpui_kit::test]
async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("db", cx);
@@ -334,7 +334,7 @@ async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mu
HostStore::empty().with_secrets(secrets.clone()),
tester.clone(),
);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("abroad", cx);
@@ -457,7 +457,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA
#[gpui_kit::test]
async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.click("commit", cx);
@@ -485,7 +485,7 @@ async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-host", cx);
window.click("new-host-panel", cx);
})
.unwrap();
cx.run_until_parked();
@@ -640,7 +640,7 @@ async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-host", cx);
window.click("new-host-panel", cx);
})
.unwrap();
cx.run_until_parked();
@@ -770,7 +770,7 @@ async fn a_new_host_saves_its_password_to_the_keychain(cx: &mut TestAppContext)
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-host", cx);
window.click("new-host-panel", cx);
})
.unwrap();
cx.run_until_parked();
@@ -944,7 +944,7 @@ async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppConte
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
let name = window.find("host-name").bounds();
let mut group = window.within("host-auth-source");
+4 -2
View File
@@ -628,7 +628,8 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
cx.run_until_parked();
for (open, field) in [
("new-host", "host-name"),
("new-host-panel", "host-name"),
("temporary-connection", "host-address"),
("new-group", "group-name"),
("new-forward", "forward-name"),
("new-credential", "credential-name"),
@@ -681,7 +682,8 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu
cx.run_until_parked();
for (open, field, error) in [
("new-host", "host-name", "请输入名称"),
("new-host-panel", "host-name", "请输入名称"),
("temporary-connection", "host-address", "请输入地址"),
("new-group", "group-name", "请输入分组名称"),
("new-forward", "forward-name", "请选择端口转发经由的主机"),
("new-credential", "credential-name", "请输入名称"),