mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
Merge pull request #143 from ayamir/feat/link-file-command
feat(links): open file links with a configurable command
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user