Skip to content

Commit b49df1f

Browse files
committed
Add hyperlink support to fd
Fixes: #1295 Fixes: #1563
1 parent be815c2 commit b49df1f

File tree

11 files changed

+133
-1
lines changed

11 files changed

+133
-1
lines changed

CHANGELOG.md

+15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
# Upcoming release
2+
3+
## Features
4+
5+
- Add --hyperlink option to add OSC 8 hyperlinks to output
6+
7+
8+
## Bugfixes
9+
10+
11+
## Changes
12+
13+
14+
## Other
15+
116
# 10.1.0
217

318
## Features

Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ default-features = false
6565
features = ["nu-ansi-term"]
6666

6767
[target.'cfg(unix)'.dependencies]
68-
nix = { version = "0.29.0", default-features = false, features = ["signal", "user"] }
68+
nix = { version = "0.29.0", default-features = false, features = ["signal", "user", "hostname"] }
6969

7070
[target.'cfg(all(unix, not(target_os = "redox")))'.dependencies]
7171
libc = "0.2"

contrib/completion/_fd

+2
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ _fd() {
139139
always\:"always use colorized output"
140140
))'
141141

142+
'--hyperlink[add hyperlinks to output paths]'
143+
142144
+ '(threads)'
143145
{-j+,--threads=}'[set the number of threads for searching and executing]:number of threads'
144146

doc/fd.1

+6
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,12 @@ Do not colorize output.
276276
Always colorize output.
277277
.RE
278278
.TP
279+
.B "\-\-hyperlink
280+
Specify that the output should use terminal escape codes to indicate a hyperlink to a
281+
file url pointing to the path.
282+
Such hyperlinks will only actually be included if color output would be used, since
283+
that is likely correlated with the output being used on a terminal.
284+
.TP
279285
.BI "\-j, \-\-threads " num
280286
Set number of threads to use for searching & executing (default: number of available CPU cores).
281287
.TP

src/cli.rs

+7
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,13 @@ pub struct Opts {
509509
)]
510510
pub color: ColorWhen,
511511

512+
/// Add a terminal hyperlink to a file:// url for each path in the output.
513+
///
514+
/// This doesn't do anything for options that don't use the defualt output such as
515+
/// --exec and --format.
516+
#[arg(long, alias = "hyper", help = "Add hyperlinks to output paths")]
517+
pub hyperlink: bool,
518+
512519
/// Set number of threads to use for searching & executing (default: number
513520
/// of available CPU cores)
514521
#[arg(long, short = 'j', value_name = "num", hide_short_help = true, value_parser = str::parse::<NonZeroUsize>)]

src/config.rs

+3
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ pub struct Config {
126126

127127
/// Whether or not to strip the './' prefix for search results
128128
pub strip_cwd_prefix: bool,
129+
130+
/// Whether or not to use hyperlinks on paths
131+
pub hyperlink: bool,
129132
}
130133

131134
impl Config {

src/hyperlink.rs

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use crate::filesystem::absolute_path;
2+
use std::fmt::{self, Formatter, Write};
3+
use std::path::{Path, PathBuf};
4+
use std::sync::OnceLock;
5+
6+
pub(crate) struct PathUrl(PathBuf);
7+
8+
#[cfg(unix)]
9+
static HOSTNAME: OnceLock<String> = OnceLock::new();
10+
11+
impl PathUrl {
12+
pub(crate) fn new(path: &Path) -> Option<PathUrl> {
13+
Some(PathUrl(absolute_path(path).ok()?))
14+
}
15+
}
16+
17+
impl fmt::Display for PathUrl {
18+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
19+
write!(f, "file://{}", host())?;
20+
let bytes = self.0.as_os_str().as_encoded_bytes();
21+
for &byte in bytes.iter() {
22+
encode(f, byte)?;
23+
}
24+
Ok(())
25+
}
26+
}
27+
28+
fn encode(f: &mut Formatter, byte: u8) -> fmt::Result {
29+
match byte {
30+
b'0'..=b'9'
31+
| b'A'..=b'Z'
32+
| b'a'..=b'z'
33+
| b'/'
34+
| b':'
35+
| b'-'
36+
| b'.'
37+
| b'_'
38+
| b'~'
39+
| 128.. => f.write_char(byte.into()),
40+
#[cfg(windows)]
41+
b'\\' => f.write_char('/'),
42+
_ => {
43+
write!(f, "%{:X}", byte)
44+
}
45+
}
46+
}
47+
48+
#[cfg(unix)]
49+
fn host() -> &'static str {
50+
HOSTNAME
51+
.get_or_init(|| {
52+
nix::unistd::gethostname()
53+
.ok()
54+
.and_then(|h| h.into_string().ok())
55+
.unwrap_or_default()
56+
})
57+
.as_ref()
58+
}
59+
60+
#[cfg(not(unix))]
61+
const fn host() -> &'static str {
62+
""
63+
}

