diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index e43307f..f1a82bc 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -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", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index a757fff..3a1643f 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -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", diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index 773b594..67afd39 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -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", diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index bbd5169..ed9e741 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -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", diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 08159b7..1d63c6a 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -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", diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 2ed02bf..0949571 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+左クリックで開く", + "link_ctrl_click": "Ctrl+左クリックで開く", "settings": "設定", "language": "言語", "about": "このアプリについて", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index b3ea93c..32e0722 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+왼쪽 클릭으로 열기", + "link_ctrl_click": "Ctrl+왼쪽 클릭으로 열기", "settings": "설정", "language": "언어", "about": "정보", diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index 6b7366e..b87db41 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -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", diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index fd75e5c..85edbb8 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -45,6 +45,8 @@ } }, "common": { + "link_command_click": "Command+щелчок левой кнопкой для открытия", + "link_ctrl_click": "Ctrl+щелчок левой кнопкой для открытия", "settings": "Настройки", "language": "Язык", "about": "О программе", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 49d48b1..25ec97f 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -209,6 +209,8 @@ } }, "common": { + "link_command_click": "Command+左键跳转", + "link_ctrl_click": "Ctrl+左键跳转", "settings": "设置", "language": "语言", "about": "关于", diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 7f220dd..b827ae9 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -69,6 +69,8 @@ } }, "common": { + "link_command_click": "Command+左鍵開啟", + "link_ctrl_click": "Ctrl+左鍵開啟", "settings": "設定", "language": "語言", "about": "關於", diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 0f7c294..26a3707 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -469,6 +469,18 @@ pub fn highlighted_at( config: &UiConfig, point: Point, mouse_mods: ModifiersState, +) -> Option { + 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( + term: &Term, + config: &UiConfig, + point: Point, + mouse_mods: ModifiersState, + override_mouse_mode: bool, ) -> Option { let mouse_mode = term.mode().intersects(TermMode::MOUSE_MODE); @@ -477,7 +489,9 @@ pub fn highlighted_at( 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; diff --git a/nebula_app/src/gpui_shell/ssh_hosts.rs b/nebula_app/src/gpui_shell/ssh_hosts.rs index 6267a37..9b9c0be 100644 --- a/nebula_app/src/gpui_shell/ssh_hosts.rs +++ b/nebula_app/src/gpui_shell/ssh_hosts.rs @@ -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 { self.hidden.iter().filter(|host| self.configured.contains(host)).cloned().collect() diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index d825407..8d68bd5 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -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( point: Point, mods: &gpui::Modifiers, ) -> Option { - 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( @@ -105,6 +120,7 @@ pub(super) fn hover_from_hint( hint: HintMatch, rows: usize, cols: usize, + language: UiLanguage, ) -> Option { let raw = hint .hyperlink() @@ -117,12 +133,15 @@ pub(super) fn hover_from_hint( 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( diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index dea90ae..a3a488d 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -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, pub(super) link_hover: Option, pending_link_open: bool, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 4a6bcf3..0ae3802 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -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, ) { 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, ) { + // 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); diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs index afaf667..4baa4a4 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -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, VisualTestContext, std::sync::mpsc::Receiver) { + 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, cx: &VisualTestContext, col: usize) -> Point { + 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 { + 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::().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")); +} diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 818accb..f0d7be7 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -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), diff --git a/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs b/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs index 7fe6cc3..d8caafd 100644 --- a/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/launcher_menu/tests.rs @@ -37,7 +37,7 @@ fn open(cx: &mut TestAppContext) -> (Entity, 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, diff --git a/nebula_app/src/gpui_shell/workspace/palette.rs b/nebula_app/src/gpui_shell/workspace/palette.rs index 6fe7e0d..63b49c3 100644 --- a/nebula_app/src/gpui_shell/workspace/palette.rs +++ b/nebula_app/src/gpui_shell/workspace/palette.rs @@ -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 }); diff --git a/nebula_app/src/gpui_shell/workspace/quick_jump.rs b/nebula_app/src/gpui_shell/workspace/quick_jump.rs index 6417753..234f40e 100644 --- a/nebula_app/src/gpui_shell/workspace/quick_jump.rs +++ b/nebula_app/src/gpui_shell/workspace/quick_jump.rs @@ -143,18 +143,21 @@ fn ssh_rows(language: crate::display::UiLanguage) -> Vec { 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), diff --git a/nebula_app/src/gpui_shell/workspace/shell_picker.rs b/nebula_app/src/gpui_shell/workspace/shell_picker.rs index d4dde13..4702c6d 100644 --- a/nebula_app/src/gpui_shell/workspace/shell_picker.rs +++ b/nebula_app/src/gpui_shell/workspace/shell_picker.rs @@ -15,13 +15,24 @@ fn fallback_shell_glyph(id: &str, has_brand: bool) -> Option { 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, profiles: Vec, - ssh_hosts: impl IntoIterator, + ssh_hosts: impl IntoIterator, 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), diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs index a9da3d8..fb76b4b 100644 --- a/nebula_app/src/gpui_shell/workspace/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -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::, "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::, + 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::, + None::<(String, String)>, &id, crate::display::UiLanguage::ZhCn, 1.0,