diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index c7ac75af0c810c55d470b1de8175c11c65855b58..e64780e2945363e71b357b79aee57024484d417c 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -155,8 +155,8 @@ enum InternalEvent { ScrollToAlacPoint(AlacPoint), SetSelection(Option<(Selection, AlacPoint)>), UpdateSelection(Point), - // Adjusted mouse position, should open FindHyperlink(Point, bool), + ProcessHyperlink((String, bool, Match), bool), // Whether keep selection when copy Copy(Option), // Vi mode events @@ -380,6 +380,7 @@ impl TerminalBuilder { is_remote_terminal: false, last_mouse_move_time: Instant::now(), last_hyperlink_search_position: None, + mouse_down_hyperlink: None, #[cfg(windows)] shell_program: None, activation_script: Vec::new(), @@ -610,6 +611,7 @@ impl TerminalBuilder { is_remote_terminal, last_mouse_move_time: Instant::now(), last_hyperlink_search_position: None, + mouse_down_hyperlink: None, #[cfg(windows)] shell_program, activation_script: activation_script.clone(), @@ -840,6 +842,7 @@ pub struct Terminal { is_remote_terminal: bool, last_mouse_move_time: Instant, last_hyperlink_search_position: Option>, + mouse_down_hyperlink: Option<(String, bool, Match)>, #[cfg(windows)] shell_program: Option, template: CopyTemplate, @@ -1152,7 +1155,6 @@ impl Terminal { } InternalEvent::FindHyperlink(position, open) => { trace!("Finding hyperlink at position: position={position:?}, open={open:?}"); - let prev_hovered_word = self.last_content.last_hovered_word.take(); let point = grid_point( *position, @@ -1166,47 +1168,53 @@ impl Terminal { point, &mut self.hyperlink_regex_searches, ) { - Some((maybe_url_or_path, is_url, url_match)) => { - let target = if is_url { - // Treat "file://" URLs like file paths to ensure - // that line numbers at the end of the path are - // handled correctly. - // file://{path} should be urldecoded, returning a urldecoded {path} - if let Some(path) = maybe_url_or_path.strip_prefix("file://") { - let decoded_path = urlencoding::decode(path) - .map(|decoded| decoded.into_owned()) - .unwrap_or(path.to_owned()); - - MaybeNavigationTarget::PathLike(PathLikeTarget { - maybe_path: decoded_path, - terminal_dir: self.working_directory(), - }) - } else { - MaybeNavigationTarget::Url(maybe_url_or_path.clone()) - } - } else { - MaybeNavigationTarget::PathLike(PathLikeTarget { - maybe_path: maybe_url_or_path.clone(), - terminal_dir: self.working_directory(), - }) - }; - if *open { - cx.emit(Event::Open(target)); - } else { - self.update_selected_word( - prev_hovered_word, - url_match, - maybe_url_or_path, - target, - cx, - ); - } + Some(hyperlink) => { + self.process_hyperlink(hyperlink, *open, cx); } None => { cx.emit(Event::NewNavigationTarget(None)); } } } + InternalEvent::ProcessHyperlink(hyperlink, open) => { + self.process_hyperlink(hyperlink.clone(), *open, cx); + } + } + } + + fn process_hyperlink( + &mut self, + hyperlink: (String, bool, Match), + open: bool, + cx: &mut Context, + ) { + let (maybe_url_or_path, is_url, url_match) = hyperlink; + let prev_hovered_word = self.last_content.last_hovered_word.take(); + + let target = if is_url { + if let Some(path) = maybe_url_or_path.strip_prefix("file://") { + let decoded_path = urlencoding::decode(path) + .map(|decoded| decoded.into_owned()) + .unwrap_or(path.to_owned()); + + MaybeNavigationTarget::PathLike(PathLikeTarget { + maybe_path: decoded_path, + terminal_dir: self.working_directory(), + }) + } else { + MaybeNavigationTarget::Url(maybe_url_or_path.clone()) + } + } else { + MaybeNavigationTarget::PathLike(PathLikeTarget { + maybe_path: maybe_url_or_path.clone(), + terminal_dir: self.working_directory(), + }) + }; + + if open { + cx.emit(Event::Open(target)); + } else { + self.update_selected_word(prev_hovered_word, url_match, maybe_url_or_path, target, cx); } } @@ -1777,6 +1785,20 @@ impl Terminal { ) { let position = e.position - self.last_content.terminal_bounds.bounds.origin; if !self.mouse_mode(e.modifiers.shift) { + if let Some((.., hyperlink_range)) = &self.mouse_down_hyperlink { + let point = grid_point( + position, + self.last_content.terminal_bounds, + self.last_content.display_offset, + ); + + if !hyperlink_range.contains(&point) { + self.mouse_down_hyperlink = None; + } else { + return; + } + } + self.selection_phase = SelectionPhase::Selecting; // Alacritty has the same ordering, of first updating the selection // then scrolling 15ms later @@ -1823,6 +1845,23 @@ impl Terminal { self.last_content.display_offset, ); + if e.button == MouseButton::Left + && e.modifiers.secondary() + && !self.mouse_mode(e.modifiers.shift) + { + let term_lock = self.term.lock(); + self.mouse_down_hyperlink = terminal_hyperlinks::find_from_grid_point( + &term_lock, + point, + &mut self.hyperlink_regex_searches, + ); + drop(term_lock); + + if self.mouse_down_hyperlink.is_some() { + return; + } + } + if self.mouse_mode(e.modifiers.shift) { if let Some(bytes) = mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode) @@ -1893,6 +1932,31 @@ 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) = { + let term_lock = self.term.lock(); + terminal_hyperlinks::find_from_grid_point( + &term_lock, + point, + &mut self.hyperlink_regex_searches, + ) + } { + 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 = @@ -2409,10 +2473,91 @@ mod tests { term::cell::Cell, }; use collections::HashMap; - use gpui::{Pixels, Point, TestAppContext, bounds, point, size, smol_timeout}; + use gpui::{ + Entity, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + Point, TestAppContext, bounds, point, size, smol_timeout, + }; use rand::{Rng, distr, rngs::ThreadRng}; use task::ShellBuilder; + fn init_ctrl_click_hyperlink_test(cx: &mut TestAppContext, output: &[u8]) -> Entity { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + }); + + let terminal = cx.new(|cx| { + TerminalBuilder::new_display_only(CursorShape::default(), AlternateScroll::On, None, 0) + .unwrap() + .subscribe(cx) + }); + + terminal.update(cx, |terminal, cx| { + terminal.write_output(output, cx); + }); + + cx.run_until_parked(); + + terminal.update(cx, |terminal, _cx| { + let term_lock = terminal.term.lock(); + terminal.last_content = Terminal::make_content(&term_lock, &terminal.last_content); + drop(term_lock); + + let terminal_bounds = TerminalBounds::new( + px(20.0), + px(10.0), + bounds(point(px(0.0), px(0.0)), size(px(400.0), px(400.0))), + ); + terminal.last_content.terminal_bounds = terminal_bounds; + terminal.events.clear(); + }); + + terminal + } + + fn ctrl_mouse_down_at( + terminal: &mut Terminal, + position: Point, + cx: &mut Context, + ) { + let mouse_down = MouseDownEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::secondary_key(), + click_count: 1, + first_mouse: true, + }; + terminal.mouse_down(&mouse_down, cx); + } + + fn ctrl_mouse_move_to( + terminal: &mut Terminal, + position: Point, + cx: &mut Context, + ) { + let terminal_bounds = terminal.last_content.terminal_bounds.bounds; + let drag_event = MouseMoveEvent { + position, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::secondary_key(), + }; + terminal.mouse_drag(&drag_event, terminal_bounds, cx); + } + + fn ctrl_mouse_up_at( + terminal: &mut Terminal, + position: Point, + cx: &mut Context, + ) { + let mouse_up = MouseUpEvent { + button: MouseButton::Left, + position, + modifiers: Modifiers::secondary_key(), + click_count: 1, + }; + terminal.mouse_up(&mouse_up, cx); + } + #[gpui::test] async fn test_basic_terminal(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -2863,6 +3008,72 @@ mod tests { ); } + #[gpui::test] + async fn test_hyperlink_ctrl_click_same_position(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| { + 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" + ); + }); + } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_drag_outside_bounds(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| { + let down_position = point(px(80.0), px(10.0)); + let up_position = point(px(10.0), px(50.0)); + + ctrl_mouse_down_at(terminal, down_position, cx); + ctrl_mouse_move_to(terminal, up_position, cx); + ctrl_mouse_up_at(terminal, up_position, cx); + + assert!( + !terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, _))), + "Should NOT have ProcessHyperlink event when dragging outside the hyperlink" + ); + }); + } + + #[gpui::test] + async fn test_hyperlink_ctrl_click_drag_within_bounds(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| { + let down_position = point(px(70.0), px(10.0)); + let up_position = point(px(130.0), px(10.0)); + + ctrl_mouse_down_at(terminal, down_position, cx); + ctrl_mouse_move_to(terminal, up_position, cx); + ctrl_mouse_up_at(terminal, up_position, cx); + + assert!( + terminal + .events + .iter() + .any(|event| matches!(event, InternalEvent::ProcessHyperlink(_, true))), + "Should have ProcessHyperlink event when dragging within hyperlink bounds" + ); + }); + } + mod perf { use super::super::*; use gpui::{