diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index cf1b550dc..54436396f 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -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 | diff --git a/helix-core/src/diff.rs b/helix-core/src/diff.rs index a5d6d7229..1d3bbe284 100644 --- a/helix-core/src/diff.rs +++ b/helix-core/src/diff.rs @@ -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::*; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b6182f8aa..b4fa25fe3 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -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}; @@ -2338,6 +2339,104 @@ fn reset_diff_change( Ok(()) } +fn get_diff_change_at_selection(editor: &mut Editor) -> anyhow::Result { + 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], + 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], + 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], @@ -3073,6 +3172,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: &[],