Files
oak-gpui/crates/terminal/src/terminal_settings.rs
T
Dave Waggoner e76b485de3 terminal: New settings for path hyperlink regexes (#40305)
Closes:
- #12338
- #40202 

1. Adds two new settings which allow customizing the set of regexes used
to identify path hyperlinks in terminal
1. Fixes path hyperlinks for paths containing unicode emoji and
punctuation, for example, `mojo.🔥`
1. Fixes path hyperlinks for Windows verbatim paths, for example,
`\\?\C:\Over\here.rs`.
1. Improves path hyperlink performance, especially for terminals with a
lot of content
1. Replaces existing custom hard-coded default path hyperlink parsing
logic with a set of customizable default regexes

## New settings

(from default.json)

### terminal.path_hyperlink_regexes

Regexes used to identify paths for hyperlink navigation. Supports
optional named capture
groups `path`, `line`, `column`, and `link`. If none of these are
present, the entire match
is the hyperlink target. If `path` is present, it is the hyperlink
target, along with `line`
and `column` if present. `link` may be used to customize what text in
terminal is part of the
hyperlink. If `link` is not present, the text of the entire match is
used. If `line` and
`column` are not present, the default built-in line and column suffix
processing is used
which parses `line:column` and `(line,column)` variants. The default
value handles Python
diagnostics and common path, line, column syntaxes. This can be extended
or replaced to
handle specific scenarios. For example, to enable support for
hyperlinking paths which
contain spaces in rust output,
```
[
  "\\s+(-->|:::|at) (?<link>(?<path>.+?))(:$|$)",
  "\\s+(Compiling|Checking|Documenting) [^(]+\\((?<link>(?<path>.+))\\)"
],
```
could be used. Processing stops at the first regex with a match, even if
no link is
produced which is the case when the cursor is not over the hyperlinked
text. For best
performance it is recommended to order regexes from most common to least
common. For
readability and documentation, each regex may be an array of strings
which are collected
into one multi-line regex string for use in terminal path hyperlink
detection.

### terminal.path_hyperlink_timeout_ms
Timeout for hover and Cmd-click path hyperlink discovery in
milliseconds. Specifying a
timeout of `0` will disable path hyperlinking in terminal.

## Performance

This PR fixes terminal to only search the hovered line for hyperlinks
and adds a benchmark. Before this fix, hyperlink detection grows
linearly with terminal content, with this fix it is proportional only to
the hovered line. The gains come from replacing
`visible_regex_match_iter`, which searched all visible lines, with code
that only searches the line hovered on (including if the line is
wrapped).

Local benchmark timings (terminal with 500 lines of content):

||main|this PR|Δ|
|-|-|-:|-|
| cargo_hyperlink_benchmark | 1.4 ms | 13 µs | -99.0% |
| rust_hyperlink_benchmark | 1.2 ms | 11 µs | -99.1% |
| ls_hyperlink_benchmark | 1.3 ms | 7 µs |  -99.5% |

Release Notes:

- terminal: New settings to allow customizing the set of regexes used to
identify path hyperlinks in terminal
- terminal: Fixed terminal path hyperlinks for paths containing unicode
punctuation and emoji, e.g. mojo.🔥
- terminal: Fixed path hyperlinks for Windows verbatim paths, for
example, `\\?\C:\Over\here.rs`
- terminal: Improved terminal hyperlink performance, especially for
terminals with a lot of content visible
2025-11-21 14:01:06 -05:00

179 lines
6.6 KiB
Rust

use alacritty_terminal::vte::ansi::{
CursorShape as AlacCursorShape, CursorStyle as AlacCursorStyle,
};
use collections::HashMap;
use gpui::{FontFallbacks, FontFeatures, FontWeight, Pixels, px};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use settings::AlternateScroll;
use settings::{
PathHyperlinkRegex, RegisterSetting, ShowScrollbar, TerminalBlink, TerminalDockPosition,
TerminalLineHeight, VenvSettings, WorkingDirectory, merge_from::MergeFrom,
};
use task::Shell;
use theme::FontFamilyName;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct Toolbar {
pub breadcrumbs: bool,
}
#[derive(Clone, Debug, Deserialize, RegisterSetting)]
pub struct TerminalSettings {
pub shell: Shell,
pub working_directory: WorkingDirectory,
pub font_size: Option<Pixels>, // todo(settings_refactor) can be non-optional...
pub font_family: Option<FontFamilyName>,
pub font_fallbacks: Option<FontFallbacks>,
pub font_features: Option<FontFeatures>,
pub font_weight: Option<FontWeight>,
pub line_height: TerminalLineHeight,
pub env: HashMap<String, String>,
pub cursor_shape: CursorShape,
pub blinking: TerminalBlink,
pub alternate_scroll: AlternateScroll,
pub option_as_meta: bool,
pub copy_on_select: bool,
pub keep_selection_on_copy: bool,
pub button: bool,
pub dock: TerminalDockPosition,
pub default_width: Pixels,
pub default_height: Pixels,
pub detect_venv: VenvSettings,
pub max_scroll_history_lines: Option<usize>,
pub scroll_multiplier: f32,
pub toolbar: Toolbar,
pub scrollbar: ScrollbarSettings,
pub minimum_contrast: f32,
pub path_hyperlink_regexes: Vec<String>,
pub path_hyperlink_timeout_ms: u64,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ScrollbarSettings {
/// When to show the scrollbar in the terminal.
///
/// Default: inherits editor scrollbar settings
pub show: Option<ShowScrollbar>,
}
fn settings_shell_to_task_shell(shell: settings::Shell) -> Shell {
match shell {
settings::Shell::System => Shell::System,
settings::Shell::Program(program) => Shell::Program(program),
settings::Shell::WithArguments {
program,
args,
title_override,
} => Shell::WithArguments {
program,
args,
title_override: title_override.map(Into::into),
},
}
}
impl settings::Settings for TerminalSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let user_content = content.terminal.clone().unwrap();
// Note: we allow a subset of "terminal" settings in the project files.
let mut project_content = user_content.project.clone();
project_content.merge_from_option(content.project.terminal.as_ref());
TerminalSettings {
shell: settings_shell_to_task_shell(project_content.shell.unwrap()),
working_directory: project_content.working_directory.unwrap(),
font_size: user_content.font_size.map(px),
font_family: user_content.font_family,
font_fallbacks: user_content.font_fallbacks.map(|fallbacks| {
FontFallbacks::from_fonts(
fallbacks
.into_iter()
.map(|family| family.0.to_string())
.collect(),
)
}),
font_features: user_content.font_features,
font_weight: user_content.font_weight.map(FontWeight),
line_height: user_content.line_height.unwrap(),
env: project_content.env.unwrap(),
cursor_shape: user_content.cursor_shape.unwrap().into(),
blinking: user_content.blinking.unwrap(),
alternate_scroll: user_content.alternate_scroll.unwrap(),
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(),
button: user_content.button.unwrap(),
dock: user_content.dock.unwrap(),
default_width: px(user_content.default_width.unwrap()),
default_height: px(user_content.default_height.unwrap()),
detect_venv: project_content.detect_venv.unwrap(),
scroll_multiplier: user_content.scroll_multiplier.unwrap(),
max_scroll_history_lines: user_content.max_scroll_history_lines,
toolbar: Toolbar {
breadcrumbs: user_content.toolbar.unwrap().breadcrumbs.unwrap(),
},
scrollbar: ScrollbarSettings {
show: user_content.scrollbar.unwrap().show,
},
minimum_contrast: user_content.minimum_contrast.unwrap(),
path_hyperlink_regexes: project_content
.path_hyperlink_regexes
.unwrap()
.into_iter()
.map(|regex| match regex {
PathHyperlinkRegex::SingleLine(regex) => regex,
PathHyperlinkRegex::MultiLine(regex) => regex.join("\n"),
})
.collect(),
path_hyperlink_timeout_ms: project_content.path_hyperlink_timeout_ms.unwrap(),
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CursorShape {
/// Cursor is a block like `█`.
#[default]
Block,
/// Cursor is an underscore like `_`.
Underline,
/// Cursor is a vertical bar like `⎸`.
Bar,
/// Cursor is a hollow box like `▯`.
Hollow,
}
impl From<settings::CursorShapeContent> for CursorShape {
fn from(value: settings::CursorShapeContent) -> Self {
match value {
settings::CursorShapeContent::Block => CursorShape::Block,
settings::CursorShapeContent::Underline => CursorShape::Underline,
settings::CursorShapeContent::Bar => CursorShape::Bar,
settings::CursorShapeContent::Hollow => CursorShape::Hollow,
}
}
}
impl From<CursorShape> for AlacCursorShape {
fn from(value: CursorShape) -> Self {
match value {
CursorShape::Block => AlacCursorShape::Block,
CursorShape::Underline => AlacCursorShape::Underline,
CursorShape::Bar => AlacCursorShape::Beam,
CursorShape::Hollow => AlacCursorShape::HollowBlock,
}
}
}
impl From<CursorShape> for AlacCursorStyle {
fn from(value: CursorShape) -> Self {
AlacCursorStyle {
shape: value.into(),
blinking: false,
}
}
}