From 79527ca871c3ac20ae41de76ab6d3143b82e1a8a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:33:02 +0800 Subject: [PATCH] feat(diff): let the pointer take a range of diff lines and copy it (#794) Refs #721. Reconciled onto the virtualised row list from #799: the selection is keyed on RowAt { path, RowId } rather than the flat list index, since collapsing a file above the selection re-points flat indices. --- src/ui/diff_list.rs | 113 ++++++- src/ui/diff_overlay.rs | 691 ++++++++++++++++++++++++++++++++++++++++- src/ui/diff_rows.rs | 345 +++++++++++++++++++- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + 7 files changed, 1115 insertions(+), 38 deletions(-) diff --git a/src/ui/diff_list.rs b/src/ui/diff_list.rs index 08a77818..ef474f29 100644 --- a/src/ui/diff_list.rs +++ b/src/ui/diff_list.rs @@ -14,11 +14,13 @@ use std::collections::HashMap; +use gpui::SharedString; + use crate::core::config::DiffViewMode; use crate::terminal::git_diff::{ AUTO_COLLAPSE_LINES, DiffSnapshot, FileDiff, FileStatus, MAX_RENDERED_FILES, Truncation, }; -use crate::ui::diff_rows::{SplitRow, UnifiedRow, split_hunk, unified_rows}; +use crate::ui::diff_rows::{RowId, SplitRow, UnifiedRow, split_hunk, unified_rows}; /// Everything the file header row draws, lifted out of its [`FileDiff`]. /// @@ -39,6 +41,21 @@ pub(crate) struct FileHead { pub(crate) expanded: bool, } +/// Where a drawn line sits in the patch: which file's rows it belongs to, and +/// its place among them. +/// +/// Carried on the row rather than read off its position in the list. The list +/// is spliced — collapsing a file above this one moves every index below it — +/// so a range keyed on list positions would go on naming the same slots while +/// the code under them changed. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RowAt { + /// One of these rides on every line of a patch, so the path is shared + /// rather than copied per row. + pub(crate) path: SharedString, + pub(crate) id: RowId, +} + #[derive(PartialEq, Eq)] pub(crate) enum DiffRow { /// The space that used to be the `gap_3` of a flex column. @@ -54,8 +71,14 @@ pub(crate) enum DiffRow { /// the hunk before. leads: bool, }, - Split(SplitRow), - Unified(UnifiedRow), + Split { + row: SplitRow, + at: RowAt, + }, + Unified { + row: UnifiedRow, + at: RowAt, + }, Truncated(Truncation), MoreFiles { rest: usize, @@ -152,18 +175,29 @@ fn file_rows(index: usize, file: &FileDiff, expanded: bool, mode: DiffViewMode) })]; if expanded && (!file.hunks.is_empty() || file.truncated.is_some()) { + let path = SharedString::from(file.path.clone()); for (h, hunk) in file.hunks.iter().enumerate() { rows.push(DiffRow::HunkHeader { text: hunk.header.clone(), leads: h == 0, }); + let at = |row| RowAt { + path: path.clone(), + id: RowId { hunk: h, row }, + }; match mode { - DiffViewMode::Split => { - rows.extend(split_hunk(&hunk.lines).into_iter().map(DiffRow::Split)) - } - DiffViewMode::Unified => { - rows.extend(unified_rows(&hunk.lines).into_iter().map(DiffRow::Unified)) - } + DiffViewMode::Split => rows.extend( + split_hunk(&hunk.lines) + .into_iter() + .enumerate() + .map(|(r, row)| DiffRow::Split { row, at: at(r) }), + ), + DiffViewMode::Unified => rows.extend( + unified_rows(&hunk.lines) + .into_iter() + .enumerate() + .map(|(r, row)| DiffRow::Unified { row, at: at(r) }), + ), } } if let Some(reason) = file.truncated { @@ -252,6 +286,63 @@ mod tests { } } + /// `(path, hunk, row)` for each line row, in list order. + fn line_coords(rows: &[DiffRow]) -> Vec<(String, usize, usize)> { + rows.iter() + .filter_map(|row| match row { + DiffRow::Split { at, .. } | DiffRow::Unified { at, .. } => { + Some((at.path.to_string(), at.id.hunk, at.id.row)) + } + _ => None, + }) + .collect() + } + + /// A row says where it is in the *patch*, not where it is in the list. + /// Collapsing a file splices the rows below it up the list; a range keyed + /// on list positions would stay where it was and come away naming other + /// code. + #[test] + fn a_line_row_names_its_file_and_its_place_in_the_hunk() { + let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]); + let open = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false); + let coords = line_coords(&open); + assert_eq!( + coords, + vec![ + ("a.rs".to_string(), 0, 0), + ("a.rs".to_string(), 0, 1), + ("a.rs".to_string(), 0, 2), + ("a.rs".to_string(), 0, 3), + ("b.rs".to_string(), 0, 0), + ("b.rs".to_string(), 0, 1), + ("b.rs".to_string(), 0, 2), + ("b.rs".to_string(), 0, 3), + ] + ); + + let collapsed = HashMap::from([("a.rs".to_string(), false)]); + let after = build_rows(&snap, &collapsed, None, DiffViewMode::Unified, false); + assert_eq!( + line_coords(&after), + coords[4..].to_vec(), + "b.rs's lines moved up the list and kept the coordinates they had" + ); + } + + /// Every hunk restarts the row count, so a range that spans two of them is + /// read hunk by hunk rather than as one run. + #[test] + fn each_hunk_numbers_its_own_rows() { + let snap = snapshot(vec![file("a.rs", 2)]); + let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Split, false); + let hunks: Vec<_> = line_coords(&rows) + .into_iter() + .map(|(_, hunk, row)| (hunk, row)) + .collect(); + assert_eq!(hunks, vec![(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]); + } + fn shape(rows: &[DiffRow]) -> Vec<&'static str> { rows.iter() .map(|row| match row { @@ -259,8 +350,8 @@ mod tests { DiffRow::Oversized => "oversized", DiffRow::FileHeader(_) => "file", DiffRow::HunkHeader { .. } => "hunk", - DiffRow::Split(_) => "split", - DiffRow::Unified(_) => "unified", + DiffRow::Split { .. } => "split", + DiffRow::Unified { .. } => "unified", DiffRow::Truncated(_) => "truncated", DiffRow::MoreFiles { .. } => "more-files", DiffRow::UntrackedHeader { .. } => "untracked-header", diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index d3d60b72..8d5eb30c 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -4,11 +4,11 @@ use std::rc::Rc; 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}; @@ -23,8 +23,8 @@ 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_list::{DiffRow, FileHead}; -use crate::ui::diff_rows::{Side, SplitCell, SplitRow, UnifiedRow}; +use crate::ui::diff_list::{DiffRow, FileHead, RowAt}; +use crate::ui::diff_rows::{DiffSelection, 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 { /// cadence a tracked file's does. pub(crate) preview: Option<(String, Option>)>, pub(crate) preview_loading: Option, + /// 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 virtualised list the rows scroll in. Held across frames: it owns /// the scroll position, and the row heights gpui has measured. pub(crate) list: gpui::ListState, @@ -142,6 +151,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(); @@ -168,6 +181,8 @@ impl Tty7App { focus, preview: None, preview_loading: None, + selection: None, + selecting: false, list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(256.)) .with_size_hint(DIFF_LINE_H), rows: Rc::new(Vec::new()), @@ -206,6 +221,127 @@ impl Tty7App { } } + /// Begin a drag at `at`, in the column it was pressed in. + fn start_diff_selection( + &mut self, + at: &RowAt, + mode: DiffViewMode, + side: Option, + 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: at.path.to_string(), + mode, + side, + anchor: at.id, + head: at.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 `at`. + /// + /// `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, + at: &RowAt, + side: Option, + 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 != at.path.as_ref() || sel.side != side || sel.head == at.id { + return; + } + sel.head = at.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(); + } + } + + /// 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 rows are + // synthesized from the file's own bytes, and so is its text. + .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 { @@ -326,6 +462,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 { @@ -429,7 +569,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(), @@ -811,6 +966,16 @@ impl Tty7App { let active = self.active; let overlay = self.tabs.get_mut(active)?.diff_overlay.as_mut()?; + // Forget a range 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. Here rather than beside the + // switch that flips the mode: this is the one place that knows which + // rows are about to be drawn. + if overlay.selection.as_ref().is_some_and(|s| s.mode != mode) { + overlay.selection = None; + overlay.selecting = false; + } + let snap = match &overlay.load { DiffLoad::Loading => return Some(DiffBody::Message(t(L10nKey::DiffReading))), DiffLoad::NotARepo => return Some(DiffBody::Message(t(L10nKey::DiffNotARepo))), @@ -881,11 +1046,22 @@ impl Tty7App { let font = SharedString::from(self.font_family.clone()); let app = cx.entity().downgrade(); let list = overlay.list.clone(); + // The selection is read here, once a frame, rather than keyed into + // `RowsKey`: it changes what a row *looks like*, not which rows there + // are, and re-flattening the patch for every step of a drag is the + // cost this list exists to avoid. `extend_diff_selection` notifies, + // the view renders, and the list rebuilds the rows on screen from the + // `Drag` this frame carries. + let drag = Drag { + sel: overlay.selection.clone().map(Rc::new), + selecting: overlay.selecting, + mode: view_mode(cx), + }; let body = gpui::list(list.clone(), move |ix, _window, cx| { #[cfg(test)] row_probe::record(); match rows.get(ix) { - Some(row) => diff_row_element(row, &font, &snap, &app, cx), + Some(row) => diff_row_element(row, ix, &drag, &font, &snap, &app, cx), // The list is spliced in step with `rows`, so this is // unreachable — and an empty row is a better answer to a bug // than an index panic in a paint. @@ -931,6 +1107,40 @@ pub(crate) mod row_probe { } } +/// What a row needs to take part in a drag, for the frame it is drawn in. +/// +/// Read off the overlay once and moved into the list's item builder, so a step +/// of a drag costs a refcount bump rather than a walk of the patch. +struct Drag { + sel: Option>, + /// Whether a drag is in flight. Rows only listen for pointer movement + /// while one is: a diff runs to thousands of rows, and a listener each is + /// worth paying for during a drag and not otherwise. + selecting: bool, + /// The view the rows on screen are drawn in, which is the view a press + /// starts its selection in. + mode: DiffViewMode, +} + +impl Drag { + /// Whether the selection covers this cell. `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. + fn covers(&self, at: &RowAt, side: Option) -> bool { + self.sel + .as_ref() + .is_some_and(|sel| sel.covers(at.path.as_ref(), at.id, side)) + } + + /// Whether this row is inside the selection at all, whichever column the + /// drag ran down. What decides whether the row offers to copy it. + fn holds(&self, at: &RowAt) -> bool { + self.sel + .as_ref() + .is_some_and(|sel| sel.covers(at.path.as_ref(), at.id, sel.side)) + } +} + /// What the overlay's scrolling area holds this frame. enum DiffBody { Message(&'static str), @@ -1077,6 +1287,8 @@ fn hunk_rule(cx: &gpui::App) -> Hsla { /// One row, inset the way every row in the list is. fn diff_row_element( row: &DiffRow, + ix: usize, + drag: &Drag, font: &SharedString, snap: &Arc, app: &gpui::WeakEntity, @@ -1106,8 +1318,20 @@ fn diff_row_element( // The lines run the full width of the list. A diff is read as a // column of code, and code that is inset from both sides reads as a // quotation of itself. - DiffRow::Split(row) => diff_split_row(row, font, cx).into_any_element(), - DiffRow::Unified(row) => diff_unified_row(row, font, cx).into_any_element(), + DiffRow::Split { row, at } => copy_menu( + diff_split_row(row, at, drag, font, app, cx), + ix, + at, + drag, + app, + ), + DiffRow::Unified { row, at } => copy_menu( + diff_unified_row(row, at, drag, font, app, cx), + ix, + at, + drag, + app, + ), DiffRow::Truncated(reason) => { let note = match reason { Truncation::PerFile => t_fmt( @@ -1144,6 +1368,72 @@ fn diff_row_element( } } +/// The one place a copy is offered by name, on the rows that would be copied. +/// +/// A drag says what will be copied; the menu says that copying is a thing you +/// can do. It hangs on the selected rows themselves because with the cards +/// gone there is no longer an element that owns a file's lines, and the +/// overlay root already carries the header's own menu — two of them over one +/// right-click would open two popups. +fn copy_menu( + row: gpui::Div, + ix: usize, + at: &RowAt, + drag: &Drag, + app: &gpui::WeakEntity, +) -> AnyElement { + if !drag.holds(at) { + return row.into_any_element(); + } + let app = app.clone(); + row.id(("diff-row-menu", ix)) + .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() +} + +/// 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( + el: E, + at: &RowAt, + side: Option, + drag: &Drag, + app: &gpui::WeakEntity, +) -> E { + let mode = drag.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 (app, at) = (app.clone(), at.clone()); + move |_: &MouseDownEvent, window, cx| { + app.update(cx, |this, cx| { + this.start_diff_selection(&at, mode, side, window, cx); + }) + .ok(); + } + }); + if !drag.selecting { + return el; + } + el.on_mouse_move({ + let (app, at) = (app.clone(), at.clone()); + move |ev: &MouseMoveEvent, _window, cx| { + let held = ev.pressed_button; + app.update(cx, |this, cx| { + this.extend_diff_selection(&at, side, held, cx); + }) + .ok(); + } + }) +} + /// The margin the file rows keep from the edge of the list. fn padded(row: AnyElement) -> AnyElement { div().w_full().px_2().child(row).into_any_element() @@ -1329,29 +1619,76 @@ fn diff_untracked_row( .into_any_element() } -fn diff_split_row(row: &SplitRow, font: &SharedString, cx: &gpui::App) -> impl IntoElement { +fn diff_split_row( + row: &SplitRow, + at: &RowAt, + drag: &Drag, + font: &SharedString, + app: &gpui::WeakEntity, + cx: &gpui::App, +) -> gpui::Div { h_flex() .w_full() .h(DIFF_LINE_H) .items_stretch() .text_xs() .font_family(font.clone()) - .child(diff_split_cell(row.left.as_ref(), Side::Old, cx)) + .child(diff_split_cell( + row.left.as_ref(), + Side::Old, + at, + drag, + app, + cx, + )) .child(div().flex_shrink_0().w(px(1.)).bg(hunk_rule(cx))) - .child(diff_split_cell(row.right.as_ref(), Side::New, cx)) + .child(diff_split_cell( + row.right.as_ref(), + Side::New, + at, + drag, + app, + cx, + )) } -fn diff_split_cell(cell: Option<&SplitCell>, side: Side, cx: &gpui::App) -> AnyElement { +fn diff_split_cell( + cell: Option<&SplitCell>, + side: Side, + at: &RowAt, + drag: &Drag, + app: &gpui::WeakEntity, + cx: &gpui::App, +) -> AnyElement { let base = h_flex().flex_1().min_w_0().h_full().items_center(); let Some(cell) = cell else { - return base.bg(cx.theme().muted.opacity(0.3)).into_any_element(); + // Blank, but still this row's half of this column. Left inert it is a + // dead band under the pointer — no I-beam, a press that starts + // nothing, and a range that visibly stops at the padding and resumes + // below it. A one-sided change is the ordinary shape of a diff, so + // that band runs down most of one column. + let fill = match drag.covers(at, Some(side)) { + true => cx.theme().selection, + false => cx.theme().muted.opacity(0.3), + }; + return diff_row_drag(base, at, Some(side), drag, app) + .bg(fill) + .into_any_element(); }; let (marker, tint) = match (cell.changed, side) { (true, Side::Old) => ("−", Some(cx.theme().danger.opacity(0.12))), (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 fill = match drag.covers(at, Some(side)) { + true => Some(cx.theme().selection), + false => tint, + }; + diff_row_drag(base, at, Some(side), drag, app) + .when_some(fill, |row, bg| row.bg(bg)) .child( h_flex() .flex_shrink_0() @@ -1384,7 +1721,14 @@ fn diff_split_cell(cell: Option<&SplitCell>, side: Side, cx: &gpui::App) -> AnyE /// than riding in the text: with three kinds of line stacked in one column, an /// inlined marker would leave the context lines' code starting two characters /// left of everything else. -fn diff_unified_row(row: &UnifiedRow, font: &SharedString, cx: &gpui::App) -> impl IntoElement { +fn diff_unified_row( + row: &UnifiedRow, + at: &RowAt, + drag: &Drag, + font: &SharedString, + app: &gpui::WeakEntity, + cx: &gpui::App, +) -> gpui::Div { let (marker_color, tint) = match row.kind { LineKind::Added => (cx.theme().success, Some(cx.theme().success.opacity(0.12))), LineKind::Removed => (cx.theme().danger, Some(cx.theme().danger.opacity(0.12))), @@ -1399,13 +1743,17 @@ fn diff_unified_row(row: &UnifiedRow, font: &SharedString, cx: &gpui::App) -> im .text_color(cx.theme().muted_foreground.opacity(0.7)) .child(no.map(|n| n.to_string()).unwrap_or_default()) }; - h_flex() + let fill = match drag.covers(at, None) { + true => Some(cx.theme().selection), + false => tint, + }; + diff_row_drag(h_flex(), at, None, drag, app) .w_full() .h(DIFF_LINE_H) .items_center() .text_xs() .font_family(font.clone()) - .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 @@ -2899,3 +3247,312 @@ 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 list hangs on it. +#[cfg(test)] +mod selection_gpui_tests { + use super::*; + use crate::terminal::git_diff::{DiffLine, LineKind}; + use crate::ui::app::test_window; + use crate::ui::diff_rows::RowId; + 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"), + ], + }], + } + } + + /// The coordinate the list carries on the row `row` of the only hunk. + fn at(row: usize) -> RowAt { + RowAt { + path: PATH.into(), + id: RowId { hunk: 0, row }, + } + } + + /// A window with one tab, showing that patch. Built by hand rather than + /// through `open_diff_overlay`: that dispatches a probe, and a probe + /// landing mid-test would drop the selection under it. + 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, + selection: None, + selecting: false, + list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(256.)) + .with_size_hint(DIFF_LINE_H), + rows: Rc::new(Vec::new()), + rows_key: None, + 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| { + this.start_diff_selection(&at(from), mode, side, window, cx); + this.extend_diff_selection(&at(to), side, 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())) + } + + fn selection(app: &Entity, vcx: &mut VisualTestContext) -> Option { + app.update_in(vcx, |this, _, _| { + this.tabs[0] + .diff_overlay + .as_ref() + .and_then(|o| o.selection.clone()) + }) + } + + #[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")); + } + + /// A drag that runs up the list leaves the head above the anchor. The + /// range is read in drawn order either way, so it copies what the same two + /// rows copy dragged the other way round. + #[gpui::test] + fn a_drag_up_a_column_copies_the_same_rows(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::New), 3, 0); + let sel = selection(&app, &mut vcx).expect("the drag this test just made"); + assert!( + sel.head < sel.anchor, + "the drag ended above where it started" + ); + assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nB\nd")); + + drag(&app, &mut vcx, DiffViewMode::Split, Some(Side::Old), 3, 0); + assert_eq!(copied(&app, &mut vcx).as_deref(), Some("a\nb\nc\nd")); + + drag(&app, &mut vcx, DiffViewMode::Unified, None, 3, 1); + 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); + + app.update_in(&mut vcx, |this, _, cx| { + this.extend_diff_selection(&at(3), Some(Side::New), 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. `sync_diff_rows` is + /// where the rows for a frame are settled, so it is where the range that + /// no longer names any of them is dropped. + #[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); + + vcx.update(|_, cx| { + let mut cfg = cx.global::().clone(); + cfg.diff_view = DiffViewMode::Unified; + cx.set_global(cfg); + }); + app.update_in(&mut vcx, |this, _, cx| { + let _ = this.sync_diff_rows(cx); + }); + assert!(selection(&app, &mut vcx).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")); + } + + /// Collapsing a file above the selection re-cuts the list, but not the + /// rows the range names. A selection keyed on list positions would come + /// away pointing at whatever slid into those slots. + #[gpui::test] + fn collapsing_a_file_above_the_range_leaves_it_on_the_same_lines(cx: &mut TestAppContext) { + let (app, mut vcx) = window(cx); + app.update_in(&mut vcx, |this, _, _| { + let overlay = this.tabs[0].diff_overlay.as_mut().unwrap(); + overlay.load = DiffLoad::Ready(Arc::new(DiffSnapshot { + files: vec![ + FileDiff { + path: "src/above.rs".to_string(), + ..patched_file() + }, + patched_file(), + ], + ..Default::default() + })); + }); + drag(&app, &mut vcx, DiffViewMode::Unified, None, 1, 3); + + app.update_in(&mut vcx, |this, _, cx| { + let active = this.active; + { + let overlay = this.tabs[active].diff_overlay.as_mut().unwrap(); + overlay.expanded.insert("src/above.rs".to_string(), false); + } + let _ = this.sync_diff_rows(cx); + }); + assert_eq!( + copied(&app, &mut vcx).as_deref(), + Some("b\nc\nB"), + "the range still names the same three lines of the same file" + ); + } +} diff --git a/src/ui/diff_rows.rs b/src/ui/diff_rows.rs index b5316d50..e6f405ad 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. @@ -28,6 +29,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, } #[derive(PartialEq, Eq)] @@ -40,18 +45,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, }), }); } @@ -60,12 +71,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 { @@ -73,11 +84,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, }), }); } @@ -93,6 +106,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 @@ -102,15 +118,134 @@ 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)), + } + } +} + +/// 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::*; @@ -242,4 +377,194 @@ 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(&[]), + "" + ); + } + + /// The one place a view mode turns into rows, so a copy is read off the + /// rows the list drew rather than a second guess at them. + #[test] + fn each_view_builds_the_rows_its_renderer_draws() { + let lines = hunk(); + let split = HunkRows::build(DiffViewMode::Split, &lines); + assert!(matches!(split, HunkRows::Split(rows) if rows.len() == 4)); + let unified = HunkRows::build(DiffViewMode::Unified, &lines); + assert!(matches!(unified, HunkRows::Unified(rows) if rows.len() == 5)); + } } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index ea205f5d..4bc309b2 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1205,6 +1205,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 ce986fc0..3a61cd94 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1271,6 +1271,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 1219d497..9464f67f 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 7063697a..3c6e6b27 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1143,6 +1143,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 需要一个名称",