diff --git a/assets/settings/default.json b/assets/settings/default.json index 8725587ade6..983784e8951 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1897,6 +1897,12 @@ "copy_on_select": false, // Whether to keep the text selection after copying it to the clipboard. "keep_selection_on_copy": true, + // Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even + // when the terminal application has enabled mouse reporting (e.g. vim with + // mouse=a, htop). When false, these clicks are forwarded to the application + // instead, and hyperlinks can still be opened with shift-cmd-click + // (shift-ctrl-click). + "open_links_in_mouse_mode": true, // Whether to show the terminal button in the status bar "button": true, // Any key-value pairs added to this list will be added to the terminal's diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index f7ad53118d3..9b8584c4e6b 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -899,6 +899,7 @@ impl VsCodeSettings { .map(FontSize::from), font_weight: None, keep_selection_on_copy: None, + open_links_in_mouse_mode: None, line_height: self .read_f32("terminal.integrated.lineHeight") .map(|lh| TerminalLineHeight::Custom(lh)), diff --git a/crates/settings_content/src/terminal.rs b/crates/settings_content/src/terminal.rs index c7c1013278f..e66795a193f 100644 --- a/crates/settings_content/src/terminal.rs +++ b/crates/settings_content/src/terminal.rs @@ -124,6 +124,14 @@ pub struct TerminalSettingsContent { /// /// Default: true pub keep_selection_on_copy: Option, + /// Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even + /// when the terminal application has enabled mouse reporting (e.g. vim with + /// mouse=a, htop). When false, these clicks are forwarded to the application + /// instead, and hyperlinks can still be opened with shift-cmd-click + /// (shift-ctrl-click). + /// + /// Default: true + pub open_links_in_mouse_mode: Option, /// Whether to show the terminal button in the status bar. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 94dff0038f0..ba35c4875ef 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7115,7 +7115,7 @@ fn terminal_page() -> SettingsPage { ] } - fn behavior_settings_section() -> [SettingsPageItem; 5] { + fn behavior_settings_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("Behavior Settings"), SettingsPageItem::SettingItem(SettingItem { @@ -7179,6 +7179,29 @@ fn terminal_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Open Links In Mouse Mode", + description: "Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting. When disabled, these clicks are forwarded to the application; links can still be opened with shift-cmd-click.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("terminal.open_links_in_mouse_mode"), + pick: |settings_content| { + settings_content + .terminal + .as_ref()? + .open_links_in_mouse_mode + .as_ref() + }, + write: |settings_content, value, _| { + settings_content + .terminal + .get_or_insert_default() + .open_links_in_mouse_mode = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Audible Bell", description: "Whether to play a sound when the BEL character (`\\a`, `0x07`) is printed", diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 9fbfd575ded..796593fe45d 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1004,6 +1004,8 @@ impl TerminalBuilder { path_style, #[cfg(any(test, feature = "test-support"))] input_log: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + pty_write_log: Default::default(), }; TerminalBuilder { @@ -1275,6 +1277,8 @@ impl TerminalBuilder { path_style, #[cfg(any(test, feature = "test-support"))] input_log: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + pty_write_log: Default::default(), }; if !activation_script.is_empty() && no_task { @@ -1442,6 +1446,8 @@ pub struct Terminal { path_style: PathStyle, #[cfg(any(test, feature = "test-support"))] input_log: Vec>, + #[cfg(any(test, feature = "test-support"))] + pty_write_log: std::cell::RefCell>>, } struct CopyTemplate { @@ -1964,8 +1970,10 @@ impl Terminal { /// Write the Input payload to the PTY, if applicable. /// (This is a no-op for display-only terminals.) fn write_to_pty(&self, input: impl Into>) { + let input = input.into(); + #[cfg(any(test, feature = "test-support"))] + self.pty_write_log.borrow_mut().push(input.to_vec()); if let TerminalType::Pty { pty_tx, .. } = &self.terminal_type { - let input = input.into(); if log::log_enabled!(log::Level::Debug) { if let Ok(str) = str::from_utf8(&input) { log::debug!("Writing to PTY: {:?}", str); @@ -2097,6 +2105,11 @@ impl Terminal { std::mem::take(&mut self.input_log) } + #[cfg(any(test, feature = "test-support"))] + pub fn take_pty_write_log(&mut self) -> Vec> { + std::mem::take(self.pty_write_log.get_mut()) + } + #[cfg(any(test, feature = "test-support"))] pub fn keyboard_input_sent(&self) -> bool { self.keyboard_input_sent @@ -2307,22 +2320,30 @@ impl Terminal { pub fn mouse_move(&mut self, e: &MouseMoveEvent, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; if self.mouse_mode(e.modifiers.shift) { - let (point, side) = grid_point_and_side( - position, - self.last_content.terminal_bounds, - self.last_content.display_offset, - ); - - if self.mouse_changed(point, side) { - let bytes = mouse_moved_report( - point, - e.pressed_button, - e.modifiers, - self.last_content.mode, + // A ctrl/cmd press on a link suppressed its button-press report in + // `mouse_down`. Since the app never saw the press, we must swallow + // the whole gesture rather than forward later motion/release + // reports, which would be a press-less (malformed) sequence. + // `mouse_up` resolves it: release on the same link opens it, + // otherwise the gesture is dropped. + if self.mouse_down_hyperlink.is_none() { + let (point, side) = grid_point_and_side( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, ); - if let Some(bytes) = bytes { - self.write_to_pty(bytes); + if self.mouse_changed(point, side) { + let bytes = mouse_moved_report( + point, + e.pressed_button, + e.modifiers, + self.last_content.mode, + ); + + if let Some(bytes) = bytes { + self.write_to_pty(bytes); + } } } } else { @@ -2444,7 +2465,7 @@ impl Terminal { Some(scroll_lines.clamp(-3, 3)) } - pub fn mouse_down(&mut self, e: &MouseDownEvent, _cx: &mut Context) { + pub fn mouse_down(&mut self, e: &MouseDownEvent, cx: &mut Context) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; let point = grid_point( position, @@ -2454,7 +2475,8 @@ impl Terminal { if e.button == MouseButton::Left && e.modifiers.secondary() - && !self.mouse_mode(e.modifiers.shift) + && (TerminalSettings::get_global(cx).open_links_in_mouse_mode + || !self.mouse_mode(e.modifiers.shift)) { self.mouse_down_hyperlink = self.find_hyperlink_at_point(point); @@ -2504,7 +2526,7 @@ impl Terminal { } #[cfg(any(target_os = "linux", target_os = "freebsd"))] MouseButton::Middle => { - if let Some(item) = _cx.read_from_primary() { + if let Some(item) = cx.read_from_primary() { let text = item.text().unwrap_or_default(); self.paste(&text); } @@ -2518,6 +2540,33 @@ impl Terminal { let setting = TerminalSettings::get_global(cx); let position = e.position - self.last_content.terminal_bounds.bounds.origin; + if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() { + let point = grid_point( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, + ); + + if self + .find_hyperlink_at_point(point) + .is_some_and(|mouse_up_hyperlink| mouse_up_hyperlink == mouse_down_hyperlink) + { + self.events + .push_back(InternalEvent::ProcessHyperlink(mouse_down_hyperlink, true)); + self.selection_phase = SelectionPhase::Ended; + self.last_mouse = None; + self.mouse_down_position = None; + return; + } + + if self.mouse_mode(e.modifiers.shift) { + self.selection_phase = SelectionPhase::Ended; + self.last_mouse = None; + self.mouse_down_position = None; + return; + } + } + if self.mouse_mode(e.modifiers.shift) { let point = grid_point( position, @@ -2536,24 +2585,6 @@ impl Terminal { self.copy(Some(true)); } - if let Some(mouse_down_hyperlink) = self.mouse_down_hyperlink.take() { - let point = grid_point( - position, - self.last_content.terminal_bounds, - self.last_content.display_offset, - ); - - if let Some(mouse_up_hyperlink) = self.find_hyperlink_at_point(point) { - if mouse_down_hyperlink == mouse_up_hyperlink { - self.events - .push_back(InternalEvent::ProcessHyperlink(mouse_up_hyperlink, true)); - self.selection_phase = SelectionPhase::Ended; - self.last_mouse = None; - return; - } - } - } - //Hyperlinks if self.selection_phase == SelectionPhase::Ended { let mouse_cell_index = @@ -3586,6 +3617,7 @@ mod tests { ); terminal.last_content.terminal_bounds = terminal_bounds; terminal.events.clear(); + terminal.take_pty_write_log(); }); terminal @@ -3649,6 +3681,20 @@ mod tests { terminal.mouse_down(&mouse_down, cx); } + fn left_mouse_up_at( + terminal: &mut Terminal, + position: GpuiPoint, + cx: &mut Context, + ) { + let mouse_up = MouseUpEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::none(), + click_count: 1, + }; + terminal.mouse_up(&mouse_up, cx); + } + fn left_mouse_drag_to( terminal: &mut Terminal, position: GpuiPoint, @@ -4408,6 +4454,166 @@ mod tests { }); } + #[gpui::test] + async fn test_hyperlink_ctrl_click_same_position_in_mouse_mode(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + + let click_position = point(px(80.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))), + "Should have ProcessHyperlink event when ctrl+clicking on same hyperlink position in mouse mode" + ); + assert!( + terminal.take_pty_write_log().is_empty(), + "a consumed link click must not be reported to the PTY" + ); + }); + } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_mismatch_in_mouse_mode_consumes_gesture( + cx: &mut TestAppContext, + ) { + let terminal = init_ctrl_click_hyperlink_test( + cx, + b"Visit https://zed.dev/ for more\r\nThis is another line\r\n", + ); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + terminal.take_pty_write_log(); + + let down_position = point(px(80.0), px(10.0)); + let up_position = point(px(10.0), px(30.0)); + + ctrl_mouse_down_at(terminal, down_position, cx); + terminal.mouse_move( + &MouseMoveEvent { + position: up_position, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::secondary_key(), + }, + cx, + ); + ctrl_mouse_up_at(terminal, up_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "Should NOT open a link when press and release land on different hyperlinks" + ); + let pty_writes = terminal.take_pty_write_log(); + assert!( + pty_writes.is_empty(), + "a captured press must consume the whole gesture, but reports leaked to the PTY: {pty_writes:?}" + ); + }); + } + + #[gpui::test] + async fn test_plain_click_on_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + terminal.take_pty_write_log(); + + let click_position = point(px(80.0), px(10.0)); + left_mouse_down_at(terminal, click_position, cx); + left_mouse_up_at(terminal, click_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "a plain click must not open a link" + ); + let pty_writes = terminal.take_pty_write_log(); + assert_eq!( + pty_writes.len(), + 2, + "expected press and release reports, got {pty_writes:?}" + ); + }); + } + + #[gpui::test] + async fn test_ctrl_click_on_non_hyperlink_in_mouse_mode_is_reported(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + terminal.take_pty_write_log(); + + // Past the end of the line: nothing link-like under the cursor. + let click_position = point(px(370.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "a secondary click off a link must not open anything" + ); + let pty_writes = terminal.take_pty_write_log(); + assert_eq!( + pty_writes.len(), + 2, + "expected press and release reports, got {pty_writes:?}" + ); + }); + } + + #[gpui::test] + async fn test_ctrl_click_in_mouse_mode_forwards_when_setting_disabled(cx: &mut TestAppContext) { + let terminal = init_ctrl_click_hyperlink_test(cx, b"Visit https://zed.dev/ for more\r\n"); + + cx.update_global(|store: &mut settings::SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings + .terminal + .get_or_insert_default() + .open_links_in_mouse_mode = Some(false); + }); + }); + + terminal.update(cx, |terminal, cx| { + terminal.last_content.mode = Modes::MOUSE_MODE; + + let click_position = point(px(80.0), px(10.0)); + ctrl_mouse_down_at(terminal, click_position, cx); + ctrl_mouse_up_at(terminal, click_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "with the setting disabled, ctrl+click must not open links in mouse mode" + ); + let pty_writes = terminal.take_pty_write_log(); + assert_eq!( + pty_writes.len(), + 2, + "expected press and release reports, got {pty_writes:?}" + ); + }); + } + #[gpui::test] async fn test_hyperlink_ctrl_click_drag_outside_bounds(cx: &mut TestAppContext) { let terminal = init_ctrl_click_hyperlink_test( diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 8a31745d67d..64d76b7de3f 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -35,6 +35,7 @@ pub struct TerminalSettings { pub option_as_meta: bool, pub copy_on_select: bool, pub keep_selection_on_copy: bool, + pub open_links_in_mouse_mode: bool, pub button: bool, pub dock: TerminalDockPosition, pub flexible: bool, @@ -105,6 +106,7 @@ impl settings::Settings for TerminalSettings { option_as_meta: user_content.option_as_meta.unwrap(), copy_on_select: user_content.copy_on_select.unwrap(), keep_selection_on_copy: user_content.keep_selection_on_copy.unwrap(), + open_links_in_mouse_mode: user_content.open_links_in_mouse_mode.unwrap(), button: user_content.button.unwrap(), dock: user_content.dock.unwrap(), default_width: px(user_content.default_width.unwrap()), diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 1ca24ef7616..1450b58d6fc 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -4158,6 +4158,7 @@ List of `integer` column numbers "blinking": "terminal_controlled", "copy_on_select": false, "keep_selection_on_copy": true, + "open_links_in_mouse_mode": true, "dock": "bottom", "default_width": 640, "default_height": 320, @@ -4352,6 +4353,26 @@ List of `integer` column numbers } ``` +### Terminal: Open Links In Mouse Mode + +- Description: Whether cmd-click (ctrl-click on Linux and Windows) opens hyperlinks even when the terminal application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). When `false`, these clicks are forwarded to the application instead, and hyperlinks can still be opened with shift-cmd-click (shift-ctrl-click). +- Setting: `open_links_in_mouse_mode` +- Default: `true` + +**Options** + +`boolean` values + +**Example** + +```json [settings] +{ + "terminal": { + "open_links_in_mouse_mode": false + } +} +``` + ### Terminal: Env - Description: Any key-value pairs added to this object will be added to the terminal's environment. Keys must be unique, use `:` to separate multiple values in a single variable diff --git a/docs/src/terminal.md b/docs/src/terminal.md index e4e876ab2db..f1e82ca0955 100644 --- a/docs/src/terminal.md +++ b/docs/src/terminal.md @@ -282,6 +282,16 @@ Common formats recognized: - `src/main.rs:42:10` — Opens at line 42, column 10 - `File "script.py", line 10` — Python tracebacks +By default, `Cmd+Click`/`Ctrl+Click` opens links even when the running application has enabled mouse reporting (e.g. vim with `mouse=a`, htop). If you prefer those clicks to be forwarded to the application instead, disable `open_links_in_mouse_mode`; links can then still be opened with `Shift+Cmd+Click` (`Shift+Ctrl+Click`): + +```json +{ + "terminal": { + "open_links_in_mouse_mode": false + } +} +``` + ## Panel Configuration ### Dock Position