diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 5317e04e..7455fd1e 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -850,6 +850,14 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmGraphFilterPlaceholder => "Filter commits…", L10nKey::ScmGraphAllBranches => "All Branches", L10nKey::ScmGraphEmpty => "No commits yet", + L10nKey::ScmGraphCurrentBranch => "Current Branch", + L10nKey::ScmGraphFoldLanes => "Hide Lanes", + L10nKey::ScmGraphShowLanes => "Show Lanes", + L10nKey::ScmCheckoutCommit => "Checkout Commit", + L10nKey::ScmCreateBranchHere => "Create Branch Here…", + L10nKey::ScmResetSoft => "Reset (Soft)", + L10nKey::ScmResetMixed => "Reset (Mixed)", + L10nKey::ScmResetHard => "Reset (Hard)", L10nKey::ScmCommitDetailTitle => "Commit", L10nKey::ScmCopyCommitSha => "Copy Commit SHA", L10nKey::ScmCherryPick => "Cherry Pick", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 7b1dbf32..c3cb5f93 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -900,6 +900,14 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGraphFilterPlaceholder => "コミットを絞り込む…", L10nKey::ScmGraphAllBranches => "すべてのブランチ", L10nKey::ScmGraphEmpty => "まだコミットがありません", + L10nKey::ScmGraphCurrentBranch => "現在のブランチ", + L10nKey::ScmGraphFoldLanes => "レーンを隠す", + L10nKey::ScmGraphShowLanes => "レーンを表示", + L10nKey::ScmCheckoutCommit => "このコミットをチェックアウト", + L10nKey::ScmCreateBranchHere => "ここにブランチを作成…", + L10nKey::ScmResetSoft => "リセット(ソフト)", + L10nKey::ScmResetMixed => "リセット(ミックス)", + L10nKey::ScmResetHard => "リセット(ハード)", L10nKey::ScmCommitDetailTitle => "コミット", L10nKey::ScmCopyCommitSha => "コミット SHA をコピー", L10nKey::ScmCherryPick => "チェリーピック", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 95df2ccd..f6b779d1 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -676,6 +676,14 @@ pub enum L10nKey { ScmGraphFilterPlaceholder, ScmGraphAllBranches, ScmGraphEmpty, + ScmGraphCurrentBranch, + ScmGraphFoldLanes, + ScmGraphShowLanes, + ScmCheckoutCommit, + ScmCreateBranchHere, + ScmResetSoft, + ScmResetMixed, + ScmResetHard, ScmCommitDetailTitle, ScmCopyCommitSha, ScmCherryPick, @@ -1141,6 +1149,14 @@ const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[ L10nKey::ScmGraphFilterPlaceholder, L10nKey::ScmGraphAllBranches, L10nKey::ScmGraphEmpty, + L10nKey::ScmGraphCurrentBranch, + L10nKey::ScmGraphFoldLanes, + L10nKey::ScmGraphShowLanes, + L10nKey::ScmCheckoutCommit, + L10nKey::ScmCreateBranchHere, + L10nKey::ScmResetSoft, + L10nKey::ScmResetMixed, + L10nKey::ScmResetHard, L10nKey::ScmCommitDetailTitle, L10nKey::ScmCherryPick, L10nKey::ScmRevertCommit, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 5d662d8d..3e83ff57 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -823,6 +823,14 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmGraphFilterPlaceholder => "筛选提交…", L10nKey::ScmGraphAllBranches => "全部分支", L10nKey::ScmGraphEmpty => "还没有提交", + L10nKey::ScmGraphCurrentBranch => "当前分支", + L10nKey::ScmGraphFoldLanes => "隐藏泳道", + L10nKey::ScmGraphShowLanes => "显示泳道", + L10nKey::ScmCheckoutCommit => "检出此提交", + L10nKey::ScmCreateBranchHere => "在此创建分支…", + L10nKey::ScmResetSoft => "重置(保留暂存)", + L10nKey::ScmResetMixed => "重置(保留工作区)", + L10nKey::ScmResetHard => "重置(丢弃更改)", L10nKey::ScmCommitDetailTitle => "提交", L10nKey::ScmCopyCommitSha => "复制提交 SHA", L10nKey::ScmCherryPick => "拣选提交", diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 577dc434..f667f4b9 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -123,6 +123,26 @@ pub struct ActiveAccent(pub u32); impl Global for ActiveAccent {} +/// How many lanes of the commit graph get a colour of their own. +/// +/// Six because that is how many hues of the ANSI set survive being pulled to a +/// contrast floor while staying apart from each other — and because the graph +/// caps its visible lanes at the same number, which is what guarantees no two +/// columns on screen are ever the same colour. +pub const LANE_SLOTS: usize = 6; + +#[derive(Debug, Clone, Copy)] +pub struct Lanes { + pub ink: [u32; LANE_SLOTS], + /// Everything past the last slot shares one column, so it gets a neutral: + /// a hue there would claim a branch identity the column does not have. + pub overflow: u32, +} + +pub struct ActiveLanes(pub Lanes); + +impl Global for ActiveLanes {} + impl Theme { pub fn background_color(&self) -> u32 { self.background.color() @@ -177,13 +197,16 @@ impl Theme { } } + /// One entry of the palette as a packed `0xRRGGBB`. + fn ansi(&self, i: usize) -> u32 { + let (r, g, b) = self.ansi16[i]; + (r as u32) << 16 | (g as u32) << 8 | b as u32 + } + pub fn semantics(&self) -> Semantics { let bg = self.background_color(); let fg = legible_foreground(bg, self.foreground); - let ansi = |i: usize| -> u32 { - let (r, g, b) = self.ansi16[i]; - (r as u32) << 16 | (g as u32) << 8 | b as u32 - }; + let ansi = |i: usize| self.ansi(i); // An error line lands on a popover or a sidebar row as often as on the // window, and both of those fills sit a step toward the foreground. // Clear the floor on every surface the ink can be painted on, not just @@ -211,6 +234,44 @@ impl Theme { } } + /// Lane colours for the commit graph, derived the same way every other + /// colour in this file is: seeded from the theme's own palette, then walked + /// to a contrast floor on each surface it can be painted on. + /// + /// Not a fixed table of hexes. A hard-coded palette would be the one thing + /// here that does not follow the theme, and — worse — the contrast tests + /// below cannot see it, so the four light builtins would ship a graph whose + /// lanes sit at 2:1 against their own background. + /// + /// The seed order is blue, yellow, magenta, green, cyan, red. Three + /// constraints picked it: no two adjacent slots share a hue family; red and + /// green are never neighbours, for the readers who cannot tell them apart; + /// and red is last because a panel three or four lanes wide never reaches + /// it, so the one colour that also means "danger" everywhere else in the UI + /// stays out of the common case. + pub fn lanes(&self) -> Lanes { + const SEEDS: [usize; LANE_SLOTS] = [4, 3, 5, 2, 6, 1]; + let bg = self.background_color(); + let fg = legible_foreground(bg, self.foreground); + // Same three surfaces as `semantics`: the graph draws on the window in + // a floating panel, on the sidebar when the panel is docked, and on a + // popover in the commit detail view. + let surfaces = [bg, mix(bg, fg, 0.03), mix(bg, fg, 0.05)]; + let clear = |seed: u32| { + surfaces.iter().fold(seed, |ink, surface| { + legible_ink(*surface, ink, ACCENT_FLOOR) + }) + }; + let mut ink = [0u32; LANE_SLOTS]; + for (slot, seed) in SEEDS.iter().enumerate() { + ink[slot] = clear(self.ansi(*seed)); + } + Lanes { + ink, + overflow: dim(fg, bg, state::TEXT_RESTING), + } + } + pub fn surfaces(&self) -> Surfaces { let m = self.neutrals(); let mut sidebar = self.surface(m.sidebar); @@ -1205,6 +1266,122 @@ mod tests { } } + /// CIE L*a*b* for a packed sRGB colour, D65. + /// + /// Contrast is a luminance ratio and says nothing about hue: two lanes can + /// both clear 3:1 against the background and still be the same colour to + /// look at. ΔE is the measure that catches that, and it needs Lab. + fn lab(c: u32) -> (f32, f32, f32) { + fn linear(v: u32) -> f32 { + let s = v as f32 / 255.0; + if s <= 0.04045 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + } + let (r, g, b) = ( + linear(c >> 16 & 0xff), + linear(c >> 8 & 0xff), + linear(c & 0xff), + ); + // sRGB → XYZ, then normalised by the D65 white point. + let x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047; + let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883; + let f = |t: f32| { + if t > 0.008856 { + t.cbrt() + } else { + 7.787 * t + 16.0 / 116.0 + } + }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)) + } + + fn delta_e76(a: u32, b: u32) -> f32 { + let (l1, a1, b1) = lab(a); + let (l2, a2, b2) = lab(b); + ((l1 - l2).powi(2) + (a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt() + } + + #[test] + fn lane_colours_clear_the_floor_on_every_surface() { + for t in builtins() { + let bg = t.background_color(); + let fg = legible_foreground(bg, t.foreground); + let lanes = t.lanes(); + for (name, surface) in [ + ("background", bg), + ("sidebar", mix(bg, fg, 0.03)), + ("popover", mix(bg, fg, 0.05)), + ] { + for (slot, ink) in lanes.ink.iter().enumerate() { + let ratio = contrast(*ink, surface); + assert!( + ratio >= ACCENT_FLOOR, + "{}/{name}: lane {slot} is only {ratio:.2}:1", + t.id + ); + } + let ratio = contrast(lanes.overflow, surface); + assert!( + ratio >= ACCENT_FLOOR, + "{}/{name}: the overflow lane is only {ratio:.2}:1", + t.id + ); + } + } + } + + #[test] + fn adjacent_lanes_are_never_the_same_colour() { + // A just-noticeable difference is around 2.3. The floor is set far + // above it because these are 1.5px lines a few pixels apart, not + // patches side by side, and the eye is much worse at hairlines. + const FLOOR: f32 = 12.0; + for t in builtins() { + let lanes = t.lanes(); + for slot in 0..LANE_SLOTS - 1 { + let d = delta_e76(lanes.ink[slot], lanes.ink[slot + 1]); + assert!( + d >= FLOOR, + "{}: lanes {slot} and {} are ΔE {d:.1} apart", + t.id, + slot + 1 + ); + } + } + } + + #[test] + fn lane_colours_are_deterministic() { + for t in builtins() { + assert_eq!( + t.lanes().ink, + t.lanes().ink, + "{}: lane derivation is not a pure function", + t.id + ); + } + } + + /// The seeds were chosen so that no two neighbours share a hue family and + /// red never sits beside green. Both are properties of the *order*, so a + /// reshuffle has to fail here rather than only looking slightly worse. + #[test] + fn the_lane_seed_order_keeps_red_and_green_apart() { + let seeds = [4usize, 3, 5, 2, 6, 1]; + let red = seeds.iter().position(|s| *s == 1).expect("red is a seed"); + let green = seeds.iter().position(|s| *s == 2).expect("green is a seed"); + assert!( + red.abs_diff(green) > 1, + "red and green ended up adjacent at slots {red} and {green}" + ); + assert_eq!(red, LANE_SLOTS - 1, "red should be the last slot reached"); + } + #[test] fn resting_labels_stay_readable() { for t in builtins() { diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index 323f8939..8ffe877d 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -403,7 +403,7 @@ impl Tty7App { cx: &mut Context, ) -> AnyElement { let files = detail.files.clone().unwrap_or_default(); - let mut list = v_flex().child(self.panel_subtitle( + let list = v_flex().child(self.panel_subtitle( &t_plural(L10nKey::ScmFilesChanged, files.len(), &[]), true, None, diff --git a/src/ui/scm/graph.rs b/src/ui/scm/graph.rs index 93384dfd..2c4dddc0 100644 --- a/src/ui/scm/graph.rs +++ b/src/ui/scm/graph.rs @@ -7,26 +7,1837 @@ //! message is the commit detail view's job, one click away. //! //! That is also VS Code's own reading of a sidebar graph, and it is why the -//! conventional-commit prefix is lifted out into a chip rather than left to -//! eat half the line. +//! conventional-commit prefix is lifted out into a chip rather than left to eat +//! half the line, and why the lane gutter folds away on request. +//! +//! # How it is drawn +//! +//! One `canvas` covering the whole list, absolutely positioned over ordinary +//! interactive rows — never one canvas per row. Every `PrimitiveBatch::Paths` +//! gpui emits ends the current encoder, opens a render pass, clears a +//! drawable-sized intermediate texture, rasterises, resolves MSAA and +//! composites back; forty visible rows would mean forty of those per frame. +//! +//! Being on top costs nothing in event terms: `Canvas::id` returns `None` and +//! it implements no interactivity, so it registers no hitbox in prepaint. The +//! rows underneath keep gpui's native hover, click, context menu and +//! scroll-into-view. This was measured, not assumed — see the G7·0 spike commit. -use gpui::{AnyElement, Context}; +use std::cell::Cell as StdCell; +use std::rc::Rc; +use std::sync::Arc; -use crate::ui::app::Tty7App; +use gpui::{ + AnyElement, BorderStyle, Bounds, Context, Corners, Edges, Focusable as _, Hsla, MouseButton, + MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Window, canvas, div, fill, point, + prelude::*, px, quad, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use tty7_core::core::git::log::{ + Commit, CommitPage, Edge, GRAPH_PAGE, GraphRow, GraphScope, Lane, RefDeco, RefKind, +}; +use tty7_core::core::git::ops::{GitOp, ResetMode}; + +use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::presets::{ActiveLanes, LANE_SLOTS, Lanes}; +use crate::ui::right_panel::info_chip; +use crate::ui::scm::path::{elide_middle, relative_time}; use crate::ui::scm::state::RepoKey; +/// One commit per row. 20px rather than the file list's 24: a graph row has no +/// icon column, and the vertical pitch wants to stay close to the lane pitch or +/// the diagonal of a merge reads as a much shallower angle than it is. +const GRAPH_ROW_H: f32 = 20.; + +/// Horizontal distance between lane centres. +const GRAPH_LANE_W: f32 = 12.; + +/// Inset before the first lane centre, and the gap between the gutter and the +/// text column. +const GRAPH_PAD_L: f32 = 6.; +const GRAPH_PAD_R: f32 = 6.; + +const GRAPH_DOT_R: f32 = 3.; +const GRAPH_LINE_W: f32 = 1.5; + +/// Most of the panel's width belongs to the message. Thirty percent is what +/// leaves five lanes at the 260px default and still keeps a readable column. +const GRAPH_GUTTER_SHARE: f32 = 0.30; + +/// Lanes are capped by what the panel can show, never by what history did. +const GRAPH_MIN_LANES: usize = 3; +const GRAPH_MAX_LANES: usize = LANE_SLOTS; + +/// The cap can never exceed the palette, or two columns on screen would come +/// out the same colour and the whole point of colouring by lane is lost. +const _: () = assert!(GRAPH_MAX_LANES <= LANE_SLOTS); + +/// Resting height of the history section and the range the divider drags it +/// through. The ceiling is a share of the window rather than a constant: the +/// file list has to keep a usable part of a short one. +const GRAPH_H_DEFAULT: f32 = 220.; +const GRAPH_H_MIN: f32 = 88.; +const GRAPH_H_MAX_RATIO: f32 = 0.65; + +/// The divider's grab area, matching `RESIZE_HANDLE_WIDTH` on the other axis. +const GRAPH_HANDLE_H: f32 = 6.; + +/// Ref chips are the widest optional thing on a row, so they get a hard cap +/// and lose their middle rather than the message losing its column. +const GRAPH_REF_CHARS: usize = 14; + +/// How many lanes fit, given the panel's width. +/// +/// A pure projection over the width, deliberately: dragging the panel narrower +/// must not re-run the layout pass or renumber a colour. Folding the gutter +/// collapses it to a single column, which is worth about six characters of the +/// message — at this width, the difference between reading a subject and +/// reading its first word. +fn max_lanes(panel_w: f32, collapsed: bool) -> usize { + if collapsed { + return 1; + } + // The share buys the whole gutter, insets included — budgeting only the + // lane strip would overrun it by a lane at every width. + let fit = ((panel_w * GRAPH_GUTTER_SHARE - GRAPH_PAD_L - GRAPH_PAD_R) / GRAPH_LANE_W).floor(); + if !fit.is_finite() { + return GRAPH_MIN_LANES; + } + (fit as usize).clamp(GRAPH_MIN_LANES, GRAPH_MAX_LANES) +} + +/// Fold a true lane onto a visible column. +/// +/// Everything past the cap shares the last column. Pure projection, never fed +/// back into the layout: the same page re-projects for free at any width, with +/// no recomputation and no colour changing under the reader. +fn project(lane: Lane, max_lanes: usize) -> Lane { + lane.min(max_lanes.saturating_sub(1) as Lane) +} + +/// Snap a lane centre to a device pixel *before* the quad is built. +/// +/// `paint_quad` snaps the bounds it is handed, but each edge independently: an +/// unsnapped centre makes `[cx - w/2, cx + w/2]` round out to one physical +/// pixel on some rows and two on others, and a column of lines that changes +/// width as it scrolls is the most visible artefact this element can produce. +/// Same shape as `powerline_solid_edge` in the terminal renderer. +fn snap(x: f32, scale: f32) -> f32 { + if !scale.is_finite() || scale <= 0. { + return x; + } + (x * scale).round() / scale +} + +/// Centre of a visible column, relative to the gutter's left edge. +fn lane_center_x(column: Lane, scale: f32) -> f32 { + snap( + GRAPH_PAD_L + GRAPH_LANE_W * column as f32 + GRAPH_LANE_W / 2., + scale, + ) +} + +/// Total width of the gutter for a given cap. +fn gutter_width(max_lanes: usize) -> f32 { + GRAPH_PAD_L + GRAPH_LANE_W * max_lanes as f32 + GRAPH_PAD_R +} + +/// Split a conventional-commit prefix off the subject. +/// +/// Returns the type, whether it was marked breaking, and what is left. This +/// repository's subjects spend an average of 12.7 characters on the prefix, +/// which is half of what a 260px panel has to give — and the type is exactly +/// the part that renders better as three coloured characters than as prose. +/// +/// Strict on purpose. Only a lowercase ASCII type, an optional parenthesised +/// scope, an optional `!`, then `": "`. `Note: see below` and `TODO: fix` are +/// not conventional commits and keep their whole line. +fn split_conventional(subject: &str) -> (Option<(&str, bool)>, &str) { + let bytes = subject.as_bytes(); + let type_len = bytes.iter().take_while(|b| b.is_ascii_lowercase()).count(); + // Two is `ci`; past twelve it is prose that happens to start lowercase. + if !(2..=12).contains(&type_len) { + return (None, subject); + } + let mut i = type_len; + if bytes.get(i) == Some(&b'(') { + match bytes[i..].iter().position(|b| *b == b')') { + // An empty scope, `feat(): x`, is malformed; treat the line as prose. + Some(0 | 1) => return (None, subject), + Some(close) => i += close + 1, + None => return (None, subject), + } + } + let breaking = bytes.get(i) == Some(&b'!'); + if breaking { + i += 1; + } + // The space matters: `fix:it` is not a conventional commit, and without it + // a URL-bearing subject would be cut at `https:`. + if bytes.get(i) != Some(&b':') || bytes.get(i + 1) != Some(&b' ') { + return (None, subject); + } + let rest = subject[i + 2..].trim_start(); + if rest.is_empty() { + return (None, subject); + } + (Some((&subject[..type_len], breaking)), rest) +} + +/// Which colour a visible column draws with. +/// +/// A column is the overflow bundle only when the page really is wider than the +/// cap. Deciding that per page rather than per row keeps a column from changing +/// colour as the reader scrolls past the one merge that widened history. +fn column_ink(column: Lane, color: u16, max_lanes: usize, overflowing: bool, lanes: &Lanes) -> u32 { + if overflowing && column as usize + 1 == max_lanes { + return lanes.overflow; + } + lanes.ink[(color as usize).min(LANE_SLOTS - 1)] +} + +/// The segments of one row's band, already folded onto visible columns. +/// +/// Deduplicated by column, which is what makes the overflow bundle work: five +/// lanes sharing the last column produce one line, not five stacked on each +/// other at five different alphas. Later writers win, and the caller feeds +/// edges in `paint_rank` order, so the node's own line lands over anything +/// merely passing behind it. +#[derive(Default)] +struct Band { + top: [Option; GRAPH_MAX_LANES], + bottom: [Option; GRAPH_MAX_LANES], + /// `(left column, right column, colour)`, at most one per pair. + turns: Vec<(Lane, Lane, u32)>, +} + +impl Band { + fn turn(&mut self, a: Lane, b: Lane, ink: u32) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + match self.turns.iter_mut().find(|t| t.0 == lo && t.1 == hi) { + Some(existing) => existing.2 = ink, + None => self.turns.push((lo, hi, ink)), + } + } +} + +/// Fold one row's edges into the segments that will be painted. +fn band_of(row: &GraphRow, max_lanes: usize, overflowing: bool, lanes: &Lanes) -> Band { + let mut band = Band::default(); + let node = project(row.node, max_lanes); + for edge in &row.edges { + let ink = |column: Lane| column_ink(column, edge.color(), max_lanes, overflowing, lanes); + match *edge { + Edge::Pass { lane, .. } => { + let c = project(lane, max_lanes); + band.top[c as usize] = Some(ink(c)); + band.bottom[c as usize] = Some(ink(c)); + } + Edge::In { from, .. } => { + let c = project(from, max_lanes); + band.top[c as usize] = Some(ink(c)); + if c != node { + band.turn(c, node, ink(c)); + } + } + Edge::Out { to, .. } => { + let c = project(to, max_lanes); + band.bottom[c as usize] = Some(ink(c)); + if c != node { + band.turn(node, c, ink(c)); + } + } + } + } + band +} + +/// Everything the paint closure needs, snapshotted at render time so nothing +/// reaches back into the view from inside the frame. +struct GraphPaint { + page: Arc, + max_lanes: usize, + overflowing: bool, + lanes: Lanes, + /// The fill behind a node ring, so a merge reads as a ring and not as a + /// disc with a hole punched through to whatever is under the panel. + surface: Hsla, + /// Whether a "load more" band follows the last row. + more: bool, +} + +/// Paint the whole gutter in one pass. +/// +/// Deliberately *not* wrapped in `paint_layer` per line, which is what Zed's +/// own graph does. A layer is a full-drawable render pass; a dozen of them per +/// frame is a dozen. Overlap ordering is already handled — `BoundsTree` hands +/// every overlapping primitive an increasing order — and within a row the +/// caller has sorted edges by `paint_rank` so the node's line is last. If a +/// future change makes something here look wrong in z, the fix is the sort +/// order, not a layer. +fn paint_graph(p: &GraphPaint, bounds: Bounds, window: &mut Window) { + let started = std::time::Instant::now(); + let scale = window.scale_factor(); + let top = bounds.origin.y.as_f32(); + let left = bounds.origin.x.as_f32(); + let rows = &p.page.rows; + + // The canvas is as tall as the whole list, so most of it is off screen. + // The mask is the viewport; only the band it allows is worth iterating. + let mask = window.content_mask().bounds; + let first = (((mask.origin.y.as_f32() - top) / GRAPH_ROW_H).floor() as isize).max(0) as usize; + let last = ((((mask.origin.y + mask.size.height).as_f32() - top) / GRAPH_ROW_H).ceil() as isize) + .max(0) as usize; + + let cx_of = |column: Lane| left + lane_center_x(column, scale); + let vline = |x: f32, y0: f32, y1: f32| { + Bounds::from_corners( + point(px(x - GRAPH_LINE_W / 2.), px(y0)), + point(px(x + GRAPH_LINE_W / 2.), px(y1)), + ) + }; + + for (i, row) in rows + .iter() + .enumerate() + .take(last.min(rows.len())) + .skip(first) + { + let y0 = top + i as f32 * GRAPH_ROW_H; + let mid = y0 + GRAPH_ROW_H / 2.; + let band = band_of(row, p.max_lanes, p.overflowing, &p.lanes); + + for (column, ink) in band.top.iter().enumerate() { + if let Some(ink) = ink { + window.paint_quad(fill(vline(cx_of(column as Lane), y0, mid), gpui::rgb(*ink))); + } + } + for (column, ink) in band.bottom.iter().enumerate() { + if let Some(ink) = ink { + window.paint_quad(fill( + vline(cx_of(column as Lane), mid, y0 + GRAPH_ROW_H), + gpui::rgb(*ink), + )); + } + } + // Cross-lane turns are right angles, which is what tig, lazygit and + // `git log --graph` all draw and what reads unambiguously at a 12px + // pitch. Curves would mean paths, and paths mean a render pass each. + // Swapping them in later touches only this loop: a curve consumes the + // same `(lo, hi, mid)` a right angle does. + // + // The horizontal runs half a line width past both centres, which is + // exactly what fills the two outside corners the vertical stubs leave + // open. Without it a turn shows a notch at every elbow. + for (lo, hi, ink) in &band.turns { + window.paint_quad(fill( + Bounds::from_corners( + point( + px(cx_of(*lo) - GRAPH_LINE_W / 2.), + px(mid - GRAPH_LINE_W / 2.), + ), + point( + px(cx_of(*hi) + GRAPH_LINE_W / 2.), + px(mid + GRAPH_LINE_W / 2.), + ), + ), + gpui::rgb(*ink), + )); + } + + let node = project(row.node, p.max_lanes); + let ink = gpui::rgb(column_ink( + node, + row.color, + p.max_lanes, + p.overflowing, + &p.lanes, + )); + let cx = cx_of(node); + let dot = |r: f32| { + Bounds::from_corners( + point(px(cx - r), px(mid - r)), + point(px(cx + r), px(mid + r)), + ) + }; + // A rounded quad rather than a path: the quad shader's rounding is an + // exact SDF with analytic anti-aliasing, where `PathBuilder` fills every + // vertex's `st` with `(0, 1)` and so falls back on 4x MSAA alone. + if row.parents > 1 { + // A merge is a ring. It is the one row shape a reader scans for, + // and an outline reads at 6px where a second fill colour does not. + let r = GRAPH_DOT_R + 1.; + window.paint_quad(quad( + dot(r), + Corners::all(px(r)), + p.surface, + Edges::all(px(GRAPH_LINE_W)), + ink, + BorderStyle::Solid, + )); + } else if row.parents == 0 { + // A root has nothing below it; hollow says "the line stops here" + // without needing a second glyph. + window.paint_quad(quad( + dot(GRAPH_DOT_R), + Corners::all(px(GRAPH_DOT_R)), + p.surface, + Edges::all(px(GRAPH_LINE_W)), + ink, + BorderStyle::Solid, + )); + } else { + window.paint_quad( + fill(dot(GRAPH_DOT_R), ink).corner_radii(Corners::all(px(GRAPH_DOT_R))), + ); + } + } + + // Past the last row the lanes that are still open get a stub. Without it a + // page boundary reads as a row of root commits — every line simply ending. + // Under a "load more" row the stubs run the full band instead, so the graph + // reads as continuing through the control rather than being cut by it. + if last > rows.len() && !p.page.open_lanes.is_empty() { + let y0 = top + rows.len() as f32 * GRAPH_ROW_H; + for lane in &p.page.open_lanes { + let column = project(*lane, p.max_lanes); + let ink = column_ink(column, *lane, p.max_lanes, p.overflowing, &p.lanes); + let x = cx_of(column); + if p.more { + let mut c: Hsla = gpui::rgb(ink).into(); + c.a = 0.3; + window.paint_quad(fill(vline(x, y0, y0 + GRAPH_ROW_H), c)); + } else { + // Three steps rather than a gradient: a gradient would be a + // second `Background` kind for four pixels of ink. + for (step, alpha) in [0.5f32, 0.3, 0.15].into_iter().enumerate() { + let mut c: Hsla = gpui::rgb(ink).into(); + c.a = alpha; + let a = y0 + step as f32 * 3.; + window.paint_quad(fill(vline(x, a, a + 3.), c)); + } + } + } + } + + if crate::ui::perf::enabled() { + crate::ui::perf::record("scm.graph.paint", started.elapsed()); + } +} + impl Tty7App { /// The history section, when it is expanded and has something to draw. /// - /// Sits below the file list as its own scroll region rather than at the - /// end of one: the graph pages, and sharing a scroller would mean scrolling - /// back past hundreds of commits to reach the message box. + /// Sits below the file list as its own scroll region rather than at the end + /// of one: the graph pages, and sharing a scroller would mean scrolling back + /// past hundreds of commits to reach the message box. pub(crate) fn render_graph_section( &mut self, - _repo: &RepoKey, - _window: &mut gpui::Window, - _cx: &mut Context, + repo: &RepoKey, + window: &mut Window, + cx: &mut Context, ) -> Option { - None + if !self.scm.graph.expanded { + // Folded, the section is one line — but it keeps the rule above it, + // or it reads as the last row of the file list rather than as a + // section of its own. + return Some( + div() + .flex_none() + .border_t_1() + .border_color(cx.theme().border) + .child(self.graph_header(repo, None, cx)) + .into_any_element(), + ); + } + self.scm_load_graph(repo, cx); + + if self.scm.graph.height.get() <= 0. { + self.scm.graph.height.set(GRAPH_H_DEFAULT); + } + let ceiling = (window.viewport_size().height.as_f32() * GRAPH_H_MAX_RATIO).max(GRAPH_H_MIN); + let height = self.scm.graph.height.get().clamp(GRAPH_H_MIN, ceiling); + + let page = self.scm.graph.page.clone(); + let header = self.graph_header(repo, page.as_deref(), cx); + let search = self.graph_search(window, cx); + let naming = self.graph_naming_row(repo, cx); + let query = self.graph_query(cx); + let body = match page { + None => self.panel_empty(t(L10nKey::PanelLoading), None, cx), + Some(page) if page.commits.is_empty() => { + self.panel_empty(t(L10nKey::ScmGraphEmpty), None, cx) + } + Some(page) => self.graph_body(repo, &page, query.as_deref(), cx), + }; + let (backing, handle) = self.graph_resize(ceiling, cx); + + Some( + v_flex() + .relative() + .flex_none() + .h(px(height)) + .border_t_1() + .border_color(cx.theme().border) + .child(backing) + .child(header) + .children(search) + .children(naming) + .child(body) + .child(handle) + .into_any_element(), + ) + } + + /// Which refs the current settings walk from. + fn graph_scope(&self) -> GraphScope { + self.scm.graph.scope.clone() + } + + /// Ask for a page, at most once per (repository, scope, size). + /// + /// Paging grows `requested` and re-runs the query rather than paging with + /// `--skip`. The layout is deterministic, so a longer run reproduces the + /// same prefix row for row — nothing already on screen moves — where + /// `--skip` is O(skip) to walk and slides under you the moment a ref moves. + fn scm_load_graph(&mut self, repo: &RepoKey, cx: &mut Context) { + // `try_global`, never `default_global`: this runs from `render`, and + // taking the global mutably there queues a global-observer effect on + // every frame, which is a panel that asks for a frame from inside one. + let epoch = cx + .try_global::() + .map_or(0, |data| data.epoch(repo.host, &repo.root)); + let scope = self.graph_scope(); + let want = self.scm.graph.requested.max(GRAPH_PAGE); + let key = (repo.clone(), epoch, scope.clone()); + let fresh = self.scm.graph.page_key.as_ref() == Some(&key) + && self + .scm + .graph + .page + .as_ref() + .is_some_and(|p| p.requested >= want); + if fresh || self.scm.graph.loading { + return; + } + let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, repo.host) else { + return; + }; + self.scm.graph.loading = true; + let root = repo.root.clone(); + let query = scope.clone(); + crate::ui::host_ops::HostOps::run( + host, + cx, + move |h| tty7_core::core::git::log::load_page(h, &root, &query, want), + move |this, page, cx| { + this.scm.graph.loading = false; + // A page that came back for a scope nobody is looking at any + // more is dropped rather than shown for one frame. + if let Some(page) = page { + this.scm.graph.page = Some(Arc::new(page)); + this.scm.graph.page_key = Some(key); + } + cx.notify(); + }, + ); + } + + /// The filter box's text, if it has any. + fn graph_query(&self, cx: &Context) -> Option { + let input = self.scm.graph.search.as_ref()?; + let text = input.read(cx).value().trim().to_lowercase(); + (!text.is_empty()).then_some(text) + } + + fn graph_search(&mut self, window: &mut Window, cx: &mut Context) -> Option { + if self.scm.graph.search.is_none() { + let input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder(t(L10nKey::ScmGraphFilterPlaceholder)) + }); + self.scm.graph.search_sub = + Some( + cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| { + if matches!(ev, gpui_component::input::InputEvent::Change) { + cx.notify(); + } + }), + ); + self.scm.graph.search = Some(input); + } + let input = self.scm.graph.search.clone()?; + Some(self.panel_search(&input, cx)) + } +} + +impl Tty7App { + /// The scrolling list: rows underneath, one canvas over the gutter. + fn graph_body( + &mut self, + repo: &RepoKey, + page: &Arc, + query: Option<&str>, + cx: &mut Context, + ) -> AnyElement { + let panel_w = cx.global::().right_panel_width; + let cap = max_lanes(panel_w, self.scm.graph.lanes_collapsed); + let gutter = gutter_width(cap); + let now = crate::ui::home::now_secs() as i64; + + // A filtered view drops rows out of the middle of history, and lanes + // drawn across a subset would connect commits that are not adjacent. + // So the filter hides the gutter entirely and the list becomes a flat + // search result — which is what it actually is. + let filtering = query.is_some(); + let rows: Vec = match query { + None => (0..page.commits.len()).collect(), + Some(q) => (0..page.commits.len()) + .filter(|i| matches_query(&page.commits[*i], q)) + .collect(), + }; + let more = query.is_none() && !page.complete; + let bands = rows.len() + usize::from(more); + + // With the gutter gone the text takes the panel's own inset, so a + // search result does not sit in a column of empty space. + let indent = if filtering { CONTENT_INSET } else { gutter }; + let list = v_flex().children( + rows.iter() + .map(|i| self.graph_row(repo, page, *i, indent, now, cx)), + ); + let mut stack = div() + .relative() + .w_full() + .h(px(bands as f32 * GRAPH_ROW_H)) + .child(list) + .children(more.then(|| self.graph_load_more(gutter, cx))); + + if !filtering { + let paint = GraphPaint { + page: page.clone(), + max_lanes: cap, + overflowing: page.max_lanes as usize > cap, + lanes: cx.global::().0, + surface: cx.theme().background, + more, + }; + stack = stack.child( + canvas( + |_, _, _| (), + move |bounds, _, window, _| paint_graph(&paint, bounds, window), + ) + .absolute() + .top_0() + .left_0() + .w(px(gutter)) + .h_full(), + ); + } + + let scroller = div() + .id("scm-graph") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scm.graph.scroll) + .child(stack); + crate::ui::scrollbar::with_vertical_scrollbar( + "scm-graph-scrollbar", + scroller, + &self.scm.graph.scroll, + ) + } + + /// One commit. + /// + /// Column order is fixed and every optional part has a hard cap, so the + /// worst case cannot squeeze the message to nothing: type chip, message, + /// ref chip, age. The age goes when a ref chip is present — a chip says + /// where a branch is, which is worth more here than three characters of + /// "3d", and the full timestamp is in the tooltip either way. + fn graph_row( + &self, + repo: &RepoKey, + page: &Arc, + i: usize, + gutter: f32, + now: i64, + cx: &mut Context, + ) -> AnyElement { + let commit = &page.commits[i]; + let mono = cx.theme().mono_font_family.clone(); + let sf = cx.global::().sidebar; + let selected = self.scm.graph.selected.as_deref() == Some(commit.oid.as_str()); + let (prefix, subject) = split_conventional(&commit.summary); + let deco = commit.refs.first(); + let extra = commit.refs.len().saturating_sub(1); + let oid = commit.oid.clone(); + + h_flex() + .id(SharedString::from(format!("scm-graph-row-{i}"))) + .items_center() + .gap(px(4.)) + .h(px(GRAPH_ROW_H)) + .pl(px(gutter)) + .pr(px(CONTENT_INSET)) + .cursor_pointer() + .when(selected, |d| d.bg(gpui::rgb(sf.selected))) + .when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover)))) + .children(prefix.map(|(kind, breaking)| self.graph_type_chip(kind, breaking, cx))) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .text_size(px(12.)) + .text_color(cx.theme().foreground) + .child(SharedString::from(subject.to_string())), + ) + .children(deco.map(|r| self.graph_ref_chip(r, extra, &mono, cx))) + .when(deco.is_none(), |d| { + d.child( + div() + .flex_none() + .min_w(px(26.)) + .text_size(px(10.5)) + .font_family(mono.clone()) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(relative_time( + now, + commit.author.at.unix, + ))), + ) + }) + .tooltip({ + let text = commit_tooltip(commit, now); + move |window, cx| { + gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) + } + }) + .on_click(cx.listener({ + let repo = repo.clone(); + let oid = oid.clone(); + // The row already holds everything the detail view renders, so + // it hands its own commit over and no `git show` is run. + let seed = commit.clone(); + move |this, _, _, cx| { + this.graph_open_commit(repo.clone(), oid.clone(), Some(seed.clone()), cx) + } + })) + .context_menu({ + let app = cx.entity().downgrade(); + let repo = repo.clone(); + let oid = oid.clone(); + move |menu, _window, cx| { + let danger = cx.theme().danger; + Tty7App::graph_row_context_menu(menu, &repo, &oid, danger, &app) + } + }) + .into_any_element() + } +} + +impl Tty7App { + /// Select a row and hand its commit to the detail view. + /// + /// `seed` is the commit the row was drawn from. Passing it means the + /// detail view opens with its message, author and refs already in hand and + /// only reads the file list — a row click costs one git command, not two. + fn graph_open_commit( + &mut self, + repo: RepoKey, + oid: String, + seed: Option, + cx: &mut Context, + ) { + self.scm.graph.selected = Some(oid.clone()); + self.open_commit_detail(repo, oid, seed, cx); + } +} + +/// Whether a commit answers the filter box. +/// +/// Subject, author and sha, all case-folded. Not the body: a search that +/// matches on text the row cannot show is a search whose results look wrong. +fn matches_query(commit: &Commit, query: &str) -> bool { + commit.summary.to_lowercase().contains(query) + || commit.author.name.to_lowercase().contains(query) + || commit.oid.starts_with(query) +} + +fn commit_tooltip(commit: &Commit, now: i64) -> SharedString { + SharedString::from(format!( + "{}\n{} · {} · {}", + commit.summary, + commit.short(), + commit.author.name, + relative_time(now, commit.author.at.unix) + )) +} + +/// The semantic slot a conventional-commit type draws from. +/// +/// Reusing the semantic ramp rather than inventing a palette: `feat` is the +/// same green as a success anywhere else in the UI, `fix` the same red as a +/// danger, and all of them have already been walked to a contrast floor on +/// every surface. Anything unrecognised is muted, so a repository with its own +/// vocabulary gets a neutral chip rather than an arbitrary colour. +fn type_tone(kind: &str, breaking: bool, cx: &gpui::App) -> (Hsla, Hsla) { + let theme = cx.theme(); + // A `!` is the one thing in a subject line worth shouting about, whatever + // the type in front of it says. + if breaking { + return (theme.danger.opacity(0.20), theme.danger); + } + let ink = match kind { + "feat" => theme.success, + "fix" => theme.danger, + "perf" | "revert" => theme.warning, + "docs" => theme.info, + _ => theme.muted_foreground, + }; + (ink.opacity(0.16), ink) +} + +impl Tty7App { + /// The prefix, as three or four coloured characters. + fn graph_type_chip(&self, kind: &str, breaking: bool, cx: &mut Context) -> AnyElement { + let (bg, fg) = type_tone(kind, breaking, cx); + div() + .flex_none() + .px(px(3.)) + .rounded(px(3.)) + .bg(bg) + .text_size(px(9.5)) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(fg) + .child(SharedString::from(match breaking { + true => format!("{kind}!"), + false => kind.to_string(), + })) + .into_any_element() + } + + /// The highest-priority ref on a commit, plus a count of the rest. + /// + /// `load_page` already sorted them HEAD → local → tag → remote, so the + /// first one is the one worth the width. + fn graph_ref_chip( + &self, + deco: &RefDeco, + extra: usize, + mono: &SharedString, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let (bg, fg, weight) = match deco.kind { + // Where you are is the one thing on this row worth a heavier + // weight; everything else is context. + RefKind::Head => ( + theme.accent.opacity(0.28), + theme.foreground, + gpui::FontWeight::SEMIBOLD, + ), + // Tags are yellow because tags are yellow — in git's own output, + // in every other client, and in the reader's memory. + RefKind::Tag => ( + theme.warning.opacity(0.16), + theme.warning, + gpui::FontWeight::NORMAL, + ), + _ => ( + theme.muted.opacity(0.9), + theme.muted_foreground, + gpui::FontWeight::NORMAL, + ), + }; + let label = match extra { + 0 => elide_middle(&deco.short, GRAPH_REF_CHARS).into_owned(), + n => format!("{} +{n}", elide_middle(&deco.short, GRAPH_REF_CHARS)), + }; + div() + .flex_none() + .max_w(px(72.)) + .truncate() + .font_weight(weight) + .child(info_chip(&label, bg, fg, mono)) + .into_any_element() + } + + /// The band under the last row that asks for the next page. + /// + /// A row rather than a scroll trigger. A remote `git log` is an RPC across + /// a host boundary, and scroll-to-load turns one flick of a trackpad into a + /// burst of concurrent ones. + fn graph_load_more(&self, gutter: f32, cx: &mut Context) -> AnyElement { + let loading = self.scm.graph.loading; + h_flex() + .id("scm-graph-more") + .items_center() + .h(px(GRAPH_ROW_H)) + .pl(px(gutter)) + .pr(px(CONTENT_INSET)) + .cursor_pointer() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .hover(|s| s.text_color(cx.theme().foreground)) + .child(SharedString::from(match loading { + true => t(L10nKey::PanelLoading), + false => t(L10nKey::ScmGraphLoadMore), + })) + .on_click(cx.listener(|this, _, _, cx| { + let now = this.scm.graph.requested.max(GRAPH_PAGE); + this.scm.graph.requested = now.saturating_add(GRAPH_PAGE); + cx.notify(); + })) + .into_any_element() + } +} + +impl Tty7App { + /// The section's own title row: fold, count, gutter toggle, scope picker. + fn graph_header( + &self, + repo: &RepoKey, + page: Option<&CommitPage>, + cx: &mut Context, + ) -> AnyElement { + let expanded = self.scm.graph.expanded; + let muted = cx.theme().muted_foreground; + let count = page.map(|p| match p.complete { + true => p.commits.len().to_string(), + false => format!("{}+", p.commits.len()), + }); + let collapsed = self.scm.graph.lanes_collapsed; + + h_flex() + .flex_none() + .items_center() + .gap(px(6.)) + .h(px(24.)) + .pl(px(CONTENT_INSET)) + .pr(px(crate::ui::app::tile_trailing_inset_sm())) + .child( + h_flex() + .id("scm-graph-fold") + .items_center() + .gap(px(4.)) + .flex_1() + .min_w(px(0.)) + .cursor_pointer() + .child( + Icon::new(match expanded { + true => IconName::ChevronDown, + false => IconName::ChevronRight, + }) + .size(px(10.)) + .text_color(muted), + ) + .child( + div() + .text_size(px(11.)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(muted) + .child(SharedString::from(t(L10nKey::ScmGraphTitle))), + ) + .children(count.map(|c| { + div() + .text_size(px(10.5)) + .text_color(muted) + .child(SharedString::from(c)) + })) + .on_click(cx.listener(|this, _, _, cx| this.scm_toggle_graph(cx))), + ) + .when(expanded, |row| { + row.child( + div() + .id("scm-graph-lanes") + .flex_none() + .size(px(18.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .cursor_pointer() + .when(collapsed, |d| d.bg(cx.theme().secondary)) + .hover(|s| s.bg(cx.theme().secondary)) + .child( + Icon::new(match collapsed { + true => IconName::ChevronRight, + false => IconName::ChevronLeft, + }) + .size(px(11.)) + .text_color(muted), + ) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(match collapsed { + true => t(L10nKey::ScmGraphShowLanes), + false => t(L10nKey::ScmGraphFoldLanes), + }) + .build(window, cx) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.scm.graph.lanes_collapsed = !this.scm.graph.lanes_collapsed; + cx.notify(); + })), + ) + .child( + Button::new("scm-graph-scope") + .ghost() + .xsmall() + .h(px(18.)) + .rounded(px(4.)) + .label(scope_label(&self.scm.graph.scope)) + .text_color(muted) + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + self.graph_scope_menu(repo, cx), + ), + ) + }) + .into_any_element() + } + + fn graph_scope_menu( + &self, + repo: &RepoKey, + cx: &mut Context, + ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static + use<> + { + let app = cx.entity().downgrade(); + let branches = self + .scm + .branches + .get(repo) + .map(|(_, names)| names.clone()) + .unwrap_or_default(); + let scope = self.scm.graph.scope.clone(); + + move |menu, _window, _cx| { + let mut menu = menu.min_w(px(180.)); + let pick = |app: &gpui::WeakEntity, next: GraphScope| { + let app = app.clone(); + move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut gpui::App| { + let _ = app.update(cx, |this, cx| this.graph_set_scope(next.clone(), cx)); + } + }; + for (label, next) in [ + ( + t(L10nKey::ScmGraphCurrentBranch), + GraphScope::HeadAndUpstream, + ), + (t(L10nKey::ScmGraphAllBranches), GraphScope::All), + ] { + menu = menu.item( + PopupMenuItem::new(label) + .checked(scope == next) + .on_click(pick(&app, next)), + ); + } + if !branches.is_empty() { + menu = menu.separator(); + } + for name in &branches { + let next = GraphScope::Refs(vec![format!("refs/heads/{name}")]); + menu = menu.item( + PopupMenuItem::new(name.clone()) + .checked(scope == next) + .on_click(pick(&app, next)), + ); + } + menu + } + } + + /// Point the graph at a different set of refs, and start it over. + /// + /// The page count resets with the scope: keeping a grown `requested` would + /// make switching to a short branch pull its whole history in one go. + fn graph_set_scope(&mut self, scope: GraphScope, cx: &mut Context) { + if self.scm.graph.scope == scope { + return; + } + self.scm.graph.scope = scope; + self.scm.graph.requested = GRAPH_PAGE; + self.scm.graph.page = None; + self.scm.graph.page_key = None; + cx.notify(); + } +} + +fn scope_label(scope: &GraphScope) -> String { + match scope { + GraphScope::All => t(L10nKey::ScmGraphAllBranches).to_string(), + GraphScope::Refs(refs) => refs + .first() + .map(|r| r.rsplit('/').next().unwrap_or(r).to_string()) + .unwrap_or_else(|| t(L10nKey::ScmGraphAllBranches).to_string()), + _ => t(L10nKey::ScmGraphCurrentBranch).to_string(), + } +} + +impl Tty7App { + /// The row's verbs. + /// + /// Every one of them goes through `scm_op`, which is where the confirmation + /// for anything that can lose work already lives — a second gate here would + /// be a second thing to keep in step with `GitOp::destructive`. + fn graph_row_context_menu( + menu: PopupMenu, + repo: &RepoKey, + oid: &str, + danger: Hsla, + app: &gpui::WeakEntity, + ) -> PopupMenu { + let op = |app: &gpui::WeakEntity, repo: &RepoKey, build: fn(String) -> GitOp| { + let app = app.clone(); + let repo = repo.clone(); + let rev = oid.to_string(); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut gpui::App| { + let _ = app.update(cx, |this, cx| { + this.scm_op(repo.clone(), build(rev.clone()), window, cx) + }); + } + }; + + let mut menu = menu + .min_w(px(200.)) + .item( + PopupMenuItem::new(t(L10nKey::ScmCheckoutCommit)) + .on_click(op(app, repo, |rev| GitOp::CheckoutDetached { rev })), + ) + .item( + PopupMenuItem::new(t(L10nKey::ScmCreateBranchHere)).on_click({ + let app = app.clone(); + let repo = repo.clone(); + let rev = oid.to_string(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.graph_begin_branch_at(repo.clone(), rev.clone(), window, cx) + }); + } + }), + ) + .separator() + .item( + PopupMenuItem::new(t(L10nKey::ScmCherryPick)).on_click(op(app, repo, |rev| { + GitOp::CherryPick { + rev, + // A merge cherry-picked without `-m` is an error, and + // the first parent is the only sane default. + mainline: true, + no_commit: false, + } + })), + ) + .item( + PopupMenuItem::new(t(L10nKey::ScmRevertCommit)).on_click(op(app, repo, |rev| { + GitOp::Revert { + rev, + mainline: true, + } + })), + ) + .separator(); + + for (label, mode) in [ + (t(L10nKey::ScmResetSoft), ResetMode::Soft), + (t(L10nKey::ScmResetMixed), ResetMode::Mixed), + (t(L10nKey::ScmResetHard), ResetMode::Hard), + ] { + let app = app.clone(); + let repo = repo.clone(); + let rev = oid.to_string(); + // `--hard` is the one entry here that discards work outright, so + // it wears the danger colour the same way the tree's Delete does. + // The confirmation still comes from `scm_op`; this is the warning + // before the warning. + let base = match mode { + ResetMode::Hard => PopupMenuItem::element(move |_window, _cx| { + div().text_color(danger).child(label) + }), + _ => PopupMenuItem::new(label), + }; + let item = base.on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.scm_op( + repo.clone(), + GitOp::Reset { + rev: rev.clone(), + mode, + }, + window, + cx, + ) + }); + }); + menu = menu.item(item); + } + + menu.separator() + .item(PopupMenuItem::new(t(L10nKey::ScmCopyCommitSha)).on_click({ + let rev = oid.to_string(); + move |_, _, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string(rev.clone())) + })) + } + + /// `right_panel_resize` rotated onto the other axis: a canvas that remembers + /// the container's bounds, an `Rc` pair for the live value and the + /// drag flag, and a one-pixel hairline that only shows on hover or while + /// held. + fn graph_resize(&self, ceiling: f32, cx: &mut Context) -> (AnyElement, AnyElement) { + let container: Rc>>> = Rc::new(StdCell::new(None)); + let backing = canvas( + { + let container = container.clone(); + move |bounds, _window, _cx| container.set(Some(bounds)) + }, + { + let container = container.clone(); + let height = self.scm.graph.height.clone(); + let dragging = self.scm.graph.dragging.clone(); + move |_bounds, _state, window: &mut Window, _cx| { + window.on_mouse_event({ + let container = container.clone(); + let height = height.clone(); + let dragging = dragging.clone(); + move |ev: &MouseMoveEvent, _phase, window: &mut Window, _cx| { + if !dragging.get() { + return; + } + let Some(b) = container.get() else { + return; + }; + // Measured from the bottom, because that edge is + // pinned and the top is the one being dragged. + let raw = (b.origin.y + b.size.height - ev.position.y).as_f32(); + height.set(raw.clamp(GRAPH_H_MIN, ceiling)); + window.refresh(); + } + }); + window.on_mouse_event({ + let dragging = dragging.clone(); + move |_ev: &MouseUpEvent, _phase, window: &mut Window, _cx| { + if !dragging.get() { + return; + } + dragging.set(false); + window.refresh(); + } + }); + } + }, + ) + .absolute() + .size_full() + .into_any_element(); + + let active = self.scm.graph.dragging.get(); + let handle = div() + .group("scm-graph-resize") + .occlude() + .absolute() + .left_0() + .top(px(-(GRAPH_HANDLE_H / 2.))) + .w_full() + .h(px(GRAPH_HANDLE_H)) + .flex() + .items_center() + .cursor_row_resize() + .child( + div() + .w_full() + .h(px(1.)) + .when(active, |d| d.bg(cx.theme().drag_border)) + .group_hover("scm-graph-resize", |s| s.bg(cx.theme().drag_border)), + ) + .on_mouse_down(MouseButton::Left, { + let dragging = self.scm.graph.dragging.clone(); + move |_ev, window: &mut Window, _cx| { + dragging.set(true); + window.refresh(); + } + }) + .into_any_element(); + + (backing, handle) + } +} + +impl Tty7App { + /// Open the inline "name a branch here" input for one commit. + /// + /// Its own input rather than the panel's naming row: that row always + /// branches from HEAD, and a branch created at the wrong commit is a + /// silent mistake rather than a visible one. + fn graph_begin_branch_at( + &mut self, + repo: RepoKey, + rev: String, + window: &mut Window, + cx: &mut Context, + ) { + let input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder(t(L10nKey::ScmCreateBranchHere)) + }); + let handle = input.read(cx).focus_handle(cx); + self.scm.graph.naming = Some((input, rev)); + self.scm.repo_override = Some(repo); + self.scm.override_tab = Some(self.active); + window.focus(&handle, cx); + cx.notify(); + } + + fn graph_naming_row(&mut self, repo: &RepoKey, cx: &mut Context) -> Option { + let (input, rev) = self.scm.graph.naming.clone()?; + let repo = repo.clone(); + Some( + h_flex() + .id("scm-graph-naming") + .flex_none() + .items_center() + .h(px(30.)) + .px(px(CONTENT_INSET)) + .child( + div() + .flex_1() + .min_w(px(0.)) + .child(gpui_component::input::Input::new(&input).xsmall()), + ) + .on_key_down( + cx.listener(move |this, ev: &gpui::KeyDownEvent, window, cx| { + match ev.keystroke.key.as_str() { + "escape" => { + this.scm.graph.naming = None; + cx.notify(); + } + "enter" => { + let Some((input, _)) = this.scm.graph.naming.take() else { + return; + }; + let name = input.read(cx).value().trim().to_string(); + cx.notify(); + if name.is_empty() { + return; + } + this.scm_op( + repo.clone(), + GitOp::CreateBranch { + name, + start: Some(rev.clone()), + // Naming a branch at an old commit is + // usually marking a place, not moving to + // it — and moving would take the working + // tree with it. + checkout: false, + }, + window, + cx, + ); + } + _ => {} + } + }), + ) + .into_any_element(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::app::test_window::harness; + use crate::ui::host_ops::HostId; + use gpui::TestAppContext; + use smallvec::smallvec; + use std::path::PathBuf; + + fn repo() -> RepoKey { + RepoKey { + host: HostId::LOCAL, + root: PathBuf::from("/tmp/tty7-graph-test"), + } + } + + fn lanes() -> Lanes { + Lanes { + ink: [0x111111, 0x222222, 0x333333, 0x444444, 0x555555, 0x666666], + overflow: 0x999999, + } + } + + #[test] + fn lanes_inside_the_cap_keep_their_own_column() { + for cap in GRAPH_MIN_LANES..=GRAPH_MAX_LANES { + let columns: Vec = (0..cap as Lane).map(|l| project(l, cap)).collect(); + let mut sorted = columns.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + columns.len(), + sorted.len(), + "cap {cap} collapsed a real lane" + ); + assert_eq!(columns, (0..cap as Lane).collect::>()); + } + } + + #[test] + fn everything_past_the_cap_lands_in_the_overflow_column() { + let cap = 4; + for lane in 4u16..=tty7_core::core::git::log::MAX_LANES { + assert_eq!(project(lane, cap), 3, "lane {lane} escaped the last column"); + } + // A single column is the folded gutter, and it has to swallow every lane + // rather than saturating into a negative index. + for lane in 0u16..8 { + assert_eq!(project(lane, 1), 0); + } + } + + #[test] + fn lane_centres_rise_and_land_on_device_pixels() { + for scale in [1.0f32, 1.25, 2.0, 3.0] { + let mut previous = f32::MIN; + for column in 0..GRAPH_MAX_LANES as Lane { + let x = lane_center_x(column, scale); + assert!(x > previous, "column {column} did not advance at {scale}x"); + previous = x; + let physical = x * scale; + assert!( + (physical - physical.round()).abs() < 1e-4, + "column {column} at {scale}x sits at {physical} device pixels" + ); + } + } + // A nonsense scale must not produce NaN geometry. + assert_eq!(lane_center_x(0, 0.), lane_center_x(0, f32::NAN)); + } + + #[test] + fn the_gutter_narrows_with_the_panel_and_folds_to_one() { + // 260px is the default panel; 216px is about as narrow as it gets. + assert_eq!(max_lanes(260., false), 5); + assert_eq!(max_lanes(216., false), 4); + assert_eq!(max_lanes(320., false), 6); + assert_eq!(max_lanes(160., false), GRAPH_MIN_LANES); + // The gutter it asks for has to fit inside the share it was given. + for w in [120., 160., 216., 260., 320., 600.] { + let cap = max_lanes(w, false); + assert!( + cap == GRAPH_MIN_LANES || gutter_width(cap) <= w * GRAPH_GUTTER_SHARE, + "{w}px: a {cap}-lane gutter is {}px of a {}px budget", + gutter_width(cap), + w * GRAPH_GUTTER_SHARE + ); + } + // Below the floor the gutter stops shrinking: three lanes is the least + // that can show a branch leaving and coming back. + assert_eq!(max_lanes(40., false), GRAPH_MIN_LANES); + // And above the palette it stops growing, or two columns would share a + // colour. + assert_eq!(max_lanes(4000., false), GRAPH_MAX_LANES); + assert!(max_lanes(4000., false) <= LANE_SLOTS); + assert_eq!(max_lanes(260., true), 1); + } + + #[test] + fn conventional_prefixes_come_off_and_prose_does_not() { + assert_eq!( + split_conventional("feat(terminal): localize the menu"), + (Some(("feat", false)), "localize the menu") + ); + assert_eq!( + split_conventional("fix: a thing"), + (Some(("fix", false)), "a thing") + ); + assert_eq!( + split_conventional("feat!: drop the old dialect"), + (Some(("feat", true)), "drop the old dialect") + ); + assert_eq!( + split_conventional("refactor(ui/scm)!: one entry point"), + (Some(("refactor", true)), "one entry point") + ); + for prose in [ + "no prefix here", + // Capitalised is not a conventional type. + "Merge pull request #1 from x", + // No space after the colon. + "fix:it", + // A bare URL must not be cut at its scheme. + "see https://example.invalid for why", + // An empty scope is malformed, not a prefix. + "feat(): nothing", + // Nothing left after the colon is not a subject. + "chore: ", + "", + ] { + assert_eq!( + split_conventional(prose), + (None, prose), + "{prose:?} should have been left alone" + ); + } + } + + #[test] + fn a_chinese_subject_survives_the_split_intact() { + // Byte indexing over a multibyte subject is exactly how this goes + // wrong, so the assertion is on the value, not on not panicking. + let subject = "修复终端右键菜单的本地化"; + assert_eq!(split_conventional(subject), (None, subject)); + let prefixed = "fix(terminal): 修复终端右键菜单的本地化"; + assert_eq!( + split_conventional(prefixed), + (Some(("fix", false)), "修复终端右键菜单的本地化") + ); + } + + /// `4 → node 0 → 4` should be one line bending, not two lines drawn twice. + #[test] + fn a_band_keeps_one_segment_per_column() { + let row = GraphRow { + node: 0, + color: 0, + parents: 2, + edges: smallvec![ + Edge::Pass { lane: 5, color: 5 }, + Edge::Pass { lane: 7, color: 7 }, + Edge::In { from: 0, color: 0 }, + Edge::Out { to: 0, color: 0 }, + Edge::Out { to: 6, color: 6 }, + ], + }; + // Cap of three: lanes 5, 6 and 7 all fall into column 2. + let band = band_of(&row, 3, true, &lanes()); + assert_eq!(band.top[0], Some(lanes().ink[0])); + assert_eq!(band.top[2], Some(lanes().overflow)); + assert_eq!(band.bottom[0], Some(lanes().ink[0])); + assert_eq!(band.bottom[2], Some(lanes().overflow)); + assert_eq!(band.top[1], None); + // Three lanes bundled into the overflow column produce exactly one + // turn, not three stacked on each other. + assert_eq!(band.turns.len(), 1); + assert_eq!(band.turns[0].0, 0); + assert_eq!(band.turns[0].1, 2); + } + + #[test] + fn a_wide_page_only_neutralises_the_column_that_is_shared() { + let l = lanes(); + // Not overflowing: every column is a real lane and keeps its hue. + assert_eq!(column_ink(2, 2, 3, false, &l), l.ink[2]); + // Overflowing: only the last column goes neutral. + assert_eq!(column_ink(2, 2, 3, true, &l), l.overflow); + assert_eq!(column_ink(1, 1, 3, true, &l), l.ink[1]); + // A colour index past the palette can only come from a lane that was + // projected into the bundle, but clamp anyway rather than panic. + assert_eq!(column_ink(0, 99, 3, false, &l), l.ink[LANE_SLOTS - 1]); + } + + #[gpui::test] + fn folding_the_history_section_survives_a_restart(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // It starts shut: a panel that unfurls two hundred commits the first + // time it is opened looks like a mess nobody asked for. + assert!(!app.read_with(&vcx, |app, _| app.scm.graph.expanded)); + app.update(&mut vcx, |app, cx| app.scm_toggle_graph(cx)); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.expanded)); + assert!(vcx.update(|_, cx| { + cx.global::() + .scm_graph_expanded + })); + } + + #[gpui::test] + fn the_lane_gutter_folds_and_stays_folded(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // Session state rather than config: unlike the section's own fold, this + // is a reading posture for one repository's shape, not a preference. + assert!(!app.read_with(&vcx, |app, _| app.scm.graph.lanes_collapsed)); + app.update(&mut vcx, |app, cx| { + app.scm.graph.lanes_collapsed = true; + cx.notify(); + }); + vcx.run_until_parked(); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.lanes_collapsed)); + } + + #[gpui::test] + fn opening_a_row_hands_that_commit_to_the_detail_view(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + // Unseeded first: a click that cannot hand a commit over still opens + // the view, and the read that fills it is dispatched later. + app.update(&mut vcx, |app, cx| { + app.graph_open_commit(repo(), "deadbeef".into(), None, cx) + }); + let (selected, detail) = app.read_with(&vcx, |app, _| { + (app.scm.graph.selected.clone(), app.scm.detail.clone()) + }); + assert_eq!(selected.as_deref(), Some("deadbeef")); + let detail = detail.expect("the row opened a commit"); + assert_eq!(detail.oid, "deadbeef"); + assert_eq!(detail.repo, repo()); + assert!(detail.commit.is_none(), "nothing was handed over"); + + // Seeded: the row draws from a commit it already holds, so the detail + // view opens with it and only the file list is left to read. This is + // the path every real click takes. + let seed = commit_named("a subject", "someone", "deadbeef"); + app.update(&mut vcx, |app, cx| { + app.graph_open_commit(repo(), "deadbeef".into(), Some(seed.clone()), cx) + }); + let detail = app + .read_with(&vcx, |app, _| app.scm.detail.clone()) + .expect("the row opened a commit"); + assert_eq!( + detail.commit.as_deref(), + Some(&seed), + "the seed spares the view a `git show`" + ); + } + + /// Changing what the graph walks has to throw the page away, not page on + /// top of it: the rows of a different scope are a different history. + #[gpui::test] + fn switching_scope_resets_the_page_and_its_size(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + + app.update(&mut vcx, |app, cx| { + app.scm.graph.requested = GRAPH_PAGE * 3; + app.scm.graph.page = Some(Arc::new(empty_page())); + app.scm.graph.page_key = Some((repo(), 7, GraphScope::HeadAndUpstream)); + app.graph_set_scope(GraphScope::All, cx); + }); + app.read_with(&vcx, |app, _| { + assert_eq!(app.scm.graph.scope, GraphScope::All); + assert_eq!(app.scm.graph.requested, GRAPH_PAGE); + assert!(app.scm.graph.page.is_none()); + assert!(app.scm.graph.page_key.is_none()); + }); + + // Picking the scope that is already showing must not throw the page + // away, or every menu open would cost a `git log`. + app.update(&mut vcx, |app, cx| { + app.scm.graph.page = Some(Arc::new(empty_page())); + app.graph_set_scope(GraphScope::All, cx); + }); + assert!(app.read_with(&vcx, |app, _| app.scm.graph.page.is_some())); + } + + #[test] + fn the_filter_matches_what_a_row_can_show() { + let commit = commit_named("feat(ui): the graph", "Ada Lovelace", "c0ffee1234"); + for hit in ["graph", "GRAPH", "feat", "ada", "Lovelace", "c0ffee"] { + assert!( + matches_query(&commit, &hit.to_lowercase()), + "{hit:?} should have matched" + ); + } + // The body is deliberately not searched: a hit the row cannot show + // looks like a wrong result. + assert!(!matches_query(&commit, "rationale")); + // A sha matches as a prefix, the way `git show` takes one — not as a + // substring, or every query of hex characters would light up. + assert!(!matches_query(&commit, "ffee")); + } + + #[test] + fn the_scope_button_says_which_history_is_showing() { + assert_eq!( + scope_label(&GraphScope::HeadAndUpstream), + t(L10nKey::ScmGraphCurrentBranch) + ); + assert_eq!( + scope_label(&GraphScope::All), + t(L10nKey::ScmGraphAllBranches) + ); + // The label is the branch, not the fully qualified ref: `refs/heads/` + // is eleven characters of the panel spent saying nothing. + assert_eq!( + scope_label(&GraphScope::Refs(vec!["refs/heads/feature/auth".into()])), + "auth" + ); + } + + fn commit_named(summary: &str, author: &str, oid: &str) -> Commit { + use tty7_core::core::git::log::{OffsetTs, Signature}; + let who = Signature { + name: author.to_string(), + email: "a@b.invalid".into(), + at: OffsetTs { + unix: 0, + offset_minutes: 0, + }, + }; + Commit { + oid: oid.to_string(), + parents: smallvec![], + author: who.clone(), + committer: who, + summary: summary.to_string(), + body: "rationale goes in the body".into(), + refs: Vec::new(), + } + } + + fn empty_page() -> CommitPage { + CommitPage { + commits: Vec::new(), + rows: Vec::new(), + max_lanes: 0, + scope: GraphScope::HeadAndUpstream, + requested: GRAPH_PAGE, + complete: true, + truncated_lanes: false, + open_lanes: Vec::new(), + } + } +} + +/// The one test that has to run against a real repository and a real pane. +/// +/// A canvas that repaints every frame would make the panel a perpetual motion +/// machine, and nothing about the code reads as wrong when it does — the only +/// way to know is to settle the window and count frames. Same shape as the file +/// tree's own idle tests, including the serial lock: the render probe is +/// thread-local and two of these at once would count each other's frames. +#[cfg(test)] +mod render_idle_gpui_tests { + use super::*; + use crate::ui::app::{render_probe, test_window}; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use std::path::Path; + use tty7_core::core::config::RightPanelTab; + + const BUDGET: u64 = 200; + + fn serial() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Git with the identity and signing pinned, so the test does not depend on + /// whatever is in the developer's `~/.gitconfig`. + fn git(root: &Path, args: &[&str]) -> bool { + let mut full = vec![ + "-c", + "user.name=tty7 test", + "-c", + "user.email=test@tty7.invalid", + "-c", + "commit.gpgsign=false", + ]; + full.extend_from_slice(args); + std::process::Command::new("git") + .args(&full) + .current_dir(root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-graph-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(&dir).unwrap() + } + + fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { + 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(); + render_probe::draws() + } + + /// Drive frames until the graph has a page, because the query only goes out + /// from `render`. + fn settle_graph(app: &Entity, vcx: &mut VisualTestContext) -> Option> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + app.update_in(vcx, |_, _, cx| cx.notify()); + vcx.background_executor.run_until_parked(); + let page = app.update_in(vcx, |app, _, _| app.scm.graph.page.clone()); + if page.is_some() { + vcx.background_executor.run_until_parked(); + return page; + } + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + #[gpui::test] + fn an_expanded_graph_with_history_reaches_render_idle(cx: &mut TestAppContext) { + let _serial = serial(); + crate::core::config::pin_test_config_dir(); + let root = scratch("idle"); + if !git(&root, &["init", "--quiet"]) { + return; // no git on this machine + } + for n in 0..6 { + std::fs::write(root.join(format!("f{n}.txt")), format!("{n}\n")).unwrap(); + assert!(git(&root, &["add", "-A"])); + assert!(git( + &root, + &["commit", "--quiet", "-m", &format!("feat(x): commit {n}")] + )); + } + + let (app, mut vcx, _pane) = test_window::harness_with_pane(cx); + crate::daemon::protocol::DaemonMsg::Cwd(root.clone()) + .encode(&mut { _pane }) + .expect("the pane's socket takes the cwd"); + app.update_in(&mut vcx, |app, _, cx| { + app.right_panel_visible = true; + app.right_panel_tab = RightPanelTab::Scm; + app.scm.graph.expanded = true; + cx.notify(); + }); + + let Some(page) = settle_graph(&app, &mut vcx) else { + // A machine where the pane never reported its cwd has nothing to + // say about idling; failing here would only be flaky. + let _ = std::fs::remove_dir_all(&root); + return; + }; + assert!(page.commits.len() >= 6, "the graph loaded no history"); + assert_eq!(page.rows.len(), page.commits.len()); + + assert_eq!(draws_while_idle(&mut vcx), 0); + // And it is still expanded and still holding the same page, i.e. the + // zero above is idleness and not the section having quietly vanished. + app.update_in(&mut vcx, |app, _, _| { + assert!(app.scm.graph.expanded); + assert!(app.scm.graph.page.is_some()); + assert!(!app.scm.graph.loading); + }); + let _ = std::fs::remove_dir_all(&root); } } diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index 04d7ba65..50f4f4e9 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -4,17 +4,13 @@ //! `file_tree.rs` use. The directory only keeps the surface from piling into //! `right_panel.rs`. -// What is left unused is what the graph will call, plus `status_rank`, which -// is the file tree's to use. Both allows come off with the step that wires -// them up. +// The graph and the commit detail view both have callers now, so their allows +// are gone. What is left is `status_rank`, which is the file tree's to use. pub(crate) mod actions; pub(crate) mod detail; -#[allow(dead_code)] pub(crate) mod graph; pub(crate) mod panel; -#[allow(dead_code)] pub(crate) mod path; -#[allow(dead_code)] pub(crate) mod state; #[allow(dead_code)] pub(crate) mod status; diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index 121fe514..06f7f77f 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use gpui::Entity; use gpui_component::input::InputState; -use tty7_core::core::git::log::{Commit, CommitFile}; +use tty7_core::core::git::log::{Commit, CommitFile, CommitPage, GraphScope}; use tty7_core::core::git::status::HeadState; use crate::ui::host_ops::HostId; @@ -158,10 +158,30 @@ pub(crate) struct GraphState { /// and shifts under you when a ref moves between pages. pub(crate) requested: usize, pub(crate) loading: bool, - /// Filter box. Like `commit_input`, created on first render. + /// The page the graph is currently drawn from, and which repository and + /// query produced it. `Arc` because paint reads it while the next page is + /// being laid out on a worker, and the key so a repository switch shows an + /// empty graph rather than the previous repository's history. + pub(crate) page: Option>, + pub(crate) page_key: Option<(RepoKey, u64, GraphScope)>, + /// Fold the lane gutter down to a single column. Worth about six + /// characters of the message, which at this width is the difference + /// between reading a subject and reading its first word. + pub(crate) lanes_collapsed: bool, + /// Filter box. Like `commit_input`, created on first render — and with the + /// subscription that turns typing into a repaint. An `InputState` is its + /// own entity; without this the box would take text the list never sees. pub(crate) search: Option>, - /// `refs/heads/...` the graph is restricted to; empty means all refs. - pub(crate) branch_filter: Option, + pub(crate) search_sub: Option, + /// An open "name a branch at this commit" input, and the rev it starts + /// from. The panel's own naming row cannot serve this: it always creates + /// at HEAD, and the whole point here is the commit under the cursor. + pub(crate) naming: Option<(Entity, String)>, + /// Which refs the graph walks from. Three states rather than an + /// `Option`: "this branch and its upstream", "one named branch", + /// and "everything" are all reachable from the header's dropdown, and only + /// the enum the data layer already takes can express all three. + pub(crate) scope: GraphScope, /// The selected row, by full sha. pub(crate) selected: Option, pub(crate) scroll: gpui::ScrollHandle, diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 18bf7c06..1a9d911f 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -283,6 +283,10 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { }); cx.set_global(surfaces.clone()); cx.set_global(presets::ActiveAccent(m.accent)); + // Same treatment as `Surfaces`: derived once here rather than recomputed + // in `render`, because the graph reads it once per visible row per frame + // and each entry costs a contrast bisection on three surfaces. + cx.set_global(presets::ActiveLanes(theme.lanes())); let t = Theme::global_mut(cx); let mut base: Hsla = rgb(m.background).into();