diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5bb8af3f38..b3255df812 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1915,7 +1915,9 @@ impl Project { return; } - let uri = lsp::Url::from_file_path(file.abs_path(cx)).unwrap(); + let abs_path = file.abs_path(cx); + let uri = lsp::Url::from_file_path(&abs_path) + .unwrap_or_else(|()| panic!("Failed to register file {abs_path:?}")); let initial_snapshot = buffer.text_snapshot(); let language = buffer.language().cloned(); let worktree_id = file.worktree_id(cx); diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 39e77b590b..3a64cff24f 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -51,7 +51,7 @@ use gpui::{ fonts, geometry::vector::{vec2f, Vector2F}, keymap_matcher::Keystroke, - platform::{MouseButton, MouseMovedEvent, TouchPhase}, + platform::{Modifiers, MouseButton, MouseMovedEvent, TouchPhase}, scene::{MouseDown, MouseDrag, MouseScrollWheel, MouseUp}, AppContext, ClipboardItem, Entity, ModelContext, Task, }; @@ -72,14 +72,15 @@ const DEBUG_TERMINAL_HEIGHT: f32 = 30.; const DEBUG_CELL_WIDTH: f32 = 5.; const DEBUG_LINE_HEIGHT: f32 = 5.; -// Regex Copied from alacritty's ui_config.rs - lazy_static! { + // Regex Copied from alacritty's ui_config.rs static ref URL_REGEX: RegexSearch = RegexSearch::new("(ipfs:|ipns:|magnet:|mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`]+").unwrap(); + + static ref WORD_REGEX: RegexSearch = RegexSearch::new("[\\w.:/@-]+").unwrap(); } ///Upward flowing events, for changing the title and such -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub enum Event { TitleChanged, BreadcrumbsChanged, @@ -88,6 +89,18 @@ pub enum Event { Wakeup, BlinkChanged, SelectionsChanged, + NewNavigationTarget(Option), + Open(MaybeNavigationTarget), +} + +/// A string inside terminal, potentially useful as a URI that can be opened. +#[derive(Clone, Debug)] +pub enum MaybeNavigationTarget { + /// HTTP, git, etc. string determined by the [`URL_REGEX`] regex. + Url(String), + /// File system path, absolute or relative, existing or not. + /// Might have line and column number(s) attached as `file.rs:1:23` + PathLike(String), } #[derive(Clone)] @@ -493,6 +506,8 @@ impl TerminalBuilder { last_mouse_position: None, next_link_id: 0, selection_phase: SelectionPhase::Ended, + cmd_pressed: false, + hovered_word: false, }; Ok(TerminalBuilder { @@ -589,7 +604,14 @@ pub struct TerminalContent { pub cursor: RenderableCursor, pub cursor_char: char, pub size: TerminalSize, - pub last_hovered_hyperlink: Option<(String, RangeInclusive, usize)>, + pub last_hovered_word: Option, +} + +#[derive(Clone)] +pub struct HoveredWord { + pub word: String, + pub word_match: RangeInclusive, + pub id: usize, } impl Default for TerminalContent { @@ -606,7 +628,7 @@ impl Default for TerminalContent { }, cursor_char: Default::default(), size: Default::default(), - last_hovered_hyperlink: None, + last_hovered_word: None, } } } @@ -623,7 +645,7 @@ pub struct Terminal { events: VecDeque, /// This is only used for mouse mode cell change detection last_mouse: Option<(Point, AlacDirection)>, - /// This is only used for terminal hyperlink checking + /// This is only used for terminal hovered word checking last_mouse_position: Option, pub matches: Vec>, pub last_content: TerminalContent, @@ -637,6 +659,8 @@ pub struct Terminal { scroll_px: f32, next_link_id: usize, selection_phase: SelectionPhase, + cmd_pressed: bool, + hovered_word: bool, } impl Terminal { @@ -769,7 +793,7 @@ impl Terminal { } InternalEvent::Scroll(scroll) => { term.scroll_display(*scroll); - self.refresh_hyperlink(); + self.refresh_hovered_word(); } InternalEvent::SetSelection(selection) => { term.selection = selection.as_ref().map(|(sel, _)| sel.clone()); @@ -804,20 +828,20 @@ impl Terminal { } InternalEvent::ScrollToPoint(point) => { term.scroll_to_point(*point); - self.refresh_hyperlink(); + self.refresh_hovered_word(); } InternalEvent::FindHyperlink(position, open) => { - let prev_hyperlink = self.last_content.last_hovered_hyperlink.take(); + let prev_hovered_word = self.last_content.last_hovered_word.take(); let point = grid_point( *position, self.last_content.size, term.grid().display_offset(), ) - .grid_clamp(term, alacritty_terminal::index::Boundary::Cursor); + .grid_clamp(term, alacritty_terminal::index::Boundary::Grid); let link = term.grid().index(point).hyperlink(); - let found_url = if link.is_some() { + let found_word = if link.is_some() { let mut min_index = point; loop { let new_min_index = @@ -847,42 +871,78 @@ impl Terminal { let url = link.unwrap().uri().to_owned(); let url_match = min_index..=max_index; - Some((url, url_match)) - } else if let Some(url_match) = regex_match_at(term, point, &URL_REGEX) { - let url = term.bounds_to_string(*url_match.start(), *url_match.end()); + Some((url, true, url_match)) + } else if let Some(word_match) = regex_match_at(term, point, &WORD_REGEX) { + let maybe_url_or_path = + term.bounds_to_string(*word_match.start(), *word_match.end()); + let is_url = regex_match_at(term, point, &URL_REGEX).is_some(); - Some((url, url_match)) + Some((maybe_url_or_path, is_url, word_match)) } else { None }; - if let Some((url, url_match)) = found_url { - if *open { - cx.platform().open_url(url.as_str()); - } else { - self.update_hyperlink(prev_hyperlink, url, url_match); + match found_word { + Some((maybe_url_or_path, is_url, url_match)) => { + if *open { + let target = if is_url { + MaybeNavigationTarget::Url(maybe_url_or_path) + } else { + MaybeNavigationTarget::PathLike(maybe_url_or_path) + }; + cx.emit(Event::Open(target)); + } else { + self.update_selected_word( + prev_hovered_word, + url_match, + maybe_url_or_path, + is_url, + cx, + ); + } + self.hovered_word = true; + } + None => { + if self.hovered_word { + cx.emit(Event::NewNavigationTarget(None)); + } + self.hovered_word = false; } } } } } - fn update_hyperlink( + fn update_selected_word( &mut self, - prev_hyperlink: Option<(String, RangeInclusive, usize)>, - url: String, - url_match: RangeInclusive, + prev_word: Option, + word_match: RangeInclusive, + word: String, + is_url: bool, + cx: &mut ModelContext, ) { - if let Some(prev_hyperlink) = prev_hyperlink { - if prev_hyperlink.0 == url && prev_hyperlink.1 == url_match { - self.last_content.last_hovered_hyperlink = Some((url, url_match, prev_hyperlink.2)); - } else { - self.last_content.last_hovered_hyperlink = - Some((url, url_match, self.next_link_id())); + if let Some(prev_word) = prev_word { + if prev_word.word == word && prev_word.word_match == word_match { + self.last_content.last_hovered_word = Some(HoveredWord { + word, + word_match, + id: prev_word.id, + }); + return; } - } else { - self.last_content.last_hovered_hyperlink = Some((url, url_match, self.next_link_id())); } + + self.last_content.last_hovered_word = Some(HoveredWord { + word: word.clone(), + word_match, + id: self.next_link_id(), + }); + let navigation_target = if is_url { + MaybeNavigationTarget::Url(word) + } else { + MaybeNavigationTarget::PathLike(word) + }; + cx.emit(Event::NewNavigationTarget(Some(navigation_target))); } fn next_link_id(&mut self) -> usize { @@ -964,6 +1024,15 @@ impl Terminal { } } + pub fn try_modifiers_change(&mut self, modifiers: &Modifiers) -> bool { + let changed = self.cmd_pressed != modifiers.cmd; + if !self.cmd_pressed && modifiers.cmd { + self.refresh_hovered_word(); + } + self.cmd_pressed = modifiers.cmd; + changed + } + ///Paste text into the terminal pub fn paste(&mut self, text: &str) { let paste_text = if self.last_content.mode.contains(TermMode::BRACKETED_PASTE) { @@ -1035,7 +1104,7 @@ impl Terminal { cursor: content.cursor, cursor_char: term.grid()[content.cursor.point].c, size: last_content.size, - last_hovered_hyperlink: last_content.last_hovered_hyperlink.clone(), + last_hovered_word: last_content.last_hovered_word.clone(), } } @@ -1089,14 +1158,14 @@ impl Terminal { self.pty_tx.notify(bytes); } } - } else { - self.hyperlink_from_position(Some(position)); + } else if self.cmd_pressed { + self.word_from_position(Some(position)); } } - fn hyperlink_from_position(&mut self, position: Option) { + fn word_from_position(&mut self, position: Option) { if self.selection_phase == SelectionPhase::Selecting { - self.last_content.last_hovered_hyperlink = None; + self.last_content.last_hovered_word = None; } else if let Some(position) = position { self.events .push_back(InternalEvent::FindHyperlink(position, false)); @@ -1208,7 +1277,7 @@ impl Terminal { let mouse_cell_index = content_index_for_mouse(position, &self.last_content.size); if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() { cx.platform().open_url(link.uri()); - } else { + } else if self.cmd_pressed { self.events .push_back(InternalEvent::FindHyperlink(position, true)); } @@ -1255,8 +1324,8 @@ impl Terminal { } } - pub fn refresh_hyperlink(&mut self) { - self.hyperlink_from_position(self.last_mouse_position); + fn refresh_hovered_word(&mut self) { + self.word_from_position(self.last_mouse_position); } fn determine_scroll_lines(&mut self, e: &MouseScrollWheel, mouse_mode: bool) -> Option { @@ -1334,6 +1403,10 @@ impl Terminal { }) .unwrap_or_else(|| "Terminal".to_string()) } + + pub fn can_navigate_to_selected_word(&self) -> bool { + self.cmd_pressed && self.hovered_word + } } impl Drop for Terminal { diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index b92059f5d6..e29beb3ad5 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -163,6 +163,7 @@ pub struct TerminalElement { terminal: WeakModelHandle, focused: bool, cursor_visible: bool, + can_navigate_to_selected_word: bool, } impl TerminalElement { @@ -170,11 +171,13 @@ impl TerminalElement { terminal: WeakModelHandle, focused: bool, cursor_visible: bool, + can_navigate_to_selected_word: bool, ) -> TerminalElement { TerminalElement { terminal, focused, cursor_visible, + can_navigate_to_selected_word, } } @@ -580,20 +583,30 @@ impl Element for TerminalElement { let background_color = terminal_theme.background; let terminal_handle = self.terminal.upgrade(cx).unwrap(); - let last_hovered_hyperlink = terminal_handle.update(cx, |terminal, cx| { + let last_hovered_word = terminal_handle.update(cx, |terminal, cx| { terminal.set_size(dimensions); terminal.try_sync(cx); - terminal.last_content.last_hovered_hyperlink.clone() + if self.can_navigate_to_selected_word && terminal.can_navigate_to_selected_word() { + terminal.last_content.last_hovered_word.clone() + } else { + None + } }); - let hyperlink_tooltip = last_hovered_hyperlink.map(|(uri, _, id)| { + let hyperlink_tooltip = last_hovered_word.clone().map(|hovered_word| { let mut tooltip = Overlay::new( Empty::new() .contained() .constrained() .with_width(dimensions.width()) .with_height(dimensions.height()) - .with_tooltip::(id, uri, None, tooltip_style, cx), + .with_tooltip::( + hovered_word.id, + hovered_word.word, + None, + tooltip_style, + cx, + ), ) .with_position_mode(gpui::elements::OverlayPositionMode::Local) .into_any(); @@ -613,7 +626,6 @@ impl Element for TerminalElement { cursor_char, selection, cursor, - last_hovered_hyperlink, .. } = { &terminal_handle.read(cx).last_content }; @@ -634,9 +646,9 @@ impl Element for TerminalElement { &terminal_theme, cx.text_layout_cache(), cx.font_cache(), - last_hovered_hyperlink + last_hovered_word .as_ref() - .map(|(_, range, _)| (link_style, range)), + .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)), ); //Layout cursor. Rectangle is used for IME, so we should lay it out even diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ad61903a9d..6ad321c735 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -261,10 +261,14 @@ impl TerminalPanel { .create_terminal(working_directory, window_id, cx) .log_err() }) { - let terminal = - Box::new(cx.add_view(|cx| { - TerminalView::new(terminal, workspace.database_id(), cx) - })); + let terminal = Box::new(cx.add_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + })); pane.update(cx, |pane, cx| { let focus = pane.has_focus(); pane.add_item(terminal, true, focus, None, cx); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 3dd401e392..cdb1d40efc 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -3,18 +3,21 @@ pub mod terminal_element; pub mod terminal_panel; use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement}; +use anyhow::Context; use context_menu::{ContextMenu, ContextMenuItem}; use dirs::home_dir; +use editor::{scroll::autoscroll::Autoscroll, Editor}; use gpui::{ actions, elements::{AnchorCorner, ChildView, Flex, Label, ParentElement, Stack}, geometry::vector::Vector2F, impl_actions, keymap_matcher::{KeymapContext, Keystroke}, - platform::KeyDownEvent, + platform::{KeyDownEvent, ModifiersChangedEvent}, AnyElement, AnyViewHandle, AppContext, Element, Entity, ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; +use language::Bias; use project::{LocalWorktree, Project}; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; @@ -30,9 +33,9 @@ use terminal::{ index::Point, term::{search::RegexSearch, TermMode}, }, - Event, Terminal, TerminalBlink, WorkingDirectory, + Event, MaybeNavigationTarget, Terminal, TerminalBlink, WorkingDirectory, }; -use util::ResultExt; +use util::{paths::PathLikeWithPosition, ResultExt}; use workspace::{ item::{BreadcrumbText, Item, ItemEvent}, notifications::NotifyResultExt, @@ -90,6 +93,7 @@ pub struct TerminalView { blinking_on: bool, blinking_paused: bool, blink_epoch: usize, + can_navigate_to_selected_word: bool, workspace_id: WorkspaceId, } @@ -117,19 +121,27 @@ impl TerminalView { .notify_err(workspace, cx); if let Some(terminal) = terminal { - let view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx)); + let view = cx.add_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + }); workspace.add_item(Box::new(view), cx) } } pub fn new( terminal: ModelHandle, + workspace: WeakViewHandle, workspace_id: WorkspaceId, cx: &mut ViewContext, ) -> Self { let view_id = cx.view_id(); cx.observe(&terminal, |_, _, cx| cx.notify()).detach(); - cx.subscribe(&terminal, |this, _, event, cx| match event { + cx.subscribe(&terminal, move |this, _, event, cx| match event { Event::Wakeup => { if !cx.is_self_focused() { this.has_new_content = true; @@ -158,7 +170,63 @@ impl TerminalView { .detach(); } } - _ => cx.emit(*event), + Event::NewNavigationTarget(maybe_navigation_target) => { + this.can_navigate_to_selected_word = match maybe_navigation_target { + Some(MaybeNavigationTarget::Url(_)) => true, + Some(MaybeNavigationTarget::PathLike(maybe_path)) => { + !possible_open_targets(&workspace, maybe_path, cx).is_empty() + } + None => false, + } + } + Event::Open(maybe_navigation_target) => match maybe_navigation_target { + MaybeNavigationTarget::Url(url) => cx.platform().open_url(url), + MaybeNavigationTarget::PathLike(maybe_path) => { + if !this.can_navigate_to_selected_word { + return; + } + let potential_abs_paths = possible_open_targets(&workspace, maybe_path, cx); + if let Some(path) = potential_abs_paths.into_iter().next() { + let visible = path.path_like.is_dir(); + let task_workspace = workspace.clone(); + cx.spawn(|_, mut cx| async move { + let opened_item = task_workspace + .update(&mut cx, |workspace, cx| { + workspace.open_abs_path(path.path_like, visible, cx) + }) + .context("workspace update")? + .await + .context("workspace update")?; + if let Some(row) = path.row { + let col = path.column.unwrap_or(0); + if let Some(active_editor) = opened_item.downcast::() { + active_editor + .downgrade() + .update(&mut cx, |editor, cx| { + let snapshot = editor.snapshot(cx).display_snapshot; + let point = snapshot.buffer_snapshot.clip_point( + language::Point::new( + row.saturating_sub(1), + col.saturating_sub(1), + ), + Bias::Left, + ); + editor.change_selections( + Some(Autoscroll::center()), + cx, + |s| s.select_ranges([point..point]), + ); + }) + .log_err(); + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + }, + _ => cx.emit(event.clone()), }) .detach(); @@ -171,6 +239,7 @@ impl TerminalView { blinking_on: false, blinking_paused: false, blink_epoch: 0, + can_navigate_to_selected_word: false, workspace_id, } } @@ -344,6 +413,40 @@ impl TerminalView { } } +fn possible_open_targets( + workspace: &WeakViewHandle, + maybe_path: &String, + cx: &mut ViewContext<'_, '_, TerminalView>, +) -> Vec> { + let path_like = PathLikeWithPosition::parse_str(maybe_path.as_str(), |path_str| { + Ok::<_, std::convert::Infallible>(Path::new(path_str).to_path_buf()) + }) + .expect("infallible"); + let maybe_path = path_like.path_like; + let potential_abs_paths = if maybe_path.is_absolute() { + vec![maybe_path] + } else if let Some(workspace) = workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace + .worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().join(&maybe_path)) + .collect() + }) + } else { + Vec::new() + }; + + potential_abs_paths + .into_iter() + .filter(|path| path.exists()) + .map(|path| PathLikeWithPosition { + path_like: path, + row: path_like.row, + column: path_like.column, + }) + .collect() +} + pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option { let searcher = match query { project::search::SearchQuery::Text { query, .. } => RegexSearch::new(&query), @@ -372,6 +475,7 @@ impl View for TerminalView { terminal_handle, focused, self.should_show_cursor(focused, cx), + self.can_navigate_to_selected_word, ) .contained(), ) @@ -393,6 +497,20 @@ impl View for TerminalView { cx.notify(); } + fn modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + cx: &mut ViewContext, + ) -> bool { + let handled = self + .terminal() + .update(cx, |term, _| term.try_modifiers_change(&event.modifiers)); + if handled { + cx.notify(); + } + handled + } + fn key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext) -> bool { self.clear_bel(cx); self.pause_cursor_blinking(cx); @@ -618,7 +736,7 @@ impl Item for TerminalView { project.create_terminal(cwd, window_id, cx) })?; Ok(pane.update(&mut cx, |_, cx| { - cx.add_view(|cx| TerminalView::new(terminal, workspace_id, cx)) + cx.add_view(|cx| TerminalView::new(terminal, workspace, workspace_id, cx)) })?) }) } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index ccf381b5b1..8f528771c9 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -895,7 +895,14 @@ pub fn dock_default_item_factory( }) .notify_err(workspace, cx)?; - let terminal_view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx)); + let terminal_view = cx.add_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + }); Some(Box::new(terminal_view)) }