Rework terminal highlight mechanism (#2743)

<img width="807" alt="image"
src="https://github.com/zed-industries/zed/assets/2690773/ef3bfeef-28f5-458f-abe6-7c19bf820106">

Closes https://github.com/zed-industries/community/issues/10
Closes https://github.com/zed-industries/community/issues/560

Initial version of improved terminal highlights and "open link"
functionality: drops old behavior where URLs were highlighted on hover.
Now, Cmd + hover is needed to highlight the links and click opens both
URLs and files that exist (either abs paths, or anything relative to the
project workspace worktree roots).
Only paths eligible for opening are highlighted.

Release Notes:

- Improved terminal highlights and selections: Cmd+Click opens local
files and links
This commit is contained in:
Kirill Bulatov 2023-07-19 09:05:48 +03:00 committed by GitHub
commit c5e47f27f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 277 additions and 61 deletions

View File

@ -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);

View File

@ -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<MaybeNavigationTarget>),
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<Point>, usize)>,
pub last_hovered_word: Option<HoveredWord>,
}
#[derive(Clone)]
pub struct HoveredWord {
pub word: String,
pub word_match: RangeInclusive<Point>,
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<InternalEvent>,
/// 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<Vector2F>,
pub matches: Vec<RangeInclusive<Point>>,
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<Point>, usize)>,
url: String,
url_match: RangeInclusive<Point>,
prev_word: Option<HoveredWord>,
word_match: RangeInclusive<Point>,
word: String,
is_url: bool,
cx: &mut ModelContext<Self>,
) {
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<Vector2F>) {
fn word_from_position(&mut self, position: Option<Vector2F>) {
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<i32> {
@ -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 {

View File

@ -163,6 +163,7 @@ pub struct TerminalElement {
terminal: WeakModelHandle<Terminal>,
focused: bool,
cursor_visible: bool,
can_navigate_to_selected_word: bool,
}
impl TerminalElement {
@ -170,11 +171,13 @@ impl TerminalElement {
terminal: WeakModelHandle<Terminal>,
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<TerminalView> 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::<TerminalElement>(id, uri, None, tooltip_style, cx),
.with_tooltip::<TerminalElement>(
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<TerminalView> for TerminalElement {
cursor_char,
selection,
cursor,
last_hovered_hyperlink,
..
} = { &terminal_handle.read(cx).last_content };
@ -634,9 +646,9 @@ impl Element<TerminalView> 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

View File

@ -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);

View File

@ -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<Terminal>,
workspace: WeakViewHandle<Workspace>,
workspace_id: WorkspaceId,
cx: &mut ViewContext<Self>,
) -> 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::<Editor>() {
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<Workspace>,
maybe_path: &String,
cx: &mut ViewContext<'_, '_, TerminalView>,
) -> Vec<PathLikeWithPosition<PathBuf>> {
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<RegexSearch> {
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<Self>,
) -> 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<Self>) -> 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))
})?)
})
}

View File

@ -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))
}