主机列表搜索也能按名称找分组
- 分组名匹配时列出这个分组和它下面的全部子分组、主机;主机仍按名称、 地址、用户名匹配。搜索框提示改为「搜索主机或分组」。 - 回车连接的第一台匹配主机改按列表里的顺序;只因分组匹配而列出的主机 不算。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
36293e8959
commit
f7928fbeba
6 files changed
+122
-35
No files matched your search
@@ -17,7 +17,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。
|
||||
|
||||
- **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。暂不做目录同步、过滤、查找、目录树和 Dock 布局持久化。
|
||||
- **主机树**:首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
|
||||
- **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
|
||||
- **主机对话框**:
|
||||
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、`~/.ssh` 默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。
|
||||
- 「测试连接」是真登录,不强制填密码。
|
||||
|
||||
@@ -38,6 +38,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
|
||||
|
||||
列表上方的搜索框(⌘K,其他平台 Ctrl+K)按名称、地址、用户名筛选主机,也按名称找分组:分组名匹配时列出这个分组和它下面的全部子分组、主机。结果都展开,匹配的主机连同它所在的各级分组一起显示。回车连接第一台匹配的主机(按列表里的顺序;只因分组匹配而列出的主机不算)。Esc 清空搜索。
|
||||
|
||||
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
|
||||
|
||||
## 临时连接
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user