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), 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, pub(crate) focus: Option, /// 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>)>, pub(crate) preview_loading: Option, /// 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>, rows_key: Option, /// 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, } /// 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.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, window: &mut Window, cx: &mut Context, ) { // 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, window: &mut Window, cx: &mut Context, ) { 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) { 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) { 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, ) { 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>, cx: &mut Context, ) { // 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::(); cx.update_global::(|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) { 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::() .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, ) -> Option { 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::(), 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, ) -> Option { 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, ) -> 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) -> AnyElement { let sf = cx.global::().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) { 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) -> 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) -> Option { 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, cx: &mut Context, ) -> 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 = 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), } /// What this frame would flatten its rows from, borrowed from the overlay. struct RowsFrom<'a> { snap: &'a Arc, preview: Option<&'a Arc>, mode: DiffViewMode, focused: Option, oversized: bool, expanded: &'a HashMap, } 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, preview: Option>, mode: DiffViewMode, focused: Option, oversized: bool, expanded: HashMap, } 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) { 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, app: &gpui::WeakEntity, 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, cx: &gpui::App, ) -> AnyElement { let hover = gpui::rgb(cx.global::().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, cx: &gpui::App, ) -> AnyElement { let hover = gpui::rgb(cx.global::().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| { 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::() .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::() .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, } 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 { 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 { 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, new: Option, 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(pairs: [(&str, bool); N]) -> HashMap { 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 { [(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 = (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 = (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, 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, 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::().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::(); cx.update_global::(|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); } }