Skip to content

Commit

Permalink
Format files and commits as OSC 8 hyperlinks
Browse files Browse the repository at this point in the history
Closes #257
  • Loading branch information
dandavison committed Jul 22, 2020
1 parent 29bf022 commit b2257cf
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 23 deletions.
25 changes: 25 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub struct Config {
pub git_config_entries: HashMap<String, GitConfigEntry>,
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,
Expand Down Expand Up @@ -140,6 +142,8 @@ impl From<cli::Opt> 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,
Expand Down
51 changes: 43 additions & 8 deletions src/delta.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::io::BufRead;
use std::io::Write;

Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 ")
Expand Down Expand Up @@ -155,7 +158,11 @@ where
continue;
} else {
painter.emit()?;
writeln!(painter.writer, "{}", raw_line)?;
writeln!(
painter.writer,
"{}",
format::format_raw_line(&raw_line, config)
)?;
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
Expand Down
88 changes: 88 additions & 0 deletions src/features/hyperlinks.rs
Original file line number Diff line number Diff line change
@@ -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<usize>,
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)
}
40 changes: 34 additions & 6 deletions src/features/line_numbers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
));
}

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -178,18 +184,20 @@ 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;
self.hunk_plus_line_number = line_numbers[line_numbers.len() - 1].0;
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;
}
}

Expand Down Expand Up @@ -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<ansi_term::ANSIGenericString<'a, str>> {
let mut ansi_strings = Vec::new();
let mut suffix = "";
Expand All @@ -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!(),
Expand All @@ -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<usize>, 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<usize>,
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()),
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ pub fn make_builtin_features() -> HashMap<String, BuiltinFeature> {
"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(),
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit b2257cf

Please sign in to comment.