Files
tty7/src/ui/diff_overlay.rs
T
l0ng-ai 2dc6a88af6 merge: main into the Source Control branch
Conflicts were the two streams touching the same seams, resolved by
taking the newer decision on each side:

- main's interface font scale (rems tokens) wins in right_panel.rs; the
  SCM panel keeps its local px steps until it moves onto that scale,
  and the now-unused PANEL_TEXT constants are gone.
- main's l10n_keys! macro (idents only) means the key list carries no
  doc comments any more; our SCM keys fold into it, and PanelUntracked
  stays deleted — its only caller was the panel this branch replaced.
- main's Command::localized palette style carries our Git group; ORDER
  keeps main's visibility and our width.
- main's ansi_seed/clear_ink refactor in presets.rs carries the lane
  colours: lanes() now clears through the same helper semantics uses.
- file_tree keeps both: main's drag-and-drop targets and this branch's
  git decorations per row.
- diff_overlay keeps both: main's sidebar-count write-back on snapshot
  install and this branch's epoch read and untracked preview.
- main's window.prompt SSH-close confirmation supersedes the bespoke
  modal our branch still carried; main's tile-glyph revert stands.
- main's two new guards are satisfied: the fourteen SCM actions carry
  authored names on the Keybindings page (their palette wording, plus
  a new CmdGitToggleGraph), ja translates ScmDetached, and CmdGroupGit
  joins the kept-in-English list — Git is a name.

2571 tests, 0 failures.
2026-08-10 13:25:30 +08:00

