Merge remote-tracking branch 'origin/main' into review/269-integration-20260924

This commit is contained in:
kuddev committed 2026-09-24 12:45:32 +08:00
commit d531ebb34b
23 files changed
+378 -58

No files matched your search

+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+Linksklick zum Öffnen",
"link_ctrl_click": "Strg+Linksklick zum Öffnen",
"settings": "Einstellungen",
"language": "Sprache",
"about": "Über",
+2
View File
@@ -209,6 +209,8 @@
}
},
"common": {
"link_command_click": "Command+left click to open",
"link_ctrl_click": "Ctrl+left click to open",
"settings": "Settings",
"language": "Language",
"about": "About",
+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+clic izquierdo para abrir",
"link_ctrl_click": "Ctrl+clic izquierdo para abrir",
"settings": "Configuración",
"language": "Idioma",
"about": "Acerca de",
+2
View File
@@ -95,6 +95,8 @@
}
},
"common": {
"link_command_click": "Command+clic gauche pour ouvrir",
"link_ctrl_click": "Ctrl+clic gauche pour ouvrir",
"settings": "Paramètres",
"language": "Langue",
"about": "À propos",
+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+clic sinistro per aprire",
"link_ctrl_click": "Ctrl+clic sinistro per aprire",
"settings": "Impostazioni",
"language": "Lingua",
"about": "Informazioni",
+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+左クリックで開く",
"link_ctrl_click": "Ctrl+左クリックで開く",
"settings": "設定",
"language": "言語",
"about": "このアプリについて",
+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+왼쪽 클릭으로 열기",
"link_ctrl_click": "Ctrl+왼쪽 클릭으로 열기",
"settings": "설정",
"language": "언어",
"about": "정보",
+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+clique esquerdo para abrir",
"link_ctrl_click": "Ctrl+clique esquerdo para abrir",
"settings": "Configurações",
"language": "Idioma",
"about": "Sobre",
+2
View File
@@ -45,6 +45,8 @@
}
},
"common": {
"link_command_click": "Command+щелчок левой кнопкой для открытия",
"link_ctrl_click": "Ctrl+щелчок левой кнопкой для открытия",
"settings": "Настройки",
"language": "Язык",
"about": "О программе",
+2
View File
@@ -209,6 +209,8 @@
}
},
"common": {
"link_command_click": "Command+左键跳转",
"link_ctrl_click": "Ctrl+左键跳转",
"settings": "设置",
"language": "语言",
"about": "关于",
+2
View File
@@ -69,6 +69,8 @@
}
},
"common": {
"link_command_click": "Command+左鍵開啟",
"link_ctrl_click": "Ctrl+左鍵開啟",
"settings": "設定",
"language": "語言",
"about": "關於",
+15 -1
View File
@@ -469,6 +469,18 @@ pub fn highlighted_at<T>(
config: &UiConfig,
point: Point,
mouse_mods: ModifiersState,
) -> Option<HintMatch> {
highlighted_at_with_mouse_override(term, config, point, mouse_mods, false)
}
/// Allow a shell's explicit link gesture to override application mouse reporting.
/// Configured hint modifiers and the mouse-enabled flag are still respected.
pub fn highlighted_at_with_mouse_override<T>(
term: &Term<T>,
config: &UiConfig,
point: Point,
mouse_mods: ModifiersState,
override_mouse_mode: bool,
) -> Option<HintMatch> {
let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE);
@@ -477,7 +489,9 @@ pub fn highlighted_at<T>(
let highlight = hint.mouse.is_some_and(|mouse| {
mouse.enabled
&& mouse_mods.contains(mouse.mods.0)
&& (!mouse_mode || mouse_mods.contains(ModifiersState::SHIFT))
&& (!mouse_mode
|| override_mouse_mode
|| mouse_mods.contains(ModifiersState::SHIFT))
});
if !highlight {
return None;
+15
View File
@@ -64,6 +64,21 @@ impl SshHostLists {
)
}
/// 与 [`Self::merged`] 同一顺序权威,同时带出用户在 SSH 设置里起的
/// 主机名称(地址 → 别名;没起名的是空串,展示端回落地址本身)。
/// profiles 在 [`Self::load`] 时已经读盘,这里不重复读 JSON;launcher /
/// Quick Jump / 命令面板三处 SSH 行共用,避免各自再解析一次。
pub fn merged_with_labels(&self) -> Vec<(String, String)> {
let labels = self.profiles.labels();
self.merged()
.into_iter()
.map(|host| {
let label = labels.get(&host).cloned().unwrap_or_default();
(host, label)
})
.collect()
}
/// 隐藏区(设置页"已隐藏"折叠列表的数据源)。
pub fn hidden_hosts(&self) -> Vec<String> {
self.hidden.iter().filter(|host| self.configured.contains(host)).cloned().collect()
@@ -1,4 +1,4 @@
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、平台修饰键+点击打开。
//!
//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做
//! 视口坐标、GPUI 修饰键和打开入口。
@@ -18,6 +18,15 @@ use winit::keyboard::ModifiersState;
use crate::config::UiConfig;
use crate::config::ui_config::{HintAction, HintInternalAction, default_hint_command};
use crate::display::hint::{self, HintMatch};
use crate::i18n::{Message, UiLanguage};
use crate::platform::Platform;
pub(super) fn link_modifier(mods: &gpui::Modifiers) -> bool {
match Platform::current() {
Platform::MacOS => mods.platform,
Platform::Windows | Platform::Linux => mods.control,
}
}
/// 悬停目标:旧壳 `highlighted_hint` + 已经解码好的预览文案。
#[derive(Clone)]
@@ -97,7 +106,13 @@ pub(super) fn highlighted_at<T: EventListener>(
point: Point,
mods: &gpui::Modifiers,
) -> Option<HintMatch> {
hint::highlighted_at(term, config, point, winit_mouse_mods(mods))
hint::highlighted_at_with_mouse_override(
term,
config,
point,
winit_mouse_mods(mods),
link_modifier(mods),
)
}
pub(super) fn hover_from_hint<T: EventListener>(
@@ -105,6 +120,7 @@ pub(super) fn hover_from_hint<T: EventListener>(
hint: HintMatch,
rows: usize,
cols: usize,
language: UiLanguage,
) -> Option<LinkHover> {
let raw = hint
.hyperlink()
@@ -117,12 +133,15 @@ pub(super) fn hover_from_hint<T: EventListener>(
point_to_viewport_from(origin, start).filter(|vp| vp.line < rows && vp.column.0 < cols);
let (anchor_row, anchor_col) =
vp.map(|vp| (vp.line as u16, vp.column.0 as u16)).unwrap_or((0, 0));
const HINT: &str = " · Ctrl+点击";
let gesture = language.text(match Platform::current() {
Platform::MacOS => Message::CommonLinkCommandClick,
Platform::Windows | Platform::Linux => Message::CommonLinkCtrlClick,
});
let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() };
let target = crate::display::strip_file_scheme(uri);
let budget = cols.saturating_sub(width(HINT) + 1);
let budget = cols.saturating_sub(width(gesture) + width(" · ") + 1);
let target = crate::display::fit_tail(&target, budget);
Some(LinkHover { hint, preview: format!("{target}{HINT}"), anchor_row, anchor_col })
Some(LinkHover { hint, preview: format!("{target} · {gesture}"), anchor_row, anchor_col })
}
pub(super) fn open_hint_match(
+1 -1
View File
@@ -402,7 +402,7 @@ pub struct TerminalView {
selection_scroll_epoch: u64,
/// 是否已有一条自动回滚定时器链在跑——每次 move 都开一条会叠出 N 倍速。
selection_scroll_active: bool,
/// OSC 8 / 正则 URL:虚线下划线、悬停预览、Ctrl+点击打开。
/// OSC 8 / 正则 URL:虚线下划线、悬停预览、平台修饰键+点击打开。
pub(super) hint_config: Arc<UiConfig>,
pub(super) link_hover: Option<super::osc_links::LinkHover>,
pending_link_open: bool,
@@ -1,3 +1,4 @@
use super::super::osc_links::link_modifier;
use super::*;
use gpui_component::WindowExt as _;
@@ -143,7 +144,13 @@ impl TerminalView {
let term = session.term.lock();
super::super::osc_links::highlighted_at(&term, &self.hint_config, point, mods)
.and_then(|hint| {
super::super::osc_links::hover_from_hint(&term, hint, self.rows, self.cols)
super::super::osc_links::hover_from_hint(
&term,
hint,
self.rows,
self.cols,
crate::gpui_shell::config::ui_language(cx),
)
})
})
};
@@ -362,6 +369,7 @@ impl TerminalView {
cx: &mut Context<Self>,
) {
window.focus(&self.focus_handle, cx);
self.pending_link_open = false;
cx.emit(TerminalViewEvent::FocusRequested);
if self.session.is_none() {
return;
@@ -407,6 +415,17 @@ impl TerminalView {
cx.notify();
return;
}
if link_modifier(&event.modifiers) && event.click_count == 1 {
self.update_link_hover(event.position, &event.modifiers, cx);
if self.link_hover.is_some() {
if let Some(session) = &self.session {
session.term.lock().selection = None;
}
self.selecting = false;
self.pending_link_open = true;
return;
}
}
if self.mouse_mode_active(&event.modifiers) {
self.send_mouse_report(
event.position,
@@ -416,28 +435,6 @@ impl TerminalView {
);
return;
}
if event.modifiers.control && event.click_count == 1 {
let (point, _) = self.grid_point(event.position);
let hit = self.session.as_ref().is_some_and(|session| {
let term = session.term.lock();
super::super::osc_links::highlighted_at(
&term,
&self.hint_config,
point,
&event.modifiers,
)
.is_some()
});
if hit {
if let Some(session) = &self.session {
session.term.lock().selection = None;
}
self.selecting = false;
self.pending_link_open = true;
self.update_link_hover(event.position, &event.modifiers, cx);
return;
}
}
let (point, side) = self.grid_point(event.position);
let ty = match event.click_count {
1 => SelectionType::Simple,
@@ -472,6 +469,11 @@ impl TerminalView {
window: &mut Window,
cx: &mut Context<Self>,
) {
// Retain the pressed link until release; dragging must not retarget it
// or leak part of the consumed gesture to the application.
if self.pending_link_open {
return;
}
if self.move_completion_popup_scrollbar(event, cx) {
cx.stop_propagation();
return;
@@ -531,6 +533,12 @@ impl TerminalView {
window.focus(&self.focus_handle, cx);
cx.emit(TerminalViewEvent::FocusRequested);
}
if event.pressed_button.is_none() && link_modifier(&event.modifiers) {
self.update_link_hover(event.position, &event.modifiers, cx);
if self.link_hover.is_some() {
return;
}
}
if self.mouse_mode_active(&event.modifiers) {
self.clear_link_hover(cx);
// 鼠标模式的移动上报:拖动 = 按钮码+32(需 DRAG 或 MOTION 任一),
@@ -581,7 +589,8 @@ impl TerminalView {
cx.notify();
return;
}
if !self.selecting && self.mouse_mode_active(&event.modifiers) {
let pending_link_open = std::mem::take(&mut self.pending_link_open);
if !pending_link_open && !self.selecting && self.mouse_mode_active(&event.modifiers) {
self.send_mouse_report(
event.position,
mouse_protocol::BUTTON_LEFT,
@@ -591,15 +600,15 @@ impl TerminalView {
return;
}
self.selecting = false;
let open_link = (self.pending_link_open || event.modifiers.control)
let (point, _) = self.grid_point(event.position);
let open_link = pending_link_open
&& self.selection_is_empty()
&& self.link_hover.is_some();
&& self.link_hover.as_ref().is_some_and(|hover| hover.hint.bounds().contains(&point));
if open_link {
self.try_open_hovered_link(window, cx);
} else if self.copy_on_select {
self.copy_selection(false, window, cx);
}
self.pending_link_open = false;
cx.notify();
}
@@ -619,6 +628,7 @@ impl TerminalView {
}
self.stop_selection_scroll();
let dragging_scrollbar = self.scrollbar_drag.take().is_some();
self.pending_link_open = false;
if !self.selecting {
if dragging_scrollbar {
cx.notify();
@@ -626,7 +636,6 @@ impl TerminalView {
return;
}
self.selecting = false;
self.pending_link_open = false;
// 指针不在终端上,链接打开不该发生;只补选中即复制这一条收尾。
if self.copy_on_select {
self.copy_selection(false, window, cx);
@@ -151,3 +151,178 @@ fn hover_does_not_steal_focus_during_drag_overlay_dialog_or_inactive_window(
move_over(&mut cx, None);
assert_eq!(probe.read_with(&cx, |probe, _| probe.requests.get()), 0);
}
fn link_modifiers() -> Modifiers {
match crate::platform::Platform::current() {
crate::platform::Platform::MacOS => Modifiers { platform: true, ..Modifiers::default() },
_ => Modifiers { control: true, ..Modifiers::default() },
}
}
fn link_fixture(
cx: &mut TestAppContext,
text: &[u8],
) -> (Entity<TerminalView>, VisualTestContext, std::sync::mpsc::Receiver<Msg>) {
cx.update(crate::gpui_shell::math_view::register);
let (probe, mut cx) = open(cx);
let terminal = probe.read_with(&cx, |probe, _| probe.terminal.clone());
let receiver = terminal.update(&mut cx, |view, cx| {
let (session, receiver) = session::test_session();
view.session = Some(session);
view.error = None;
view.exited = None;
view.copy_on_select = false;
// Use the production matcher and dispatcher with a deterministic action;
// tests must not launch a user's browser or editor.
let mut config = UiConfig::default();
Arc::make_mut(&mut config.hints.enabled[0]).action =
crate::config::ui_config::HintAction::Action(
crate::config::ui_config::HintInternalAction::Copy,
);
view.hint_config = Arc::new(config);
super::super::startup_tests::feed(view, text);
cx.notify();
receiver
});
draw(&mut cx);
cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into())));
(terminal, cx, receiver)
}
fn cell(view: &Entity<TerminalView>, cx: &VisualTestContext, col: usize) -> Point<Pixels> {
view.read_with(cx, |view, _| {
point(
view.origin.x + view.cell_width * (col as f32 + 0.5),
view.origin.y + view.line_height * 0.5,
)
})
}
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
}
#[gpui::test]
fn link_gesture_opens_regex_files_and_osc8_with_or_without_mouse_reporting(
cx: &mut TestAppContext,
) {
for (output, target) in [
("https://example.com", "https://example.com"),
("file:///tmp/pebrel-notes.md", "file:///tmp/pebrel-notes.md"),
("[notes](./notes.md)", "[notes](./notes.md)"),
("\x1b]8;;https://example.com/osc8\x1b\\site\x1b]8;;\x1b\\", "https://example.com/osc8"),
] {
for mouse_mode in [false, true] {
let output = if mouse_mode {
format!("\x1b[?1003h\x1b[?1006h{output}")
} else {
output.to_owned()
};
let (view, mut window, receiver) = link_fixture(cx, output.as_bytes());
let position = cell(&view, &window, 1);
window.simulate_mouse_move(position, None, link_modifiers());
assert!(view.read_with(&window, |view, _| view.link_hover.is_some()));
window.simulate_mouse_down(position, MouseButton::Left, link_modifiers());
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
assert!(view.read_with(&window, |view, _| view.pending_link_open));
// Releasing Command/Ctrl first must not send an orphan mouse-up to the TUI.
window.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
assert_eq!(clipboard(&mut window).as_deref(), Some(target));
assert!(!receiver.try_iter().any(|event| matches!(event, Msg::Input(_))));
}
}
}
#[gpui::test]
fn plain_click_and_wrong_modifier_do_not_open_links(cx: &mut TestAppContext) {
let (view, mut window, _) = link_fixture(cx, b"https://example.com");
let position = cell(&view, &window, 1);
let wrong = if link_modifiers().platform {
Modifiers { control: true, ..Modifiers::default() }
} else {
Modifiers { platform: true, ..Modifiers::default() }
};
for modifiers in [Modifiers::default(), wrong] {
window.simulate_mouse_move(position, None, modifiers);
window.simulate_mouse_down(position, MouseButton::Left, modifiers);
window.simulate_mouse_up(position, MouseButton::Left, modifiers);
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
}
}
#[gpui::test]
fn link_drag_cannot_retarget_or_leave_a_pending_open(cx: &mut TestAppContext) {
let (view, mut window, _) = link_fixture(cx, b"https://one.test https://two.test");
let start = cell(&view, &window, 1);
for end in [cell(&view, &window, 20), point(px(10.0), px(10.0))] {
window.simulate_mouse_down(start, MouseButton::Left, link_modifiers());
window.simulate_mouse_move(end, Some(MouseButton::Left), link_modifiers());
window.simulate_mouse_up(end, MouseButton::Left, link_modifiers());
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
assert!(!view.read_with(&window, |view, _| view.pending_link_open));
}
}
#[gpui::test]
fn mouse_reporting_still_receives_ordinary_and_non_link_clicks(cx: &mut TestAppContext) {
let (view, mut window, receiver) =
link_fixture(cx, b"\x1b[?1000h\x1b[?1006hhttps://example.com plain");
for (col, modifiers) in [(1, Modifiers::default()), (22, link_modifiers())] {
let position = cell(&view, &window, col);
window.simulate_mouse_down(position, MouseButton::Left, modifiers);
window.simulate_mouse_up(position, MouseButton::Left, modifiers);
let reports: Vec<_> = receiver
.try_iter()
.filter_map(|event| match event {
Msg::Input(bytes) => Some(bytes.into_owned()),
_ => None,
})
.collect();
assert_eq!(reports.len(), 2);
assert!(reports[0].ends_with(b"M"));
assert!(reports[1].ends_with(b"m"));
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
}
}
#[gpui::test]
fn link_hover_uses_current_language_and_platform(cx: &mut TestAppContext) {
use crate::i18n::UiLanguage;
let (view, mut window, _) = link_fixture(cx, b"https://example.com");
let position = cell(&view, &window, 1);
let modifier = if link_modifiers().platform { "Command" } else { "Ctrl" };
for (language, suffix) in [
(UiLanguage::ZhCn, "+左键跳转"),
(UiLanguage::EnUs, "+left click to open"),
(UiLanguage::KoKr, "+왼쪽 클릭으로 열기"),
] {
window.update(|_, cx| cx.global_mut::<Settings>().ui_language = language);
window.simulate_mouse_move(position, None, Modifiers::default());
let preview =
view.read_with(&window, |view, _| view.link_hover.as_ref().unwrap().preview.clone());
assert!(preview.ends_with(&format!(" · {modifier}{suffix}")), "{preview}");
draw(&mut window);
}
}
#[gpui::test]
fn explicit_link_gesture_respects_disabled_hints_and_required_modifiers(cx: &mut TestAppContext) {
let (view, mut window, _) = link_fixture(cx, b"\x1b[?1000hhttps://example.com");
let position = cell(&view, &window, 1);
for enabled in [false, true] {
view.update(&mut window, |view, _| {
let config = Arc::make_mut(&mut view.hint_config);
let hint = Arc::make_mut(&mut config.hints.enabled[0]);
let mouse = hint.mouse.as_mut().unwrap();
mouse.enabled = enabled;
mouse.mods.0 = winit::keyboard::ModifiersState::SHIFT;
});
window.simulate_mouse_down(position, MouseButton::Left, link_modifiers());
window.simulate_mouse_up(position, MouseButton::Left, link_modifiers());
assert_eq!(clipboard(&mut window).as_deref(), Some("before"));
}
let modifiers = Modifiers { shift: true, ..link_modifiers() };
window.simulate_mouse_down(position, MouseButton::Left, modifiers);
window.simulate_mouse_up(position, MouseButton::Left, modifiers);
assert_eq!(clipboard(&mut window).as_deref(), Some("https://example.com"));
}
+1 -1
View File
@@ -2228,7 +2228,7 @@ impl NebulaWorkspace {
crate::terminal_profiles::TerminalProfiles::load()
.map(|store| store.as_config_profiles())
.unwrap_or_default(),
crate::gpui_shell::ssh_hosts::SshHostLists::load().merged(),
crate::gpui_shell::ssh_hosts::SshHostLists::load().merged_with_labels(),
&default_shell_id,
language,
window.scale_factor().max(0.5),
@@ -37,7 +37,7 @@ fn open(cx: &mut TestAppContext) -> (Entity<NebulaWorkspace>, VisualTestContext)
workspace.palette_override = Some(shell_palette_rows(
vec![shell("cmd"), shell("pwsh")],
vec![],
["test-host".into()],
[("test-host".into(), String::new())],
"cmd",
workspace_ui_language(),
1.0,
+12 -7
View File
@@ -100,26 +100,31 @@ impl NebulaWorkspace {
// 次序由共享 merge 权威裁定。
let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
rows.extend(
crate::gpui_shell::ssh_hosts::SshHostLists::load().merged().into_iter().map(
|host| {
crate::gpui_shell::ssh_hosts::SshHostLists::load()
.merged_with_labels()
.into_iter()
.map(|(host, label)| {
let glyph = crate::display::ui::os_icons::resolve(
ssh_icons.get(&host).map(String::as_str),
)
.glyph;
let named = (!label.is_empty()).then_some(label.as_str());
let (label, hint) = shell_picker::ssh_host_display(named, &host);
let search =
format!("{label} {host} ssh host remote lianjie 连接").to_lowercase();
WorkspacePaletteRow {
group_order: usize::MAX,
group: language.pick("SSH 主机", "SSH HOSTS").to_owned(),
label: host.clone(),
hint: "SSH".to_owned(),
label,
hint,
hint_style: WorkspacePaletteHintStyle::Metadata,
search: format!("{host} ssh host remote lianjie 连接").to_lowercase(),
search,
action: WorkspacePaletteAction::LaunchSshHost(host),
icon: None,
icon_glyph: Some(glyph),
icon_path: None,
}
},
),
}),
);
rows
});
@@ -143,18 +143,21 @@ fn ssh_rows(language: crate::display::UiLanguage) -> Vec<WorkspacePaletteRow> {
let group = language.pick("SSH 主机", "SSH hosts");
let icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
crate::gpui_shell::ssh_hosts::SshHostLists::load()
.merged()
.merged_with_labels()
.into_iter()
.map(|host| {
.map(|(host, label)| {
let glyph =
crate::display::ui::os_icons::resolve(icons.get(&host).map(String::as_str)).glyph;
let named = (!label.is_empty()).then_some(label.as_str());
let (label, hint) = shell_picker::ssh_host_display(named, &host);
let search = format!("{label} {host} ssh host remote 远程 连接").to_lowercase();
WorkspacePaletteRow {
group_order: 3,
group: group.to_owned(),
label: host.clone(),
hint: "SSH".to_owned(),
label,
hint,
hint_style: super::WorkspacePaletteHintStyle::Metadata,
search: format!("{host} ssh host remote 远程 连接").to_lowercase(),
search,
action: WorkspacePaletteAction::LaunchSshHost(host),
icon: None,
icon_glyph: Some(glyph),
@@ -15,13 +15,24 @@ fn fallback_shell_glyph(id: &str, has_brand: bool) -> Option<char> {
crate::shell_detect::icon_for_id(id).chars().next()
}
/// SSH 主机的展示名与次要信息,与旧壳 `SshRow` 同口径:
///
/// - 用户在 SSH 设置里起过"主机名称":label 显示别名,hint 给真实连接地址;
/// - 没起名:回落地址本身当 label,hint 保持 "SSH" 类型标签,避免重复。
pub(super) fn ssh_host_display(label: Option<&str>, host: &str) -> (String, String) {
match label {
Some(label) if !label.trim().is_empty() => (label.trim().to_owned(), host.to_owned()),
_ => (host.to_owned(), "SSH".to_owned()),
}
}
/// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳
/// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。
/// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。
pub(super) fn shell_palette_rows(
shells: Vec<crate::shell_detect::DetectedShell>,
profiles: Vec<crate::config::ui_config::Profile>,
ssh_hosts: impl IntoIterator<Item = String>,
ssh_hosts: impl IntoIterator<Item = (String, String)>,
default_shell_id: &str,
language: crate::display::UiLanguage,
scale_factor: f32,
@@ -85,16 +96,20 @@ pub(super) fn shell_palette_rows(
rows.insert(0, default_row);
}
let ssh_icons = ssh_host_icon_ids(&crate::display::nebula_data_dir());
rows.extend(ssh_hosts.into_iter().map(|host| {
rows.extend(ssh_hosts.into_iter().map(|(host, label)| {
let glyph =
crate::display::ui::os_icons::resolve(ssh_icons.get(&host).map(String::as_str)).glyph;
// 空串 = 没起名:`ssh_host_display` 回落地址本身,hint 保持 "SSH"。
let named = (!label.is_empty()).then_some(label.as_str());
let (label, hint) = ssh_host_display(named, &host);
let search = format!("{label} {host} ssh host remote lianjie 连接").to_lowercase();
WorkspacePaletteRow {
group_order: 2,
group: ssh_group.to_owned(),
label: host.clone(),
hint: "SSH".to_owned(),
label,
hint,
hint_style: WorkspacePaletteHintStyle::Metadata,
search: format!("{host} ssh host remote lianjie 连接").to_lowercase(),
search,
action: WorkspacePaletteAction::LaunchSshHost(host),
icon: None,
icon_glyph: Some(glyph),
+47 -4
View File
@@ -556,7 +556,7 @@ fn shell_palette_puts_the_default_shell_first() {
let rows = shell_palette_rows(
shells.clone(),
Vec::new(),
["box.example".to_owned()],
[("box.example".to_owned(), String::new())],
"nu",
language,
1.0,
@@ -583,7 +583,14 @@ fn shell_palette_puts_the_default_shell_first() {
assert_eq!(rows[4].group, "SSH 主机");
// 默认 id 没在检测结果里(WSL 发行版被卸载等):不置顶也不 panic。
let rows = shell_palette_rows(shells, Vec::new(), None::<String>, "wsl:Ghost", language, 1.0);
let rows = shell_palette_rows(
shells,
Vec::new(),
None::<(String, String)>,
"wsl:Ghost",
language,
1.0,
);
assert!(matches!(
&rows[0].action,
WorkspacePaletteAction::LaunchShell(shell) if shell.id == "pwsh"
@@ -591,6 +598,42 @@ fn shell_palette_puts_the_default_shell_first() {
assert_eq!(rows[0].group, "所有 Shell");
}
/// 启动器 SSH 行的"主机名称":起过名的显示别名、hint 给真实地址;没起名
/// 回落地址本身、hint 保持 "SSH"。搜索词必须同时覆盖别名与地址。
#[test]
fn shell_palette_ssh_rows_show_the_configured_host_name() {
let rows = shell_palette_rows(
Vec::new(),
Vec::new(),
[
("root@box.example".to_owned(), "生产机".to_owned()),
("plain.example".to_owned(), String::new()),
],
"cmd",
crate::display::UiLanguage::ZhCn,
1.0,
);
assert_eq!(rows.len(), 2);
let named = &rows[0];
assert_eq!(named.label, "生产机");
assert_eq!(named.hint, "root@box.example");
assert!(named.search.contains("生产机"));
assert!(named.search.contains("root@box.example"));
assert!(matches!(
&named.action,
WorkspacePaletteAction::LaunchSshHost(host) if host == "root@box.example"
));
let plain = &rows[1];
assert_eq!(plain.label, "plain.example");
assert_eq!(plain.hint, "SSH");
assert!(matches!(
&plain.action,
WorkspacePaletteAction::LaunchSshHost(host) if host == "plain.example"
));
}
/// 回归锁:新建终端弹窗里,没有品牌贴图的 shell 不能空着——必须回落到按 id
/// 取字的 Nerd Font 字形。之前这一列只填了 `icon`,于是 zsh、csh、ksh 这些
/// 整行左边什么都没有。
@@ -608,7 +651,7 @@ fn shell_palette_falls_back_to_an_id_glyph_when_brand_art_is_absent() {
let rows = shell_palette_rows(
shells,
Vec::new(),
None::<String>,
None::<(String, String)>,
"zsh",
crate::display::UiLanguage::ZhCn,
1.0,
@@ -649,7 +692,7 @@ fn shell_palette_includes_imported_terminal_profiles() {
let rows = shell_palette_rows(
Vec::new(),
vec![profile],
None::<String>,
None::<(String, String)>,
&id,
crate::display::UiLanguage::ZhCn,
1.0,