Merge pull request #143 from ayamir/feat/link-file-command

feat(links): open file links with a configurable command
This commit is contained in:
l0ng-ai
2026-07-21 12:22:42 +08:00
committed by GitHub
4 changed files with 228 additions and 5 deletions
+17
View File
@@ -87,6 +87,14 @@ pub struct Config {
/// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on
/// hover, and open them on ⌘/Ctrl-click. On by default.
pub link_url: bool,
/// Optional command template run when ⌘/Ctrl-clicking a detected file-path
/// link, instead of tty7's built-in "open in the default app" behavior. The
/// template is tokenized on whitespace and the placeholders `{path}`,
/// `{line}`, and `{column}` are substituted per argument; an argument that
/// contains a placeholder with no value (e.g. `{line}` on a link that has no
/// line number) is dropped. `None` (the default) keeps the built-in open.
/// Example: `"herdr edit {path} --line {line}"`.
pub link_file_command: Option<String>,
/// When a pane is in a detected SSH session, Command-clicking loopback URLs
/// opens them through a temporary local SSH port-forward. Off by default
/// because it starts background `ssh` processes.
@@ -475,6 +483,7 @@ impl Default for Config {
// out: URL detection on, cursor blinking, 10k scrollback, new tabs
// after the active one, notify only while unfocused.
link_url: true,
link_file_command: None,
ssh_loopback_forward: false,
cursor_blink: true,
scrollback_limit: 10_000,
@@ -589,6 +598,14 @@ impl Config {
self.sidebar_width = default_sidebar_width();
}
self.sidebar_width = self.sidebar_width.clamp(100.0, 2000.0);
// An empty or whitespace-only file-open command means "no override"; the
// settings text field yields `""` when cleared, so fold it back to `None`
// rather than trying to run an empty command.
if let Some(command) = &self.link_file_command
&& command.trim().is_empty()
{
self.link_file_command = None;
}
}
/// Write the current config back to disk, creating the parent directory if
+137 -5
View File
@@ -3975,7 +3975,14 @@ impl TerminalView {
if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) {
match link.target {
LinkTarget::Url(url) => self.open_url(&url, cx),
LinkTarget::File { path, .. } => open_file_path(&path),
LinkTarget::File { path, line, column } => {
// A configured template (e.g. opening the file in an editor)
// takes precedence; otherwise fall back to the OS opener.
match cx.global::<Config>().link_file_command.as_deref() {
Some(template) => run_file_command(template, &path, line, column),
None => open_file_path(&path),
}
}
}
true
} else if self.can_forward_loopback(cx)
@@ -5112,6 +5119,81 @@ fn open_file_path(path: &std::path::Path) {
}
}
/// Run a user-configured file-open command for a clicked file link. The template
/// is expanded by [`expand_file_command_template`] and the first token is the
/// program; the rest are its arguments. Spawned detached — tty7 doesn't wait for
/// or read from the editor it launches.
fn run_file_command(
template: &str,
path: &std::path::Path,
line: Option<u32>,
column: Option<u32>,
) {
let argv = expand_file_command_template(template, path, line, column);
let Some((program, args)) = argv.split_first() else {
log::warn!("link_file_command is empty; ignoring file link");
return;
};
if let Err(e) = std::process::Command::new(program).args(args).spawn() {
log::warn!("failed to run link_file_command {template:?}: {e}");
}
}
/// Expand a file-open command template into an argv vector.
///
/// The template is split on whitespace into tokens. Within a token the
/// placeholders `{path}`, `{line}`, and `{column}` are replaced with their
/// values. If a token references a placeholder whose value is absent (e.g.
/// `{line}` for a link with no line number), the whole token is dropped — this
/// lets a combined token like `--line={line}` disappear cleanly rather than
/// leaving a dangling flag. `{path}` is always present, so a token that only
/// references `{path}` is never dropped.
fn expand_file_command_template(
template: &str,
path: &std::path::Path,
line: Option<u32>,
column: Option<u32>,
) -> Vec<String> {
let path = path.to_string_lossy();
template
.split_whitespace()
.filter_map(|token| expand_file_command_token(token, &path, line, column))
.collect()
}
/// Substitute placeholders in a single template token, or return `None` if the
/// token references a placeholder with no value (so the caller drops it).
fn expand_file_command_token(
token: &str,
path: &str,
line: Option<u32>,
column: Option<u32>,
) -> Option<String> {
let mut out = String::with_capacity(token.len());
let mut rest = token;
while let Some(open) = rest.find('{') {
let Some(close_rel) = rest[open..].find('}') else {
// No closing brace: the remainder is literal text.
break;
};
let close = open + close_rel;
out.push_str(&rest[..open]);
let value = match &rest[open + 1..close] {
"path" => Some(path.to_string()),
"line" => line.map(|l| l.to_string()),
"column" => column.map(|c| c.to_string()),
// An unknown placeholder is left verbatim rather than dropping the
// token, so a stray brace doesn't silently swallow an argument.
other => Some(format!("{{{other}}}")),
};
// A recognized-but-absent placeholder drops the entire token.
out.push_str(&value?);
rest = &rest[close + 1..];
}
out.push_str(rest);
Some(out)
}
/// One mouse report, encoded for the protocol the app negotiated. SGR (1006)
/// prints decimal 1-based coordinates and keeps the button in the final
/// letter (`M` press / `m` release); X10 packs everything into three bytes,
@@ -5466,15 +5548,65 @@ fn drag_scroll_step(overshoot: f32) -> i32 {
mod tests {
use super::{
SelectEndCopy, WheelRoute, clipboard_paste_text, display_width, drag_scroll_step,
encode_mouse, fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes,
input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes, select_end_copy,
shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route,
encode_mouse, expand_file_command_template, fallback_chain, fig_icon_emoji, fig_icon_glyph,
focus_report_bytes, input_overflow_shift, input_overlay_rows, menu_layout, paste_bytes,
select_end_copy, shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route,
wrapped_click_index,
};
use alacritty_terminal::term::TermMode;
use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers};
use gpui_component::IconName;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
#[test]
fn file_command_template_substitutes_path_line_and_column() {
let argv = expand_file_command_template(
"herdr edit {path} --line={line} --column={column}",
Path::new("/tmp/foo.rs"),
Some(42),
Some(7),
);
assert_eq!(
argv,
vec!["herdr", "edit", "/tmp/foo.rs", "--line=42", "--column=7",]
);
}
#[test]
fn file_command_template_drops_tokens_for_absent_values() {
// No line/column: the combined flag tokens vanish entirely, leaving no
// dangling `--line` for the downstream parser.
let argv = expand_file_command_template(
"herdr edit {path} --line={line} --column={column}",
Path::new("/tmp/foo.rs"),
None,
None,
);
assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs"]);
// Column absent but line present: only the column flag drops.
let argv = expand_file_command_template(
"herdr edit {path} --line={line} --column={column}",
Path::new("/tmp/foo.rs"),
Some(42),
None,
);
assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs", "--line=42"]);
}
#[test]
fn file_command_template_keeps_path_only_token_and_unknown_placeholder() {
// A path-only program still runs; an unknown placeholder is left verbatim
// rather than dropping its token.
let argv = expand_file_command_template(
"code --goto {path}:{line} {other}",
Path::new("/tmp/foo.rs"),
None,
None,
);
// `{path}:{line}` drops (line absent); `{other}` stays literal.
assert_eq!(argv, vec!["code", "--goto", "{other}"]);
}
#[cfg(not(target_os = "macos"))]
#[test]
+55
View File
@@ -3114,6 +3114,7 @@ impl Tty7App {
self.build_font_selects(&mut subs, window, cx);
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
// Live filter for the theme picker panel; each keystroke re-renders the
@@ -3149,6 +3150,7 @@ impl Tty7App {
shell_program_input,
shell_args_input,
wd_path_input,
link_file_command_input,
scroll_slider,
window_opacity_slider,
theme_editor: None,
@@ -3335,6 +3337,59 @@ impl Tty7App {
(shell_program_input, shell_args_input, wd_path_input)
}
/// File-open command template input (Links section), committing on Enter/blur.
fn build_link_file_command_input(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<InputState> {
let value = cx
.global::<Config>()
.link_file_command
.clone()
.unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("open in default app")
.default_value(value)
});
subs.push(
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
this.commit_link_file_command(cx);
}
}),
);
input
}
/// Persist the file-open command template from the Links settings input. An
/// empty value clears the override (falls back to the built-in open).
fn commit_link_file_command(&mut self, cx: &mut Context<Self>) {
let Some(command) = self.active_settings().map(|s| {
s.link_file_command_input
.read(cx)
.value()
.trim()
.to_string()
}) else {
return;
};
let command = if command.is_empty() {
None
} else {
Some(command)
};
let cfg = cx.global_mut::<Config>();
if cfg.link_file_command == command {
return; // no change — avoid a redundant disk write on every Blur
}
cfg.link_file_command = command;
cfg.save();
cx.notify();
}
/// Window-opacity slider for the Appearance page (20%100%). Emits `Change`
/// continuously as the user drags; each tick sets the global override and
/// repaints, so the translucency is live under the thumb.
+19
View File
@@ -360,6 +360,9 @@ pub(crate) struct SettingsState {
pub(crate) shell_args_input: Entity<InputState>,
/// Custom working-directory path (used when the strategy is `Custom`).
pub(crate) wd_path_input: Entity<InputState>,
/// Command template run when ⌘-clicking a file link (Links section). Empty
/// clears the override, restoring the built-in "open in default app".
pub(crate) link_file_command_input: Entity<InputState>,
/// Mouse-scroll multiplier slider (Terminal section).
pub(crate) scroll_slider: Entity<SliderState>,
/// Global window-opacity slider (Appearance's Window section). Shows the
@@ -2794,6 +2797,10 @@ impl Tty7App {
Some(s) => s.scroll_slider.clone(),
None => return div().into_any_element(),
};
let link_file_command_input = match self.active_settings() {
Some(s) => s.link_file_command_input.clone(),
None => return div().into_any_element(),
};
let link_switch = Switch::new("term-link-url")
.checked(link_url)
@@ -2803,6 +2810,10 @@ impl Tty7App {
.checked(ssh_loopback_forward)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx)))
.into_any_element();
let link_file_command_control = div()
.w(px(300.))
.child(Input::new(&link_file_command_input).small())
.into_any_element();
let scrollback_radio = self.segmented(
"term-scrollback",
&["1,000", "10,000", "100,000"],
@@ -2983,6 +2994,14 @@ impl Tty7App {
ssh_loopback_switch,
cx,
))
.child(self.settings_row(
"Open files with",
"Command run when ⌘-clicking a file link, instead of the default app. \
Use {path}, {line}, {column}; a flag whose value is absent is dropped \
(e.g. herdr edit {path} --line={line}). Empty uses the default app.",
link_file_command_control,
cx,
))
.child(self.section_rule(cx))
.child(self.section_header("Clipboard", cx))
.child(self.settings_row(