mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
`sync_diff_rows` wrote `rows_key` only when it rebuilt the rows. A probe that found nothing new lands a fresh `Arc` over an equal snapshot, so `describes` answered yes the long way — by walking every line of the patch — and then left the key holding the *old* `Arc`. Every frame after that paid the same walk: once per wheel event, over the ~1 MB of `String` at the 20k-line budget, which is the cost the key exists to avoid. Retarget it on a match, so the pointer comparison settles the next frame. The preview was doubly exposed: `install_diff_snapshot` clears it on every landing, so the re-read produces a fresh `Arc<FileDiff>` too. Its comparison also ran eagerly, ahead of the scalars that could already have answered no; move it into the `&&` chain. `stack_corners` lost its last non-test caller when the file cards went, and `cargo build` had started reporting it. Drop it and the test that held it up.
2873 lines
107 KiB
Rust
2873 lines
107 KiB
Rust
use std::collections::HashMap;
|
||
use std::path::{Path, PathBuf};
|
||
use std::rc::Rc;
|
||
use std::sync::Arc;
|
||
|
||
use gpui::{
|
||
AnyElement, Background, FocusHandle, FontWeight, Hsla, KeyDownEvent, Pixels, SharedString,
|
||
Window, div, prelude::*, px,
|
||
};
|
||
use gpui_component::button::Button;
|
||
use gpui_component::menu::ContextMenuExt as _;
|
||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||
|
||
use crate::core::config::{Config, DiffViewMode};
|
||
use crate::core::git::status::DecoStatus;
|
||
use crate::terminal::git_diff::{
|
||
self, CommitLabel, DiffSnapshot, DiffSource, DiffStats, FileDiff, FileStatus, LineKind,
|
||
Truncation,
|
||
};
|
||
|
||
/// How much of an untracked file the preview will read. Past this the card
|
||
/// says the read failed rather than showing a silently cut-off file — and the
|
||
/// 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::document_column::DocumentChrome;
|
||
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
|
||
use crate::ui::right_panel::info_chip;
|
||
use crate::ui::rounding;
|
||
use crate::ui::scm::path::relative_time;
|
||
use crate::ui::scm::status::{status_color, status_glyph};
|
||
|
||
pub(crate) enum DiffLoad {
|
||
Loading,
|
||
Ready(Arc<DiffSnapshot>),
|
||
NotARepo,
|
||
}
|
||
|
||
pub(crate) struct DiffOverlayState {
|
||
pub(crate) host_id: crate::ui::host_ops::HostId,
|
||
pub(crate) cwd: PathBuf,
|
||
/// Which patch this overlay is showing. Part of its identity, not a
|
||
/// setting: two sources over one directory are two different overlays.
|
||
pub(crate) source: DiffSource,
|
||
pub(crate) focus_handle: FocusHandle,
|
||
pub(crate) load: DiffLoad,
|
||
pub(crate) loading: bool,
|
||
pub(crate) expanded: HashMap<String, bool>,
|
||
pub(crate) focus: Option<String>,
|
||
/// A synthesized all-added card for a focused *untracked* file, keyed by
|
||
/// path; `None` in the value means the read failed. git has no patch for
|
||
/// an untracked file, so focusing one reads its bytes instead — lazily,
|
||
/// only for the file on screen, never for the whole list. Cleared when a
|
||
/// fresh snapshot lands, so an edit to the file shows up on the same
|
||
/// cadence a tracked file's does.
|
||
pub(crate) preview: Option<(String, Option<Arc<FileDiff>>)>,
|
||
pub(crate) preview_loading: Option<String>,
|
||
/// 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,
|
||
/// The patch, flattened into one row per line — see
|
||
/// [`crate::ui::diff_list`]. Rebuilt only when [`RowsKey`] changes.
|
||
pub(crate) rows: Rc<Vec<DiffRow>>,
|
||
rows_key: Option<RowsKey>,
|
||
/// The [`ScmData`](crate::terminal::git_data::ScmData) epoch this patch was
|
||
/// read at, for the two sources that can go stale.
|
||
///
|
||
/// Recorded when a probe *starts*, so a `git add` that lands while one is
|
||
/// running is not mistaken for a change the result already reflects.
|
||
/// `None` until the first snapshot arrives: the epoch is keyed by the
|
||
/// repository root, and only a snapshot knows where that is.
|
||
pub(crate) epoch: Option<u64>,
|
||
}
|
||
|
||
/// Paints the full-window diff surface without inheriting workspace opacity.
|
||
/// The preset's solid or gradient design remains intact, but neither it nor
|
||
/// the plain theme fallback may reveal the OS backdrop through diff text.
|
||
fn diff_overlay_background(
|
||
active: Option<&crate::ui::presets::ActiveBackground>,
|
||
fallback: Hsla,
|
||
) -> Background {
|
||
match active {
|
||
Some(bg) => crate::ui::theme::window_background_opaque(bg),
|
||
None => fallback.alpha(1.0).into(),
|
||
}
|
||
}
|
||
|
||
impl Tty7App {
|
||
pub(crate) fn toggle_diff_overlay(
|
||
&mut self,
|
||
host: crate::ui::host_ops::HostId,
|
||
cwd: PathBuf,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
self.toggle_diff_overlay_at(host, cwd, None, window, cx)
|
||
}
|
||
|
||
pub(crate) fn toggle_diff_overlay_at(
|
||
&mut self,
|
||
host: crate::ui::host_ops::HostId,
|
||
cwd: PathBuf,
|
||
focus: Option<String>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
// The sidebar's `+N −M` is `diff --numstat HEAD`, so opening it has
|
||
// to show the same span. The panel names its own source per group.
|
||
self.open_diff_overlay(host, cwd, DiffSource::Head, focus, window, cx)
|
||
}
|
||
|
||
pub(crate) fn open_diff_overlay(
|
||
&mut self,
|
||
host: crate::ui::host_ops::HostId,
|
||
cwd: PathBuf,
|
||
source: DiffSource,
|
||
focus: Option<String>,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let active = self.active;
|
||
let was_front = self.tabs.get(active).is_some_and(|t| {
|
||
t.overlay_top == crate::ui::app::OverlayTop::Diff || !self.code_panel_visible()
|
||
});
|
||
if let Some(tab) = self.tabs.get_mut(active) {
|
||
tab.overlay_top = crate::ui::app::OverlayTop::Diff;
|
||
}
|
||
// The source belongs in this filter: an open worktree overlay reused
|
||
// for a staged file would only move the focus, and go on showing the
|
||
// unstaged patch under the staged file's name.
|
||
match self
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.as_mut())
|
||
.filter(|o| o.cwd == cwd && o.host_id == host && o.source == source)
|
||
{
|
||
Some(o) if o.focus == focus && was_front => {
|
||
self.close_diff_overlay(window, cx);
|
||
return;
|
||
}
|
||
Some(o) => {
|
||
o.focus = focus;
|
||
let handle = o.focus_handle.clone();
|
||
window.focus(&handle, cx);
|
||
cx.notify();
|
||
return;
|
||
}
|
||
None => {}
|
||
}
|
||
self.remember_active_pane(window, cx);
|
||
let Some(tab) = self.tabs.get_mut(active) else {
|
||
return;
|
||
};
|
||
let focus_handle = cx.focus_handle();
|
||
tab.diff_overlay = Some(DiffOverlayState {
|
||
host_id: host,
|
||
cwd,
|
||
source,
|
||
focus_handle: focus_handle.clone(),
|
||
// Every open starts at Loading until its own probe lands. The old
|
||
// panel-snapshot seeding died with the panel that held a snapshot
|
||
// per source; re-seeding would need the caller to carry one.
|
||
load: DiffLoad::Loading,
|
||
loading: false,
|
||
expanded: HashMap::new(),
|
||
focus,
|
||
preview: None,
|
||
preview_loading: None,
|
||
list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(256.)),
|
||
rows: Rc::new(Vec::new()),
|
||
rows_key: None,
|
||
epoch: None,
|
||
});
|
||
window.focus(&focus_handle, cx);
|
||
self.spawn_diff_probe(cx);
|
||
cx.notify();
|
||
}
|
||
|
||
/// Which file the open overlay is focused on — for the row that asked,
|
||
/// which means the *source* has to match too: a file staged and edited
|
||
/// again sits in two panel groups, and only the row whose patch is
|
||
/// actually on screen may draw itself selected.
|
||
pub(crate) fn diff_overlay_focus(
|
||
&self,
|
||
host: crate::ui::host_ops::HostId,
|
||
cwd: &std::path::Path,
|
||
source: &crate::terminal::git_diff::DiffSource,
|
||
) -> Option<&str> {
|
||
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
|
||
(overlay.cwd == cwd && overlay.host_id == host && overlay.source == *source)
|
||
.then_some(overlay.focus.as_deref())?
|
||
}
|
||
|
||
pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||
let active = self.active;
|
||
let taken = self
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.take());
|
||
if taken.is_some() {
|
||
self.focus_active(window, cx);
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
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 {
|
||
return;
|
||
};
|
||
if overlay.loading {
|
||
return;
|
||
}
|
||
let cwd = overlay.cwd.clone();
|
||
let source = overlay.source.clone();
|
||
let id = overlay.host_id;
|
||
// Read before the probe is dispatched, not after it lands: anything
|
||
// bumped in between belongs to the next read, not this one.
|
||
let epoch = match &overlay.load {
|
||
DiffLoad::Ready(snap) => Some(scm_epoch(cx, id, &snap.root)),
|
||
_ => None,
|
||
};
|
||
let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, id) else {
|
||
return;
|
||
};
|
||
let Some(overlay) = self
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.as_mut())
|
||
else {
|
||
return;
|
||
};
|
||
overlay.loading = true;
|
||
overlay.epoch = epoch;
|
||
self.spawn_diff_probe_for(host, cwd, source, cx);
|
||
}
|
||
|
||
pub(crate) fn spawn_diff_probe_for(
|
||
&mut self,
|
||
host: crate::ui::host_ops::SharedHost,
|
||
cwd: PathBuf,
|
||
source: DiffSource,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
let key = probe_key(host.id(), &cwd, &source);
|
||
if !self.diff_probes_inflight.insert(key.clone()) {
|
||
self.diff_probes_restale.insert(key);
|
||
return;
|
||
}
|
||
let host_for_retry = host.clone();
|
||
let probe_cwd = cwd.clone();
|
||
let probe_source = source.clone();
|
||
crate::ui::host_ops::HostOps::run(
|
||
host,
|
||
cx,
|
||
move |h| {
|
||
let req = git_diff::DiffRequest {
|
||
source: probe_source,
|
||
..Default::default()
|
||
};
|
||
git_diff::probe_diff(h, &probe_cwd, &req)
|
||
},
|
||
move |app, result, cx| {
|
||
let id = key.0;
|
||
app.diff_probes_inflight.remove(&key);
|
||
app.install_diff_snapshot(id, &cwd, &source, result.map(Arc::new), cx);
|
||
if app.diff_probes_restale.remove(&key) {
|
||
app.spawn_diff_probe_for(host_for_retry, cwd, source, cx);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
fn install_diff_snapshot(
|
||
&mut self,
|
||
host: crate::ui::host_ops::HostId,
|
||
cwd: &Path,
|
||
source: &DiffSource,
|
||
snap: Option<Arc<DiffSnapshot>>,
|
||
cx: &mut Context<Self>,
|
||
) {
|
||
// A diff read is a fresher answer to the question the sidebar's branch
|
||
// and +N −N ask, and it is the one the reader is looking at. Hand the
|
||
// branch and the numbers back before anything renders, or the row can
|
||
// disagree with the overlay it just opened — and `maybe_refresh` reads
|
||
// that disagreement as a reason to probe again.
|
||
//
|
||
// A read that failed is not an answer at all: its totals are whatever
|
||
// got parsed before git gave up, usually zero. Publishing those wipes
|
||
// the counts the sidebar already had right — the overlay says so in
|
||
// words a few lines below, and the row would silently disagree.
|
||
let mut landed = if let Some(snap) = snap.as_ref().filter(|s| !s.read_failed) {
|
||
// Only a HEAD snapshot counts what the sidebar counts. A worktree
|
||
// or staged patch is a smaller answer to a different question, and
|
||
// a commit or a range is not about the working tree at all.
|
||
let counts = matches!(snap.source, DiffSource::Head).then(|| snap.totals());
|
||
let root = snap.root.clone();
|
||
let branch = snap.branch.clone();
|
||
cx.default_global::<crate::terminal::git_status::GitStatusCache>();
|
||
cx.update_global::<crate::terminal::git_status::GitStatusCache, _>(|cache, _| {
|
||
cache.note_diff_read(host, &root, &branch, counts)
|
||
})
|
||
} else {
|
||
false
|
||
};
|
||
// Only wanted by an overlay whose first probe could not know the root,
|
||
// and so could not read its own epoch before dispatching.
|
||
let landing_epoch = snap.as_ref().map(|s| scm_epoch(cx, host, &s.root));
|
||
for tab in self.tabs.iter_mut() {
|
||
let Some(overlay) = tab
|
||
.diff_overlay
|
||
.as_mut()
|
||
.filter(|o| o.cwd == cwd && o.host_id == host && o.source == *source)
|
||
else {
|
||
continue;
|
||
};
|
||
overlay.loading = false;
|
||
overlay.epoch = overlay.epoch.or(landing_epoch);
|
||
overlay.load = match &snap {
|
||
Some(snap) => DiffLoad::Ready(Arc::clone(snap)),
|
||
None => DiffLoad::NotARepo,
|
||
};
|
||
// 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;
|
||
landed = true;
|
||
}
|
||
if landed {
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
pub(crate) fn maybe_refresh_diff_overlay(&mut self, cx: &mut Context<Self>) {
|
||
let Some(overlay) = self
|
||
.tabs
|
||
.get(self.active)
|
||
.and_then(|t| t.diff_overlay.as_ref())
|
||
else {
|
||
return;
|
||
};
|
||
if overlay.loading {
|
||
return;
|
||
}
|
||
let DiffLoad::Ready(snap) = &overlay.load else {
|
||
return;
|
||
};
|
||
let stale = match overlay.source {
|
||
// A commit and a range are fixed patches. Nothing can make either
|
||
// of them out of date, so nothing should reprobe them.
|
||
DiffSource::Commit { .. } | DiffSource::Range { .. } => return,
|
||
// The cached counts come from `git diff --numstat HEAD`, so only a
|
||
// HEAD snapshot is comparable to them.
|
||
DiffSource::Head => {
|
||
let Some(status) = cx
|
||
.try_global::<crate::terminal::git_status::GitStatusCache>()
|
||
.and_then(|cache| cache.status_for(overlay.host_id, &overlay.cwd))
|
||
else {
|
||
return;
|
||
};
|
||
status.branch != snap.branch || (status.added, status.removed) != snap.totals()
|
||
}
|
||
// Those same counts would differ from a staged or unstaged patch
|
||
// the moment anything is staged, and the overlay would reprobe
|
||
// forever. The epoch answers the question that was actually being
|
||
// asked — "did anything happen to this repository" — without
|
||
// knowing what either side is counting.
|
||
DiffSource::Worktree | DiffSource::Staged => {
|
||
let Some(seen) = overlay.epoch else {
|
||
return;
|
||
};
|
||
scm_epoch(cx, overlay.host_id, &snap.root) != seen
|
||
}
|
||
};
|
||
if stale {
|
||
self.spawn_diff_probe(cx);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn render_diff_overlay(
|
||
&mut self,
|
||
chrome: DocumentChrome,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<AnyElement> {
|
||
self.spawn_untracked_preview_if_needed(cx);
|
||
let body = self.sync_diff_rows(cx)?;
|
||
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
|
||
|
||
let content = match body {
|
||
DiffBody::Message(text) => self.diff_message(text, cx),
|
||
DiffBody::Rows(snap) => self.diff_rows_list(overlay, snap, cx),
|
||
};
|
||
|
||
let header = chrome
|
||
.renders_own_header()
|
||
.then(|| self.diff_header(overlay, chrome, window, cx));
|
||
let focus_handle = overlay.focus_handle.clone();
|
||
|
||
let shell = v_flex();
|
||
let shell = match chrome {
|
||
DocumentChrome::Fill => shell
|
||
.absolute()
|
||
.inset_0()
|
||
.occlude()
|
||
// Opaque on purpose: this overlay covers the entire workspace,
|
||
// so window translucency and backdrop material must stop here.
|
||
.bg(diff_overlay_background(
|
||
cx.try_global::<crate::ui::presets::ActiveBackground>(),
|
||
cx.theme().background,
|
||
))
|
||
// The opaque fill above covers the theme background image the
|
||
// workspace root paints, so the overlay carries its own copy,
|
||
// dimmed back to the strength it had when this overlay was
|
||
// itself translucent.
|
||
.children(crate::ui::app::overlay_surface_layers(cx)),
|
||
// Docked, the column wrapper has already painted the surface this
|
||
// sits on — the same one the right panel uses — and nothing behind
|
||
// it needs stopping.
|
||
DocumentChrome::Dock | DocumentChrome::DockHoisted => shell.size_full().min_w_0(),
|
||
};
|
||
Some(
|
||
shell
|
||
.text_color(cx.theme().foreground)
|
||
.track_focus(&focus_handle)
|
||
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
|
||
if ev.keystroke.key.as_str() == "escape" {
|
||
this.close_diff_overlay(window, cx);
|
||
}
|
||
}))
|
||
.children(header)
|
||
.child(content)
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
/// The diff header alone, for the strip above a docked column.
|
||
pub(crate) fn render_diff_header_only(
|
||
&mut self,
|
||
chrome: DocumentChrome,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> Option<AnyElement> {
|
||
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
|
||
Some(
|
||
self.diff_header(overlay, chrome, window, cx)
|
||
.into_any_element(),
|
||
)
|
||
}
|
||
|
||
fn diff_header(
|
||
&self,
|
||
overlay: &DiffOverlayState,
|
||
chrome: DocumentChrome,
|
||
window: &mut Window,
|
||
cx: &mut Context<Self>,
|
||
) -> impl IntoElement + use<> {
|
||
let (branch, files, untracked, added, removed) = match &overlay.load {
|
||
DiffLoad::Ready(s) => {
|
||
let stats = s.stats();
|
||
let (a, r) = stats.totals;
|
||
(s.branch.clone(), s.files.len(), stats.untracked_count, a, r)
|
||
}
|
||
_ => (String::new(), 0, 0, 0, 0),
|
||
};
|
||
// See `render_editor_header`: the traffic-light inset belongs to a
|
||
// header that starts at the window's left edge, which a column's does
|
||
// not.
|
||
let lead = if self.left_panel_open(cx) || chrome.is_dock() {
|
||
crate::ui::app::CONTENT_INSET
|
||
} else {
|
||
crate::ui::app::TITLE_BAR_LEAD
|
||
};
|
||
let mono = SharedString::from(self.font_family.clone());
|
||
let subject = source_subject(&overlay.source, branch);
|
||
let subject_takes_the_slack =
|
||
chrome.is_dock() && !subject.is_rev && subject.label.is_none();
|
||
let menu_app = cx.entity().downgrade();
|
||
let row = h_flex().id("diff-overlay-header");
|
||
let row = if chrome.header_is_title_strip() {
|
||
crate::ui::app::title_bar_drag(row, "diff-overlay-header", window, cx)
|
||
} else {
|
||
row
|
||
};
|
||
row.flex_shrink_0()
|
||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||
.pl(px(lead))
|
||
.pr(px(crate::ui::app::tile_trailing_inset()))
|
||
.gap_2()
|
||
.items_center()
|
||
.border_b_1()
|
||
.border_color(cx.theme().border)
|
||
.child(
|
||
gpui::svg()
|
||
.path(subject.icon)
|
||
.flex_shrink_0()
|
||
.size(px(13.))
|
||
.text_color(cx.theme().muted_foreground),
|
||
)
|
||
.child(if subject.is_rev {
|
||
// A revision is an identifier, not a name: it belongs in the
|
||
// same monospace the patch below it is set in.
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_size(px(13.))
|
||
.font_family(self.font_family.clone())
|
||
.child(subject.text)
|
||
.into_any_element()
|
||
} else {
|
||
// Docked, this is the name that gives: the header has a
|
||
// column's width rather than a window's, and a branch name that
|
||
// refused to yield any of it pushed the view toggle and the
|
||
// close tile off the end. It takes the slack the spacer below
|
||
// would otherwise have — the same trade the label branch makes,
|
||
// and for the same reason two `flex_1` siblings would split the
|
||
// line and truncate the name with empty space beside it.
|
||
div()
|
||
.when(subject_takes_the_slack, |d| d.flex_1().min_w_0().truncate())
|
||
.when(!subject_takes_the_slack, |d| d.flex_shrink_0())
|
||
.text_sm()
|
||
.font_weight(FontWeight::MEDIUM)
|
||
.child(subject.text)
|
||
.into_any_element()
|
||
})
|
||
.when_some(subject.chip, |bar, text| {
|
||
bar.child(info_chip(
|
||
text,
|
||
cx.theme().accent.opacity(0.16),
|
||
cx.theme().foreground,
|
||
&mono,
|
||
))
|
||
})
|
||
// The subject takes the slack the spacer below would otherwise
|
||
// have, which is why that one is skipped when a label is present:
|
||
// two `flex_1` siblings split the line in half and the subject
|
||
// would truncate with empty space beside it.
|
||
.when_some(subject.label.as_ref(), |bar, label| {
|
||
bar.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_sm()
|
||
.child(SharedString::from(label.subject.clone())),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(label_byline(label, now_unix())),
|
||
)
|
||
})
|
||
.when_some(focused_name(overlay), |bar, name| {
|
||
bar.child(
|
||
div().occlude().flex_shrink_0().child(
|
||
h_flex()
|
||
.id("diff-overlay-unfocus")
|
||
.items_center()
|
||
.gap_1()
|
||
.px_1p5()
|
||
.py_0p5()
|
||
.rounded_md()
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(cx.theme().list_hover))
|
||
.on_click(cx.listener(|this, _, _window, cx| {
|
||
let active = this.active;
|
||
if let Some(overlay) = this
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.as_mut())
|
||
{
|
||
overlay.focus = None;
|
||
cx.notify();
|
||
}
|
||
}))
|
||
.child(
|
||
Icon::new(IconName::ChevronLeft)
|
||
.small()
|
||
.text_color(cx.theme().muted_foreground),
|
||
)
|
||
.child(
|
||
div()
|
||
.text_xs()
|
||
.font_family(self.font_family.clone())
|
||
.child(name),
|
||
),
|
||
),
|
||
)
|
||
})
|
||
.when(
|
||
matches!(overlay.load, DiffLoad::Ready(_)) && overlay.focus.is_none(),
|
||
|bar| {
|
||
let mut summary = t_plural(L10nKey::DiffChangedFiles, files, &[]);
|
||
if untracked > 0 {
|
||
summary.push_str(&t_plural(L10nKey::DiffUntrackedCount, untracked, &[]));
|
||
}
|
||
// The file count is the first thing a column drops: the
|
||
// same number is one line down, at the top of the list.
|
||
// The totals stay — they have no second home.
|
||
bar.when(!chrome.is_dock(), |bar| {
|
||
bar.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(summary),
|
||
)
|
||
})
|
||
.when(added > 0, |bar| {
|
||
bar.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(cx.theme().success)
|
||
.child(format!("+{added}")),
|
||
)
|
||
})
|
||
.when(removed > 0, |bar| {
|
||
bar.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(cx.theme().danger)
|
||
.child(format!("−{removed}")),
|
||
)
|
||
})
|
||
},
|
||
)
|
||
.when(
|
||
overlay.loading && matches!(overlay.load, DiffLoad::Ready(_)),
|
||
|bar| {
|
||
bar.child(
|
||
div()
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(t(L10nKey::Refreshing)),
|
||
)
|
||
},
|
||
)
|
||
.when(subject.label.is_none() && !subject_takes_the_slack, |bar| {
|
||
bar.child(div().flex_1())
|
||
})
|
||
.child(
|
||
div()
|
||
.occlude()
|
||
.flex_shrink_0()
|
||
.child(self.diff_view_switch(cx)),
|
||
)
|
||
.child(
|
||
div().occlude().flex_shrink_0().child(
|
||
crate::ui::tab_strip::chrome_tile_sized(
|
||
Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)),
|
||
crate::ui::app::TILE_SIZE,
|
||
crate::ui::app::TILE_GLYPH_LINE,
|
||
false,
|
||
cx,
|
||
)
|
||
.rounded_lg()
|
||
.tooltip(t(L10nKey::DiffCloseTooltip))
|
||
.on_click(cx.listener(|this, _, window, cx| {
|
||
this.close_diff_overlay(window, cx);
|
||
})),
|
||
),
|
||
)
|
||
.context_menu(move |menu, _window, cx| {
|
||
Tty7App::document_header_menu(menu, &menu_app, cx)
|
||
})
|
||
}
|
||
|
||
/// The two views, as a switch rather than a control.
|
||
///
|
||
/// Not [`Tty7App::segmented_on`]: that one is a bordered track, which is
|
||
/// right in a settings row, where it ends a line of prose and has to
|
||
/// announce itself as something you operate. On a title bar it was the
|
||
/// only bordered thing on the strip — the close tile beside it is a bare
|
||
/// glyph, and so is every tile at the other end of the window — so it read
|
||
/// as pasted on. Same two choices, no frame: the live one carries a soft
|
||
/// fill, the other is quiet text that lights up under the pointer.
|
||
fn diff_view_switch(&self, cx: &mut Context<Self>) -> AnyElement {
|
||
let sf = cx.global::<crate::ui::presets::Surfaces>().window;
|
||
let current = view_mode(cx);
|
||
let cells = [
|
||
(DiffViewMode::Split, t(L10nKey::DiffViewSplit)),
|
||
(DiffViewMode::Unified, t(L10nKey::DiffViewUnified)),
|
||
];
|
||
h_flex()
|
||
.id("diff-overlay-view")
|
||
.flex_shrink_0()
|
||
.gap(px(2.))
|
||
.children(cells.into_iter().enumerate().map(|(i, (mode, label))| {
|
||
let live = mode == current;
|
||
h_flex()
|
||
.id(("diff-overlay-view-cell", i))
|
||
.items_center()
|
||
.h(px(22.))
|
||
.px(px(8.))
|
||
.rounded(ROW_RADIUS)
|
||
.text_sm()
|
||
.cursor_pointer()
|
||
.when(live, |cell| {
|
||
cell.bg(gpui::rgb(sf.selected))
|
||
.text_color(gpui::rgb(sf.text_selected))
|
||
.font_weight(FontWeight::MEDIUM)
|
||
})
|
||
.when(!live, |cell| {
|
||
cell.text_color(cx.theme().muted_foreground)
|
||
.hover(|h| h.bg(gpui::rgb(sf.hover)))
|
||
})
|
||
.active(|cell| cell.bg(gpui::rgb(sf.pressed)))
|
||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||
this.update_config(cx, |cfg| cfg.diff_view = mode);
|
||
}))
|
||
.child(label)
|
||
}))
|
||
.into_any_element()
|
||
}
|
||
|
||
/// Dispatch the byte read behind an untracked file's preview, at most
|
||
/// once per (path, snapshot). Runs from `render`, so the guards are the
|
||
/// point: `preview` says the answer is in hand, `preview_loading` says it
|
||
/// is on the way.
|
||
fn spawn_untracked_preview_if_needed(&mut self, cx: &mut Context<Self>) {
|
||
let want = {
|
||
let overlay = self
|
||
.tabs
|
||
.get(self.active)
|
||
.and_then(|t| t.diff_overlay.as_ref());
|
||
match overlay {
|
||
Some(o) => match &o.load {
|
||
DiffLoad::Ready(snap) => {
|
||
untracked_focus(snap, o.focus.as_deref()).and_then(|path| {
|
||
let seen = o.preview.as_ref().is_some_and(|(held, _)| held == path)
|
||
|| o.preview_loading.as_deref() == Some(path);
|
||
(!seen).then(|| (o.host_id, snap.root.clone(), path.to_string()))
|
||
})
|
||
}
|
||
_ => None,
|
||
},
|
||
None => None,
|
||
}
|
||
};
|
||
let Some((host_id, root, path)) = want else {
|
||
return;
|
||
};
|
||
let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, host_id) else {
|
||
return;
|
||
};
|
||
let active = self.active;
|
||
if let Some(o) = self
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.as_mut())
|
||
{
|
||
o.preview_loading = Some(path.clone());
|
||
}
|
||
let read_path = root.join(&path);
|
||
let key_path = path.clone();
|
||
crate::ui::host_ops::HostOps::run(
|
||
host,
|
||
cx,
|
||
move |h| {
|
||
h.read_file(&read_path, MAX_PREVIEW_BYTES)
|
||
.ok()
|
||
.map(|bytes| {
|
||
Arc::new(git_diff::synthesize_added(
|
||
&path,
|
||
&bytes,
|
||
&git_diff::DiffBudget::SINGLE_FILE,
|
||
))
|
||
})
|
||
},
|
||
move |this, file, cx| {
|
||
let active = this.active;
|
||
let Some(o) = this
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.as_mut())
|
||
.filter(|o| o.host_id == host_id)
|
||
else {
|
||
return;
|
||
};
|
||
if o.preview_loading.as_deref() == Some(key_path.as_str()) {
|
||
o.preview_loading = None;
|
||
}
|
||
o.preview = Some((key_path.clone(), file));
|
||
cx.notify();
|
||
},
|
||
);
|
||
}
|
||
|
||
fn diff_message(&self, text: &'static str, cx: &Context<Self>) -> AnyElement {
|
||
div()
|
||
.flex_1()
|
||
.flex()
|
||
.items_center()
|
||
.justify_center()
|
||
.text_sm()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(text)
|
||
.into_any_element()
|
||
}
|
||
|
||
/// Brings the active overlay's flattened rows up to date with what it is
|
||
/// meant to be showing, and says what to draw.
|
||
///
|
||
/// Called from `render`, so the [`RowsKey`] comparison is what keeps it
|
||
/// cheap: flattening a twenty-thousand-line patch allocates a row per
|
||
/// line, and nothing about that changes between two frames of scrolling.
|
||
fn sync_diff_rows(&mut self, cx: &mut Context<Self>) -> Option<DiffBody> {
|
||
let mode = view_mode(cx);
|
||
let active = self.active;
|
||
let overlay = self.tabs.get_mut(active)?.diff_overlay.as_mut()?;
|
||
|
||
let snap = match &overlay.load {
|
||
DiffLoad::Loading => return Some(DiffBody::Message(t(L10nKey::DiffReading))),
|
||
DiffLoad::NotARepo => return Some(DiffBody::Message(t(L10nKey::DiffNotARepo))),
|
||
DiffLoad::Ready(snap) if empty_snapshot(snap) && snap.read_failed => {
|
||
return Some(DiffBody::Message(t(L10nKey::DiffReadFailed)));
|
||
}
|
||
DiffLoad::Ready(snap) if empty_snapshot(snap) => {
|
||
return Some(DiffBody::Message(t(L10nKey::DiffWorkingTreeClean)));
|
||
}
|
||
DiffLoad::Ready(snap) => Arc::clone(snap),
|
||
};
|
||
|
||
// A focused *untracked* file has no patch in the snapshot; its card is
|
||
// synthesized from the file's own bytes — see `preview`.
|
||
let preview = match untracked_focus(&snap, overlay.focus.as_deref()) {
|
||
Some(path) => match &overlay.preview {
|
||
Some((held, file)) if held == path => match file {
|
||
Some(file) => Some(Arc::clone(file)),
|
||
None => return Some(DiffBody::Message(t(L10nKey::DiffReadFailed))),
|
||
},
|
||
_ => return Some(DiffBody::Message(t(L10nKey::DiffReading))),
|
||
},
|
||
None => None,
|
||
};
|
||
|
||
let focused = focused_file(&snap, overlay);
|
||
let from = RowsFrom {
|
||
snap: &snap,
|
||
preview: preview.as_ref(),
|
||
mode,
|
||
focused,
|
||
oversized: focused.is_none() && snap.stats().oversized,
|
||
expanded: &overlay.expanded,
|
||
};
|
||
let stale = overlay
|
||
.rows_key
|
||
.as_ref()
|
||
.is_none_or(|held| !held.describes(&from));
|
||
if stale {
|
||
let rows = match from.preview {
|
||
Some(file) => crate::ui::diff_list::preview_rows(file, from.mode),
|
||
None => crate::ui::diff_list::build_rows(
|
||
from.snap,
|
||
from.expanded,
|
||
from.focused,
|
||
from.mode,
|
||
from.oversized,
|
||
),
|
||
};
|
||
let key = from.to_key();
|
||
resync_list(&overlay.list, &overlay.rows, &rows);
|
||
overlay.rows = Rc::new(rows);
|
||
overlay.rows_key = Some(key);
|
||
} else if let Some(held) = overlay.rows_key.as_mut() {
|
||
held.retarget(&from);
|
||
}
|
||
Some(DiffBody::Rows(snap))
|
||
}
|
||
|
||
/// The rows, in the virtualised list that draws only the visible ones.
|
||
fn diff_rows_list(
|
||
&self,
|
||
overlay: &DiffOverlayState,
|
||
snap: Arc<DiffSnapshot>,
|
||
cx: &mut Context<Self>,
|
||
) -> AnyElement {
|
||
let rows = Rc::clone(&overlay.rows);
|
||
let font = SharedString::from(self.font_family.clone());
|
||
let app = cx.entity().downgrade();
|
||
let list = overlay.list.clone();
|
||
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),
|
||
// 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.
|
||
None => div().into_any_element(),
|
||
}
|
||
})
|
||
.size_full()
|
||
// Only the vertical padding: `List` lays every item out at its own
|
||
// full width and puts it at its own left edge, so a horizontal
|
||
// padding here would be silently ignored. The rows carry their own —
|
||
// see `diff_row_element`.
|
||
.py_4();
|
||
// A whole working tree can scroll past here with nothing to say how
|
||
// far it runs or where in it you are — the one long document in the
|
||
// app without the bar every other scroll area has.
|
||
//
|
||
// The bar reads the list's own height, and a list only knows the rows
|
||
// it has measured: until the reader has been to the bottom once, the
|
||
// thumb is sized against a document that is still being discovered, so
|
||
// it shrinks as they scroll. `ListState::measure_all` would settle it
|
||
// by laying out every row on the first frame, which is the cost this
|
||
// whole list exists to avoid.
|
||
crate::ui::scrollbar::with_vertical_scrollbar("diff-overlay-scrollbar", body, &list)
|
||
}
|
||
}
|
||
|
||
/// Counts the rows the list actually built, so a test can tell that a patch of
|
||
/// any size costs the handful of rows on screen rather than all of them.
|
||
#[cfg(test)]
|
||
pub(crate) mod row_probe {
|
||
use std::cell::Cell;
|
||
|
||
thread_local! {
|
||
static BUILT: Cell<u64> = const { Cell::new(0) };
|
||
}
|
||
|
||
pub(crate) fn record() {
|
||
BUILT.set(BUILT.get() + 1);
|
||
}
|
||
|
||
/// The count since the last call, and zero from here.
|
||
pub(crate) fn take() -> u64 {
|
||
BUILT.replace(0)
|
||
}
|
||
}
|
||
|
||
/// What the overlay's scrolling area holds this frame.
|
||
enum DiffBody {
|
||
Message(&'static str),
|
||
/// The rows are in [`DiffOverlayState::rows`]; the snapshot rides along
|
||
/// for the few rows whose text is derived from it.
|
||
Rows(Arc<DiffSnapshot>),
|
||
}
|
||
|
||
/// What this frame would flatten its rows from, borrowed from the overlay.
|
||
struct RowsFrom<'a> {
|
||
snap: &'a Arc<DiffSnapshot>,
|
||
preview: Option<&'a Arc<FileDiff>>,
|
||
mode: DiffViewMode,
|
||
focused: Option<usize>,
|
||
oversized: bool,
|
||
expanded: &'a HashMap<String, bool>,
|
||
}
|
||
|
||
impl RowsFrom<'_> {
|
||
fn to_key(&self) -> RowsKey {
|
||
RowsKey {
|
||
snap: Arc::clone(self.snap),
|
||
preview: self.preview.cloned(),
|
||
mode: self.mode,
|
||
focused: self.focused,
|
||
oversized: self.oversized,
|
||
expanded: self.expanded.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What [`DiffOverlayState::rows`] was flattened from, kept so the next frame
|
||
/// can tell whether it would flatten the same rows again.
|
||
struct RowsKey {
|
||
snap: Arc<DiffSnapshot>,
|
||
preview: Option<Arc<FileDiff>>,
|
||
mode: DiffViewMode,
|
||
focused: Option<usize>,
|
||
oversized: bool,
|
||
expanded: HashMap<String, bool>,
|
||
}
|
||
|
||
impl RowsKey {
|
||
/// Whether the rows built from `from` would be the rows already held.
|
||
///
|
||
/// The snapshot is compared by pointer first and by contents second: a
|
||
/// probe that found nothing new still lands a fresh `Arc` over an equal
|
||
/// snapshot, and rebuilding every row of the patch for that would undo the
|
||
/// point of keeping them.
|
||
///
|
||
/// The scalars go first so that the walk of the patch behind that second
|
||
/// comparison is only ever paid to answer a question the cheap fields
|
||
/// have not already answered.
|
||
fn describes(&self, from: &RowsFrom<'_>) -> bool {
|
||
self.mode == from.mode
|
||
&& self.focused == from.focused
|
||
&& self.oversized == from.oversized
|
||
&& self.expanded == *from.expanded
|
||
&& self.same_preview(from)
|
||
&& (Arc::ptr_eq(&self.snap, from.snap) || self.snap == *from.snap)
|
||
}
|
||
|
||
/// The preview, by pointer and then by contents — a re-read of an
|
||
/// untracked file lands a fresh `Arc` over bytes that did not change.
|
||
fn same_preview(&self, from: &RowsFrom<'_>) -> bool {
|
||
match (&self.preview, from.preview) {
|
||
(None, None) => true,
|
||
(Some(a), Some(b)) => Arc::ptr_eq(a, b) || a == b,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Points the key at the `Arc`s this frame was asked about, having just
|
||
/// found them equal to the ones held.
|
||
///
|
||
/// Without this the key goes on holding the snapshot from the last
|
||
/// *rebuild*, so every frame after a probe that found nothing new proves
|
||
/// the two equal the long way — a walk of every line of the patch, once
|
||
/// per wheel event, which is the cost this key exists to avoid.
|
||
fn retarget(&mut self, from: &RowsFrom<'_>) {
|
||
self.snap = Arc::clone(from.snap);
|
||
self.preview = from.preview.cloned();
|
||
}
|
||
}
|
||
|
||
/// Tells the list which rows changed, rather than that all of them did.
|
||
///
|
||
/// `ListState::reset` would drop the scroll position, so collapsing one file
|
||
/// would throw the reader back to the top of the tree. The rows either side of
|
||
/// an edit are untouched, so the shared prefix and suffix are kept and only
|
||
/// what is between them is spliced.
|
||
fn resync_list(list: &gpui::ListState, old: &[DiffRow], new: &[DiffRow]) {
|
||
let (replaced, with) = spliced_range(old, new);
|
||
list.splice(replaced, with);
|
||
}
|
||
|
||
/// Which of the old rows were replaced, and by how many new ones.
|
||
fn spliced_range(old: &[DiffRow], new: &[DiffRow]) -> (std::ops::Range<usize>, usize) {
|
||
let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
|
||
// Whatever is left of the shorter list once the shared head is off it —
|
||
// the most the shared tail can be, and what keeps the two slices below in
|
||
// step with each other.
|
||
let rest = old.len().min(new.len()) - prefix;
|
||
let suffix = old[old.len() - rest..]
|
||
.iter()
|
||
.rev()
|
||
.zip(new[new.len() - rest..].iter().rev())
|
||
.take_while(|(a, b)| a == b)
|
||
.count();
|
||
(prefix..old.len() - suffix, new.len() - prefix - suffix)
|
||
}
|
||
|
||
/// The row inset every row of the list shares, matching the source control
|
||
/// panel's — the overlay is a second view of that panel's list, and the two
|
||
/// stopped looking like one app when this one drew cards.
|
||
const ROW_INSET: Pixels = px(10.);
|
||
|
||
/// The height of a row that is a *file* rather than a line of one: the same
|
||
/// 26px the panel gives its file rows.
|
||
const FILE_ROW_H: Pixels = px(26.);
|
||
|
||
/// The radius on a row that lights up under the pointer. Matches the panel's.
|
||
const ROW_RADIUS: Pixels = px(5.);
|
||
|
||
/// The rule between one hunk and the last line of the one before it.
|
||
///
|
||
/// Barely there on purpose: with the cards gone it is the only line left in
|
||
/// the list, and it is separating two parts of one file rather than two
|
||
/// files.
|
||
fn hunk_rule(cx: &gpui::App) -> Hsla {
|
||
cx.theme().border.opacity(0.6)
|
||
}
|
||
|
||
/// One row, inset the way every row in the list is.
|
||
fn diff_row_element(
|
||
row: &DiffRow,
|
||
font: &SharedString,
|
||
snap: &Arc<DiffSnapshot>,
|
||
app: &gpui::WeakEntity<Tty7App>,
|
||
cx: &mut gpui::App,
|
||
) -> AnyElement {
|
||
match row {
|
||
// Stands in for the gap between the groups this list used to be a
|
||
// flex column of.
|
||
DiffRow::Gap => div().w_full().h(gpui::rems(0.75)).into_any_element(),
|
||
DiffRow::Oversized => padded(diff_oversized_notice(snap, cx)),
|
||
DiffRow::FileHeader(head) => padded(diff_file_header(head, font, app, cx)),
|
||
DiffRow::HunkHeader { text, leads } => padded(
|
||
div()
|
||
.w_full()
|
||
.px(ROW_INSET)
|
||
.py_1()
|
||
.when(!leads, |h| {
|
||
h.mt_1().border_t_1().border_color(hunk_rule(cx))
|
||
})
|
||
.text_xs()
|
||
.font_family(font.clone())
|
||
.text_color(cx.theme().muted_foreground)
|
||
.truncate()
|
||
.child(text.clone())
|
||
.into_any_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::Truncated(reason) => {
|
||
let note = match reason {
|
||
Truncation::PerFile => t_fmt(
|
||
L10nKey::DiffTruncatedPerFile,
|
||
&[("limit", &git_diff::MAX_LINES_PER_FILE.to_string())],
|
||
),
|
||
Truncation::Budget => t(L10nKey::DiffTruncatedBudget).to_string(),
|
||
};
|
||
padded(note_row(note, cx))
|
||
}
|
||
DiffRow::MoreFiles { rest } => {
|
||
padded(note_row(t_plural(L10nKey::DiffMoreFiles, *rest, &[]), cx))
|
||
}
|
||
// A section label, in the shape the sidebar gives its group headings:
|
||
// small, quiet, and carried by the space around it rather than a bar
|
||
// of its own.
|
||
DiffRow::UntrackedHeader { total } => padded(
|
||
div()
|
||
.w_full()
|
||
.px(ROW_INSET)
|
||
.py_1()
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(t_plural(L10nKey::DiffUntrackedHeader, *total, &[]))
|
||
.into_any_element(),
|
||
),
|
||
DiffRow::Untracked { index, path } => {
|
||
padded(diff_untracked_row(*index, path, font, app, cx))
|
||
}
|
||
DiffRow::MoreUntracked { rest } => padded(note_row(
|
||
t_plural(L10nKey::DiffMoreUntracked, *rest, &[]),
|
||
cx,
|
||
)),
|
||
}
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// An aside in the list's own voice — a cap that was hit, a tail that was not
|
||
/// drawn. Never a row you can act on, so never one that lights up.
|
||
fn note_row(text: String, cx: &gpui::App) -> AnyElement {
|
||
div()
|
||
.w_full()
|
||
.px(ROW_INSET)
|
||
.py_1()
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(text)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn diff_oversized_notice(snap: &DiffSnapshot, cx: &gpui::App) -> AnyElement {
|
||
let stats = snap.stats();
|
||
let text = t_fmt(
|
||
L10nKey::DiffOversizedNotice,
|
||
&[("summary", &oversized_summary(snap, &stats))],
|
||
);
|
||
div()
|
||
.w_full()
|
||
.px(ROW_INSET)
|
||
.py_2()
|
||
.rounded(rounding::CARD_RADIUS)
|
||
.bg(cx.theme().secondary)
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(text)
|
||
.into_any_element()
|
||
}
|
||
|
||
fn diff_file_header(
|
||
head: &FileHead,
|
||
font: &SharedString,
|
||
app: &gpui::WeakEntity<Tty7App>,
|
||
cx: &gpui::App,
|
||
) -> AnyElement {
|
||
let hover = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().window.hover);
|
||
let deco = deco_status(head.status);
|
||
let (glyph, glyph_color) = (status_glyph(deco), status_color(deco, cx));
|
||
let mut header = h_flex()
|
||
.id(("diff-file-header", head.index))
|
||
.w_full()
|
||
.items_center()
|
||
.gap_2()
|
||
.h(FILE_ROW_H)
|
||
.px(ROW_INSET)
|
||
.rounded(ROW_RADIUS)
|
||
.when(head.expandable, |h| {
|
||
let path = head.path.clone();
|
||
let want = !head.expanded;
|
||
let app = app.clone();
|
||
h.cursor_pointer()
|
||
.hover(|s| s.bg(hover))
|
||
.on_click(move |_, _window, cx| {
|
||
let path = path.clone();
|
||
app.update(cx, |this, cx| {
|
||
let active = this.active;
|
||
if let Some(overlay) = this
|
||
.tabs
|
||
.get_mut(active)
|
||
.and_then(|t| t.diff_overlay.as_mut())
|
||
{
|
||
overlay.expanded.insert(path, want);
|
||
cx.notify();
|
||
}
|
||
})
|
||
.ok();
|
||
})
|
||
.child(
|
||
Icon::new(if head.expanded {
|
||
IconName::ChevronDown
|
||
} else {
|
||
IconName::ChevronRight
|
||
})
|
||
.small()
|
||
.text_color(cx.theme().muted_foreground),
|
||
)
|
||
})
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.font_family(font.clone())
|
||
.text_xs()
|
||
.font_weight(FontWeight::BOLD)
|
||
.text_color(glyph_color)
|
||
.child(glyph),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.text_xs()
|
||
.font_family(font.clone())
|
||
.child(head.shown_path.clone()),
|
||
);
|
||
if head.binary {
|
||
header = header.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_xs()
|
||
.text_color(cx.theme().muted_foreground)
|
||
.child(t(L10nKey::Binary)),
|
||
);
|
||
}
|
||
if head.added > 0 {
|
||
header = header.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_xs()
|
||
.text_color(cx.theme().success)
|
||
.child(format!("+{}", head.added)),
|
||
);
|
||
}
|
||
if head.removed > 0 {
|
||
header = header.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.text_xs()
|
||
.text_color(cx.theme().danger)
|
||
.child(format!("−{}", head.removed)),
|
||
);
|
||
}
|
||
header.into_any_element()
|
||
}
|
||
|
||
// An untracked file has no patch in the snapshot, so it cannot be expanded in
|
||
// place the way the files above it are — its contents are read one file at a
|
||
// time and shown on their own. The row asks for that read, which until now
|
||
// only the Source Control panel could: in the overlay these rows were the only
|
||
// files in a list of files that did nothing when clicked.
|
||
fn diff_untracked_row(
|
||
index: usize,
|
||
path: &str,
|
||
font: &SharedString,
|
||
app: &gpui::WeakEntity<Tty7App>,
|
||
cx: &gpui::App,
|
||
) -> AnyElement {
|
||
let hover = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().window.hover);
|
||
let for_focus = path.to_string();
|
||
let app = app.clone();
|
||
h_flex()
|
||
.id(("diff-untracked", index))
|
||
.w_full()
|
||
.items_center()
|
||
.gap_2()
|
||
.h(FILE_ROW_H)
|
||
.px(ROW_INSET)
|
||
.rounded(ROW_RADIUS)
|
||
.text_xs()
|
||
.font_family(font.clone())
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(hover))
|
||
.on_click(move |_, window, cx| {
|
||
let for_focus = for_focus.clone();
|
||
app.update(cx, |this, cx| {
|
||
let Some((host, cwd, source)) = this
|
||
.tabs
|
||
.get(this.active)
|
||
.and_then(|t| t.diff_overlay.as_ref())
|
||
.map(|o| (o.host_id, o.cwd.clone(), o.source.clone()))
|
||
else {
|
||
return;
|
||
};
|
||
this.open_diff_overlay(host, cwd, source, Some(for_focus), window, cx);
|
||
})
|
||
.ok();
|
||
})
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.font_weight(FontWeight::BOLD)
|
||
.text_color(status_color(DecoStatus::Untracked, cx))
|
||
.child(status_glyph(DecoStatus::Untracked)),
|
||
)
|
||
.child(div().flex_1().min_w_0().truncate().child(path.to_string()))
|
||
.into_any_element()
|
||
}
|
||
|
||
fn diff_split_row(row: &SplitRow, font: &SharedString, cx: &gpui::App) -> impl IntoElement {
|
||
h_flex()
|
||
.w_full()
|
||
.h(px(19.))
|
||
.items_stretch()
|
||
.text_xs()
|
||
.font_family(font.clone())
|
||
.child(diff_split_cell(row.left.as_ref(), Side::Old, cx))
|
||
.child(div().flex_shrink_0().w(px(1.)).bg(hunk_rule(cx)))
|
||
.child(diff_split_cell(row.right.as_ref(), Side::New, cx))
|
||
}
|
||
|
||
fn diff_split_cell(cell: Option<&SplitCell>, side: Side, 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();
|
||
};
|
||
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))
|
||
.child(
|
||
h_flex()
|
||
.flex_shrink_0()
|
||
.w(px(42.))
|
||
.justify_end()
|
||
.pr_1p5()
|
||
.text_color(cx.theme().muted_foreground.opacity(0.7))
|
||
.child(cell.no.map(|n| n.to_string()).unwrap_or_default()),
|
||
)
|
||
.child(
|
||
div()
|
||
.flex_1()
|
||
.min_w_0()
|
||
.truncate()
|
||
.child(format!("{marker} {}", cell.text)),
|
||
)
|
||
.into_any_element()
|
||
}
|
||
|
||
/// One line of the unified view.
|
||
///
|
||
/// Every measurement it shares with [`diff_split_cell`] is shared on purpose —
|
||
/// the same 19px row, the same `text_xs` in the same family, and above all the
|
||
/// same `0.12` wash behind an addition and a removal. The two views are one
|
||
/// diff seen twice; a different green would read as a different thing.
|
||
///
|
||
/// What differs is forced by the shape. The line numbers get 34px a side
|
||
/// rather than 42 (there are two gutters here in front of one column of text,
|
||
/// not one in front of each), and the `+`/`−` gets a column of its own rather
|
||
/// 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 {
|
||
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))),
|
||
LineKind::Context => (cx.theme().muted_foreground, None),
|
||
};
|
||
let gutter = |no: Option<u32>| {
|
||
h_flex()
|
||
.flex_shrink_0()
|
||
.w(px(34.))
|
||
.justify_end()
|
||
.pr_1p5()
|
||
.text_color(cx.theme().muted_foreground.opacity(0.7))
|
||
.child(no.map(|n| n.to_string()).unwrap_or_default())
|
||
};
|
||
h_flex()
|
||
.w_full()
|
||
.h(px(19.))
|
||
.items_center()
|
||
.text_xs()
|
||
.font_family(font.clone())
|
||
.when_some(tint, |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
|
||
// same thing: everything left of it is a number, everything right of
|
||
// it is the file.
|
||
.child(div().flex_shrink_0().w(px(1.)).h_full().bg(hunk_rule(cx)))
|
||
.child(
|
||
div()
|
||
.flex_shrink_0()
|
||
.w(px(12.))
|
||
.text_center()
|
||
.text_color(marker_color)
|
||
.child(unified_marker(row.kind)),
|
||
)
|
||
.child(div().flex_1().min_w_0().truncate().child(row.text.clone()))
|
||
}
|
||
|
||
/// Which layout the overlay draws. One setting for the window, not one per
|
||
/// overlay: VS Code's `diffEditor.renderSideBySide` is global for the same
|
||
/// reason — re-picking on every open is a chore, not a choice.
|
||
fn view_mode(cx: &gpui::App) -> DiffViewMode {
|
||
cx.try_global::<Config>()
|
||
.map(|cfg| cfg.diff_view)
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// The change column. `−` is U+2212, matching the split view: the ASCII hyphen
|
||
/// is narrower than `+` and the two columns would not line up.
|
||
fn unified_marker(kind: LineKind) -> &'static str {
|
||
match kind {
|
||
LineKind::Added => "+",
|
||
LineKind::Removed => "−",
|
||
LineKind::Context => "",
|
||
}
|
||
}
|
||
|
||
/// The git status letter and colour every part of the app agrees on.
|
||
///
|
||
/// `Copied` and `TypeChanged` have no decoration of their own — porcelain v2's
|
||
/// index folds them the same way — so they take the nearest one rather than
|
||
/// inventing a `C` and a `T` that appear in the overlay and nowhere else.
|
||
pub(crate) fn deco_status(status: FileStatus) -> DecoStatus {
|
||
match status {
|
||
FileStatus::Added => DecoStatus::Added,
|
||
FileStatus::Modified => DecoStatus::Modified,
|
||
FileStatus::Deleted => DecoStatus::Deleted,
|
||
FileStatus::Renamed | FileStatus::Copied => DecoStatus::Renamed,
|
||
FileStatus::TypeChanged => DecoStatus::Modified,
|
||
FileStatus::Unmerged => DecoStatus::Conflict,
|
||
}
|
||
}
|
||
|
||
/// The current epoch for a repository, or 0 where nothing has ever bumped one.
|
||
/// Zero is the same value a never-touched repository reports, so an overlay
|
||
/// that reads it before the global exists simply never looks stale.
|
||
fn scm_epoch(cx: &gpui::App, host: crate::ui::host_ops::HostId, root: &Path) -> u64 {
|
||
cx.try_global::<crate::terminal::git_data::ScmData>()
|
||
.map(|data| data.epoch(host, root))
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// What the header calls the patch it is showing.
|
||
struct SourceSubject {
|
||
icon: &'static str,
|
||
text: String,
|
||
/// Set only where the branch name alone would be ambiguous.
|
||
chip: Option<&'static str>,
|
||
is_rev: bool,
|
||
/// What the commit is *about*, where whoever opened it knew. An object id
|
||
/// is an address, not a name, and a header with nothing but eight hex
|
||
/// digits leaves the reader to remember which commit that was.
|
||
label: Option<CommitLabel>,
|
||
}
|
||
|
||
fn source_subject(source: &DiffSource, branch: String) -> SourceSubject {
|
||
let branch_of = |chip| SourceSubject {
|
||
icon: "icons/git-branch.svg",
|
||
text: branch.clone(),
|
||
chip,
|
||
is_rev: false,
|
||
label: None,
|
||
};
|
||
match source {
|
||
// Worktree and Head are both "the branch, right now"; the header for
|
||
// them is what it has always been.
|
||
DiffSource::Worktree | DiffSource::Head => branch_of(None),
|
||
// Staged is the branch too, but a patch that does not match the files
|
||
// on disk — without the chip it is indistinguishable from the above.
|
||
DiffSource::Staged => branch_of(Some(t(L10nKey::ScmChipStaged))),
|
||
DiffSource::Commit { rev, label } => SourceSubject {
|
||
icon: "icons/git-commit.svg",
|
||
text: short_rev(rev),
|
||
chip: None,
|
||
is_rev: true,
|
||
// An empty subject is no more use than no label at all, and a
|
||
// `Default::default()` that leaked through would render as one.
|
||
label: label.clone().filter(|l| !l.subject.is_empty()),
|
||
},
|
||
DiffSource::Range { base, head } => SourceSubject {
|
||
icon: "icons/git-commit.svg",
|
||
text: format!("{}…{}", short_rev(base), short_rev(head)),
|
||
chip: None,
|
||
is_rev: true,
|
||
label: None,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// `Ada · 2h`, the byline under a commit's subject.
|
||
///
|
||
/// One string rather than two elements: the separator has to disappear along
|
||
/// with whichever half is missing, and a `when_some` chain around a middle dot
|
||
/// says less than this does.
|
||
fn label_byline(label: &CommitLabel, now: i64) -> String {
|
||
let when = (label.at > 0).then(|| relative_time(now, label.at));
|
||
match (label.author.trim(), when) {
|
||
("", Some(when)) => when,
|
||
(author, Some(when)) => format!("{author} · {when}"),
|
||
(author, None) => author.to_string(),
|
||
}
|
||
}
|
||
|
||
fn now_unix() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map_or(0, |d| d.as_secs() as i64)
|
||
}
|
||
|
||
/// Object ids get cut to eight characters; anything else is already a name a
|
||
/// person chose, and cutting `origin/main` in half would only hide which it is.
|
||
fn short_rev(rev: &str) -> String {
|
||
let is_oid = rev.len() >= 40 && rev.chars().all(|c| c.is_ascii_hexdigit());
|
||
match is_oid {
|
||
true => rev[..8].to_string(),
|
||
false => rev.to_string(),
|
||
}
|
||
}
|
||
|
||
fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option<usize> {
|
||
let path = overlay.focus.as_deref()?;
|
||
snap.files.iter().position(|f| f.path == path)
|
||
}
|
||
|
||
/// The focused path, when it is an *untracked* file — one the snapshot lists
|
||
/// by name but holds no patch for. A path that is both (staged half tracked,
|
||
/// say) prefers the real patch.
|
||
fn untracked_focus<'a>(snap: &DiffSnapshot, focus: Option<&'a str>) -> Option<&'a str> {
|
||
let path = focus?;
|
||
if snap.files.iter().any(|f| f.path == path) {
|
||
return None;
|
||
}
|
||
snap.untracked.iter().any(|u| u == path).then_some(path)
|
||
}
|
||
|
||
/// The file the overlay is focused on, for the header's way back to the list.
|
||
///
|
||
/// An untracked file is focused like any other but has no entry in `files` —
|
||
/// its card is synthesized from the file's own bytes — so reading only `files`
|
||
/// left the one view with no way out of it: the breadcrumb never drew, and the
|
||
/// list was reachable again only by closing the overlay and reopening it.
|
||
fn focused_name(overlay: &DiffOverlayState) -> Option<String> {
|
||
let DiffLoad::Ready(snap) = &overlay.load else {
|
||
return None;
|
||
};
|
||
if let Some(idx) = focused_file(snap, overlay) {
|
||
return Some(snap.files[idx].path.clone());
|
||
}
|
||
untracked_focus(snap, overlay.focus.as_deref()).map(str::to_string)
|
||
}
|
||
|
||
fn empty_snapshot(snap: &DiffSnapshot) -> bool {
|
||
snap.files.is_empty() && snap.untracked.is_empty()
|
||
}
|
||
|
||
fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String {
|
||
let mut parts = vec![t_plural(L10nKey::DiffChangedFiles, snap.files.len(), &[])];
|
||
let (added, removed) = stats.totals;
|
||
let total_lines = (added + removed) as usize;
|
||
let loaded = stats.retained_lines;
|
||
let budget = stats.budget_exhausted;
|
||
let per_file = stats.per_file_truncated;
|
||
parts.push(match (budget, per_file) {
|
||
(false, false) => t_plural(L10nKey::DiffLines, total_lines, &[]),
|
||
_ => {
|
||
let cap_key = match (budget, per_file) {
|
||
(true, true) => L10nKey::DiffBudgetAndCap,
|
||
(true, false) => L10nKey::DiffBudget,
|
||
_ => L10nKey::DiffPerFileCap,
|
||
};
|
||
t_fmt(
|
||
L10nKey::DiffChangedLines,
|
||
&[
|
||
("total", &total_lines.to_string()),
|
||
("loaded", &loaded.to_string()),
|
||
("cap", t(cap_key)),
|
||
],
|
||
)
|
||
}
|
||
});
|
||
if stats.untracked_count > 0 {
|
||
parts.push(t_plural(
|
||
L10nKey::DiffUntrackedSummary,
|
||
stats.untracked_count,
|
||
&[],
|
||
));
|
||
}
|
||
parts.join(", ")
|
||
}
|
||
|
||
/// The de-duplication sets on `Tty7App` are keyed by `(HostId, PathBuf)`, so
|
||
/// the source rides along inside the path: two sources over one directory are
|
||
/// two independent probes and must not cancel one another.
|
||
///
|
||
/// `DiffSource::tag` rather than `Debug`, which is what this used to be built
|
||
/// from. `Debug` prints a commit's label too, so the same commit opened with a
|
||
/// subject in hand and without one would have been two keys and two probes for
|
||
/// one patch — the same split `DiffSource`'s own `PartialEq` is written to
|
||
/// avoid. The separator is a byte no path contains.
|
||
fn probe_key(
|
||
host: crate::ui::host_ops::HostId,
|
||
cwd: &Path,
|
||
source: &DiffSource,
|
||
) -> (crate::ui::host_ops::HostId, PathBuf) {
|
||
let mut tagged = std::ffi::OsString::from(format!("{}\u{1}", source.tag()));
|
||
tagged.push(cwd.as_os_str());
|
||
(host, PathBuf::from(tagged))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::terminal::git_diff::{AUTO_COLLAPSE_LINES, DiffLine, LineKind, MAX_RENDERED_FILES};
|
||
use crate::ui::diff_list::{build_rows, file_expanded};
|
||
use crate::ui::diff_rows::split_hunk;
|
||
use crate::ui::i18n::set_locale;
|
||
|
||
#[test]
|
||
fn full_window_diff_background_is_opaque_with_or_without_a_preset() {
|
||
let active = crate::ui::presets::ActiveBackground {
|
||
fill: crate::ui::presets::Fill::Solid(0x12_34_56),
|
||
opacity: Some(0.2),
|
||
image: None,
|
||
};
|
||
let mut fallback: Hsla = gpui::rgb(0x65_43_21).into();
|
||
fallback.a = 0.3;
|
||
let mut opaque_fallback = fallback;
|
||
opaque_fallback.a = 1.0;
|
||
|
||
assert_eq!(
|
||
diff_overlay_background(Some(&active), fallback),
|
||
crate::ui::theme::window_background_opaque(&active),
|
||
"the active preset must keep its fill while discarding workspace translucency"
|
||
);
|
||
assert_eq!(
|
||
diff_overlay_background(None, fallback),
|
||
opaque_fallback.into(),
|
||
"the theme fallback must also block the window material"
|
||
);
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_probe_key_separates_the_sources_over_one_directory() {
|
||
let host = crate::ui::host_ops::HostId::LOCAL;
|
||
let cwd = Path::new("/repo");
|
||
let worktree = probe_key(host, cwd, &DiffSource::Worktree);
|
||
assert_ne!(worktree, probe_key(host, cwd, &DiffSource::Staged));
|
||
assert_ne!(worktree, probe_key(host, cwd, &DiffSource::Head));
|
||
assert_ne!(
|
||
probe_key(host, cwd, &DiffSource::commit("a")),
|
||
probe_key(host, cwd, &DiffSource::commit("b")),
|
||
"two commits are two probes"
|
||
);
|
||
assert_eq!(worktree, probe_key(host, cwd, &DiffSource::Worktree));
|
||
assert_ne!(
|
||
worktree,
|
||
probe_key(host, Path::new("/other"), &DiffSource::Worktree)
|
||
);
|
||
// …and one commit is one probe however much is known about it. Built
|
||
// from `Debug`, as this key once was, the labelled one would have been
|
||
// a second in-flight probe for a patch already being read.
|
||
assert_eq!(
|
||
probe_key(host, cwd, &DiffSource::commit("a")),
|
||
probe_key(
|
||
host,
|
||
cwd,
|
||
&DiffSource::Commit {
|
||
rev: "a".into(),
|
||
label: Some(CommitLabel {
|
||
subject: "s".into(),
|
||
author: "Ada".into(),
|
||
at: 1,
|
||
}),
|
||
}
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_file_status_lands_on_a_shared_decoration() {
|
||
use DecoStatus as D;
|
||
for (status, want) in [
|
||
(FileStatus::Added, D::Added),
|
||
(FileStatus::Modified, D::Modified),
|
||
(FileStatus::Deleted, D::Deleted),
|
||
(FileStatus::Renamed, D::Renamed),
|
||
// A copy is a rename that left the original behind: same letter.
|
||
(FileStatus::Copied, D::Renamed),
|
||
// A symlink that became a file is a modification, not a category
|
||
// of its own — the overlay is the only place that ever saw a `T`.
|
||
(FileStatus::TypeChanged, D::Modified),
|
||
(FileStatus::Unmerged, D::Conflict),
|
||
] {
|
||
assert_eq!(deco_status(status), want, "{status:?}");
|
||
}
|
||
assert_eq!(status_glyph(deco_status(FileStatus::Unmerged)), "U");
|
||
assert_eq!(status_glyph(deco_status(FileStatus::Copied)), "R");
|
||
}
|
||
|
||
#[test]
|
||
fn the_change_column_uses_the_typographic_minus() {
|
||
assert_eq!(unified_marker(LineKind::Added), "+");
|
||
assert_eq!(unified_marker(LineKind::Removed), "\u{2212}");
|
||
assert_ne!(
|
||
unified_marker(LineKind::Removed),
|
||
"-",
|
||
"the ASCII hyphen is narrower than `+`, and the column would wobble"
|
||
);
|
||
assert_eq!(
|
||
unified_marker(LineKind::Context),
|
||
"",
|
||
"a context line is neither, and a placeholder glyph would be noise"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_header_shortens_an_object_id_and_nothing_else() {
|
||
let oid = "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a";
|
||
assert_eq!(short_rev(oid), "3f2a1b9c");
|
||
assert_eq!(short_rev("v26.7.5"), "v26.7.5");
|
||
assert_eq!(
|
||
short_rev("origin/main"),
|
||
"origin/main",
|
||
"half a ref name says less than the whole of it"
|
||
);
|
||
assert_eq!(short_rev("3f2a1b9"), "3f2a1b9", "already short");
|
||
}
|
||
|
||
#[test]
|
||
fn each_source_names_itself_in_the_header() {
|
||
let branch = || "main".to_string();
|
||
let plain = source_subject(&DiffSource::Worktree, branch());
|
||
assert_eq!((plain.icon, plain.text.as_str()), (BRANCH_ICON, "main"));
|
||
assert_eq!(plain.chip, None);
|
||
assert!(!plain.is_rev);
|
||
|
||
assert_eq!(source_subject(&DiffSource::Head, branch()).chip, None);
|
||
|
||
let staged = source_subject(&DiffSource::Staged, branch());
|
||
assert_eq!(staged.icon, BRANCH_ICON, "still a branch, still its name");
|
||
assert_eq!(
|
||
staged.chip,
|
||
Some("STAGED"),
|
||
"without it the staged patch is indistinguishable from the unstaged one"
|
||
);
|
||
|
||
let commit = source_subject(
|
||
&DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"),
|
||
branch(),
|
||
);
|
||
assert_eq!(commit.icon, COMMIT_ICON);
|
||
assert_eq!(commit.text, "3f2a1b9c", "the branch is not what is shown");
|
||
assert!(commit.is_rev);
|
||
|
||
let range = source_subject(
|
||
&DiffSource::Range {
|
||
base: "main".into(),
|
||
head: "feature".into(),
|
||
},
|
||
branch(),
|
||
);
|
||
assert_eq!(range.icon, COMMIT_ICON);
|
||
assert_eq!(range.text, "main…feature");
|
||
}
|
||
|
||
#[test]
|
||
fn a_labelled_commit_says_what_it_was_about() {
|
||
let label = CommitLabel {
|
||
subject: "fix(scm): stop the panel asking twice".into(),
|
||
author: "Ada".into(),
|
||
at: 1_786_255_391,
|
||
};
|
||
let with = source_subject(
|
||
&DiffSource::Commit {
|
||
rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(),
|
||
label: Some(label.clone()),
|
||
},
|
||
"main".to_string(),
|
||
);
|
||
assert_eq!(with.text, "3f2a1b9c", "the sha is still the identifier");
|
||
assert_eq!(
|
||
with.label.as_ref().map(|l| l.subject.as_str()),
|
||
Some(label.subject.as_str())
|
||
);
|
||
|
||
// Nothing else grows a subject line, least of all a working-tree
|
||
// patch, whose "subject" would be a branch name repeated.
|
||
assert!(
|
||
source_subject(&DiffSource::Worktree, "main".into())
|
||
.label
|
||
.is_none()
|
||
);
|
||
assert!(
|
||
source_subject(&DiffSource::Head, "main".into())
|
||
.label
|
||
.is_none()
|
||
);
|
||
assert!(
|
||
source_subject(&DiffSource::commit("deadbeef"), "main".into())
|
||
.label
|
||
.is_none(),
|
||
"a commit nobody has read yet has nothing to say"
|
||
);
|
||
// A default-constructed label is indistinguishable from none, and must
|
||
// not paint an empty row where the subject would go.
|
||
let empty = source_subject(
|
||
&DiffSource::Commit {
|
||
rev: "deadbeef".into(),
|
||
label: Some(CommitLabel::default()),
|
||
},
|
||
"main".into(),
|
||
);
|
||
assert!(empty.label.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn the_byline_drops_the_separator_along_with_the_half_it_joined() {
|
||
let now = 1_786_255_391 + 7200;
|
||
let full = CommitLabel {
|
||
subject: "s".into(),
|
||
author: "Ada".into(),
|
||
at: 1_786_255_391,
|
||
};
|
||
assert_eq!(label_byline(&full, now), "Ada · 2h");
|
||
assert_eq!(
|
||
label_byline(
|
||
&CommitLabel {
|
||
author: String::new(),
|
||
..full.clone()
|
||
},
|
||
now
|
||
),
|
||
"2h",
|
||
"a commit with no author is not `· 2h`"
|
||
);
|
||
assert_eq!(
|
||
label_byline(&CommitLabel { at: 0, ..full }, now),
|
||
"Ada",
|
||
"and a timestamp that would not parse is not `Ada · 56y`"
|
||
);
|
||
}
|
||
|
||
const BRANCH_ICON: &str = "icons/git-branch.svg";
|
||
const COMMIT_ICON: &str = "icons/git-commit.svg";
|
||
|
||
fn small_file(path: &str, added: u32) -> FileDiff {
|
||
FileDiff {
|
||
path: path.to_string(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
added,
|
||
removed: 0,
|
||
binary: false,
|
||
truncated: None,
|
||
hunks: vec![git_diff::Hunk {
|
||
header: "@@ -1,1 +1,1 @@".to_string(),
|
||
lines: (0..added)
|
||
.map(|i| line(LineKind::Added, None, Some(i + 1), "x"))
|
||
.collect(),
|
||
}],
|
||
}
|
||
}
|
||
|
||
fn context_heavy_file(path: &str) -> FileDiff {
|
||
FileDiff {
|
||
path: path.to_string(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
added: 1,
|
||
removed: 0,
|
||
binary: false,
|
||
truncated: None,
|
||
hunks: vec![git_diff::Hunk {
|
||
header: "@@ -1,7 +1,7 @@".to_string(),
|
||
lines: (0..6)
|
||
.map(|i| line(LineKind::Context, Some(i + 1), Some(i + 1), "ctx"))
|
||
.chain(std::iter::once(line(LineKind::Added, None, Some(7), "x")))
|
||
.collect(),
|
||
}],
|
||
}
|
||
}
|
||
|
||
fn choices<const N: usize>(pairs: [(&str, bool); N]) -> HashMap<String, bool> {
|
||
pairs.into_iter().map(|(p, v)| (p.to_string(), v)).collect()
|
||
}
|
||
|
||
fn banner(snap: &DiffSnapshot) -> String {
|
||
set_locale("en");
|
||
oversized_summary(snap, &snap.stats())
|
||
}
|
||
|
||
#[test]
|
||
fn per_file_collapse_is_unchanged_below_the_repo_threshold() {
|
||
let small = small_file("small.rs", 10);
|
||
let big = small_file("big.rs", AUTO_COLLAPSE_LINES + 1);
|
||
let none = HashMap::new();
|
||
assert!(file_expanded(&small, &none, false));
|
||
assert!(!file_expanded(&big, &none, false));
|
||
|
||
let picked = choices([("small.rs", false), ("big.rs", true)]);
|
||
assert!(!file_expanded(&small, &picked, false));
|
||
assert!(file_expanded(&big, &picked, false));
|
||
}
|
||
|
||
#[test]
|
||
fn repo_wide_collapse_overrides_the_per_file_default() {
|
||
let small = small_file("small.rs", 10);
|
||
let none = HashMap::new();
|
||
assert!(file_expanded(&small, &none, false));
|
||
assert!(!file_expanded(&small, &none, true), "collapsed en masse");
|
||
|
||
assert!(
|
||
file_expanded(&small, &choices([("small.rs", true)]), true),
|
||
"the user's own click still opens it"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_choices_survive_an_oversized_transition() {
|
||
let opened = small_file("opened.rs", 10);
|
||
let closed = small_file("closed.rs", 10);
|
||
let untouched = small_file("untouched.rs", 10);
|
||
let picked = choices([("opened.rs", true), ("closed.rs", false)]);
|
||
|
||
for collapse_all in [true, false] {
|
||
assert!(
|
||
file_expanded(&opened, &picked, collapse_all),
|
||
"an explicitly opened file stays open (collapse_all={collapse_all})"
|
||
);
|
||
assert!(
|
||
!file_expanded(&closed, &picked, collapse_all),
|
||
"an explicitly closed file stays closed (collapse_all={collapse_all})"
|
||
);
|
||
}
|
||
assert!(!file_expanded(&untouched, &picked, true));
|
||
assert!(file_expanded(&untouched, &picked, false));
|
||
}
|
||
|
||
#[test]
|
||
fn many_medium_files_are_oversized_and_build_no_rows() {
|
||
let snap = DiffSnapshot {
|
||
files: (0..60)
|
||
.map(|i| small_file(&format!("f{i}.rs"), 150))
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
assert!(
|
||
snap.files.iter().all(|f| f.added <= AUTO_COLLAPSE_LINES),
|
||
"no single file is over the per-file threshold"
|
||
);
|
||
assert!(snap.stats().oversized);
|
||
|
||
let none = HashMap::new();
|
||
let rows_expanded: usize = snap
|
||
.files
|
||
.iter()
|
||
.filter(|f| file_expanded(f, &none, false))
|
||
.flat_map(|f| &f.hunks)
|
||
.map(|h| split_hunk(&h.lines).len())
|
||
.sum();
|
||
let rows_collapsed: usize = snap
|
||
.files
|
||
.iter()
|
||
.filter(|f| file_expanded(f, &none, true))
|
||
.flat_map(|f| &f.hunks)
|
||
.map(|h| split_hunk(&h.lines).len())
|
||
.sum();
|
||
assert_eq!(rows_expanded, 9000, "what the old rule would have built");
|
||
assert_eq!(rows_collapsed, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_ordinary_busy_tree_is_not_oversized() {
|
||
let snap = DiffSnapshot {
|
||
files: (0..40)
|
||
.map(|i| {
|
||
let mut f = context_heavy_file(&format!("f{i}.rs"));
|
||
f.hunks = std::iter::repeat_n(f.hunks[0].clone(), 10).collect();
|
||
f.added = 10;
|
||
f
|
||
})
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
let (added, removed) = snap.totals();
|
||
assert!(
|
||
snap.stats().retained_lines > (added + removed) as usize * 4,
|
||
"the context lines dominate, as they do in a real diff"
|
||
);
|
||
assert!(
|
||
!snap.stats().oversized,
|
||
"an ordinary afternoon must not read as a tree too large to render \
|
||
({} retained lines)",
|
||
snap.stats().retained_lines
|
||
);
|
||
let none = HashMap::new();
|
||
assert!(snap.files.iter().all(|f| file_expanded(f, &none, false)));
|
||
}
|
||
|
||
#[test]
|
||
fn an_empty_snapshot_reads_as_clean_only_when_the_probe_worked() {
|
||
let clean = DiffSnapshot {
|
||
branch: "main".into(),
|
||
..Default::default()
|
||
};
|
||
assert!(empty_snapshot(&clean));
|
||
assert!(!clean.read_failed, "nothing went wrong: this tree is clean");
|
||
|
||
let broken = DiffSnapshot {
|
||
branch: "main".into(),
|
||
read_failed: true,
|
||
..Default::default()
|
||
};
|
||
assert!(
|
||
empty_snapshot(&broken),
|
||
"indistinguishable by shape — which is the point"
|
||
);
|
||
assert!(broken.read_failed, "and distinguishable by this");
|
||
|
||
let partial = DiffSnapshot {
|
||
files: vec![small_file("one.rs", 3)],
|
||
read_failed: true,
|
||
..Default::default()
|
||
};
|
||
assert!(!empty_snapshot(&partial));
|
||
}
|
||
|
||
#[test]
|
||
fn a_huge_untracked_list_does_not_collapse_the_diff() {
|
||
let snap = DiffSnapshot {
|
||
files: vec![small_file("one.rs", 3)],
|
||
untracked: (0..git_diff::MAX_UNTRACKED)
|
||
.map(|i| format!("node_modules/p{i}/index.js"))
|
||
.collect(),
|
||
untracked_total: 40_000,
|
||
..Default::default()
|
||
};
|
||
assert!(snap.stats().retained_lines < git_diff::AUTO_COLLAPSE_TOTAL_LINES);
|
||
assert!(snap.files.len() < git_diff::AUTO_COLLAPSE_TOTAL_FILES);
|
||
assert!(
|
||
!snap.stats().oversized,
|
||
"collapsing the diff would not have removed a single untracked row"
|
||
);
|
||
|
||
assert_eq!(
|
||
snap.untracked.len(),
|
||
git_diff::MAX_UNTRACKED,
|
||
"retention is capped at the parser"
|
||
);
|
||
assert_eq!(
|
||
snap.untracked.len().min(MAX_RENDERED_FILES),
|
||
MAX_RENDERED_FILES,
|
||
"and rows at the renderer"
|
||
);
|
||
assert_eq!(
|
||
snap.untracked_count(),
|
||
40_000,
|
||
"while the reported count stays the true total"
|
||
);
|
||
}
|
||
|
||
/// `ListState::reset` would drop the scroll position, so opening or
|
||
/// closing one file in a long tree would throw the reader back to the top.
|
||
/// Only the rows that actually changed are spliced.
|
||
#[test]
|
||
fn only_the_rows_that_changed_are_spliced() {
|
||
let snap = DiffSnapshot {
|
||
files: vec![
|
||
small_file("a.rs", 2),
|
||
small_file("b.rs", 2),
|
||
small_file("c.rs", 2),
|
||
],
|
||
..Default::default()
|
||
};
|
||
let shut = |path: &str| -> HashMap<String, bool> {
|
||
[(path.to_string(), false)].into_iter().collect()
|
||
};
|
||
let open = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
|
||
let middle_shut = build_rows(&snap, &shut("b.rs"), None, DiffViewMode::Unified, false);
|
||
|
||
let (replaced, with) = spliced_range(&open, &middle_shut);
|
||
assert_eq!(
|
||
(replaced.start, with),
|
||
(5, 1),
|
||
"the first card and the gap after it are untouched"
|
||
);
|
||
assert_eq!(
|
||
open.len() - replaced.end,
|
||
middle_shut.len() - (replaced.start + with),
|
||
"and so is everything below the file that closed"
|
||
);
|
||
|
||
let (replaced, with) = spliced_range(&open, &open);
|
||
assert_eq!(
|
||
(replaced.start, replaced.end, with),
|
||
(open.len(), open.len(), 0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_list_that_was_empty_or_becomes_empty_splices_in_one_piece() {
|
||
let snap = DiffSnapshot {
|
||
files: vec![small_file("a.rs", 2)],
|
||
..Default::default()
|
||
};
|
||
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
|
||
|
||
let (replaced, with) = spliced_range(&[], &rows);
|
||
assert_eq!((replaced.start, replaced.end, with), (0, 0, rows.len()));
|
||
|
||
let (replaced, with) = spliced_range(&rows, &[]);
|
||
assert_eq!((replaced.start, replaced.end, with), (0, rows.len(), 0));
|
||
}
|
||
|
||
/// A probe that found nothing new still lands a fresh `Arc` over an equal
|
||
/// snapshot. The rows are rightly kept — and the key has to come away
|
||
/// holding the `Arc` that landed, or every frame from then on proves the
|
||
/// two equal the long way: a walk of every line of the patch, per wheel
|
||
/// event.
|
||
#[test]
|
||
fn an_equal_snapshot_leaves_the_key_pointing_at_the_one_that_landed() {
|
||
let held = Arc::new(DiffSnapshot {
|
||
files: vec![small_file("a.rs", 2)],
|
||
..Default::default()
|
||
});
|
||
let landed = Arc::new(DiffSnapshot {
|
||
files: vec![small_file("a.rs", 2)],
|
||
..Default::default()
|
||
});
|
||
assert!(
|
||
!Arc::ptr_eq(&held, &landed),
|
||
"two separate Arcs over equal contents"
|
||
);
|
||
|
||
let expanded = HashMap::new();
|
||
let mut key = RowsFrom {
|
||
snap: &held,
|
||
preview: None,
|
||
mode: DiffViewMode::Unified,
|
||
focused: None,
|
||
oversized: false,
|
||
expanded: &expanded,
|
||
}
|
||
.to_key();
|
||
let landed_from = RowsFrom {
|
||
snap: &landed,
|
||
preview: None,
|
||
mode: DiffViewMode::Unified,
|
||
focused: None,
|
||
oversized: false,
|
||
expanded: &expanded,
|
||
};
|
||
|
||
assert!(
|
||
key.describes(&landed_from),
|
||
"nothing about the rows changed"
|
||
);
|
||
key.retarget(&landed_from);
|
||
assert!(
|
||
Arc::ptr_eq(&key.snap, &landed),
|
||
"so the next frame settles it by pointer rather than by contents"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_focused_untracked_file_asks_for_a_preview_not_the_list() {
|
||
let snap = DiffSnapshot {
|
||
files: vec![small_file("tracked.rs", 3)],
|
||
untracked: vec!["new.md".to_string()],
|
||
..Default::default()
|
||
};
|
||
assert_eq!(untracked_focus(&snap, Some("new.md")), Some("new.md"));
|
||
assert_eq!(
|
||
untracked_focus(&snap, Some("tracked.rs")),
|
||
None,
|
||
"a real patch wins over the name list"
|
||
);
|
||
assert_eq!(untracked_focus(&snap, Some("absent.rs")), None);
|
||
assert_eq!(untracked_focus(&snap, None), None);
|
||
}
|
||
|
||
#[test]
|
||
fn untracked_rows_are_capped_but_the_count_stays_true() {
|
||
let snap = DiffSnapshot {
|
||
untracked: (0..git_diff::MAX_UNTRACKED)
|
||
.map(|i| format!("p{i}"))
|
||
.collect(),
|
||
untracked_total: 12_345,
|
||
..Default::default()
|
||
};
|
||
let rendered = snap.untracked.len().min(MAX_RENDERED_FILES);
|
||
assert_eq!(rendered, MAX_RENDERED_FILES);
|
||
assert_eq!(snap.untracked_count(), 12_345, "header count is honest");
|
||
assert_eq!(snap.untracked_count() - rendered, 12_045);
|
||
}
|
||
|
||
#[test]
|
||
fn untracked_count_falls_back_to_the_retained_length() {
|
||
let snap = DiffSnapshot {
|
||
untracked: vec!["a".into(), "b".into(), "c".into()],
|
||
..Default::default()
|
||
};
|
||
assert_eq!(snap.untracked_total, 0);
|
||
assert_eq!(snap.untracked_count(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn file_cards_are_capped() {
|
||
let snap = DiffSnapshot {
|
||
files: (0..MAX_RENDERED_FILES + 25)
|
||
.map(|i| small_file(&format!("f{i}.rs"), 1))
|
||
.collect(),
|
||
..Default::default()
|
||
};
|
||
let shown = snap.files.len().min(MAX_RENDERED_FILES);
|
||
assert_eq!(shown, MAX_RENDERED_FILES);
|
||
assert_eq!(
|
||
snap.files.len() - shown,
|
||
25,
|
||
"the tail gets one summary line"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_banner_names_the_budget_when_context_outweighs_the_changes() {
|
||
let files: Vec<FileDiff> = (0..200)
|
||
.map(|i| context_heavy_file(&format!("f{i}.rs")))
|
||
.collect();
|
||
|
||
let mut truncated = files.clone();
|
||
truncated.push(FileDiff {
|
||
hunks: vec![],
|
||
truncated: Some(Truncation::Budget),
|
||
..context_heavy_file("dropped.rs")
|
||
});
|
||
let snap = DiffSnapshot {
|
||
files: truncated,
|
||
..Default::default()
|
||
};
|
||
let (added, removed) = snap.totals();
|
||
let total = (added + removed) as usize;
|
||
assert!(
|
||
snap.stats().retained_lines > total,
|
||
"the context lines outweigh the changed ones — the shape that slipped through"
|
||
);
|
||
assert!(snap.stats().budget_exhausted);
|
||
|
||
let summary = banner(&snap);
|
||
assert!(summary.contains("budget"), "{summary}");
|
||
assert!(
|
||
summary.contains(&format!("{total} changed lines")),
|
||
"the exact `+N −N` from the header, not the retained count: {summary}"
|
||
);
|
||
|
||
let whole = DiffSnapshot {
|
||
files,
|
||
..Default::default()
|
||
};
|
||
assert!(!whole.stats().budget_exhausted);
|
||
assert!(!banner(&whole).contains("budget"));
|
||
}
|
||
|
||
#[test]
|
||
fn the_banner_names_the_per_file_cap_when_context_outweighs_the_changes() {
|
||
let files: Vec<FileDiff> = (0..200)
|
||
.map(|i| context_heavy_file(&format!("f{i}.rs")))
|
||
.collect();
|
||
|
||
let mut cut = files.clone();
|
||
cut.push(FileDiff {
|
||
truncated: Some(Truncation::PerFile),
|
||
..context_heavy_file("huge.rs")
|
||
});
|
||
let snap = DiffSnapshot {
|
||
files: cut,
|
||
..Default::default()
|
||
};
|
||
let (added, removed) = snap.totals();
|
||
let total = (added + removed) as usize;
|
||
assert!(
|
||
snap.stats().retained_lines > total,
|
||
"the shape the comparison reads backwards"
|
||
);
|
||
assert!(
|
||
!snap.stats().budget_exhausted,
|
||
"the budget axis is not what fired"
|
||
);
|
||
|
||
let summary = banner(&snap);
|
||
assert!(summary.contains("per-file cap"), "{summary}");
|
||
assert!(
|
||
summary.contains(&format!("{total} changed lines")),
|
||
"the exact `+N −N` from the header, not the retained count: {summary}"
|
||
);
|
||
|
||
let whole = DiffSnapshot {
|
||
files: files.clone(),
|
||
..Default::default()
|
||
};
|
||
assert!(!banner(&whole).contains("per-file"));
|
||
|
||
let mut both = files;
|
||
both.push(FileDiff {
|
||
truncated: Some(Truncation::PerFile),
|
||
..context_heavy_file("huge.rs")
|
||
});
|
||
both.push(FileDiff {
|
||
hunks: vec![],
|
||
truncated: Some(Truncation::Budget),
|
||
..context_heavy_file("dropped.rs")
|
||
});
|
||
let summary = banner(&DiffSnapshot {
|
||
files: both,
|
||
..Default::default()
|
||
});
|
||
assert!(summary.contains("budget"), "{summary}");
|
||
assert!(summary.contains("per-file cap"), "{summary}");
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "measurement, not an assertion"]
|
||
fn bench_snapshot_share() {
|
||
use std::time::Instant;
|
||
|
||
for (label, files, per_file) in [
|
||
("unbudgeted (v26.7.5 shape)", 300, 300),
|
||
("budgeted (this build retains)", 300, 67),
|
||
] {
|
||
let snap = Arc::new(DiffSnapshot {
|
||
files: (0..files)
|
||
.map(|i| small_file(&format!("f{i}.rs"), per_file))
|
||
.collect(),
|
||
..Default::default()
|
||
});
|
||
let lines: usize = snap.stats().retained_lines;
|
||
|
||
let t = Instant::now();
|
||
for _ in 0..10 {
|
||
let _deep = (*snap).clone();
|
||
}
|
||
let deep = t.elapsed() / 10;
|
||
|
||
let t = Instant::now();
|
||
for _ in 0..100 {
|
||
let _shared = Arc::clone(&snap);
|
||
}
|
||
let shared = t.elapsed() / 100;
|
||
println!(
|
||
"{label}: {files} files / {lines} lines — deep clone {deep:?} vs \
|
||
Arc::clone {shared:?}, per holder on the UI thread"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(all(test, unix))]
|
||
mod overlay_gpui_tests {
|
||
use super::*;
|
||
use crate::ui::app::test_window;
|
||
use crate::ui::host_ops::HostId;
|
||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||
|
||
fn overlay_source_and_load(
|
||
app: &Entity<Tty7App>,
|
||
vcx: &mut VisualTestContext,
|
||
) -> (DiffSource, bool) {
|
||
app.update_in(vcx, |app, _, _| {
|
||
let overlay = app.tabs[app.active]
|
||
.diff_overlay
|
||
.as_ref()
|
||
.expect("an overlay is open");
|
||
(
|
||
overlay.source.clone(),
|
||
matches!(overlay.load, DiffLoad::Loading),
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Opening a staged file while a worktree overlay is up must re-probe. The
|
||
/// filter used to match on `(cwd, host)` alone, so it took the "just move
|
||
/// the focus" branch and left the unstaged patch on screen under the
|
||
/// staged file's name.
|
||
#[gpui::test]
|
||
fn a_second_source_over_one_directory_is_a_second_overlay(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
let cwd = std::path::PathBuf::from("/no/such/tty7/repo");
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_diff_overlay(
|
||
HostId::LOCAL,
|
||
cwd.clone(),
|
||
DiffSource::Worktree,
|
||
Some("a.rs".to_string()),
|
||
window,
|
||
cx,
|
||
);
|
||
});
|
||
assert_eq!(
|
||
overlay_source_and_load(&app, &mut vcx),
|
||
(DiffSource::Worktree, true)
|
||
);
|
||
|
||
// Pretend the worktree probe landed, so a reused overlay would show it.
|
||
app.update_in(&mut vcx, |app, _, _| {
|
||
let active = app.active;
|
||
let overlay = app.tabs[active].diff_overlay.as_mut().unwrap();
|
||
overlay.loading = false;
|
||
overlay.load = DiffLoad::Ready(Arc::new(DiffSnapshot {
|
||
source: DiffSource::Worktree,
|
||
branch: "main".into(),
|
||
..Default::default()
|
||
}));
|
||
});
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_diff_overlay(
|
||
HostId::LOCAL,
|
||
cwd.clone(),
|
||
DiffSource::Staged,
|
||
Some("a.rs".to_string()),
|
||
window,
|
||
cx,
|
||
);
|
||
});
|
||
assert_eq!(
|
||
overlay_source_and_load(&app, &mut vcx),
|
||
(DiffSource::Staged, true),
|
||
"the same file from a different source is a different question"
|
||
);
|
||
}
|
||
|
||
fn one_file_snapshot(source: DiffSource) -> DiffSnapshot {
|
||
use crate::terminal::git_diff::{DiffLine, Hunk, LineKind};
|
||
DiffSnapshot {
|
||
root: std::path::PathBuf::from("/no/such/tty7/repo"),
|
||
source,
|
||
branch: "main".into(),
|
||
files: vec![FileDiff {
|
||
path: "a.rs".into(),
|
||
old_path: None,
|
||
status: FileStatus::Modified,
|
||
added: 1,
|
||
removed: 1,
|
||
binary: false,
|
||
truncated: None,
|
||
hunks: vec![Hunk {
|
||
header: "@@ -1,2 +1,2 @@".into(),
|
||
lines: vec![
|
||
DiffLine {
|
||
kind: LineKind::Context,
|
||
old_no: Some(1),
|
||
new_no: Some(1),
|
||
text: "keep".into(),
|
||
},
|
||
DiffLine {
|
||
kind: LineKind::Removed,
|
||
old_no: Some(2),
|
||
new_no: None,
|
||
text: "old".into(),
|
||
},
|
||
DiffLine {
|
||
kind: LineKind::Added,
|
||
old_no: None,
|
||
new_no: Some(2),
|
||
text: "new".into(),
|
||
},
|
||
],
|
||
}],
|
||
}],
|
||
untracked: vec!["scratch.txt".into()],
|
||
untracked_total: 1,
|
||
read_failed: false,
|
||
}
|
||
}
|
||
|
||
fn show(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, source: DiffSource) {
|
||
let cwd = std::path::PathBuf::from("/no/such/tty7/repo");
|
||
app.update_in(vcx, |app, window, cx| {
|
||
app.open_diff_overlay(HostId::LOCAL, cwd.clone(), source.clone(), None, window, cx);
|
||
let active = app.active;
|
||
let overlay = app.tabs[active].diff_overlay.as_mut().unwrap();
|
||
overlay.loading = false;
|
||
overlay.load = DiffLoad::Ready(Arc::new(one_file_snapshot(source)));
|
||
// The card is what carries the rows, so open it.
|
||
overlay.expanded.insert("a.rs".to_string(), true);
|
||
});
|
||
}
|
||
|
||
/// Every header branch, every row renderer, once each. A missing icon, an
|
||
/// unset global or a panicking helper shows up here rather than the first
|
||
/// time somebody opens a commit.
|
||
#[gpui::test]
|
||
fn every_source_renders_in_both_views(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
|
||
for mode in [DiffViewMode::Split, DiffViewMode::Unified] {
|
||
app.update_in(&mut vcx, |app, _, cx| {
|
||
app.update_config(cx, |cfg| cfg.diff_view = mode);
|
||
});
|
||
for source in [
|
||
DiffSource::Worktree,
|
||
DiffSource::Staged,
|
||
DiffSource::Head,
|
||
DiffSource::commit("3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a"),
|
||
DiffSource::Commit {
|
||
rev: "3f2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a".into(),
|
||
label: Some(CommitLabel {
|
||
subject: "fix(scm): read a commit's own header".into(),
|
||
author: "Ada".into(),
|
||
at: 1_786_255_391,
|
||
}),
|
||
},
|
||
DiffSource::Range {
|
||
base: "main".into(),
|
||
head: "feature".into(),
|
||
},
|
||
] {
|
||
show(&app, &mut vcx, source.clone());
|
||
// A real frame, so layout and paint run too: `title_bar_drag`
|
||
// and the segmented track both want a window that is drawing.
|
||
crate::ui::app::render_probe::arm(10_000);
|
||
app.update_in(&mut vcx, |_, _, cx| cx.notify());
|
||
vcx.background_executor.run_until_parked();
|
||
assert!(
|
||
crate::ui::app::render_probe::draws() > 0,
|
||
"nothing was drawn, so nothing was proved: {source:?} in {mode:?}"
|
||
);
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.close_diff_overlay(window, cx)
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn toggling_the_diff_view_mode_writes_config(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
let mode =
|
||
|vcx: &mut VisualTestContext| vcx.update(|_, cx| cx.global::<Config>().diff_view);
|
||
|
||
assert_eq!(
|
||
mode(&mut vcx),
|
||
DiffViewMode::Split,
|
||
"side by side is what everyone already sees"
|
||
);
|
||
|
||
app.update_in(&mut vcx, |app, _, cx| app.toggle_diff_view_mode(cx));
|
||
assert_eq!(mode(&mut vcx), DiffViewMode::Unified);
|
||
|
||
app.update_in(&mut vcx, |app, _, cx| app.toggle_diff_view_mode(cx));
|
||
assert_eq!(mode(&mut vcx), DiffViewMode::Split, "and back again");
|
||
}
|
||
|
||
/// Focusing an untracked file has to leave a way back to the list.
|
||
///
|
||
/// Its card is synthesized from the file's own bytes, so it has no entry in
|
||
/// `files` — and the header's breadcrumb read only `files`. The one view
|
||
/// that could be entered was the one view that could not be left except by
|
||
/// closing the overlay and opening it again.
|
||
#[gpui::test]
|
||
fn a_focused_untracked_file_still_has_a_way_back(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
let cwd = std::path::PathBuf::from("/no/such/tty7/repo");
|
||
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_diff_overlay(
|
||
HostId::LOCAL,
|
||
cwd.clone(),
|
||
DiffSource::Worktree,
|
||
Some("scratch.txt".to_string()),
|
||
window,
|
||
cx,
|
||
);
|
||
let active = app.active;
|
||
let overlay = app.tabs[active].diff_overlay.as_mut().unwrap();
|
||
overlay.loading = false;
|
||
overlay.load = DiffLoad::Ready(Arc::new(DiffSnapshot {
|
||
source: DiffSource::Worktree,
|
||
branch: "main".into(),
|
||
untracked: vec!["scratch.txt".to_string()],
|
||
untracked_total: 1,
|
||
..Default::default()
|
||
}));
|
||
});
|
||
|
||
app.update_in(&mut vcx, |app, _, _| {
|
||
let overlay = app.tabs[app.active].diff_overlay.as_ref().unwrap();
|
||
assert_eq!(
|
||
focused_name(overlay).as_deref(),
|
||
Some("scratch.txt"),
|
||
"the breadcrumb is the way back"
|
||
);
|
||
});
|
||
}
|
||
|
||
/// The same source and the same focus still toggles the overlay shut.
|
||
#[gpui::test]
|
||
fn the_same_source_twice_still_closes(cx: &mut TestAppContext) {
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
let cwd = std::path::PathBuf::from("/no/such/tty7/repo");
|
||
|
||
for _ in 0..2 {
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_diff_overlay(
|
||
HostId::LOCAL,
|
||
cwd.clone(),
|
||
DiffSource::Worktree,
|
||
None,
|
||
window,
|
||
cx,
|
||
);
|
||
});
|
||
}
|
||
app.update_in(&mut vcx, |app, _, _| {
|
||
assert!(app.tabs[app.active].diff_overlay.is_none());
|
||
});
|
||
}
|
||
}
|
||
|
||
/// An overlay that has read its patch has to stop reading it.
|
||
///
|
||
/// `maybe_refresh_diff_overlay` calls a `Head` overlay stale when the cached
|
||
/// git status disagrees with the snapshot on screen, and every landed probe
|
||
/// wakes that check by touching the status cache. So a disagreement the probe
|
||
/// cannot settle is not a stale badge — it is a loop: read the diff, publish
|
||
/// it, wake the watchers, find the same disagreement, read it again. Two `git`
|
||
/// processes a lap, `refreshing…` pinned to the header, and a window that
|
||
/// costs 7% of a core sitting still.
|
||
///
|
||
/// The way in is ordinary: switch branches anywhere outside tty7 — another
|
||
/// terminal, an editor, a worktree command — and the cached branch is a branch
|
||
/// the repository has left. That is what the stale entry below stands for.
|
||
///
|
||
/// Unix-gated like every other window harness in this tree: `harness_with_tabs`
|
||
/// hands back a `std::os::unix::net::UnixStream` for the pane.
|
||
#[cfg(all(test, unix))]
|
||
mod render_idle_gpui_tests {
|
||
use super::*;
|
||
use crate::terminal::git_status::{GitStatusCache, RepoSnapshot};
|
||
use crate::ui::app::{render_probe, test_window};
|
||
use crate::ui::host_ops::HostId;
|
||
use gpui::TestAppContext;
|
||
|
||
const BUDGET: u64 = 200;
|
||
|
||
fn git(root: &Path, args: &[&str]) {
|
||
let out = std::process::Command::new("git")
|
||
.args(args)
|
||
.current_dir(root)
|
||
.output()
|
||
.expect("git runs");
|
||
assert!(out.status.success(), "git {args:?} failed");
|
||
}
|
||
|
||
/// The whole point of the flattened row list: a patch of any size costs
|
||
/// the rows on screen, not the rows in the patch.
|
||
///
|
||
/// Before this, the overlay built a card per file and an element per line
|
||
/// on every frame, and gpui notifies the view on every scroll wheel event
|
||
/// — so a few hundred lines of diff rebuilt tens of thousands of elements
|
||
/// tens of times a second, and the window visibly stalled.
|
||
#[gpui::test]
|
||
fn a_long_patch_builds_only_the_rows_on_screen(cx: &mut TestAppContext) {
|
||
const LINES: usize = 800;
|
||
|
||
let root = std::env::temp_dir().join(format!("tty7-diff-rows-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
let root = std::fs::canonicalize(&root).unwrap();
|
||
git(&root, &["init", "--quiet"]);
|
||
let before: String = (0..LINES).map(|i| format!("line {i}\n")).collect();
|
||
std::fs::write(root.join("long.txt"), &before).unwrap();
|
||
git(&root, &["add", "long.txt"]);
|
||
git(
|
||
&root,
|
||
&[
|
||
"-c",
|
||
"user.email=t@x",
|
||
"-c",
|
||
"user.name=t",
|
||
"commit",
|
||
"-qm",
|
||
"one",
|
||
],
|
||
);
|
||
// Every line rewritten, so the patch is a removal and an addition per
|
||
// line rather than a handful of hunks in a sea of context.
|
||
let after: String = (0..LINES).map(|i| format!("edited {i}\n")).collect();
|
||
std::fs::write(root.join("long.txt"), &after).unwrap();
|
||
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
let open = root.clone();
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_diff_overlay(HostId::LOCAL, open, DiffSource::Head, None, window, cx);
|
||
});
|
||
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||
loop {
|
||
vcx.background_executor.run_until_parked();
|
||
let ready = app.update_in(&mut vcx, |app, _, _| {
|
||
app.tabs[app.active]
|
||
.diff_overlay
|
||
.as_ref()
|
||
.is_some_and(|o| matches!(o.load, DiffLoad::Ready(_)) && !o.loading)
|
||
});
|
||
if ready {
|
||
break;
|
||
}
|
||
assert!(
|
||
std::time::Instant::now() < deadline,
|
||
"the overlay never landed a snapshot"
|
||
);
|
||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||
}
|
||
// 1600 changed lines is well past the auto-collapse threshold, so the
|
||
// card starts shut. Open it: a reader opening a large file is exactly
|
||
// the frame this is about.
|
||
app.update_in(&mut vcx, |app, _, cx| {
|
||
let active = app.active;
|
||
app.tabs[active]
|
||
.diff_overlay
|
||
.as_mut()
|
||
.expect("the overlay is open")
|
||
.expanded
|
||
.insert("long.txt".to_string(), true);
|
||
cx.notify();
|
||
});
|
||
vcx.background_executor.run_until_parked();
|
||
|
||
let rows = app.update_in(&mut vcx, |app, _, _| {
|
||
app.tabs[app.active]
|
||
.diff_overlay
|
||
.as_ref()
|
||
.map(|o| o.rows.len())
|
||
.unwrap_or(0)
|
||
});
|
||
assert!(
|
||
rows > LINES,
|
||
"the patch flattens to a row per changed line ({rows} rows)"
|
||
);
|
||
|
||
row_probe::take();
|
||
app.update_in(&mut vcx, |_, _, cx| cx.notify());
|
||
vcx.background_executor.run_until_parked();
|
||
let built = row_probe::take();
|
||
assert!(
|
||
built > 0,
|
||
"the list drew something — a probe that counts nothing proves nothing"
|
||
);
|
||
assert!(
|
||
built < rows as u64 / 4,
|
||
"a frame built {built} of {rows} rows: the list is not virtualised"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[gpui::test]
|
||
fn an_overlay_over_a_stale_branch_reaches_render_idle(cx: &mut TestAppContext) {
|
||
let root = std::env::temp_dir().join(format!("tty7-diff-idle-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(&root).unwrap();
|
||
let root = std::fs::canonicalize(&root).unwrap();
|
||
git(&root, &["init", "--quiet"]);
|
||
std::fs::write(root.join("a.rs"), "fn main() {}\n").unwrap();
|
||
git(&root, &["add", "a.rs"]);
|
||
git(
|
||
&root,
|
||
&[
|
||
"-c",
|
||
"user.email=t@x",
|
||
"-c",
|
||
"user.name=t",
|
||
"commit",
|
||
"-qm",
|
||
"one",
|
||
],
|
||
);
|
||
// Something for the overlay to actually show, so it settles on a
|
||
// snapshot rather than on the empty state.
|
||
std::fs::write(root.join("a.rs"), "fn main() { /* edited */ }\n").unwrap();
|
||
|
||
let (app, mut vcx, _pane) = test_window::harness_with_tabs(cx, 1);
|
||
|
||
// The cache as a branch switch outside tty7 leaves it: a branch this
|
||
// repository is no longer on, and counts from before the switch.
|
||
let stale = root.clone();
|
||
app.update_in(&mut vcx, |_, _, cx| {
|
||
cx.default_global::<GitStatusCache>();
|
||
cx.update_global::<GitStatusCache, _>(|cache, _| {
|
||
cache.finish_probe(
|
||
HostId::LOCAL,
|
||
&stale,
|
||
Some(RepoSnapshot {
|
||
root: stale.clone(),
|
||
home: stale.clone(),
|
||
branch: "a-branch-this-repo-has-left".into(),
|
||
counts: Some((99, 99)),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
let open = root.clone();
|
||
app.update_in(&mut vcx, |app, window, cx| {
|
||
app.open_diff_overlay(HostId::LOCAL, open, DiffSource::Head, None, window, cx);
|
||
});
|
||
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||
loop {
|
||
vcx.background_executor.run_until_parked();
|
||
let ready = app.update_in(&mut vcx, |app, _, _| {
|
||
app.tabs[app.active]
|
||
.diff_overlay
|
||
.as_ref()
|
||
.is_some_and(|o| matches!(o.load, DiffLoad::Ready(_)) && !o.loading)
|
||
});
|
||
if ready {
|
||
break;
|
||
}
|
||
assert!(
|
||
std::time::Instant::now() < deadline,
|
||
"the overlay never landed a snapshot"
|
||
);
|
||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||
}
|
||
|
||
test_window::quiesce(&mut vcx, Some(&root));
|
||
render_probe::arm(BUDGET);
|
||
vcx.background_executor.run_until_parked();
|
||
vcx.executor()
|
||
.advance_clock(std::time::Duration::from_secs(3));
|
||
vcx.background_executor.run_until_parked();
|
||
render_probe::arm(BUDGET);
|
||
vcx.executor()
|
||
.advance_clock(std::time::Duration::from_secs(9));
|
||
vcx.background_executor.run_until_parked();
|
||
|
||
assert_eq!(
|
||
render_probe::draws(),
|
||
0,
|
||
"a settled overlay must stop re-reading its own diff"
|
||
);
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
}
|