diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 2e869e31..5a58e197 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -3,11 +3,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui::{ - AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, Pixels, SharedString, - Window, div, prelude::*, px, + AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, Pixels, SharedString, Window, div, prelude::*, px, }; use gpui_component::button::Button; -use gpui_component::menu::ContextMenuExt as _; +use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use crate::core::config::{Config, DiffViewMode}; @@ -22,7 +22,7 @@ use crate::terminal::git_diff::{ /// line budget below cuts rendering long before this does anyway. const MAX_PREVIEW_BYTES: u64 = 4 * 1024 * 1024; use crate::ui::app::Tty7App; -use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows}; +use crate::ui::diff_rows::{DiffSelection, HunkRows, RowId, Side, SplitCell, SplitRow, UnifiedRow}; use crate::ui::document_column::DocumentChrome; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::info_chip; @@ -57,6 +57,15 @@ pub(crate) struct DiffOverlayState { pub(crate) preview: Option<(String, Option>)>, pub(crate) preview_loading: Option, pub(crate) scroll: gpui::ScrollHandle, + /// The rows the pointer has dragged over, and whether it is still down. + /// + /// A diff is read in order to be copied out of, and until this existed the + /// text on screen was unreachable — no selection, no clipboard, nothing + /// but retyping it (#721). Line-granular on purpose: the rows are a grid + /// of independent elements, not one text run, so a range of them is the + /// selection this layout can honestly offer. + pub(crate) selection: Option, + pub(crate) selecting: bool, /// The [`ScmData`](crate::terminal::git_data::ScmData) epoch this patch was /// read at, for the two sources that can go stale. /// @@ -67,22 +76,24 @@ pub(crate) struct DiffOverlayState { pub(crate) epoch: Option, } -/// One hunk, already turned into whichever kind of row the current view draws. -enum HunkRows { - Split(Vec), - Unified(Vec), +/// What one drawn row needs to take part in a drag: the card it belongs to, +/// the view that drew it, and the selection it may already be inside. +#[derive(Clone, Copy)] +struct RowSelect<'a> { + path: &'a SharedString, + mode: DiffViewMode, + /// Only ever the selection belonging to *this* card — filtered once, where + /// the card is drawn, rather than re-compared on every row. + sel: Option<&'a DiffSelection>, + /// Whether a drag is in flight. Rows only listen for pointer movement + /// while one is: a diff runs to hundreds of rows, and a listener each is + /// worth paying for during a drag and not otherwise. + selecting: bool, } -impl HunkRows { - fn len(&self) -> usize { - match self { - HunkRows::Split(rows) => rows.len(), - HunkRows::Unified(rows) => rows.len(), - } - } - - fn is_empty(&self) -> bool { - self.len() == 0 +impl RowSelect<'_> { + fn covers(&self, id: RowId, side: Option) -> bool { + self.sel.is_some_and(|sel| sel.covers(self.path, id, side)) } } @@ -154,6 +165,10 @@ impl Tty7App { } Some(o) => { o.focus = focus; + // Another file is on screen now; the range belonged to the + // one that left. + o.selection = None; + o.selecting = false; let handle = o.focus_handle.clone(); window.focus(&handle, cx); cx.notify(); @@ -181,6 +196,8 @@ impl Tty7App { preview: None, preview_loading: None, scroll: gpui::ScrollHandle::new(), + selection: None, + selecting: false, epoch: None, }); window.focus(&focus_handle, cx); @@ -215,6 +232,177 @@ impl Tty7App { } } + /// Wire one drawn row into the drag: a press starts a selection there, and + /// while one is in flight a move across the row extends it. + fn diff_row_drag( + &self, + el: E, + rowsel: RowSelect, + side: Option, + id: RowId, + cx: &Context, + ) -> E { + let mode = rowsel.mode; + // The I-beam is the only standing sign that this text can be taken; + // nothing else about a row says so until one is dragged. + let el = el.cursor_text().on_mouse_down(MouseButton::Left, { + let path = rowsel.path.clone(); + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + this.start_diff_selection(&path, mode, side, id, window, cx); + }) + }); + if !rowsel.selecting { + return el; + } + el.on_mouse_move({ + let path = rowsel.path.clone(); + cx.listener(move |this, ev: &MouseMoveEvent, _window, cx| { + this.extend_diff_selection(&path, side, id, ev.pressed_button, cx); + }) + }) + } + + /// Begin a drag at `id`, in the column it was pressed in. + fn start_diff_selection( + &mut self, + path: &SharedString, + mode: DiffViewMode, + side: Option, + id: RowId, + window: &mut Window, + cx: &mut Context, + ) { + let active = self.active; + let Some(overlay) = self + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + else { + return; + }; + overlay.selection = Some(DiffSelection { + path: path.to_string(), + mode, + side, + anchor: id, + head: id, + }); + overlay.selecting = true; + let handle = overlay.focus_handle.clone(); + // Copying needs the overlay to hold the keyboard. Docked beside a + // shell it often does not, and Ctrl+C would otherwise reach the pane + // and interrupt whatever is running in it. + window.focus(&handle, cx); + cx.notify(); + } + + /// Extend the drag in flight to `id`. + /// + /// `held` is what the pointer is still pressing. A move with nothing held + /// means the button came up somewhere the overlay never saw — over a pane, + /// or outside the window — so the drag ends here rather than resuming the + /// next time the pointer wanders back over a row. + fn extend_diff_selection( + &mut self, + path: &SharedString, + side: Option, + id: RowId, + held: Option, + cx: &mut Context, + ) { + let active = self.active; + let Some(overlay) = self + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + else { + return; + }; + if !overlay.selecting { + return; + } + if held != Some(MouseButton::Left) { + overlay.selecting = false; + cx.notify(); + return; + } + let Some(sel) = overlay.selection.as_mut() else { + return; + }; + if sel.path != path.as_ref() || sel.side != side || sel.head == id { + return; + } + sel.head = id; + cx.notify(); + } + + fn end_diff_selection(&mut self, cx: &mut Context) { + let active = self.active; + if let Some(overlay) = self + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + && overlay.selecting + { + overlay.selecting = false; + cx.notify(); + } + } + + /// Forget a selection the rows under it no longer answer to. The two views + /// pair the same lines differently, so a row range drawn in one of them + /// points at other code in the other. + fn drop_stale_diff_selection(&mut self, mode: DiffViewMode) { + let active = self.active; + let Some(overlay) = self + .tabs + .get_mut(active) + .and_then(|t| t.diff_overlay.as_mut()) + else { + return; + }; + if overlay.selection.as_ref().is_some_and(|s| s.mode != mode) { + overlay.selection = None; + overlay.selecting = false; + } + } + + /// Put the selected rows on the clipboard, as the file spells them. + fn copy_diff_selection(&self, cx: &mut Context) { + let Some(overlay) = self + .tabs + .get(self.active) + .and_then(|t| t.diff_overlay.as_ref()) + else { + return; + }; + let Some(sel) = overlay.selection.as_ref() else { + return; + }; + let hunks = match &overlay.load { + DiffLoad::Ready(snap) => snap + .files + .iter() + .find(|f| f.path == sel.path) + .map(|f| f.hunks.as_slice()), + _ => None, + } + // An untracked file has no patch in the snapshot — its card is + // synthesized from the file's own bytes, and so are its rows. + .or_else(|| match &overlay.preview { + Some((held, Some(file))) if *held == sel.path => Some(file.hunks.as_slice()), + _ => None, + }); + let Some(hunks) = hunks else { + return; + }; + let text = sel.text(hunks); + if text.is_empty() { + return; + } + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + } + fn spawn_diff_probe(&mut self, cx: &mut Context) { let active = self.active; let Some(overlay) = self.tabs.get(active).and_then(|t| t.diff_overlay.as_ref()) else { @@ -335,6 +523,10 @@ impl Tty7App { // A new snapshot restarts any untracked preview: the file may // have changed with the tree, and the re-read costs one file. overlay.preview = None; + // The rows it was drawn against are gone. A range that survived + // would keep its coordinates and quietly cover other code. + overlay.selection = None; + overlay.selecting = false; landed = true; } if landed { @@ -395,6 +587,7 @@ impl Tty7App { cx: &mut Context, ) -> Option { self.spawn_untracked_preview_if_needed(cx); + self.drop_stale_diff_selection(view_mode(cx)); let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?; let content = match &overlay.load { @@ -464,7 +657,22 @@ impl Tty7App { if ev.keystroke.key.as_str() == "escape" { this.close_diff_overlay(window, cx); } + // The overlay takes focus when a row is dragged, so this is + // the copy key for the selection that drag made — and only + // then: with nothing selected it falls through to whatever + // else the window binds it to. + let mods = ev.keystroke.modifiers; + if ev.keystroke.key.as_str() == "c" && mods.secondary() && !mods.alt { + this.copy_diff_selection(cx); + } })) + // A drag that ends anywhere in the overlay ends here; one that + // ends outside it is caught by the next move over a row, which + // sees no button held. + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _window, cx| this.end_diff_selection(cx)), + ) .children(header) .child(content) .into_any_element(), @@ -1026,17 +1234,27 @@ impl Tty7App { .child(header); if has_body { - let mut body = v_flex().w_full(); + let (selection, selecting) = match self + .tabs + .get(self.active) + .and_then(|t| t.diff_overlay.as_ref()) + { + Some(o) => (o.selection.as_ref(), o.selecting), + None => (None, false), + }; + let path: SharedString = file.path.clone().into(); + let rowsel = RowSelect { + // Only the card the drag started in draws or extends it. + sel: selection.filter(|s| s.path == file.path), + path: &path, + mode, + selecting, + }; + let mut body = v_flex().id(("diff-card-body", idx)).w_full(); let hunks: Vec<_> = file .hunks .iter() - .map(|hunk| { - let rows = match mode { - DiffViewMode::Split => HunkRows::Split(split_hunk(&hunk.lines)), - DiffViewMode::Unified => HunkRows::Unified(unified_rows(&hunk.lines)), - }; - (hunk, rows) - }) + .map(|hunk| (hunk, HunkRows::build(mode, &hunk.lines))) .collect(); let closing_row = if file.truncated.is_some() { None @@ -1062,8 +1280,11 @@ impl Tty7App { match rows { HunkRows::Split(rows) => { for (r, row) in rows.iter().enumerate() { + let id = RowId { hunk: h, row: r }; body = body.child(self.diff_split_row( row, + id, + rowsel, closing_row == Some((h, r)), cx, )); @@ -1071,8 +1292,11 @@ impl Tty7App { } HunkRows::Unified(rows) => { for (r, row) in rows.iter().enumerate() { + let id = RowId { hunk: h, row: r }; body = body.child(self.diff_unified_row( row, + id, + rowsel, closing_row == Some((h, r)), cx, )); @@ -1098,12 +1322,35 @@ impl Tty7App { .child(note), ); } - card = card.child(body); + // The one place a copy is offered by name. A drag says what will + // be copied; the menu says that copying is a thing you can do. + card = card.child(match rowsel.sel.is_some() { + true => { + let app = cx.entity().downgrade(); + body.context_menu(move |menu, _window, _cx| { + menu.item(PopupMenuItem::new(t(L10nKey::DiffCopySelection)).on_click({ + let app = app.clone(); + move |_, _window, cx| { + app.update(cx, |this, cx| this.copy_diff_selection(cx)).ok(); + } + })) + }) + .into_any_element() + } + false => body.into_any_element(), + }); } card.into_any_element() } - fn diff_split_row(&self, row: &SplitRow, closes_card: bool, cx: &Context) -> AnyElement { + fn diff_split_row( + &self, + row: &SplitRow, + id: RowId, + rowsel: RowSelect, + closes_card: bool, + cx: &Context, + ) -> AnyElement { let radius = if closes_card { rounding::inner_radius(rounding::CARD_RADIUS, rounding::HAIRLINE) } else { @@ -1115,9 +1362,9 @@ impl Tty7App { .items_stretch() .text_xs() .font_family(self.font_family.clone()) - .child(self.diff_split_cell(row.left.as_ref(), Side::Old, radius, cx)) + .child(self.diff_split_cell(row.left.as_ref(), Side::Old, id, rowsel, radius, cx)) .child(div().flex_shrink_0().w(px(1.)).bg(cx.theme().border)) - .child(self.diff_split_cell(row.right.as_ref(), Side::New, radius, cx)) + .child(self.diff_split_cell(row.right.as_ref(), Side::New, id, rowsel, radius, cx)) .into_any_element() } @@ -1125,6 +1372,8 @@ impl Tty7App { &self, cell: Option<&SplitCell>, side: Side, + id: RowId, + rowsel: RowSelect, outer_radius: Pixels, cx: &Context, ) -> AnyElement { @@ -1141,7 +1390,16 @@ impl Tty7App { (true, Side::New) => ("+", Some(cx.theme().success.opacity(0.12))), (false, _) => (" ", None), }; - base.when_some(tint, |row, bg| row.bg(bg)) + // A selected cell wears the theme's selection colour in place of its + // own wash, the way selected text does anywhere else. The `+`/`−` in + // front of the code still says which side of the change it is. + let selected = rowsel.covers(id, Some(side)); + let fill = match selected { + true => Some(cx.theme().selection), + false => tint, + }; + self.diff_row_drag(base, rowsel, Some(side), id, cx) + .when_some(fill, |row, bg| row.bg(bg)) .child( h_flex() .flex_shrink_0() @@ -1178,6 +1436,8 @@ impl Tty7App { fn diff_unified_row( &self, row: &UnifiedRow, + id: RowId, + rowsel: RowSelect, closes_card: bool, cx: &Context, ) -> AnyElement { @@ -1200,7 +1460,11 @@ impl Tty7App { .text_color(cx.theme().muted_foreground.opacity(0.7)) .child(no.map(|n| n.to_string()).unwrap_or_default()) }; - h_flex() + let fill = match rowsel.covers(id, None) { + true => Some(cx.theme().selection), + false => tint, + }; + self.diff_row_drag(h_flex(), rowsel, None, id, cx) .w_full() .h(px(19.)) .items_center() @@ -1208,7 +1472,7 @@ impl Tty7App { .font_family(self.font_family.clone()) .rounded_bl(radius) .rounded_br(radius) - .when_some(tint, |line, bg| line.bg(bg)) + .when_some(fill, |line, bg| line.bg(bg)) .child(gutter(row.old)) .child(gutter(row.new)) // The split view's centre rule, in the one place it still means the @@ -1545,6 +1809,7 @@ fn probe_key( mod tests { use super::*; use crate::terminal::git_diff::{DiffLine, LineKind}; + use crate::ui::diff_rows::split_hunk; use crate::ui::i18n::set_locale; #[test] @@ -2579,3 +2844,234 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } } + +/// The drag itself, in a window: a press, a move, and what lands on the +/// clipboard. The row geometry is `diff_rows`' business and tested there — +/// what these check is the wiring the renderer hangs on it. +#[cfg(test)] +mod gpui_tests { + use super::*; + use crate::terminal::git_diff::{DiffLine, LineKind}; + use crate::ui::app::test_window; + use crate::ui::pane::{Pane, PaneSlot}; + use crate::ui::pending_pane::{PendingPane, PendingSpawn}; + use gpui::{Entity, MouseButton, TestAppContext, VisualTestContext}; + + const PATH: &str = "src/a.rs"; + + fn line(kind: LineKind, old: Option, new: Option, text: &str) -> DiffLine { + DiffLine { + kind, + old_no: old, + new_no: new, + text: text.to_string(), + } + } + + /// `a` kept, `b`/`c` replaced by `B`, `d` kept — four split rows, five + /// unified ones. + fn patched_file() -> FileDiff { + FileDiff { + path: PATH.to_string(), + old_path: None, + status: FileStatus::Modified, + added: 1, + removed: 2, + binary: false, + truncated: None, + hunks: vec![git_diff::Hunk { + header: "@@ -1,4 +1,3 @@".to_string(), + lines: vec![ + line(LineKind::Context, Some(1), Some(1), "a"), + line(LineKind::Removed, Some(2), None, "b"), + line(LineKind::Removed, Some(3), None, "c"), + line(LineKind::Added, None, Some(2), "B"), + line(LineKind::Context, Some(4), Some(3), "d"), + ], + }], + } + } + + /// A window with one tab, showing that patch. + fn window(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { + let (app, mut vcx) = test_window::harness(cx); + app.update_in(&mut vcx, |app, _, cx| { + let pending = cx.new(|cx| { + PendingPane::new( + "test-box", + PendingSpawn { + workspace: None, + working_directory: None, + restore_pane: None, + shell: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + owner: None, + font_size: 14.0, + }, + cx, + ) + }); + app.tabs + .push(crate::ui::app::Tab::new(Pane::leaf(PaneSlot::Connecting( + pending, + )))); + app.active = 0; + app.tabs[0].diff_overlay = Some(DiffOverlayState { + host_id: crate::ui::host_ops::HostId::LOCAL, + cwd: PathBuf::from("/repo"), + source: DiffSource::Head, + focus_handle: cx.focus_handle(), + load: DiffLoad::Ready(Arc::new(DiffSnapshot { + files: vec![patched_file()], + ..Default::default() + })), + loading: false, + expanded: HashMap::new(), + focus: None, + preview: None, + preview_loading: None, + scroll: gpui::ScrollHandle::new(), + selection: None, + selecting: false, + epoch: None, + }); + }); + (app, vcx) + } + + fn drag( + app: &Entity, + vcx: &mut VisualTestContext, + mode: DiffViewMode, + side: Option, + from: usize, + to: usize, + ) { + // The view mode is a window-wide setting, and the overlay drops a + // selection whose rows the current view never drew — so a drag in the + // unified view has to happen with the unified view on. + vcx.update(|_, cx| { + let mut cfg = cx.global::().clone(); + cfg.diff_view = mode; + cx.set_global(cfg); + }); + app.update_in(vcx, |this, window, cx| { + let path: SharedString = PATH.into(); + let row = |row| RowId { hunk: 0, row }; + this.start_diff_selection(&path, mode, side, row(from), window, cx); + this.extend_diff_selection(&path, side, row(to), Some(MouseButton::Left), cx); + }); + } + + fn copied(app: &Entity, vcx: &mut VisualTestContext) -> Option { + app.update_in(vcx, |this, _, cx| this.copy_diff_selection(cx)); + vcx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text())) + } + + #[gpui::test] + fn a_drag_down_a_column_copies_that_column(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 3); + assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nB\nd")); + + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::Old), 0, 3); + assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nb\nc\nd")); + + drag(&app, &mut vcx, DiffViewMode::Unified, None, 1, 3); + assert_eq!(copied(&app, &mut vcx).as_deref(), Some("b\nc\nB")); + } + + /// The overlay is often drawn beside a live shell that holds the keyboard. + /// Ctrl+C is the copy key only once the overlay has taken focus — until + /// then the same keystroke would reach the pane and interrupt whatever is + /// running in it. + #[gpui::test] + fn starting_a_drag_takes_the_keyboard(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 1); + let focused = app.update_in(&mut vcx, |this, window, _| { + this.tabs[0] + .diff_overlay + .as_ref() + .expect("the overlay this window was built with") + .focus_handle + .is_focused(window) + }); + assert!(focused); + } + + /// A move with no button held is a release the overlay never saw — over a + /// pane, or outside the window. The drag ends there rather than resuming + /// the next time the pointer crosses a row. + #[gpui::test] + fn a_release_the_overlay_missed_ends_the_drag(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 1); + + let path: SharedString = PATH.into(); + app.update_in(&mut vcx, |this, _, cx| { + this.extend_diff_selection(&path, Some(Side::New), RowId { hunk: 0, row: 3 }, None, cx); + }); + assert_eq!( + copied(&app, &mut vcx).as_deref(), + Some("a\nB"), + "the range stops where the pointer was last seen holding the button" + ); + } + + /// The two views pair the same lines into different rows, so a range drawn + /// in one of them points at other code in the other. + #[gpui::test] + fn switching_the_view_drops_the_selection(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 3); + + app.update_in(&mut vcx, |this, _, _| { + this.drop_stale_diff_selection(DiffViewMode::Unified); + assert!( + this.tabs[0] + .diff_overlay + .as_ref() + .unwrap() + .selection + .is_none() + ); + }); + } + + /// A fresh read re-cuts the hunks. A range that survived one would keep its + /// coordinates and quietly cover other code. + #[gpui::test] + fn a_fresh_snapshot_drops_the_selection(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 0, 3); + + app.update_in(&mut vcx, |this, _, cx| { + this.install_diff_snapshot( + crate::ui::host_ops::HostId::LOCAL, + &PathBuf::from("/repo"), + &DiffSource::Head, + Some(Arc::new(DiffSnapshot { + files: vec![patched_file()], + ..Default::default() + })), + cx, + ); + let overlay = this.tabs[0].diff_overlay.as_ref().unwrap(); + assert!(overlay.selection.is_none()); + assert!(!overlay.selecting); + }); + } + + #[gpui::test] + fn a_copy_with_nothing_selected_leaves_the_clipboard_alone(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + vcx.update(|_, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string("untouched".into())) + }); + assert_eq!(copied(&app, &mut vcx).as_deref(), Some("untouched")); + } +} diff --git a/src/ui/diff_rows.rs b/src/ui/diff_rows.rs index 4aa16876..bc04f0e0 100644 --- a/src/ui/diff_rows.rs +++ b/src/ui/diff_rows.rs @@ -4,7 +4,8 @@ //! the pairing logic lives here — outside either renderer — and is unit tested //! without a window. -use crate::terminal::git_diff::{DiffLine, LineKind}; +use crate::core::config::DiffViewMode; +use crate::terminal::git_diff::{DiffLine, Hunk, LineKind}; /// A tab is worth this many columns. Not configurable: a diff is read next to /// the file's other lines, not on its own, and the grid has to line up. @@ -27,6 +28,10 @@ pub(crate) struct SplitCell { pub(crate) no: Option, pub(crate) text: String, pub(crate) changed: bool, + /// Which of the hunk's own lines this cell draws. `text` is the tab- + /// expanded copy the grid needs; a copy to the clipboard has to reach past + /// it to the line as the file wrote it. + pub(crate) line: usize, } pub(crate) struct SplitRow { @@ -38,18 +43,24 @@ pub(crate) struct SplitRow { /// rewritten line sits opposite the line it replaced. Whichever run is shorter /// leaves empty cells at the bottom of the pair. pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec { - fn flush(rows: &mut Vec, rem: &mut Vec<&DiffLine>, add: &mut Vec<&DiffLine>) { + fn flush( + rows: &mut Vec, + rem: &mut Vec<(usize, &DiffLine)>, + add: &mut Vec<(usize, &DiffLine)>, + ) { for i in 0..rem.len().max(add.len()) { rows.push(SplitRow { - left: rem.get(i).map(|l| SplitCell { + left: rem.get(i).map(|(idx, l)| SplitCell { no: l.old_no, text: expand_tabs(&l.text), changed: true, + line: *idx, }), - right: add.get(i).map(|l| SplitCell { + right: add.get(i).map(|(idx, l)| SplitCell { no: l.new_no, text: expand_tabs(&l.text), changed: true, + line: *idx, }), }); } @@ -58,12 +69,12 @@ pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec { } let mut rows = Vec::new(); - let mut rem: Vec<&DiffLine> = Vec::new(); - let mut add: Vec<&DiffLine> = Vec::new(); - for line in lines { + let mut rem: Vec<(usize, &DiffLine)> = Vec::new(); + let mut add: Vec<(usize, &DiffLine)> = Vec::new(); + for (idx, line) in lines.iter().enumerate() { match line.kind { - LineKind::Removed => rem.push(line), - LineKind::Added => add.push(line), + LineKind::Removed => rem.push((idx, line)), + LineKind::Added => add.push((idx, line)), LineKind::Context => { flush(&mut rows, &mut rem, &mut add); rows.push(SplitRow { @@ -71,11 +82,13 @@ pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec { no: line.old_no, text: expand_tabs(&line.text), changed: false, + line: idx, }), right: Some(SplitCell { no: line.new_no, text: expand_tabs(&line.text), changed: false, + line: idx, }), }); } @@ -90,6 +103,9 @@ pub(crate) struct UnifiedRow { pub(crate) new: Option, pub(crate) kind: LineKind, pub(crate) text: String, + /// Which of the hunk's own lines this row draws — the same reach past + /// `text` a [`SplitCell`] needs, and one row is one line here. + pub(crate) line: usize, } /// One row per line, in git's own order — every removal in a run first, then @@ -99,15 +115,145 @@ pub(crate) struct UnifiedRow { pub(crate) fn unified_rows(lines: &[DiffLine]) -> Vec { lines .iter() - .map(|line| UnifiedRow { + .enumerate() + .map(|(idx, line)| UnifiedRow { old: line.old_no, new: line.new_no, kind: line.kind, text: expand_tabs(&line.text), + line: idx, }) .collect() } +/// One hunk, already turned into whichever kind of row the current view draws. +pub(crate) enum HunkRows { + Split(Vec), + Unified(Vec), +} + +impl HunkRows { + pub(crate) fn build(mode: DiffViewMode, lines: &[DiffLine]) -> Self { + match mode { + DiffViewMode::Split => Self::Split(split_hunk(lines)), + DiffViewMode::Unified => Self::Unified(unified_rows(lines)), + } + } + + pub(crate) fn len(&self) -> usize { + match self { + Self::Split(rows) => rows.len(), + Self::Unified(rows) => rows.len(), + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Where a row sits in a file's card: which hunk, and which row inside it. +/// +/// Ordered the way the rows are drawn, so a drag is nothing more than the +/// range between the two of these the pointer touched. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub(crate) struct RowId { + pub(crate) hunk: usize, + pub(crate) row: usize, +} + +/// The rows a drag has run over, in one file. +#[derive(Clone, Debug)] +pub(crate) struct DiffSelection { + /// The file the drag started in. A selection never spans two cards: two + /// files are two documents, and a range across them would copy code from + /// one into the middle of another. + pub(crate) path: String, + /// The view the rows were drawn in when the drag happened. The two views + /// pair the same lines into different rows, so a selection means nothing + /// in the other one — the overlay drops it when the mode changes rather + /// than pretending to translate it. + pub(crate) mode: DiffViewMode, + /// Which column of the split view the drag started in; that column alone + /// is copied, so dragging down the left gets the code as it was and down + /// the right gets it as it is. `None` in the unified view, where a row + /// *is* the line. + pub(crate) side: Option, + pub(crate) anchor: RowId, + pub(crate) head: RowId, +} + +impl DiffSelection { + fn range(&self) -> (RowId, RowId) { + match self.anchor <= self.head { + true => (self.anchor, self.head), + false => (self.head, self.anchor), + } + } + + /// Whether the selection covers this row. `side` names the column a split + /// cell sits in, and is `None` for a unified row — a selection made in one + /// column never lights up the other. + pub(crate) fn covers(&self, path: &str, id: RowId, side: Option) -> bool { + if self.path != path || self.side != side { + return false; + } + let (start, end) = self.range(); + start <= id && id <= end + } + + /// The code the drag ran over, spelled the way the file spells it. + /// + /// No `+`/`−` marker and no line numbers: what lands on the clipboard has + /// to compile when it is pasted back, and the gutter is the diff talking + /// about the code rather than the code itself. Tabs are left alone for the + /// same reason — [`expand_tabs`] is how the grid draws a tab, not how the + /// file stores one, and four spaces pasted into a tab-indented file is a + /// whitespace bug the copier did not ask for. + /// + /// A split row with nothing on the selected side contributes nothing: the + /// blank half of a pair is padding the layout invented, not an empty line + /// in the file. + pub(crate) fn text(&self, hunks: &[Hunk]) -> String { + let (start, end) = self.range(); + let mut out: Vec<&str> = Vec::new(); + for (h, hunk) in hunks.iter().enumerate() { + if h < start.hunk || h > end.hunk { + continue; + } + let first = if h == start.hunk { start.row } else { 0 }; + let last = if h == end.hunk { end.row } else { usize::MAX }; + let mut push = |line: usize| { + if let Some(l) = hunk.lines.get(line) { + out.push(&l.text); + } + }; + match HunkRows::build(self.mode, &hunk.lines) { + HunkRows::Split(rows) => { + for row in rows.iter().take(last.saturating_add(1)).skip(first) { + let cell = match self.side.unwrap_or(Side::New) { + Side::Old => row.left.as_ref(), + Side::New => row.right.as_ref(), + }; + if let Some(cell) = cell { + push(cell.line); + } + } + } + HunkRows::Unified(rows) => { + for row in rows.iter().take(last.saturating_add(1)).skip(first) { + push(row.line); + } + } + } + } + out.join( + " +", + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -239,4 +385,191 @@ mod tests { "a context line fills two cells, a change fills one" ); } + fn patch(lines: Vec) -> Hunk { + Hunk { + header: "@@ -1,4 +1,3 @@".to_string(), + lines, + } + } + + fn drag( + mode: DiffViewMode, + side: Option, + anchor: (usize, usize), + head: (usize, usize), + ) -> DiffSelection { + DiffSelection { + path: "src/a.rs".to_string(), + mode, + side, + anchor: RowId { + hunk: anchor.0, + row: anchor.1, + }, + head: RowId { + hunk: head.0, + row: head.1, + }, + } + } + + #[test] + fn a_drag_down_the_new_column_copies_the_file_as_it_now_reads() { + let hunks = [patch(hunk())]; + let text = drag(DiffViewMode::Split, Some(Side::New), (0, 0), (0, 3)).text(&hunks); + assert_eq!( + text, "a\nB\nd", + "the removed-only row is padding on this side, not an empty line" + ); + } + + #[test] + fn a_drag_down_the_old_column_copies_the_file_as_it_was() { + let hunks = [patch(hunk())]; + let text = drag(DiffViewMode::Split, Some(Side::Old), (0, 0), (0, 3)).text(&hunks); + assert_eq!(text, "a\nb\nc\nd"); + } + + #[test] + fn the_unified_view_copies_the_rows_in_the_order_it_drew_them() { + let hunks = [patch(hunk())]; + let text = drag(DiffViewMode::Unified, None, (0, 1), (0, 3)).text(&hunks); + assert_eq!( + text, "b\nc\nB", + "both removals then the addition — the order on screen" + ); + } + + #[test] + fn a_drag_the_other_way_round_copies_the_same_rows() { + let hunks = [patch(hunk())]; + let forwards = drag(DiffViewMode::Unified, None, (0, 1), (0, 3)).text(&hunks); + let backwards = drag(DiffViewMode::Unified, None, (0, 3), (0, 1)).text(&hunks); + assert_eq!(forwards, backwards); + } + + #[test] + fn one_row_copies_one_line() { + let hunks = [patch(hunk())]; + assert_eq!( + drag(DiffViewMode::Unified, None, (0, 2), (0, 2)).text(&hunks), + "c" + ); + } + + /// What lands on the clipboard has to compile when it is pasted back, so + /// none of the diff's own furniture may ride along with it. + #[test] + fn nothing_the_gutter_draws_is_copied() { + let lines = vec![ + line(LineKind::Removed, Some(9), None, "let old = 1;"), + line(LineKind::Added, None, Some(9), "let new = 2;"), + ]; + let hunks = [patch(lines)]; + for (mode, side) in [ + (DiffViewMode::Split, Some(Side::Old)), + (DiffViewMode::Split, Some(Side::New)), + (DiffViewMode::Unified, None), + ] { + let text = drag(mode, side, (0, 0), (0, 9)).text(&hunks); + assert!(!text.contains('+'), "marker in {text:?}"); + assert!(!text.contains('−'), "marker in {text:?}"); + assert!(!text.contains('9'), "line number in {text:?}"); + } + } + + /// [`expand_tabs`] is how the grid draws a tab, not how the file stores + /// one. Copying the drawn text would paste four spaces into a tab-indented + /// file — a whitespace change nobody asked for. + #[test] + fn copying_keeps_the_tabs_the_file_was_written_with() { + let hunks = [patch(vec![line( + LineKind::Added, + None, + Some(1), + "\tindented", + )])]; + assert_eq!( + unified_rows(&hunks[0].lines)[0].text, + " indented", + "drawn with the tab expanded" + ); + assert_eq!( + drag(DiffViewMode::Unified, None, (0, 0), (0, 0)).text(&hunks), + "\tindented", + "copied with the tab intact" + ); + } + + #[test] + fn a_drag_across_hunks_copies_every_row_between_its_ends() { + let hunks = [ + patch(vec![ + line(LineKind::Context, Some(1), Some(1), "one"), + line(LineKind::Context, Some(2), Some(2), "two"), + ]), + patch(vec![ + line(LineKind::Context, Some(9), Some(9), "nine"), + line(LineKind::Context, Some(10), Some(10), "ten"), + ]), + ]; + let text = drag(DiffViewMode::Unified, None, (0, 1), (1, 0)).text(&hunks); + assert_eq!( + text, "two\nnine", + "the tail of the first hunk and the head of the second, nothing else" + ); + let all = drag(DiffViewMode::Split, Some(Side::New), (0, 0), (1, 1)).text(&hunks); + assert_eq!(all, "one\ntwo\nnine\nten"); + } + + #[test] + fn a_selection_lights_only_the_column_the_drag_started_in() { + let sel = drag(DiffViewMode::Split, Some(Side::New), (0, 0), (0, 2)); + let inside = RowId { hunk: 0, row: 1 }; + assert!(sel.covers("src/a.rs", inside, Some(Side::New))); + assert!(!sel.covers("src/a.rs", inside, Some(Side::Old))); + assert!( + !sel.covers("src/b.rs", inside, Some(Side::New)), + "a selection belongs to one file's card" + ); + assert!(!sel.covers("src/a.rs", RowId { hunk: 0, row: 3 }, Some(Side::New))); + assert!(!sel.covers("src/a.rs", RowId { hunk: 1, row: 0 }, Some(Side::New))); + } + + #[test] + fn a_unified_selection_never_lights_a_split_cell() { + let sel = drag(DiffViewMode::Unified, None, (0, 0), (0, 2)); + let inside = RowId { hunk: 0, row: 1 }; + assert!(sel.covers("src/a.rs", inside, None)); + assert!(!sel.covers("src/a.rs", inside, Some(Side::New))); + } + + /// Rows are ordered the way they are drawn, so the range between two of + /// them is exactly what the pointer crossed. + #[test] + fn rows_order_by_hunk_before_row() { + assert!(RowId { hunk: 0, row: 9 } < RowId { hunk: 1, row: 0 }); + assert!(RowId { hunk: 1, row: 0 } < RowId { hunk: 1, row: 1 }); + } + + #[test] + fn a_selection_pointing_past_the_patch_copies_nothing() { + let hunks = [patch(hunk())]; + assert_eq!( + drag(DiffViewMode::Unified, None, (4, 0), (4, 2)).text(&hunks), + "" + ); + assert_eq!( + drag(DiffViewMode::Unified, None, (0, 0), (0, 2)).text(&[]), + "" + ); + } + + #[test] + fn each_view_builds_the_rows_its_renderer_draws() { + let lines = hunk(); + assert_eq!(HunkRows::build(DiffViewMode::Split, &lines).len(), 4); + assert_eq!(HunkRows::build(DiffViewMode::Unified, &lines).len(), 5); + assert!(HunkRows::build(DiffViewMode::Split, &[]).is_empty()); + } } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index eb62d36f..f844dcb8 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1204,6 +1204,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::DiffUntrackedSummary => "{count} untracked", L10nKey::DiffViewSplit => "Side by Side", L10nKey::DiffViewUnified => "Unified", + L10nKey::DiffCopySelection => "Copy Selected Lines", L10nKey::PendingConnecting => "Connecting to {machine}…", L10nKey::PendingUnreachable => "Could not reach {machine}", L10nKey::WorktreePromptNeedsName => "The worktree needs a name", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 367c4d95..d084059a 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1270,6 +1270,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::DiffUntrackedSummary => "未追跡 {count}", L10nKey::DiffViewSplit => "左右分割", L10nKey::DiffViewUnified => "統合", + L10nKey::DiffCopySelection => "選択した行をコピー", L10nKey::PendingConnecting => "{machine} に接続中…", L10nKey::PendingUnreachable => "{machine} に到達できませんでした", L10nKey::WorktreePromptNeedsName => "ワークツリーには名前が必要です", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1b54fbca..4addab27 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -901,6 +901,7 @@ l10n_keys! { DiffUntrackedSummary, DiffViewSplit, DiffViewUnified, + DiffCopySelection, PendingConnecting, PendingUnreachable, WorktreePromptNeedsName, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index f802207a..35898557 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1142,6 +1142,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::DiffUntrackedSummary => "{count} 个未跟踪", L10nKey::DiffViewSplit => "并排", L10nKey::DiffViewUnified => "统一", + L10nKey::DiffCopySelection => "复制选中的行", L10nKey::PendingConnecting => "正在连接 {machine}…", L10nKey::PendingUnreachable => "无法连接到 {machine}", L10nKey::WorktreePromptNeedsName => "worktree 需要一个名称",