diff --git a/src/core/config.rs b/src/core/config.rs index bb16b895..7f5927d9 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -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, /// 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 diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 097669f8..eee4bc3b 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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::().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, + column: Option, +) { + 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, + column: Option, +) -> Vec { + 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, + column: Option, +) -> Option { + 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] diff --git a/src/ui/app.rs b/src/ui/app.rs index 4a74cf81..b7570049 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let value = cx + .global::() + .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) { + 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::(); + 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. diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1e237067..fe07863a 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -360,6 +360,9 @@ pub(crate) struct SettingsState { pub(crate) shell_args_input: Entity, /// Custom working-directory path (used when the strategy is `Custom`). pub(crate) wd_path_input: Entity, + /// 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, /// Mouse-scroll multiplier slider (Terminal section). pub(crate) scroll_slider: Entity, /// 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(