fix(macos): open terminal links with Command-click

This commit is contained in:
YinBuLiao committed 2026-09-23 15:22:24 +08:00
1 parent 0110c7c139
commit cddeec78b3
16 files changed
+274 -35

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;
@@ -1,4 +1,4 @@
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、Ctrl+点击打开。
//! GPUI 壳的 OSC 8 / 正则 hint 接线:虚线下划线、悬停预览、平台修饰键+点击打开。
//!
//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做
//! 视口坐标、GPUI 修饰键和打开入口。
@@ -16,6 +16,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)]
@@ -78,7 +87,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>(
@@ -86,6 +101,7 @@ pub(super) fn hover_from_hint<T: EventListener>(
hint: HintMatch,
rows: usize,
cols: usize,
language: UiLanguage,
) -> Option<LinkHover> {
let raw = hint
.hyperlink()
@@ -98,12 +114,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),
)
})
})
};
@@ -355,6 +362,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;
@@ -400,6 +408,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,
@@ -409,28 +428,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,
@@ -465,6 +462,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;
@@ -524,6 +526,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 任一),
@@ -574,7 +582,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,
@@ -584,15 +593,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();
}
@@ -612,6 +621,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();
@@ -619,7 +629,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"));
}