@@ -155,8 +155,8 @@ enum InternalEvent {
ScrollToAlacPoint(AlacPoint),
SetSelection(Option<(Selection, AlacPoint)>),
UpdateSelection(Point<Pixels>),
- // Adjusted mouse position, should open
FindHyperlink(Point<Pixels>, bool),
+ ProcessHyperlink((String, bool, Match), bool),
// Whether keep selection when copy
Copy(Option<bool>),
// 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<Point<Pixels>>,
+ mouse_down_hyperlink: Option<(String, bool, Match)>,
#[cfg(windows)]
shell_program: Option<String>,
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<Self>,
+ ) {
+ 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<Terminal> {
+ 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<Pixels>,
+ cx: &mut Context<Terminal>,
+ ) {
+ 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<Pixels>,
+ cx: &mut Context<Terminal>,
+ ) {
+ 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<Pixels>,
+ cx: &mut Context<Terminal>,
+ ) {
+ 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::{