Add hyperlink support to fd

Fixes: #1295
Fixes: #1563
This commit is contained in:
Thayne McCombs 2024-06-08 15:36:02 -06:00
parent be815c261a
commit bd649e2fd7
11 changed files with 133 additions and 1 deletions

View File

@ -1,3 +1,18 @@
# Upcoming release
## Features
- Add --hyperlink option to add OSC 8 hyperlinks to output
## Bugfixes
## Changes
## Other
# 10.1.0
## Features

View File

@ -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"

View File

@ -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'

6
doc/fd.1 vendored
View File

@ -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

View File

@ -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::<NonZeroUsize>)]

View File

@ -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 {

63
src/hyperlink.rs Normal file
View File

@ -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<String> = OnceLock::new();
impl PathUrl {
pub(crate) fn new(path: &Path) -> Option<PathUrl> {
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 {
""
}

View File

@ -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<Config
threads: opts.threads().get(),
max_buffer_time: opts.max_buffer_time,
ls_colors,
hyperlink: opts.hyperlink,
interactive_terminal,
file_types: opts.filetype.as_ref().map(|values| {
use crate::cli::FileType::*;

View File

@ -8,6 +8,7 @@ use crate::dir_entry::DirEntry;
use crate::error::print_error;
use crate::exit_codes::ExitCode;
use crate::fmt::FormatTemplate;
use crate::hyperlink::PathUrl;
fn replace_path_separator(path: &str, new_path_separator: &str) -> String {
path.replace(std::path::MAIN_SEPARATOR, new_path_separator)
@ -83,9 +84,17 @@ fn print_entry_colorized<W: Write>(
) -> 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<W: Write>(
ls_colors.style_for_indicator(Indicator::Directory),
)?;
if has_hyperlink {
write!(stdout, "\x1B]8;;\x1B\\")?;
}
if config.null_separator {
write!(stdout, "\0")?;
} else {

View File

@ -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*.

View File

@ -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);
}