diff --git a/CLAUDE.md b/CLAUDE.md index 84652ea..4812635 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。 - **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。暂不做目录同步、过滤、查找、目录树和 Dock 布局持久化。 -- **主机树**:首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。 +- **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。 - **主机对话框**: - 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。 - 「测试连接」是真登录,不强制填密码。 diff --git a/docs/manual.md b/docs/manual.md index 0f394a3..ae45391 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -38,6 +38,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。 +列表上方的搜索框(⌘K,其他平台 Ctrl+K)按名称、地址、用户名筛选主机,也按名称找分组:分组名匹配时列出这个分组和它下面的全部子分组、主机。结果都展开,匹配的主机连同它所在的各级分组一起显示。回车连接第一台匹配的主机(按列表里的顺序;只因分组匹配而列出的主机不算)。Esc 清空搜索。 + 拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。 ## 临时连接 diff --git a/src/host/host_panel.rs b/src/host/host_panel.rs index a67dafa..6b8ccaa 100644 --- a/src/host/host_panel.rs +++ b/src/host/host_panel.rs @@ -30,7 +30,8 @@ use crate::app::{ use crate::shared::{HostMark, RowTooltip, RowTooltips}; use super::{ - GroupId, Host, HostId, HostNode, HostOs, HostStore, NodeDrop, host_tree_items, matches_query, + GroupId, Host, HostId, HostNode, HostOs, HostStore, NodeDrop, first_matching_host, + host_tree_items, }; /// The host list of the left dock: a searchable, grouped tree of hosts. @@ -82,7 +83,7 @@ impl HostPanel { let tree_state = cx.new(|cx| TreeState::new(cx).items(items)); let search = cx.new(|cx| { InputState::new(window, cx) - .placeholder("搜索主机") + .placeholder("搜索主机或分组") .clean_on_escape() }); @@ -258,13 +259,8 @@ impl HostPanel { } fn connect_first_match(&mut self, window: &mut Window, cx: &mut Context) { - let first = self - .store - .read(cx) - .hosts() - .iter() - .find(|host| matches_query(host, &self.query)) - .map(|host| host.id); + let store = self.store.read(cx); + let first = first_matching_host(store.groups(), store.hosts(), &self.query); if let Some(id) = first { window.dispatch_action(Box::new(ConnectHost(id)), cx); } diff --git a/src/host/mod.rs b/src/host/mod.rs index fb3417f..343f36f 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -28,7 +28,9 @@ pub use host_dialog::{ pub use host_panel::{HostPanel, host_menu}; pub use login::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin}; pub use model::*; -pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query}; +pub use outline::{ + HostNode, NodeDrop, first_matching_host, group_options, host_tree_items, matches_query, +}; pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key}; pub use quick_connect::{QuickChoice, QuickHost, open_quick_connect_dialog, quick_hosts}; pub use secret_fields::SecretFields; diff --git a/src/host/outline.rs b/src/host/outline.rs index e882ef1..57ffb06 100644 --- a/src/host/outline.rs +++ b/src/host/outline.rs @@ -69,11 +69,18 @@ pub fn matches_query(host: &Host, query: &str) -> bool { .any(|field| field.to_lowercase().contains(&query)) } +/// Case-insensitive match on a group's name. +fn group_matches_query(group: &HostGroup, query: &str) -> bool { + let query = query.trim().to_lowercase(); + !query.is_empty() && group.name.to_lowercase().contains(&query) +} + /// Build the nested tree. Each level lists its subgroups first, then the /// hosts that belong to it; hosts with no group sit at the root beside -/// the top-level groups. A non-empty query keeps only matching hosts, drops -/// the groups left without any, and expands what remains; otherwise -/// `expanded` decides which groups are open. +/// the top-level groups. A non-empty query keeps the matching hosts and +/// the matching groups with everything in them, drops the groups left +/// without either, and expands what remains; otherwise `expanded` decides +/// which groups are open. pub fn host_tree_items( groups: &[HostGroup], hosts: &[Host], @@ -81,7 +88,44 @@ pub fn host_tree_items( expanded: &HashSet, ) -> Vec { let filtering = !query.trim().is_empty(); - items_under(None, 0, groups, hosts, query, filtering, expanded) + let level = Level { + groups, + hosts, + query, + filtering, + expanded, + }; + items_under(&level, None, 0, false) +} + +/// The first host the query itself matches, in the order the tree lists +/// them: what Enter in the search field connects. A host shown only because +/// its group matched does not count. +pub fn first_matching_host(groups: &[HostGroup], hosts: &[Host], query: &str) -> Option { + fn first(items: &[TreeItem], hosts: &[Host], query: &str) -> Option { + items.iter().find_map(|item| { + match HostNode::parse(&item.id)? { + HostNode::Host(id) => hosts + .iter() + .find(|host| host.id == id) + .filter(|host| matches_query(host, query)) + .map(|host| host.id), + HostNode::Group(_) => None, + } + .or_else(|| first(&item.children, hosts, query)) + }) + } + let items = host_tree_items(groups, hosts, query, &HashSet::new()); + first(&items, hosts, query) +} + +/// What every level of the tree is built from. +struct Level<'a> { + groups: &'a [HostGroup], + hosts: &'a [Host], + query: &'a str, + filtering: bool, + expanded: &'a HashSet, } /// The groups directly under `parent`, in display order. `None` asks for the @@ -95,44 +139,37 @@ fn child_groups(groups: &[HostGroup], parent: Option) -> Vec<&HostGroup children } +/// The items under `parent`. `everything`: a group above matched the query, +/// so all of it is listed. fn items_under( + level: &Level, parent: Option, depth: usize, - groups: &[HostGroup], - hosts: &[Host], - query: &str, - filtering: bool, - expanded: &HashSet, + everything: bool, ) -> Vec { // Nesting cannot run deeper than the number of groups unless the data has // a cycle, which the store refuses to create. Stop rather than recurse // forever if a hand-edited database ever produces one. - if depth > groups.len() { + if depth > level.groups.len() { return Vec::new(); } let mut items: Vec = Vec::new(); - for group in child_groups(groups, parent) { - let children = items_under( - Some(group.id), - depth + 1, - groups, - hosts, - query, - filtering, - expanded, - ); - if filtering && children.is_empty() { + for group in child_groups(level.groups, parent) { + let matched = everything || group_matches_query(group, level.query); + let children = items_under(level, Some(group.id), depth + 1, matched); + if level.filtering && !matched && children.is_empty() { continue; } items.push( TreeItem::new(HostNode::Group(group.id).id(), group.name.clone()) - .expanded(filtering || expanded.contains(&group.id)) + .expanded(level.filtering || level.expanded.contains(&group.id)) .children(children), ); } - let mut child_hosts: Vec<_> = hosts + let mut child_hosts: Vec<_> = level + .hosts .iter() - .filter(|s| s.group == parent && matches_query(s, query)) + .filter(|s| s.group == parent && (everything || matches_query(s, level.query))) .collect(); child_hosts.sort_by_key(|host| (host.sort_order, host.id)); items.extend( @@ -312,6 +349,38 @@ mod tests { assert_eq!(paths, ["生产", "测试"]); } + #[test] + fn a_query_matching_a_group_lists_all_of_it() { + let store = nested_store(); + let items = host_tree_items(store.groups(), store.hosts(), "生产", &HashSet::new()); + assert_eq!(labels(&items), ["生产"]); + assert!(items[0].is_expanded()); + assert_eq!(labels(&items[0].children), ["数据库", "web-01"]); + assert_eq!(labels(&items[0].children[0].children), ["db-01"]); + + // A subgroup: its ancestors for the way there, but not their hosts. + let items = host_tree_items(store.groups(), store.hosts(), "数据库", &HashSet::new()); + assert_eq!(labels(&items), ["生产"]); + assert_eq!(labels(&items[0].children), ["数据库"]); + assert_eq!(labels(&items[0].children[0].children), ["db-01"]); + } + + #[test] + fn enter_connects_the_first_host_the_query_matches_as_the_tree_lists_them() { + let store = nested_store(); + let id = |name: &str| store.hosts().iter().find(|h| h.name == name).unwrap().id; + // In the tree, 数据库's db-01 comes before 生产's own web-01. + assert_eq!( + first_matching_host(store.groups(), store.hosts(), "-01"), + Some(id("db-01")) + ); + // Hosts listed only because their group matched are not chosen. + assert_eq!( + first_matching_host(store.groups(), store.hosts(), "生产"), + None + ); + } + #[test] fn a_query_matching_only_a_root_host_drops_every_group() { let store = nested_store(); diff --git a/tests/ui/host_tree.rs b/tests/ui/host_tree.rs index 67861b9..e39fafb 100644 --- a/tests/ui/host_tree.rs +++ b/tests/ui/host_tree.rs @@ -30,6 +30,24 @@ fn search_filters_the_tree(cx: &mut TestAppContext) { .unwrap(); } +#[gpui_kit::test] +fn search_finds_a_group_by_name_with_all_of_it(cx: &mut TestAppContext) { + let (handle, _) = open_workspace(cx); + in_frame(cx, handle, |window, cx| { + window.click("host-search", cx); + window.input("生产", cx); + }); + in_frame(cx, handle, |window, _| { + let tree = window.within("host-tree"); + assert!(tree.try_find(("group-row", PRODUCTION)).is_some()); + for host in [WEB_01, WEB_02, DB_01] { + assert!(tree.try_find(("host-row", host)).is_some(), "{host}"); + } + assert!(tree.try_find(("host-row", STAGING_API)).is_none()); + assert!(tree.try_find(("group-row", DEVELOPMENT)).is_none()); + }); +} + #[gpui_kit::test] fn group_expansion_survives_reopening_the_database(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap();