From f7fd1d43fd790fba5977fe06427ebe59fb2c45b1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:18:21 +0800 Subject: [PATCH] fix(diff): let a drag cross the blank half of a split row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split row pads whichever side of a change has no line, and that half was drawn inert: no I-beam, a press that started nothing, and a range that visibly stopped at the padding and resumed below it. A one-sided change is the ordinary shape of a diff, so the dead band runs down most of one column — the drag it swallows is the common one. The blank half now carries the same press and move listeners as any other cell and takes the selection colour when the range covers it. What lands on the clipboard is unchanged: `DiffSelection::text` already reads past the padding, because the blank half is layout, not a line. Also drives an upward drag through the real entry points. `range()` ordering was only asserted in `diff_rows`, where a selection is built by hand; nothing checked that a drag whose head ends above its anchor copies the same rows. --- src/ui/diff_overlay.rs | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 5a58e197..73479c0e 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -1383,7 +1383,19 @@ impl Tty7App { Side::New => base.rounded_br(outer_radius), }; 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 rowsel.covers(id, Some(side)) { + true => cx.theme().selection, + false => cx.theme().muted.opacity(0.3), + }; + return self + .diff_row_drag(base, rowsel, Some(side), id, cx) + .bg(fill) + .into_any_element(); }; let (marker, tint) = match (cell.changed, side) { (true, Side::Old) => ("−", Some(cx.theme().danger.opacity(0.12))), @@ -2984,6 +2996,32 @@ mod gpui_tests { assert_eq!(copied(&app, &mut vcx).as_deref(), Some("b\nc\nB")); } + /// A drag that runs up the card 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 reversed = app.update_in(&mut vcx, |this, _, _| { + let sel = this.tabs[0] + .diff_overlay + .as_ref() + .and_then(|o| o.selection.as_ref()) + .expect("the drag this test just made"); + sel.head < sel.anchor + }); + assert!(reversed, "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