From b2257cfae7eacc73e47299d90d9a8d479b3e362c Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 18 Jul 2020 15:34:43 -0400 Subject: [PATCH] Format files and commits as OSC 8 hyperlinks Closes #257 --- src/cli.rs | 25 ++++++++++ src/config.rs | 4 ++ src/delta.rs | 51 +++++++++++++++++---- src/features/hyperlinks.rs | 88 ++++++++++++++++++++++++++++++++++++ src/features/line_numbers.rs | 40 +++++++++++++--- src/features/mod.rs | 5 ++ src/format.rs | 16 +++++++ src/git_config_entry.rs | 40 ++++++++++++++++ src/main.rs | 1 + src/options/set.rs | 14 ++++++ src/parse.rs | 31 +++++++++---- 11 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 src/features/hyperlinks.rs create mode 100644 src/format.rs create mode 100644 src/git_config_entry.rs diff --git a/src/cli.rs b/src/cli.rs index e62363cf5..92e76847a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -341,6 +341,31 @@ pub struct Opt { /// (overline), or the combination 'ul ol'. pub file_decoration_style: String, + #[structopt(long = "hyperlinks")] + /// Render commit hashes, file names, and line numbers as hyperlinks, according to the + /// hyperlink spec for terminal emulators: + /// https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda. By default, file names + /// and line numbers link to the local file using a file URL, whereas commit hashes link to the + /// commit in GitHub, if the remote repository is hosted by GitHub. See + /// --hyperlinks-file-link-format for full control over the file URLs emitted. Hyperlinks are + /// supported by several common terminal emulators. However, they are not yet supported by + /// less, so they will not work in delta unless you install a patched fork of less (see + /// https://github.com/dandavison/less). If you use tmux, then you will also need a patched + /// fork of tmux (see https://github.com/dandavison/tmux). + pub hyperlinks: bool, + + /// Format string for file hyperlinks. The placeholders "{path}" and "{line}" will be replaced + /// by the absolute file path and the line number, respectively. The default value of this + /// option creates hyperlinks using standard file URLs; your operating system should open these + /// in the application registered for that file type. However, these do not make use of the + /// line number. In order for the link to open the file at the correct line number, you could + /// use a custom URL format such as "file-line://{path}:{line_number}" and register an + /// application to handle the custom "file-line" URL scheme by opening the file in your + /// editor/IDE at the indicated line number. See https://github.com/dandavison/open-in-editor + /// for an example. + #[structopt(long = "hyperlinks-file-link-format", default_value = "file://{path}")] + pub hyperlinks_file_link_format: String, + #[structopt(long = "hunk-header-style", default_value = "syntax")] /// Style (foreground, background, attributes) for the hunk-header. See STYLES section. The /// style 'omit' can be used to remove the hunk header section from the output. diff --git a/src/config.rs b/src/config.rs index e29f668ca..58be4941d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,8 @@ pub struct Config { pub git_config_entries: HashMap, pub keep_plus_minus_markers: bool, pub hunk_header_style: Style, + pub hyperlinks: bool, + pub hyperlinks_file_link_format: String, pub max_buffered_lines: usize, pub max_line_distance: f64, pub max_line_distance_for_naively_paired_lines: f64, @@ -140,6 +142,8 @@ impl From for Config { git_config_entries: opt.git_config_entries, keep_plus_minus_markers: opt.keep_plus_minus_markers, hunk_header_style, + hyperlinks: opt.hyperlinks, + hyperlinks_file_link_format: opt.hyperlinks_file_link_format, max_buffered_lines: 32, max_line_distance: opt.max_line_distance, max_line_distance_for_naively_paired_lines, diff --git a/src/delta.rs b/src/delta.rs index c5ad0b419..803e36eb5 100644 --- a/src/delta.rs +++ b/src/delta.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::io::BufRead; use std::io::Write; @@ -7,6 +8,8 @@ use unicode_segmentation::UnicodeSegmentation; use crate::config::Config; use crate::draw; +use crate::features; +use crate::format; use crate::paint::Painter; use crate::parse; use crate::style::DecorationStyle; @@ -60,7 +63,7 @@ where { let mut painter = Painter::new(writer, config); let mut minus_file = "".to_string(); - let mut plus_file; + let mut plus_file = "".to_string(); let mut state = State::Unknown; let mut source = Source::Unknown; @@ -115,7 +118,7 @@ where painter.set_highlighter(); if should_handle(&state, config) { painter.emit()?; - handle_hunk_header_line(&mut painter, &line, &raw_line, config)?; + handle_hunk_header_line(&mut painter, &line, &raw_line, &plus_file, config)?; continue; } } else if source == Source::DiffUnified && line.starts_with("Only in ") @@ -155,7 +158,11 @@ where continue; } else { painter.emit()?; - writeln!(painter.writer, "{}", raw_line)?; + writeln!( + painter.writer, + "{}", + format::format_raw_line(&raw_line, config) + )?; } } @@ -240,10 +247,25 @@ fn handle_commit_meta_header_line( draw::write_no_decoration } }; + let (formatted_line, formatted_raw_line) = if config.hyperlinks { + ( + Cow::from( + features::hyperlinks::format_commit_line_with_osc8_commit_hyperlink(line, config), + ), + Cow::from( + features::hyperlinks::format_commit_line_with_osc8_commit_hyperlink( + raw_line, config, + ), + ), + ) + } else { + (Cow::from(line), Cow::from(raw_line)) + }; + draw_fn( painter.writer, - &format!("{}{}", line, if pad { " " } else { "" }), - &format!("{}{}", raw_line, if pad { " " } else { "" }), + &format!("{}{}", formatted_line, if pad { " " } else { "" }), + &format!("{}{}", formatted_raw_line, if pad { " " } else { "" }), &config.decorations_width, config.commit_style, decoration_ansi_term_style, @@ -332,6 +354,7 @@ fn handle_hunk_header_line( painter: &mut Painter, line: &str, raw_line: &str, + plus_file: &str, config: &Config, ) -> std::io::Result<()> { if config.hunk_header_style.is_omitted { @@ -425,16 +448,28 @@ fn handle_hunk_header_line( }; // Emit a single line number, or prepare for full line-numbering if config.line_numbers { - painter.line_numbers_data.initialize_hunk(line_numbers); + painter + .line_numbers_data + .initialize_hunk(line_numbers, plus_file.to_string()); } else { let plus_line_number = line_numbers[line_numbers.len() - 1].0; + let formatted_plus_line_number = if config.hyperlinks { + features::hyperlinks::format_osc8_file_hyperlink( + plus_file, + Some(plus_line_number), + &format!("{}", plus_line_number), + config, + ) + } else { + Cow::from(format!("{}", plus_line_number)) + }; match config.hunk_header_style.decoration_ansi_term_style() { Some(style) => writeln!( painter.writer, "{}", - style.paint(format!("{}", plus_line_number)) + style.paint(formatted_plus_line_number) )?, - None => writeln!(painter.writer, "{}", plus_line_number)?, + None => writeln!(painter.writer, "{}", formatted_plus_line_number)?, } } Ok(()) diff --git a/src/features/hyperlinks.rs b/src/features/hyperlinks.rs new file mode 100644 index 000000000..8fc58e9f8 --- /dev/null +++ b/src/features/hyperlinks.rs @@ -0,0 +1,88 @@ +use std::borrow::Cow; + +use lazy_static::lazy_static; +use regex::{Captures, Regex}; + +use crate::config::Config; +use crate::features::OptionValueFunction; +use crate::git_config_entry::{GitConfigEntry, GitRemoteRepo}; + +pub fn make_feature() -> Vec<(String, OptionValueFunction)> { + builtin_feature!([ + ( + "hyperlinks", + bool, + None, + _opt => true + ) + ]) +} + +pub fn format_commit_line_with_osc8_commit_hyperlink<'a>( + line: &'a str, + config: &Config, +) -> Cow<'a, str> { + if let Some(GitConfigEntry::GitRemote(GitRemoteRepo::GitHubRepo(repo))) = + config.git_config_entries.get("remote.origin.url") + { + COMMIT_LINE_REGEX.replace(line, |captures: &Captures| { + format_commit_line_captures_with_osc8_commit_hyperlink(captures, repo) + }) + } else { + Cow::from(line) + } +} + +/// Create a file hyperlink to `path`, displaying `text`. +pub fn format_osc8_file_hyperlink<'a>( + relative_path: &'a str, + line_number: Option, + text: &str, + config: &Config, +) -> Cow<'a, str> { + if let Some(GitConfigEntry::Path(workdir)) = config.git_config_entries.get("delta.__workdir__") + { + let absolute_path = workdir.join(relative_path); + let mut url = config + .hyperlinks_file_link_format + .replace("{path}", &absolute_path.to_string_lossy()); + if let Some(n) = line_number { + url = url.replace("{line}", &format!("{}", n)) + } else { + url = url.replace("{line}", "") + }; + Cow::from(format!( + "{osc}8;;{url}{st}{text}{osc}8;;{st}", + url = url, + text = text, + osc = "\x1b]", + st = "\x1b\\" + )) + } else { + Cow::from(relative_path) + } +} + +lazy_static! { + static ref COMMIT_LINE_REGEX: Regex = Regex::new("(.* )([0-9a-f]{40})(.*)").unwrap(); +} + +fn format_commit_line_captures_with_osc8_commit_hyperlink<'a, 'b>( + captures: &'a Captures, + github_repo: &'b str, +) -> String { + let commit = captures.get(2).unwrap().as_str(); + format!( + "{prefix}{osc}8;;{url}{st}{commit}{osc}8;;{st}{suffix}", + url = format_github_commit_url(commit, github_repo), + commit = commit, + prefix = captures.get(1).unwrap().as_str(), + suffix = captures.get(3).unwrap().as_str(), + osc = "\x1b]", + st = "\x1b\\" + ) +} + +fn format_github_commit_url(commit: &str, github_repo: &str) -> String { + format!("https://github.com/{}/commit/{}", github_repo, commit) +} diff --git a/src/features/line_numbers.rs b/src/features/line_numbers.rs index b1d9b5b00..1543f46dc 100644 --- a/src/features/line_numbers.rs +++ b/src/features/line_numbers.rs @@ -6,6 +6,7 @@ use regex::Regex; use crate::config; use crate::delta::State; +use crate::features::hyperlinks; use crate::features::side_by_side; use crate::features::OptionValueFunction; use crate::style::Style; @@ -112,6 +113,8 @@ pub fn format_and_paint_line_numbers<'a>( line_numbers_data.hunk_max_line_number_width, &minus_style, &plus_style, + &line_numbers_data.plus_file, + config, )); } @@ -124,6 +127,8 @@ pub fn format_and_paint_line_numbers<'a>( line_numbers_data.hunk_max_line_number_width, &minus_style, &plus_style, + &line_numbers_data.plus_file, + config, )); } formatted_numbers @@ -155,6 +160,7 @@ pub struct LineNumbersData<'a> { pub hunk_minus_line_number: usize, pub hunk_plus_line_number: usize, pub hunk_max_line_number_width: usize, + pub plus_file: String, } // Although it's probably unusual, a single format string can contain multiple placeholders. E.g. @@ -178,11 +184,12 @@ impl<'a> LineNumbersData<'a> { hunk_minus_line_number: 0, hunk_plus_line_number: 0, hunk_max_line_number_width: 0, + plus_file: "".to_string(), } } /// Initialize line number data for a hunk. - pub fn initialize_hunk(&mut self, line_numbers: Vec<(usize, usize)>) { + pub fn initialize_hunk(&mut self, line_numbers: Vec<(usize, usize)>, plus_file: String) { // Typically, line_numbers has length 2: an entry for the minus file, and one for the plus // file. In the case of merge commits, it may be longer. self.hunk_minus_line_number = line_numbers[0].0; @@ -190,6 +197,7 @@ impl<'a> LineNumbersData<'a> { let hunk_max_line_number = line_numbers.iter().map(|(n, d)| n + d).max().unwrap(); self.hunk_max_line_number_width = 1 + (hunk_max_line_number as f64).log10().floor() as usize; + self.plus_file = plus_file; } } @@ -233,6 +241,8 @@ fn format_and_paint_line_number_field<'a>( min_field_width: usize, minus_number_style: &Style, plus_number_style: &Style, + plus_file: &str, + config: &config::Config, ) -> Vec> { let mut ansi_strings = Vec::new(); let mut suffix = ""; @@ -251,11 +261,15 @@ fn format_and_paint_line_number_field<'a>( minus_number, alignment_spec, width, + None, + config, ))), Some("np") => ansi_strings.push(plus_number_style.paint(format_line_number( plus_number, alignment_spec, width, + Some(plus_file), + config, ))), None => {} Some(_) => unreachable!(), @@ -267,15 +281,29 @@ fn format_and_paint_line_number_field<'a>( } /// Return line number formatted according to `alignment` and `width`. -fn format_line_number(line_number: Option, alignment: &str, width: usize) -> String { - let n = line_number - .map(|n| format!("{}", n)) - .unwrap_or_else(|| "".to_string()); - match alignment { +fn format_line_number( + line_number: Option, + alignment: &str, + width: usize, + plus_file: Option<&str>, + config: &config::Config, +) -> String { + let format_n = |n| match alignment { "<" => format!("{0:<1$}", n, width), "^" => format!("{0:^1$}", n, width), ">" => format!("{0:>1$}", n, width), _ => unreachable!(), + }; + match (line_number, config.hyperlinks, plus_file) { + (None, _, _) => format_n(""), + (Some(n), true, Some(file)) => hyperlinks::format_osc8_file_hyperlink( + file, + line_number, + &format_n(&n.to_string()), + config, + ) + .to_string(), + (Some(n), _, _) => format_n(&n.to_string()), } } diff --git a/src/features/mod.rs b/src/features/mod.rs index 58599ea6b..67dbd6ba5 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -38,6 +38,10 @@ pub fn make_builtin_features() -> HashMap { "diff-so-fancy".to_string(), diff_so_fancy::make_feature().into_iter().collect(), ), + ( + "hyperlinks".to_string(), + hyperlinks::make_feature().into_iter().collect(), + ), ( "line-numbers".to_string(), line_numbers::make_feature().into_iter().collect(), @@ -81,6 +85,7 @@ macro_rules! builtin_feature { pub mod color_only; pub mod diff_highlight; pub mod diff_so_fancy; +pub mod hyperlinks; pub mod line_numbers; pub mod navigate; pub mod raw; diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 000000000..ed368c0d7 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,16 @@ +use std::borrow::Cow; + +use atty; + +use crate::config::Config; +use crate::features; + +/// If output is going to a tty, emit hyperlinks if requested. +// Although raw output should basically be emitted unaltered, we do this. +pub fn format_raw_line<'a>(line: &'a str, config: &Config) -> Cow<'a, str> { + if config.hyperlinks && atty::is(atty::Stream::Stdout) { + features::hyperlinks::format_commit_line_with_osc8_commit_hyperlink(line, config) + } else { + Cow::from(line) + } +} diff --git a/src/git_config_entry.rs b/src/git_config_entry.rs new file mode 100644 index 000000000..f6a594002 --- /dev/null +++ b/src/git_config_entry.rs @@ -0,0 +1,40 @@ +use std::path::PathBuf; +use std::result::Result; +use std::str::FromStr; + +use lazy_static::lazy_static; +use regex::Regex; + +use crate::errors::*; +use crate::style::Style; + +#[derive(Clone, Debug)] +pub enum GitConfigEntry { + Style(Style), + GitRemote(GitRemoteRepo), + Path(PathBuf), +} + +#[derive(Clone, Debug)] +pub enum GitRemoteRepo { + GitHubRepo(String), +} + +lazy_static! { + static ref GITHUB_REMOTE_URL: Regex = Regex::new(r"github\.com[:/]([^/]+)/(.+)\.git").unwrap(); +} + +impl FromStr for GitRemoteRepo { + type Err = Error; + fn from_str(s: &str) -> Result { + if let Some(caps) = GITHUB_REMOTE_URL.captures(s) { + Ok(Self::GitHubRepo(format!( + "{user}/{repo}", + user = caps.get(1).unwrap().as_str(), + repo = caps.get(2).unwrap().as_str() + ))) + } else { + Err("Not a GitHub repo.".into()) + } + } +} diff --git a/src/main.rs b/src/main.rs index 630934172..f66bf4895 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod draw; mod edits; mod env; mod features; +mod format; mod git_config; mod git_config_entry; mod options; diff --git a/src/options/set.rs b/src/options/set.rs index 8d52938d6..6be35bf67 100644 --- a/src/options/set.rs +++ b/src/options/set.rs @@ -127,6 +127,8 @@ pub fn set_options( file_style, hunk_header_decoration_style, hunk_header_style, + hyperlinks, + hyperlinks_file_link_format, keep_plus_minus_markers, max_line_distance, // Hack: minus-style must come before minus-*emph-style because the latter default @@ -296,6 +298,9 @@ fn gather_features<'a>( if opt.diff_so_fancy { gather_builtin_features_recursively("diff-so-fancy", &mut features, &builtin_features, opt); } + if opt.hyperlinks { + gather_builtin_features_recursively("hyperlinks", &mut features, &builtin_features, opt); + } if opt.line_numbers { gather_builtin_features_recursively("line-numbers", &mut features, &builtin_features, opt); } @@ -523,6 +528,15 @@ fn set_git_config_entries(opt: &mut cli::Opt, git_config: &mut git_config::GitCo } } } + + if let Some(repo) = &git_config.repo { + if let Some(workdir) = repo.workdir() { + opt.git_config_entries.insert( + "delta.__workdir__".to_string(), + GitConfigEntry::Path(workdir.to_path_buf()), + ); + } + } } #[cfg(test)] diff --git a/src/parse.rs b/src/parse.rs index 6899345b4..1e221b45d 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -1,8 +1,10 @@ use lazy_static::lazy_static; use regex::Regex; +use std::borrow::Cow; use std::path::Path; use crate::config::Config; +use crate::features; // https://git-scm.com/docs/git-config#Documentation/git-config.txt-diffmnemonicPrefix const DIFF_PREFIXES: [&str; 6] = ["a/", "b/", "c/", "i/", "o/", "w/"]; @@ -67,23 +69,34 @@ pub fn get_file_change_description_from_file_paths( "".to_string() } }; + let format_file = |file| { + if config.hyperlinks { + features::hyperlinks::format_osc8_file_hyperlink(file, None, file, config) + } else { + Cow::from(file) + } + }; match (minus_file, plus_file) { (minus_file, plus_file) if minus_file == plus_file => format!( "{}{}", format_label(&config.file_modified_label), - minus_file + format_file(minus_file) + ), + (minus_file, "/dev/null") => format!( + "{}{}", + format_label(&config.file_removed_label), + format_file(minus_file) + ), + ("/dev/null", plus_file) => format!( + "{}{}", + format_label(&config.file_added_label), + format_file(plus_file) ), - (minus_file, "/dev/null") => { - format!("{}{}", format_label(&config.file_removed_label), minus_file) - } - ("/dev/null", plus_file) => { - format!("{}{}", format_label(&config.file_added_label), plus_file) - } (minus_file, plus_file) => format!( "{}{} ⟶ {}", format_label(&config.file_renamed_label), - minus_file, - plus_file + format_file(minus_file), + format_file(plus_file) ), } }