Skip to content

Commit

Permalink
Adds two new typeable commands: show-selection-diff-popup and `yank…
Browse files Browse the repository at this point in the history
…-selection-diff`

These are similar to the `reset-diff-change` command, except that rather
wthan apply the diff base, the unsaved buffer changes (in `diff` syntax)
are shown in a popup (with `show-selection-diff-popup`) or yanked to a
register (using `yank-selection-diff`).

This uses the new `hunks_intersecting_line_ranges` method from helix-editor#10178,
so probably that PR should be merged first before considering merging
this one.
  • Loading branch information
mandx committed Apr 30, 2024
1 parent d8701bf commit 1bca24c
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 0 deletions.
2 changes: 2 additions & 0 deletions book/src/generated/typable-cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@
| `:pipe-to` | Pipe each selection to the shell command, ignoring output. |
| `:run-shell-command`, `:sh` | Run a shell command |
| `:reset-diff-change`, `:diffget`, `:diffg` | Reset the diff change at the cursor position. |
| `:show-selection-diff-popup`, `:diffshow` | Show a popup with the unsaved diff hunks intersecting the primary selection. |
| `:yank-selection-diff`, `:diffyank` | Yank the unsaved diff hunks intersecting the primary selection. |
| `:clear-register` | Clear given register. If no argument is provided, clear all registers. |
| `:redraw` | Clear and re-render the whole UI |
| `:move` | Move the current buffer and its corresponding file to a different path |
Expand Down
14 changes: 14 additions & 0 deletions helix-core/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,20 @@ pub fn compare_ropes(before: &Rope, after: &Rope) -> Transaction {
res
}

/// Compares `old` and `new` to generate a text diff
pub fn diff_ropes(before: RopeSlice, after: RopeSlice) -> String {
let file = InternedInput::new(RopeLines(before), RopeLines(after));
imara_diff::diff(
Algorithm::Histogram,
&file,
imara_diff::UnifiedDiffBuilder::new(&file),
)
// Unsure why we get empty lines...
.split_inclusive('\n')
.filter(|line| !line.trim().is_empty())
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
120 changes: 120 additions & 0 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::job::Job;

use super::*;

use helix_core::diff::diff_ropes;
use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT;
use helix_core::{line_ending, shellwords::Shellwords};
Expand Down Expand Up @@ -2339,6 +2340,111 @@ fn reset_diff_change(
Ok(())
}

fn get_diff_change_at_selection(editor: &mut Editor) -> anyhow::Result<String> {
let (view, doc) = current!(editor);
let Some(handle) = doc.diff_handle() else {
bail!("Diff is not available in the current buffer")
};

let diff = handle.load();
let doc_text = doc.text().slice(..);
let primary_selection = doc.selection(view.id).primary();

let Some((base_start, base_end, doc_start, doc_end)) = diff
.hunks_intersecting_line_ranges(
[primary_selection.line_range(doc_text)].into_iter(),
)
.fold(None, |line_ranges, hunk| {
match line_ranges {
Some((base_start, base_end, doc_start, doc_end)) => {
Some((
hunk.before.start.min(base_start),
hunk.before.end.max(base_end),
hunk.after.start.min(doc_start),
hunk.after.end.max(doc_end),
))
},
None => {
Some((
hunk.before.start,
hunk.before.end,
hunk.after.start,
hunk.after.end,
))}
}
},
) else {
bail!("There are no changes in the primary selection");
};

let base = diff.diff_base();
let doc = diff.doc();
Ok(diff_ropes(
base.slice(
base.line_to_char(base_start as usize)..base.line_to_char((base_end as usize) + 1) - 1,
),
doc.slice(
doc.line_to_char(doc_start as usize)..doc.line_to_char((doc_end as usize) + 1) - 1,
),
))
}

fn show_selection_diff_popup(
cx: &mut compositor::Context,
args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}
ensure!(
args.is_empty(),
"show-selection-diff-popup takes no arguments"
);

let text = get_diff_change_at_selection(cx.editor)?;

let callback = async move {
let call: job::Callback = Callback::EditorCompositor(Box::new(
move |editor: &mut Editor, compositor: &mut Compositor| {
let contents =
ui::Markdown::new(format!("```diff\n{}```", text), editor.syn_loader.clone());
let popup = Popup::new("show-selection-diff-popup", contents).auto_close(true);
compositor.replace_or_push("show-selection-diff-popup", popup);
},
));
Ok(call)
};
cx.jobs.callback(callback);
Ok(())
}

