mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
+529
-33
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "ワークツリーには名前が必要です",
|
||||
|
||||
@@ -901,6 +901,7 @@ l10n_keys! {
|
||||
DiffUntrackedSummary,
|
||||
DiffViewSplit,
|
||||
DiffViewUnified,
|
||||
DiffCopySelection,
|
||||
PendingConnecting,
|
||||
PendingUnreachable,
|
||||
WorktreePromptNeedsName,
|
||||
|
||||
@@ -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 需要一个名称",
|
||||
|
||||
Reference in New Issue
Block a user