merge: the commit graph

Two conflicts, both from the graph and the commit detail view landing in the
same week:

- scm/mod.rs: each had removed its own dead-code allow. Both are gone now;
  status_rank's stays, since the file tree is what will use it.
- graph.rs built a CommitDetailView by hand while detail.rs had grown a
  constructor that takes the commit the caller already holds. It now goes
  through open_commit_detail with the row's own commit as the seed, which is
  what the detail view's author asked for: a click costs one git command
  (the file list) instead of two.

The test that covered the old hand-built view asserted `loading`, which was
an artefact of building it directly. It now asserts what its name says — that
the seed arrives — and covers the unseeded path too.
This commit is contained in:
l0ng-ai
2026-08-09 11:01:12 +08:00
10 changed files with 2074 additions and 26 deletions
+8
View File
@@ -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",
+8
View File
@@ -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 => "チェリーピック",
+16
View File
@@ -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,
+8
View File
@@ -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 => "拣选提交",
+181 -4
View File
@@ -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() {
+1 -1
View File
@@ -403,7 +403,7 @@ impl Tty7App {
cx: &mut Context<Self>,
) -> 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,
+1822 -11
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -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;
+24 -4
View File
@@ -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<Arc<CommitPage>>,
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<Entity<InputState>>,
/// `refs/heads/...` the graph is restricted to; empty means all refs.
pub(crate) branch_filter: Option<String>,
pub(crate) search_sub: Option<gpui::Subscription>,
/// 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<InputState>, String)>,
/// Which refs the graph walks from. Three states rather than an
/// `Option<branch>`: "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<String>,
pub(crate) scroll: gpui::ScrollHandle,
+4
View File
@@ -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();