Files
oak-gpui/crates/project/src/terminals.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

599 lines
23 KiB
Rust

use anyhow::Result;
use collections::HashMap;
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
use futures::{FutureExt, future::Shared};
use itertools::Itertools as _;
use language::LanguageName;
use remote::RemoteClient;
use settings::{Settings, SettingsLocation};
use smol::channel::bounded;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
use terminal::{
TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
};
use util::{command::new_std_command, get_default_system_shell, maybe, rel_path::RelPath};
use crate::{Project, ProjectPath};
pub struct Terminals {
pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
}
impl Project {
pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
self.active_entry()
.and_then(|entry_id| self.worktree_for_entry(entry_id, cx))
.into_iter()
.chain(self.worktrees(cx))
.find_map(|tree| tree.read(cx).root_dir())
}
pub fn first_project_directory(&self, cx: &App) -> Option<PathBuf> {
let worktree = self.worktrees(cx).next()?;
let worktree = worktree.read(cx);
if worktree.root_entry()?.is_dir() {
Some(worktree.abs_path().to_path_buf())
} else {
None
}
}
pub fn create_terminal_task(
&mut self,
spawn_task: SpawnInTerminal,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let is_via_remote = self.remote_client.is_some();
let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
if is_via_remote {
Some(Arc::from(cwd.as_ref()))
} else {
let cwd = cwd.to_string_lossy();
let tilde_substituted = shellexpand::tilde(&cwd);
Some(Arc::from(Path::new(tilde_substituted.as_ref())))
}
} else {
self.active_project_directory(cx)
};
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path: RelPath::empty(),
});
}
let settings = TerminalSettings::get(settings_location, cx).clone();
let detect_venv = settings.detect_venv.as_option().is_some();
let (completion_tx, completion_rx) = bounded(1);
let local_path = if is_via_remote { None } else { path.clone() };
let task_state = Some(TaskState {
spawned_task: spawn_task.clone(),
status: TaskStatus::Running,
completion_rx,
});
let remote_client = self.remote_client.clone();
let shell = match &remote_client {
Some(remote_client) => remote_client
.read(cx)
.shell()
.unwrap_or_else(get_default_system_shell),
None => settings.shell.program(),
};
let is_windows = self.path_style(cx).is_windows();
let shell_kind = ShellKind::new(&shell, is_windows);
// Prepare a task for resolving the environment
let env_task =
self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
let project_path_contexts = self
.active_entry()
.and_then(|entry_id| self.path_for_entry(entry_id, cx))
.into_iter()
.chain(
self.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.map(|worktree_id| ProjectPath {
worktree_id,
path: Arc::from(RelPath::empty()),
}),
);
let toolchains = project_path_contexts
.filter(|_| detect_venv)
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
.collect::<Vec<_>>();
let lang_registry = self.languages.clone();
cx.spawn(async move |project, cx| {
let mut env = env_task.await.unwrap_or_default();
env.extend(settings.env);
let activation_script = maybe!(async {
for toolchain in toolchains {
let Some(toolchain) = toolchain.await else {
continue;
};
let language = lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok();
let lister = language?.toolchain_lister()?;
return cx
.update(|cx| lister.activation_script(&toolchain, shell_kind, cx))
.ok();
}
None
})
.await
.unwrap_or_default();
let builder = project
.update(cx, move |_, cx| {
let format_to_run = || {
if let Some(command) = &spawn_task.command {
let command = shell_kind.prepend_command_prefix(command);
let command = shell_kind.try_quote_prefix_aware(&command);
let args = spawn_task
.args
.iter()
.filter_map(|arg| shell_kind.try_quote(&arg));
command.into_iter().chain(args).join(" ")
} else {
// todo: this breaks for remotes to windows
format!("exec {shell} -l")
}
};
let (shell, env) = {
env.extend(spawn_task.env);
match remote_client {
Some(remote_client) => match activation_script.clone() {
activation_script if !activation_script.is_empty() => {
let separator = shell_kind.sequential_commands_separator();
let activation_script =
activation_script.join(&format!("{separator} "));
let to_run = format_to_run();
let arg = format!("{activation_script}{separator} {to_run}");
let args = shell_kind.args_for_shell(false, arg);
let shell = remote_client
.read(cx)
.shell()
.unwrap_or_else(get_default_system_shell);
create_remote_shell(
Some((&shell, &args)),
env,
path,
remote_client,
cx,
)?
}
_ => create_remote_shell(
spawn_task
.command
.as_ref()
.map(|command| (command, &spawn_task.args)),
env,
path,
remote_client,
cx,
)?,
},
None => match activation_script.clone() {
activation_script if !activation_script.is_empty() => {
let separator = shell_kind.sequential_commands_separator();
let activation_script =
activation_script.join(&format!("{separator} "));
let to_run = format_to_run();
let mut arg =
format!("{activation_script}{separator} {to_run}");
if shell_kind == ShellKind::Cmd {
// We need to put the entire command in quotes since otherwise CMD tries to execute them
// as separate commands rather than chaining one after another.
arg = format!("\"{arg}\"");
}
let args = shell_kind.args_for_shell(false, arg);
(
Shell::WithArguments {
program: shell,
args,
title_override: None,
},
env,
)
}
_ => (
if let Some(program) = spawn_task.command {
Shell::WithArguments {
program,
args: spawn_task.args,
title_override: None,
}
} else {
Shell::System
},
env,
),
},
}
};
anyhow::Ok(TerminalBuilder::new(
local_path.map(|path| path.to_path_buf()),
task_state,
shell,
env,
settings.cursor_shape,
settings.alternate_scroll,
settings.max_scroll_history_lines,
settings.path_hyperlink_regexes,
settings.path_hyperlink_timeout_ms,
is_via_remote,
cx.entity_id().as_u64(),
Some(completion_tx),
cx,
activation_script,
))
})??
.await?;
project.update(cx, move |this, cx| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
this.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
}
})
.detach();
terminal_handle
})
})
}
pub fn create_terminal_shell(
&mut self,
cwd: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
let path = cwd.map(|p| Arc::from(&*p));
let is_via_remote = self.remote_client.is_some();
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path: RelPath::empty(),
});
}
let settings = TerminalSettings::get(settings_location, cx).clone();
let detect_venv = settings.detect_venv.as_option().is_some();
let local_path = if is_via_remote { None } else { path.clone() };
let project_path_contexts = self
.active_entry()
.and_then(|entry_id| self.path_for_entry(entry_id, cx))
.into_iter()
.chain(
self.visible_worktrees(cx)
.map(|wt| wt.read(cx).id())
.map(|worktree_id| ProjectPath {
worktree_id,
path: RelPath::empty().into(),
}),
);
let toolchains = project_path_contexts
.filter(|_| detect_venv)
.map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx))
.collect::<Vec<_>>();
let remote_client = self.remote_client.clone();
let shell = match &remote_client {
Some(remote_client) => remote_client
.read(cx)
.shell()
.unwrap_or_else(get_default_system_shell),
None => settings.shell.program(),
};
let is_windows = self.path_style(cx).is_windows();
// Prepare a task for resolving the environment
let env_task =
self.resolve_directory_environment(&shell, path.clone(), remote_client.clone(), cx);
let lang_registry = self.languages.clone();
cx.spawn(async move |project, cx| {
let shell_kind = ShellKind::new(&shell, is_windows);
let mut env = env_task.await.unwrap_or_default();
env.extend(settings.env);
let activation_script = maybe!(async {
for toolchain in toolchains {
let Some(toolchain) = toolchain.await else {
continue;
};
let language = lang_registry
.language_for_name(&toolchain.language_name.0)
.await
.ok();
let lister = language?.toolchain_lister()?;
return cx
.update(|cx| lister.activation_script(&toolchain, shell_kind, cx))
.ok();
}
None
})
.await
.unwrap_or_default();
let builder = project
.update(cx, move |_, cx| {
let (shell, env) = {
match remote_client {
Some(remote_client) => {
create_remote_shell(None, env, path, remote_client, cx)?
}
None => (settings.shell, env),
}
};
anyhow::Ok(TerminalBuilder::new(
local_path.map(|path| path.to_path_buf()),
None,
shell,
env,
settings.cursor_shape,
settings.alternate_scroll,
settings.max_scroll_history_lines,
settings.path_hyperlink_regexes,
settings.path_hyperlink_timeout_ms,
is_via_remote,
cx.entity_id().as_u64(),
None,
cx,
activation_script,
))
})??
.await?;
project.update(cx, move |this, cx| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
this.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
}
})
.detach();
terminal_handle
})
})
}
pub fn clone_terminal(
&mut self,
terminal: &Entity<Terminal>,
cx: &mut Context<'_, Project>,
cwd: Option<PathBuf>,
) -> Task<Result<Entity<Terminal>>> {
// We cannot clone the task's terminal, as it will effectively re-spawn the task, which might not be desirable.
// For now, create a new shell instead.
if terminal.read(cx).task().is_some() {
return self.create_terminal_shell(cwd, cx);
}
let local_path = if self.is_via_remote_server() {
None
} else {
cwd
};
let builder = terminal.read(cx).clone_builder(cx, local_path);
cx.spawn(async |project, cx| {
let terminal = builder.await?;
project.update(cx, |project, cx| {
let terminal_handle = cx.new(|cx| terminal.subscribe(cx));
project
.terminals
.local_handles
.push(terminal_handle.downgrade());
let id = terminal_handle.entity_id();
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
let handles = &mut project.terminals.local_handles;
if let Some(index) = handles
.iter()
.position(|terminal| terminal.entity_id() == id)
{
handles.remove(index);
cx.notify();
}
})
.detach();
terminal_handle
})
})
}
pub fn terminal_settings<'a>(
&'a self,
path: &'a Option<PathBuf>,
cx: &'a App,
) -> &'a TerminalSettings {
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
path: RelPath::empty(),
});
}
TerminalSettings::get(settings_location, cx)
}
pub fn exec_in_shell(
&self,
command: String,
cx: &mut Context<Self>,
) -> Task<Result<smol::process::Command>> {
let path = self.first_project_directory(cx);
let remote_client = self.remote_client.clone();
let settings = self.terminal_settings(&path, cx).clone();
let shell = remote_client
.as_ref()
.and_then(|remote_client| remote_client.read(cx).shell())
.map(Shell::Program)
.unwrap_or_else(|| settings.shell.clone());
let is_windows = self.path_style(cx).is_windows();
let builder = ShellBuilder::new(&shell, is_windows).non_interactive();
let (command, args) = builder.build(Some(command), &Vec::new());
let env_task = self.resolve_directory_environment(
&shell.program(),
path.as_ref().map(|p| Arc::from(&**p)),
remote_client.clone(),
cx,
);
cx.spawn(async move |project, cx| {
let mut env = env_task.await.unwrap_or_default();
env.extend(settings.env);
project.update(cx, move |_, cx| {
match remote_client {
Some(remote_client) => {
let command_template = remote_client.read(cx).build_command(
Some(command),
&args,
&env,
None,
None,
)?;
let mut command = new_std_command(command_template.program);
command.args(command_template.args);
command.envs(command_template.env);
Ok(command)
}
None => {
let mut command = new_std_command(command);
command.args(args);
command.envs(env);
if let Some(path) = path {
command.current_dir(path);
}
Ok(command)
}
}
.map(|mut process| {
util::set_pre_exec_to_start_new_session(&mut process);
smol::process::Command::from(process)
})
})?
})
}
pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
&self.terminals.local_handles
}
fn resolve_directory_environment(
&self,
shell: &str,
path: Option<Arc<Path>>,
remote_client: Option<Entity<RemoteClient>>,
cx: &mut App,
) -> Shared<Task<Option<HashMap<String, String>>>> {
if let Some(path) = &path {
let shell = Shell::Program(shell.to_string());
self.environment
.update(cx, |project_env, cx| match &remote_client {
Some(remote_client) => project_env.remote_directory_environment(
&shell,
path.clone(),
remote_client.clone(),
cx,
),
None => project_env.local_directory_environment(&shell, path.clone(), cx),
})
} else {
Task::ready(None).shared()
}
}
}
fn create_remote_shell(
spawn_command: Option<(&String, &Vec<String>)>,
mut env: HashMap<String, String>,
working_directory: Option<Arc<Path>>,
remote_client: Entity<RemoteClient>,
cx: &mut App,
) -> Result<(Shell, HashMap<String, String>)> {
// Set default terminfo that does not break the highlighting via ssh.
env.insert("TERM".to_string(), "xterm-256color".to_string());
let (program, args) = match spawn_command {
Some((program, args)) => (Some(program.clone()), args),
None => (None, &Vec::new()),
};
let command = remote_client.read(cx).build_command(
program,
args.as_slice(),
&env,
working_directory.map(|path| path.display().to_string()),
None,
)?;
log::debug!("Connecting to a remote server: {:?}", command.program);
let host = remote_client.read(cx).connection_options().display_name();
Ok((
Shell::WithArguments {
program: command.program,
args: command.args,
title_override: Some(format!("{} — Terminal", host)),
},
command.env,
))
}