2320 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
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::{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, AUTO_COLLAPSE_LINES, CommitLabel, DiffSnapshot, DiffSource, DiffStats, FileDiff,
FileStatus, LineKind, MAX_RENDERED_FILES, 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_rows::{Side, SplitCell, SplitRow, UnifiedRow, split_hunk, unified_rows};
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::right_panel::info_chip;
use crate::ui::rounding;
use crate::ui::rounding::RoundedCorners as _;
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>,
pub(crate) scroll: gpui::ScrollHandle,
/// 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>,
}
/// One hunk, already turned into whichever kind of row the current view draws.
enum HunkRows {
Split(Vec<SplitRow>),
Unified(Vec<UnifiedRow>),
}
impl HunkRows {
fn len(&self) -> usize {
match self {
HunkRows::Split(rows) => rows.len(),
HunkRows::Unified(rows) => rows.len(),
}
}
fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// 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,
scroll: gpui::ScrollHandle::new(),
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
// +N N asks, and it is the one the reader is looking at. Hand the
// numbers back before anything renders, or the row can disagree with
// the overlay it just opened.
//
// 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) {
let (added, removed) = snap.totals();
let root = snap.root.clone();
cx.default_global::<crate::terminal::git_status::GitStatusCache>();
cx.update_global::<crate::terminal::git_status::GitStatusCache, _>(|cache, _| {
cache.note_counts(host, &root, added, removed)
})
} 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,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
self.spawn_untracked_preview_if_needed(cx);
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
let content = match &overlay.load {
DiffLoad::Loading => self.diff_message(t(L10nKey::DiffReading), cx),
DiffLoad::NotARepo => self.diff_message(t(L10nKey::DiffNotARepo), cx),
DiffLoad::Ready(snap) if empty_snapshot(snap) && snap.read_failed => {
self.diff_message(t(L10nKey::DiffReadFailed), cx)
}
DiffLoad::Ready(snap) if empty_snapshot(snap) => {
self.diff_message(t(L10nKey::DiffWorkingTreeClean), cx)
}
// A focused *untracked* file has no patch in the snapshot; its
// card is synthesized from the file's own bytes — see `preview`.
DiffLoad::Ready(snap) if untracked_focus(snap, overlay.focus.as_deref()).is_some() => {
let path = untracked_focus(snap, overlay.focus.as_deref()).unwrap();
match &overlay.preview {
Some((held, Some(file))) if held == path => {
self.diff_preview_card(file.as_ref(), &overlay.scroll, cx)
}
Some((held, None)) if held == path => {
self.diff_message(t(L10nKey::DiffReadFailed), cx)
}
_ => self.diff_message(t(L10nKey::DiffReading), cx),
}
}
DiffLoad::Ready(snap) => self.diff_file_list(
snap,
&overlay.expanded,
focused_file(snap, overlay),
&overlay.scroll,
cx,
),
};
let header = self.diff_header(overlay, window, cx);
Some(
v_flex()
.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,
))
.text_color(cx.theme().foreground)
.track_focus(&overlay.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);
}
}))
// 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))
.child(header)
.child(content)
.into_any_element(),
)
}
fn diff_header(
&self,
overlay: &DiffOverlayState,
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),
};
let lead = if self.left_panel_open(cx) {
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 row = crate::ui::app::title_bar_drag(
h_flex().id("diff-overlay-header"),
"diff-overlay-header",
window,
cx,
);
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 {
div()
.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, &[]));
}
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(), |bar| bar.child(div().flex_1()))
.child(div().occlude().flex_shrink_0().child({
let sf = cx.global::<crate::ui::presets::Surfaces>().window;
let selected = usize::from(view_mode(cx) == DiffViewMode::Unified);
self.segmented_on(
sf,
"diff-overlay-view",
&[t(L10nKey::DiffViewSplit), t(L10nKey::DiffViewUnified)],
selected,
cx,
|this, index, _window, cx| {
let mode = if index == 0 {
DiffViewMode::Split
} else {
DiffViewMode::Unified
};
this.update_config(cx, |cfg| cfg.diff_view = mode);
},
)
}))
.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);
})),
),
)
}
/// 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();
},
);
}
/// The one synthesized card, in the same scroll shell the file list uses.
fn diff_preview_card(
&self,
file: &FileDiff,
scroll: &gpui::ScrollHandle,
cx: &mut Context<Self>,
) -> AnyElement {
let mode = view_mode(cx);
let list = v_flex()
.gap_3()
.p_4()
.w_full()
// `usize::MAX` keeps the element ids clear of the real list's.
.child(self.diff_file_card(usize::MAX, file, true, mode, cx));
crate::ui::scrollbar::with_vertical_scrollbar(
"diff-overlay-scrollbar",
div()
.id("diff-overlay-scroll")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.track_scroll(scroll)
.child(list),
scroll,
)
}
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()
}
fn diff_file_list(
&self,
snap: &DiffSnapshot,
expanded: &HashMap<String, bool>,
focused: Option<usize>,
scroll: &gpui::ScrollHandle,
cx: &mut Context<Self>,
) -> AnyElement {
let stats = snap.stats();
let mode = view_mode(cx);
let oversized = focused.is_none() && stats.oversized;
let mut list = v_flex().gap_3().p_4().w_full();
if oversized {
list = list.child(self.diff_oversized_notice(snap, &stats, cx));
}
let shown = snap.files.len().min(MAX_RENDERED_FILES);
for (idx, file) in snap.files.iter().enumerate() {
if focused.is_some_and(|f| f != idx) {
continue;
}
if focused.is_none() && idx >= shown {
break;
}
let is_expanded = if focused == Some(idx) {
expanded.get(&file.path).copied().unwrap_or(true)
} else {
file_expanded(file, expanded, oversized)
};
list = list.child(self.diff_file_card(idx, file, is_expanded, mode, cx));
}
if focused.is_none() && snap.files.len() > shown {
let rest = snap.files.len() - shown;
list = list.child(
div()
.w_full()
.px_2p5()
.py_1p5()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::DiffMoreFiles, rest, &[])),
);
}
if focused.is_none() && !snap.untracked.is_empty() {
list = list.child(self.diff_untracked_section(snap, cx));
}
// 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.
crate::ui::scrollbar::with_vertical_scrollbar(
"diff-overlay-scrollbar",
div()
.id("diff-overlay-scroll")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.track_scroll(scroll)
.child(list),
scroll,
)
}
fn diff_oversized_notice(
&self,
snap: &DiffSnapshot,
stats: &DiffStats,
cx: &Context<Self>,
) -> AnyElement {
let text = t_fmt(
L10nKey::DiffOversizedNotice,
&[("summary", &oversized_summary(snap, stats))],
);
div()
.w_full()
.px_2p5()
.py_2()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().secondary)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(text)
.into_any_element()
}
fn diff_file_card(
&self,
idx: usize,
file: &FileDiff,
expanded: bool,
mode: DiffViewMode,
cx: &mut Context<Self>,
) -> AnyElement {
let expandable =
!file.binary && (!file.hunks.is_empty() || file.truncated == Some(Truncation::Budget));
let deco = deco_status(file.status);
let (glyph, glyph_color) = (status_glyph(deco), status_color(deco, cx));
let shown_path = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let has_body = expanded && (!file.hunks.is_empty() || file.truncated.is_some());
let header_corners = rounding::stack_corners(
0,
if has_body { 2 } else { 1 },
rounding::CARD_RADIUS,
rounding::HAIRLINE,
);
let mut header = h_flex()
.id(("diff-file-header", idx))
.w_full()
.items_center()
.gap_2()
.px_2p5()
.py_1p5()
.rounded_corners(header_corners)
.bg(cx.theme().secondary)
.when(expandable, |h| {
let path = file.path.clone();
h.cursor_pointer()
.hover(|s| s.bg(cx.theme().list_hover))
.on_click(cx.listener(move |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.expanded.insert(path.clone(), !expanded);
cx.notify();
}
}))
.child(
Icon::new(if expanded {
IconName::ChevronDown
} else {
IconName::ChevronRight
})
.small()
.text_color(cx.theme().muted_foreground),
)
})
.child(
div()
.flex_shrink_0()
.font_family(self.font_family.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(self.font_family.clone())
.child(shown_path),
);
if file.binary {
header = header.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(t(L10nKey::Binary)),
);
}
if file.added > 0 {
header = header.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().success)
.child(format!("+{}", file.added)),
);
}
if file.removed > 0 {
header = header.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().danger)
.child(format!("{}", file.removed)),
);
}
let mut card = v_flex()
.w_full()
.border_1()
.border_color(cx.theme().border)
.rounded(rounding::CARD_RADIUS)
.overflow_hidden()
.child(header);
if has_body {
let mut body = v_flex().w_full();
let hunks: Vec<_> = file
.hunks
.iter()
.map(|hunk| {
let rows = match mode {
DiffViewMode::Split => HunkRows::Split(split_hunk(&hunk.lines)),
DiffViewMode::Unified => HunkRows::Unified(unified_rows(&hunk.lines)),
};
(hunk, rows)
})
.collect();
let closing_row = if file.truncated.is_some() {
None
} else {
hunks
.iter()
.rposition(|(_, rows)| !rows.is_empty())
.map(|h| (h, hunks[h].1.len() - 1))
};
for (h, (hunk, rows)) in hunks.iter().enumerate() {
body = body.child(
div()
.w_full()
.px_2()
.py_0p5()
.bg(cx.theme().muted)
.text_xs()
.font_family(self.font_family.clone())
.text_color(cx.theme().muted_foreground)
.truncate()
.child(hunk.header.clone()),
);
match rows {
HunkRows::Split(rows) => {
for (r, row) in rows.iter().enumerate() {
body = body.child(self.diff_split_row(
row,
closing_row == Some((h, r)),
cx,
));
}
}
HunkRows::Unified(rows) => {
for (r, row) in rows.iter().enumerate() {
body = body.child(self.diff_unified_row(
row,
closing_row == Some((h, r)),
cx,
));
}
}
}
}
if let Some(reason) = file.truncated {
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(),
};
body = body.child(
div()
.w_full()
.px_2()
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(note),
);
}
card = card.child(body);
}
card.into_any_element()
}
fn diff_split_row(&self, row: &SplitRow, closes_card: bool, cx: &Context<Self>) -> AnyElement {
let radius = if closes_card {
rounding::inner_radius(rounding::CARD_RADIUS, rounding::HAIRLINE)
} else {
px(0.)
};
h_flex()
.w_full()
.h(px(19.))
.items_stretch()
.text_xs()
.font_family(self.font_family.clone())
.child(self.diff_split_cell(row.left.as_ref(), Side::Old, radius, cx))
.child(div().flex_shrink_0().w(px(1.)).bg(cx.theme().border))
.child(self.diff_split_cell(row.right.as_ref(), Side::New, radius, cx))
.into_any_element()
}
fn diff_split_cell(
&self,
cell: Option<&SplitCell>,
side: Side,
outer_radius: Pixels,
cx: &Context<Self>,
) -> AnyElement {
let base = h_flex().flex_1().min_w_0().h_full().items_center();
let base = match side {
Side::Old => base.rounded_bl(outer_radius),
Side::New => base.rounded_br(outer_radius),
};
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 [`Self::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(
&self,
row: &UnifiedRow,
closes_card: bool,
cx: &Context<Self>,
) -> AnyElement {
let radius = if closes_card {
rounding::inner_radius(rounding::CARD_RADIUS, rounding::HAIRLINE)
} else {
px(0.)
};
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(self.font_family.clone())
.rounded_bl(radius)
.rounded_br(radius)
.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(cx.theme().border),
)
.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()))
.into_any_element()
}
fn diff_untracked_section(&self, snap: &DiffSnapshot, cx: &Context<Self>) -> AnyElement {
let total = snap.untracked_count();
let untracked = &snap.untracked[..snap.untracked.len().min(MAX_RENDERED_FILES)];
let header_corners = rounding::stack_corners(
0,
if total == 0 { 1 } else { 2 },
rounding::CARD_RADIUS,
rounding::HAIRLINE,
);
let mut section = v_flex()
.w_full()
.border_1()
.border_color(cx.theme().border)
.rounded(rounding::CARD_RADIUS)
.overflow_hidden()
.child(
div()
.w_full()
.px_2p5()
.py_1p5()
.rounded_corners(header_corners)
.bg(cx.theme().secondary)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::DiffUntrackedHeader, total, &[])),
);
for path in untracked {
section = section.child(
h_flex()
.w_full()
.items_center()
.gap_2()
.px_2p5()
.py_1()
.text_xs()
.font_family(self.font_family.clone())
.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.clone())),
);
}
if total > untracked.len() {
let rest = total - untracked.len();
section = section.child(
div()
.w_full()
.px_2p5()
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::DiffMoreUntracked, rest, &[])),
);
}
section.into_any_element()
}
}
/// 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)
}
fn focused_name(overlay: &DiffOverlayState) -> Option<String> {
let DiffLoad::Ready(snap) = &overlay.load else {
return None;
};
let idx = focused_file(snap, overlay)?;
Some(snap.files[idx].path.clone())
}
fn empty_snapshot(snap: &DiffSnapshot) -> bool {
snap.files.is_empty() && snap.untracked.is_empty()
}
fn file_expanded(file: &FileDiff, expanded: &HashMap<String, bool>, collapse_all: bool) -> bool {
if let Some(&want) = expanded.get(&file.path) {
return want;
}
!collapse_all && file.added + file.removed <= AUTO_COLLAPSE_LINES
}
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::{DiffLine, LineKind};
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"
);
}
#[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");
}
/// 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());
});
}
}