From 78fd216fb66624dd98523bb46dfbfa1a841e8f0f Mon Sep 17 00:00:00 2001 From: Nex-Z Date: Sun, 11 Oct 2026 21:26:22 -0700 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=90=8D=E7=A7=B0=E7=95=99?= =?UTF-8?q?=E7=A9=BA=E6=97=B6=E9=BB=98=E8=AE=A4=E4=BD=BF=E7=94=A8=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/manual.md | 2 +- locales/host.yml | 4 +-- src/cli/manage.rs | 28 +++++++++++++++++-- src/host/host_dialog.rs | 20 ++++++-------- src/host/model.rs | 44 +++++++++++++++++++++++++---- tests/ui/cli.rs | 15 ++++++++-- tests/ui/credential.rs | 6 ++-- tests/ui/host_dialog.rs | 61 ++++++++++++++++++++++++++++++++++++++++- tests/ui/workspace.rs | 2 +- 9 files changed, 153 insertions(+), 29 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 0692439..10d6cd9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -12,7 +12,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 ## 主机列表 -左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。 +左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。名称可以留空,保存时默认使用地址;也可以填写自定义名称。 主机对话框的「认证方式」有三种: diff --git a/locales/host.yml b/locales/host.yml index f96cd4c..4d24365 100644 --- a/locales/host.yml +++ b/locales/host.yml @@ -127,8 +127,8 @@ host.dialog.name: zh-CN: 名称 en: Name host.dialog.name_placeholder: - zh-CN: 例如 web-01 - en: e.g. web-01 + zh-CN: 可选,默认用地址 + en: Optional; the address if left empty host.dialog.temporary_name_placeholder: zh-CN: 可选,默认用地址 en: Optional; the address if left empty diff --git a/src/cli/manage.rs b/src/cli/manage.rs index 6f5eb74..c7a34d4 100644 --- a/src/cli/manage.rs +++ b/src/cli/manage.rs @@ -740,7 +740,7 @@ mod tests { } #[test] - fn a_new_host_needs_a_name_and_an_address_and_takes_the_forms_defaults() { + fn a_new_host_needs_an_address_and_takes_the_forms_defaults() { let (store, _, _) = store(); let plan = plan_host( fields(serde_json::json!({ "name": " db ", "host": "10.0.0.2" })), @@ -761,7 +761,11 @@ mod tests { ); for (json, message) in [ - (serde_json::json!({ "host": "10.0.0.2" }), "请输入名称"), + (serde_json::json!({}), "请输入地址"), + ( + serde_json::json!({ "name": "", "host": " " }), + "请输入地址", + ), (serde_json::json!({ "name": "db" }), "请输入地址"), ( serde_json::json!({ "name": "db", "host": "h", "port": 0 }), @@ -793,6 +797,26 @@ mod tests { } } + #[test] + fn a_host_with_no_name_uses_its_address() { + let (store, web, _) = store(); + for json in [ + serde_json::json!({ "host": " 10.0.0.2 " }), + serde_json::json!({ "name": "", "host": " 10.0.0.2 " }), + serde_json::json!({ "name": " ", "host": " 10.0.0.2 " }), + ] { + let plan = plan_host(fields(json), None, &store).unwrap(); + assert_eq!(plan.draft.name.as_ref(), "10.0.0.2"); + } + let plan = plan_host( + fields(serde_json::json!({ "name": " " })), + Some(web), + &store, + ) + .unwrap(); + assert_eq!(plan.draft.name, plan.draft.address); + } + #[test] fn a_group_path_names_a_group_or_the_groups_to_make() { let (mut store, web, _) = store(); diff --git a/src/host/host_dialog.rs b/src/host/host_dialog.rs index 8cb8c33..5f7ab11 100644 --- a/src/host/host_dialog.rs +++ b/src/host/host_dialog.rs @@ -677,17 +677,13 @@ impl HostForm { /// 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(); - let checked = if name.is_empty() { - Err(HostDraftError::Name.message()) - } else { - self.endpoint(cx).and_then(|endpoint| { - Ok(( - endpoint, - self.committed_login(cx)?, - self.committed_route(cx)?, - )) - }) - }; + let checked = self.endpoint(cx).and_then(|endpoint| { + Ok(( + endpoint, + self.committed_login(cx)?, + self.committed_route(cx)?, + )) + }); let ((host, port), login, route) = match checked { Ok(checked) => checked, Err(error) => { @@ -696,6 +692,8 @@ impl HostForm { } }; + let name = if name.is_empty() { host.clone() } else { name }; + let group = self .group .read(cx) diff --git a/src/host/model.rs b/src/host/model.rs index 3397a65..3271541 100644 --- a/src/host/model.rs +++ b/src/host/model.rs @@ -585,20 +585,21 @@ impl HostDraft { SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port) } - /// The draft with surrounding blanks removed and an empty user name - /// taken as [`super::DEFAULT_USER`], or the first thing wrong with it, + /// The draft with surrounding blanks removed, an empty name taken as + /// the address and an empty user name as [`super::DEFAULT_USER`], or the + /// first thing wrong with it, /// in the order the host form checks. What the form cannot leave out /// of a draft (a credential to pick, a proxy password without a user) /// is the caller's to check. pub fn validated(mut self) -> Result { self.name = self.name.trim().to_string().into(); - if self.name.is_empty() { - return Err(HostDraftError::Name); - } self.address = self.address.trim().to_string().into(); if self.address.is_empty() { return Err(HostDraftError::Address); } + if self.name.is_empty() { + self.name = self.address.clone(); + } if self.port == 0 { return Err(HostDraftError::Port); } @@ -671,6 +672,39 @@ impl std::error::Error for HostDraftError {} mod tests { use super::*; + #[test] + fn a_blank_host_name_uses_the_trimmed_address() { + for (name, expected) in [ + ("", "example.com"), + (" \t ", "example.com"), + (" web-01 ", "web-01"), + ] { + let draft = HostDraft::new(name, " example.com ", 2222, "", AuthKind::Password, None) + .validated() + .unwrap(); + assert_eq!(draft.name.as_ref(), expected); + assert_eq!(draft.address.as_ref(), "example.com"); + assert_eq!(draft.user.as_ref(), super::super::DEFAULT_USER); + assert_eq!(draft.port, 2222); + } + } + + #[test] + fn a_host_still_requires_an_address_with_or_without_a_name() { + for name in ["", " ", "web-01"] { + for address in ["", " \t "] { + let error = HostDraft::new(name, address, 22, "root", AuthKind::Password, None) + .validated() + .unwrap_err(); + assert_eq!(error, HostDraftError::Address); + } + } + let error = HostDraft::new("", "example.com", 0, "root", AuthKind::Password, None) + .validated() + .unwrap_err(); + assert_eq!(error, HostDraftError::Port); + } + #[test] fn the_distributions_are_linux_and_the_rest_are_not() { let not_linux: Vec<_> = HostOs::ALL diff --git a/tests/ui/cli.rs b/tests/ui/cli.rs index 02df058..60bfc6e 100644 --- a/tests/ui/cli.rs +++ b/tests/ui/cli.rs @@ -98,10 +98,21 @@ fn a_host_from_the_cli_lands_in_the_tree_with_its_group_and_password(cx: &mut Te host: Some("10.0.9.2".into()), ..HostFields::default() }; - let error = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)).unwrap_err(); + let Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)) + else { + panic!("host without a name not created"); + }; + assert_eq!(created.name, "10.0.9.2"); + let error = apply( + cx, + handle, + &workspace, + CliChange::CreateHost(HostFields::default()), + ) + .unwrap_err(); assert_eq!( (error.code, error.message.as_str()), - (ErrorCode::BadRequest, "请输入名称") + (ErrorCode::BadRequest, "请输入地址") ); } diff --git a/tests/ui/credential.rs b/tests/ui/credential.rs index 68122b2..57a5df7 100644 --- a/tests/ui/credential.rs +++ b/tests/ui/credential.rs @@ -234,8 +234,6 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC ); 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); window.click("host-address", cx); window.input("10.0.1.12", cx); window.within("host-auth-source").click(1usize, cx); @@ -274,8 +272,8 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC let created = store .hosts() .iter() - .find(|host| host.name == "web-01") - .expect("web-01 inserted"); + .find(|host| host.name == "10.0.1.12") + .expect("host named after its address inserted"); assert_eq!(created.credential, Some(credential)); assert_eq!(created.user.as_ref(), "deploy"); assert_eq!(created.auth, AuthKind::Password); diff --git a/tests/ui/host_dialog.rs b/tests/ui/host_dialog.rs index 0d3a77c..637ee17 100644 --- a/tests/ui/host_dialog.rs +++ b/tests/ui/host_dialog.rs @@ -479,6 +479,65 @@ async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut Te }); } +#[gpui_kit::test] +async fn a_host_without_an_address_is_not_saved(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty()); + for (name, address) in [("", ""), ("", " "), ("web-01", "")] { + 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(name, cx); + window.click("host-address", cx); + window.input(address, cx); + window.click("commit", cx); + }); + in_frame(cx, handle, |window, _| { + assert!(window.find("commit").visible()); + assert_eq!(window.find("form-error").label(), Some("请输入地址")); + }); + cx.update(|cx| assert!(workspace.read(cx).store().read(cx).hosts().is_empty())); + in_frame(cx, handle, |window, cx| window.press("escape", cx)); + wait_for_dialog_to_close(cx, handle).await; + } +} + +#[gpui_kit::test] +async fn a_blank_host_name_uses_the_address_and_is_saved(cx: &mut TestAppContext) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("shellrs.db"); + let store = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap(); + let (handle, workspace) = open_workspace_with_store(cx, store); + for (name, address) in [("", "example.com"), (" ", "10.0.0.2")] { + in_frame(cx, handle, |window, cx| { + // Saving closes the dialog and leaves focus outside the host panel. + window.click("host-search", cx); + window.click("new-host-panel", cx); + }); + in_frame(cx, handle, |window, cx| { + window.click("host-name", cx); + window.input(name, cx); + window.click("host-address", cx); + window.input(&format!(" {address} "), cx); + window.click("commit", cx); + }); + wait_for_dialog_to_close(cx, handle).await; + cx.update(|cx| { + let store = workspace.read(cx).store().read(cx); + let host = store + .hosts() + .iter() + .find(|host| host.address == address) + .unwrap(); + assert_eq!(host.name.as_ref(), address); + }); + } + let saved = HostDatabase::open(&path).unwrap().load().unwrap(); + assert_eq!(saved.hosts.len(), 2); + for host in saved.hosts { + assert_eq!(host.name, host.address); + } +} + #[gpui_kit::test] async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace(cx); @@ -527,7 +586,7 @@ async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) { // The error pops up as a notification over the dialog, not as a line // at the bottom of the form. assert_eq!(window.notifications(cx).len(), 1); - assert_eq!(window.find("form-error").label(), Some("请输入名称")); + assert_eq!(window.find("form-error").label(), Some("请输入地址")); window.click("commit", cx); }) .unwrap(); diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index 6d89069..91eab37 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -749,7 +749,7 @@ 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-panel", "host-name", "请输入名称"), + ("new-host-panel", "host-address", "请输入地址"), ("temporary-connection", "host-address", "请输入地址"), ("new-group", "group-name", "请输入分组名称"), ("new-forward", "forward-name", "请选择端口转发经由的主机"),