feat(diff): let the pointer take a range of diff lines and copy it (#721)

The overlay drew its patch as a grid of independent text elements, so
there was no way to get code out of it: no selection, no clipboard, and
nothing to right-click. Reading a diff and then retyping what you read
is not a workflow.

Pressing on a row starts a range, dragging extends it, and Ctrl/Cmd+C
copies it — as does the new Copy Selected Lines item on the card's
context menu. The rows carry an I-beam so the offer is visible before
anyone tries it.

What lands on the clipboard is the file's own text: no +/- marker, no
line numbers, and tabs left as the file wrote them rather than the four
spaces the grid draws. In the split view a drag reads the column it
started in, so the left one gives the code as it was and the right one
as it is; in the unified view a row is a line. A range never spans two
cards, and it is dropped when the view mode flips or a fresh patch
lands, since either one re-cuts the rows underneath it.

Selection is line-granular. Half a line, or a range spanning two files,
is not on offer here.
This commit is contained in:
l0ng-ai
2026-09-07 18:48:42 +08:00
parent 893172f57d
commit 87dc26f061
6 changed files with 876 additions and 43 deletions
+529 -33
View File
@@ -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<Arc<FileDiff>>)>,
pub(crate) preview_loading: Option<String>,
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<DiffSelection>,
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<u64>,
}
/// One hunk, already turned into whichever kind of row the current view draws.
enum HunkRows {
Split(Vec<SplitRow>),
Unified(Vec<UnifiedRow>),
/// 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<Side>) -> 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<E: InteractiveElement + Styled>(
&self,
el: E,
rowsel: RowSelect,
side: Option<Side>,
id: RowId,
cx: &Context<Self>,
) -> 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<Side>,
id: RowId,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Side>,
id: RowId,
held: Option<MouseButton>,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) -> Option<AnyElement> {
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<Self>) -> AnyElement {
fn diff_split_row(
&self,
row: &SplitRow,
id: RowId,
rowsel: RowSelect,
closes_card: bool,
cx: &Context<Self>,
) -> 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<Self>,
) -> 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<Self>,
) -> 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<u32>, new: Option<u32>, 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<Tty7App>, 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<Tty7App>,
vcx: &mut VisualTestContext,
mode: DiffViewMode,
side: Option<Side>,
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::<Config>().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<Tty7App>, vcx: &mut VisualTestContext) -> Option<String> {
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"));
}
}
+343 -10
View File
@@ -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<u32>,
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<SplitRow> {
fn flush(rows: &mut Vec<SplitRow>, rem: &mut Vec<&DiffLine>, add: &mut Vec<&DiffLine>) {
fn flush(
rows: &mut Vec<SplitRow>,
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<SplitRow> {
}
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<SplitRow> {
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<u32>,
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<UnifiedRow> {
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<SplitRow>),
Unified(Vec<UnifiedRow>),
}
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<Side>,
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<Side>) -> 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<DiffLine>) -> Hunk {
Hunk {
header: "@@ -1,4 +1,3 @@".to_string(),
lines,
}
}
fn drag(
mode: DiffViewMode,
side: Option<Side>,
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());
}
}
+1
View File
@@ -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",
+1
View File
@@ -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 => "ワークツリーには名前が必要です",
+1
View File
@@ -901,6 +901,7 @@ l10n_keys! {
DiffUntrackedSummary,
DiffViewSplit,
DiffViewUnified,
DiffCopySelection,
PendingConnecting,
PendingUnreachable,
WorktreePromptNeedsName,
+1
View File
@@ -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 需要一个名称",