diff --git a/CHANGELOG.md b/CHANGELOG.md index 34136f6..fb7e937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# Upcoming release + +## Features + +- Add --hyperlink option to add OSC 8 hyperlinks to output + + +## Bugfixes + + +## Changes + + +## Other + # 10.1.0 ## Features diff --git a/Cargo.toml b/Cargo.toml index a307294..562dc7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ default-features = false features = ["nu-ansi-term"] [target.'cfg(unix)'.dependencies] -nix = { version = "0.29.0", default-features = false, features = ["signal", "user"] } +nix = { version = "0.29.0", default-features = false, features = ["signal", "user", "hostname"] } [target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] libc = "0.2" diff --git a/contrib/completion/_fd b/contrib/completion/_fd index dc7e94d..367497d 100644 --- a/contrib/completion/_fd +++ b/contrib/completion/_fd @@ -139,6 +139,8 @@ _fd() { always\:"always use colorized output" ))' + '--hyperlink[add hyperlinks to output paths]' + + '(threads)' {-j+,--threads=}'[set the number of threads for searching and executing]:number of threads' diff --git a/doc/fd.1 b/doc/fd.1 index 108c759..e478594 100644 --- a/doc/fd.1 +++ b/doc/fd.1 @@ -276,6 +276,12 @@ Do not colorize output. Always colorize output. .RE .TP +.B "\-\-hyperlink +Specify that the output should use terminal escape codes to indicate a hyperlink to a +file url pointing to the path. +Such hyperlinks will only actually be included if color output would be used, since +that is likely correlated with the output being used on a terminal. +.TP .BI "\-j, \-\-threads " num Set number of threads to use for searching & executing (default: number of available CPU cores). .TP diff --git a/src/cli.rs b/src/cli.rs index 0eabd12..94c0e3f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -509,6 +509,13 @@ pub struct Opts { )] pub color: ColorWhen, + /// Add a terminal hyperlink to a file:// url for each path in the output. + /// + /// This doesn't do anything for options that don't use the defualt output such as + /// --exec and --format. + #[arg(long, alias = "hyper", help = "Add hyperlinks to output paths")] + pub hyperlink: bool, + /// Set number of threads to use for searching & executing (default: number /// of available CPU cores) #[arg(long, short = 'j', value_name = "num", hide_short_help = true, value_parser = str::parse::)] diff --git a/src/config.rs b/src/config.rs index cf7a660..8cee778 100644 --- a/src/config.rs +++ b/src/config.rs @@ -126,6 +126,9 @@ pub struct Config { /// Whether or not to strip the './' prefix for search results pub strip_cwd_prefix: bool, + + /// Whether or not to use hyperlinks on paths + pub hyperlink: bool, } impl Config { diff --git a/src/hyperlink.rs b/src/hyperlink.rs new file mode 100644 index 0000000..d27f7b4 --- /dev/null +++ b/src/hyperlink.rs @@ -0,0 +1,63 @@ +use crate::filesystem::absolute_path; +use std::fmt::{self, Formatter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) struct PathUrl(PathBuf); + +#[cfg(unix)] +static HOSTNAME: OnceLock = OnceLock::new(); + +impl PathUrl { + pub(crate) fn new(path: &Path) -> Option { + Some(PathUrl(absolute_path(path).ok()?)) + } +} + +impl fmt::Display for PathUrl { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "file://{}", host())?; + let bytes = self.0.as_os_str().as_encoded_bytes(); + for &byte in bytes.iter() { + encode(f, byte)?; + } + Ok(()) + } +} + +fn encode(f: &mut Formatter, byte: u8) -> fmt::Result { + match byte { + b'0'..=b'9' + | b'A'..=b'Z' + | b'a'..=b'z' + | b'/' + | b':' + | b'-' + | b'.' + | b'_' + | b'~' + | 128.. => f.write_char(byte.into()), + #[cfg(windows)] + b'\\' => f.write_char('/'), + _ => { + write!(f, "%{:X}", byte) + } + } +} + +#[cfg(unix)] +fn host() -> &'static str { + HOSTNAME + .get_or_init(|| { + nix::unistd::gethostname() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_default() + }) + .as_ref() +} + +#[cfg(not(unix))] +const fn host() -> &'static str { + "" +} diff --git a/src/main.rs b/src/main.rs index 31db976..a1b9d92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod filesystem; mod filetypes; mod filter; mod fmt; +mod hyperlink; mod output; mod regex_helper; mod walk; @@ -258,6 +259,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result String { path.replace(std::path::MAIN_SEPARATOR, new_path_separator) @@ -83,9 +84,17 @@ fn print_entry_colorized( ) -> io::Result<()> { // Split the path between the parent and the last component let mut offset = 0; + let mut has_hyperlink = false; let path = entry.stripped_path(config); let path_str = path.to_string_lossy(); + if config.hyperlink { + if let Some(url) = PathUrl::new(entry.path()) { + write!(stdout, "\x1B]8;;{}\x1B\\", url)?; + has_hyperlink = true; + } + } + if let Some(parent) = path.parent() { offset = parent.to_string_lossy().len(); for c in path_str[offset..].chars() { @@ -123,6 +132,10 @@ fn print_entry_colorized( ls_colors.style_for_indicator(Indicator::Directory), )?; + if has_hyperlink { + write!(stdout, "\x1B]8;;\x1B\\")?; + } + if config.null_separator { write!(stdout, "\0")?; } else { diff --git a/tests/testenv/mod.rs b/tests/testenv/mod.rs index c39fa69..d40ab2f 100644 --- a/tests/testenv/mod.rs +++ b/tests/testenv/mod.rs @@ -316,6 +316,9 @@ impl TestEnv { } else { cmd.arg("--no-global-ignore-file"); } + // Make sure LS_COLORS is unset to ensure consistent + // color output + cmd.env("LS_COLORS", ""); cmd.args(args); // Run *fd*. diff --git a/tests/tests.rs b/tests/tests.rs index 8d1ce39..aaa1fc1 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -2672,3 +2672,21 @@ fn test_gitignore_parent() { te.assert_output_subdirectory("sub", &["--hidden"], ""); te.assert_output_subdirectory("sub", &["--hidden", "--search-path", "."], ""); } + +#[test] +fn test_hyperlink() { + let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES); + + #[cfg(unix)] + let hostname = nix::unistd::gethostname().unwrap().into_string().unwrap(); + #[cfg(not(unix))] + let hostname = ""; + + let expected = format!( + "\x1b]8;;file://{}{}/a.foo\x1b\\a.foo\x1b]8;;\x1b\\", + hostname, + get_absolute_root_path(&te), + ); + + te.assert_output(&["--color=always", "--hyperlink", "a.foo"], &expected); +}