src/main.rs

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod filesystem;
88
mod filetypes;
99
mod filter;
1010
mod fmt;
11+
mod hyperlink;
1112
mod output;
1213
mod regex_helper;
1314
mod walk;
@@ -258,6 +259,7 @@ fn construct_config(mut opts: Opts, pattern_regexps: &[String]) -> Result<Config
258259
threads: opts.threads().get(),
259260
max_buffer_time: opts.max_buffer_time,
260261
ls_colors,
262+
hyperlink: opts.hyperlink,
261263
interactive_terminal,
262264
file_types: opts.filetype.as_ref().map(|values| {
263265
use crate::cli::FileType::*;

src/output.rs

+13
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::dir_entry::DirEntry;
88
use crate::error::print_error;
99
use crate::exit_codes::ExitCode;
1010
use crate::fmt::FormatTemplate;
11+
use crate::hyperlink::PathUrl;
1112

1213
fn replace_path_separator(path: &str, new_path_separator: &str) -> String {
1314
path.replace(std::path::MAIN_SEPARATOR, new_path_separator)
@@ -83,9 +84,17 @@ fn print_entry_colorized<W: Write>(
8384
) -> io::Result<()> {
8485
// Split the path between the parent and the last component
8586
let mut offset = 0;
87+
let mut has_hyperlink = false;
8688
let path = entry.stripped_path(config);
8789
let path_str = path.to_string_lossy();
8890

91+
if config.hyperlink {
92+
if let Some(url) = PathUrl::new(entry.path()) {
93+
write!(stdout, "\x1B]8;;{}\x1B\\", url)?;
94+
has_hyperlink = true;
95+
}
96+
}
97+
8998
if let Some(parent) = path.parent() {
9099
offset = parent.to_string_lossy().len();
91100
for c in path_str[offset..].chars() {
@@ -123,6 +132,10 @@ fn print_entry_colorized<W: Write>(
123132
ls_colors.style_for_indicator(Indicator::Directory),
124133
)?;
125134

135+
if has_hyperlink {
136+
write!(stdout, "\x1B]8;;\x1B\\")?;
137+
}
138+
126139
if config.null_separator {
127140
write!(stdout, "\0")?;
128141
} else {

tests/testenv/mod.rs

+3
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ impl TestEnv {
316316
} else {
317317
cmd.arg("--no-global-ignore-file");
318318
}
319+
// Make sure LS_COLORS is unset to ensure consistent
320+
// color output
321+
cmd.env("LS_COLORS", "");
319322
cmd.args(args);
320323

321324
// Run *fd*.

tests/tests.rs

+18
Original file line numberDiff line numberDiff line change
@@ -2672,3 +2672,21 @@ fn test_gitignore_parent() {
26722672
te.assert_output_subdirectory("sub", &["--hidden"], "");
26732673
te.assert_output_subdirectory("sub", &["--hidden", "--search-path", "."], "");
26742674
}
2675+
2676+
#[test]
2677+
fn test_hyperlink() {
2678+
let te = TestEnv::new(DEFAULT_DIRS, DEFAULT_FILES);
2679+
2680+
#[cfg(unix)]
2681+
let hostname = nix::unistd::gethostname().unwrap().into_string().unwrap();
2682+
#[cfg(not(unix))]
2683+
let hostname = "";
2684+
2685+
let expected = format!(
2686+
"\x1b]8;;file://{}{}/a.foo\x1b\\a.foo\x1b]8;;\x1b\\",
2687+
hostname,
2688+
te.test_root().canonicalize().unwrap().to_str().unwrap(),
2689+
);
2690+
2691+
te.assert_output(&["--color=always", "--hyperlink", "a.foo"], &expected);
2692+
}

0 commit comments

Comments
 (0)