fn yank_selection_diff(
cx: &mut compositor::Context,
args: &[Cow<str>],
event: PromptEvent,
) -> anyhow::Result<()> {
if event != PromptEvent::Validate {
return Ok(());
}

let reg = match args.first() {
Some(s) => {
ensure!(s.chars().count() == 1, format!("Invalid register {s}"));
s.chars().next().unwrap()
}
None => '+',
};

let text = get_diff_change_at_selection(cx.editor)?;

cx.editor.registers.write(reg, vec![text])?;
cx.editor.set_status(format!(
"Yanked diff changes in the primary selection to register {reg}",
));
Ok(())
}

fn clear_register(
cx: &mut compositor::Context,
args: &[Cow<str>],
Expand Down Expand Up @@ -3074,6 +3180,20 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
fun: reset_diff_change,
signature: CommandSignature::none(),
},
TypableCommand {
name: "show-selection-diff-popup",
aliases: &["diffshow"],
doc: "Show a popup with the unsaved diff hunks intersecting the primary selection.",
fun: show_selection_diff_popup,
signature: CommandSignature::none(),
},
TypableCommand {
name: "yank-selection-diff",
aliases: &["diffyank"],
doc: "Yank the unsaved diff hunks intersecting the primary selection.",
fun: yank_selection_diff,
signature: CommandSignature::all(completers::register),
},
TypableCommand {
name: "clear-register",
aliases: &[],
Expand Down
56 changes: 56 additions & 0 deletions helix-vcs/src/diff.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::iter::Peekable;
use std::ops::Range;
use std::sync::Arc;

Expand Down Expand Up @@ -259,6 +260,22 @@ impl Diff<'_> {
}
}

/// Iterates over all hunks that intersect with the given line ranges.
///
/// Hunks are returned at most once even when intersecting with multiple of the line
/// ranges.
pub fn hunks_intersecting_line_ranges<I>(&self, line_ranges: I) -> impl Iterator<Item = &Hunk>
where
I: Iterator<Item = (usize, usize)>,
{
HunksInLineRangesIter {
hunks: &self.diff.hunks,
line_ranges: line_ranges.peekable(),
inverted: self.inverted,
cursor: 0,
}
}

pub fn hunk_at(&self, line: u32, include_removal: bool) -> Option<u32> {
let hunk_range = if self.inverted {
|hunk: &Hunk| hunk.before.clone()
Expand Down Expand Up @@ -290,3 +307,42 @@ impl Diff<'_> {
}
}
}

pub struct HunksInLineRangesIter<'a, I: Iterator<Item = (usize, usize)>> {
hunks: &'a [Hunk],
line_ranges: Peekable<I>,
inverted: bool,
cursor: usize,
}

impl<'a, I: Iterator<Item = (usize, usize)>> Iterator for HunksInLineRangesIter<'a, I> {
type Item = &'a Hunk;

fn next(&mut self) -> Option<Self::Item> {
let hunk_range = if self.inverted {
|hunk: &Hunk| hunk.before.clone()
} else {
|hunk: &Hunk| hunk.after.clone()
};

loop {
let (start_line, end_line) = self.line_ranges.peek()?;
let hunk = self.hunks.get(self.cursor)?;

if (hunk_range(hunk).end as usize) < *start_line {
// If the hunk under the cursor comes before this range, jump the cursor
// ahead to the next hunk that overlaps with the line range.
self.cursor += self.hunks[self.cursor..]
.partition_point(|hunk| (hunk_range(hunk).end as usize) < *start_line);
} else if (hunk_range(hunk).start as usize) <= *end_line {
// If the hunk under the cursor overlaps with this line range, emit it
// and move the cursor up so that the hunk cannot be emitted twice.
self.cursor += 1;
return Some(hunk);
} else {
// Otherwise, go to the next line range.
self.line_ranges.next();
}
}
}
}

0 comments on commit 1bca24c

Please sign in to comment.