主机列表搜索也能按名称找分组

- 分组名匹配时列出这个分组和它下面的全部子分组、主机;主机仍按名称、
  地址、用户名匹配。搜索框提示改为「搜索主机或分组」。
- 回车连接的第一台匹配主机改按列表里的顺序;只因分组匹配而列出的主机
  不算。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 11:25:37 +08:00
1 parent 36293e8959
commit f7928fbeba
6 files changed
+122 -35

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。暂不做目录同步、过滤、查找、目录树和 Dock 布局持久化。
- **主机树**:首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
- **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
- **主机对话框**:
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。
- 「测试连接」是真登录,不强制填密码。
+2
View File
@@ -38,6 +38,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
列表上方的搜索框(⌘K,其他平台 Ctrl+K)按名称、地址、用户名筛选主机,也按名称找分组:分组名匹配时列出这个分组和它下面的全部子分组、主机。结果都展开,匹配的主机连同它所在的各级分组一起显示。回车连接第一台匹配的主机(按列表里的顺序;只因分组匹配而列出的主机不算)。Esc 清空搜索。
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
## 临时连接
+5 -9
View File
@@ -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<Self>) {
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);
}
+3 -1
View File
@@ -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;
+93 -24
View File
@@ -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<GroupId>,
) -> Vec<TreeItem> {
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<HostId> {
fn first(items: &[TreeItem], hosts: &[Host], query: &str) -> Option<HostId> {
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<GroupId>,
}
/// The groups directly under `parent`, in display order. `None` asks for the
@@ -95,44 +139,37 @@ fn child_groups(groups: &[HostGroup], parent: Option<GroupId>) -> 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<GroupId>,
depth: usize,
groups: &[HostGroup],
hosts: &[Host],
query: &str,
filtering: bool,
expanded: &HashSet<GroupId>,
everything: bool,
) -> Vec<TreeItem> {
// 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<TreeItem> = 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();
+18
View File
@@ -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();