T19 Git: diffs unified and split with word marks and folds, three-way merge and conflict resolver, changes list and commit input, commit graph and list, branch selector, branch, tag and stash lists, blame with annotation, file history, pull request card, review thread, diff stat and status badge

This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 01:59:27 -04:00
1 parent e79ab866c3
commit 950f17636c
31 files changed
+4166 -21

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
use std::rc::Rc;
use ely_gpui_component::{
git::{ConflictResolver, DiffLayout, DiffViewer, Take, ThreeWayMerge, regions},
theme::{ActiveTheme, Radius},
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::ui::{keep, section, set};
const OLD: &str = r#"use std::collections::HashMap;
/// A palette of named colors.
pub struct Palette {
colors: HashMap<String, Color>,
fallback: Color,
}
impl Palette {
pub fn new(fallback: Color) -> Self {
Self { colors: HashMap::new(), fallback }
}
pub fn insert(&mut self, name: &str, color: Color) {
self.colors.insert(name.to_string(), color);
}
pub fn len(&self) -> usize {
self.colors.len()
}
pub fn is_empty(&self) -> bool {
self.colors.is_empty()
}
/// The color for `name`.
pub fn lookup(&self, name: &str) -> Color {
self.colors[name]
}
}
"#;
const NEW: &str = r#"use std::collections::BTreeMap;
/// A palette of named colors, in a steady order.
pub struct Palette {
colors: BTreeMap<String, Color>,
fallback: Color,
}
impl Palette {
pub fn new(fallback: Color) -> Self {
Self { colors: BTreeMap::new(), fallback }
}
pub fn insert(&mut self, name: &str, color: Color) {
self.colors.insert(name.to_string(), color);
}
pub fn len(&self) -> usize {
self.colors.len()
}
pub fn is_empty(&self) -> bool {
self.colors.is_empty()
}
/// The color for `name`, blended toward white by `lift`.
pub fn lookup(&self, name: &str, lift: f32) -> Color {
let base = self.colors.get(name).copied().unwrap_or(self.fallback);
base.lift(lift)
}
}
"#;
pub fn viewer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let layout = keep("git-layout", || DiffLayout::Unified, window, cx);
let open = keep("git-open", Vec::<usize>::new, window, cx);
let (now_layout, now_open) = (*layout.read(cx), open.read(cx).clone());
let (flip, unfold) = (layout.clone(), open.clone());
let theme = cx.theme();
section(
"DiffViewer / InlineDiff",
"One file's changes, unified or side by side: each change in its context with old and new numbers, the words that changed washed, the stretches between folded until pressed. InlineDiff inside an editor is CodeEditor::set_diff.",
cx,
)
.child(
div()
.w(px(840.))
.h(px(360.))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.overflow_hidden()
.child(
DiffViewer::new("git-diff", "src/palette.rs", OLD, NEW)
.layout(now_layout)
.open(now_open)
.on_layout(move |next, _, cx| set(&flip, next, cx))
.on_open(move |stretch, _, cx| {
let mut next = unfold.read(cx).clone();
next.push(stretch);
set(&unfold, next, cx)
}),
),
)
}
const BASE: &str =
"fn lift(color: Color) -> Color {\n color.mix(WHITE, 0.5)\n}\n\nfn keep() {}\n";
const OURS: &str =
"fn lift(color: Color) -> Color {\n color.mix(WHITE, 0.6)\n}\n\nfn keep() {}\n";
const THEIRS: &str = "fn lift(color: Color) -> Color {\n color.mix(WHITE, LIFT)\n}\n\nfn keep() { log::info!(\"kept\") }\n";
const MARKED: &str = "fn lift(color: Color) -> Color {\n<<<<<<< HEAD\n color.mix(WHITE, 0.6)\n=======\n color.mix(WHITE, LIFT)\n>>>>>>> feature/lift\n}\n\nfn keep() { log::info!(\"kept\") }\n";
pub fn merges(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let takes = keep("git-takes", Vec::<Option<Take>>::new, window, cx);
let marked = keep("git-marked", || SharedString::from(MARKED), window, cx);
let (now_takes, now_marked) = (takes.read(cx).clone(), marked.read(cx).clone());
let (take, settle) = (takes.clone(), marked.clone());
section(
"ThreeWayMerge / ConflictResolver",
"Ours, the base and theirs side by side: clean changes merge themselves, each conflict waits for a side, and the result shows what is still open. In a file with markers, each conflict offers the current change, the incoming one, or both.",
cx,
)
.child(
div().w(px(840.)).child(
ThreeWayMerge::new("git-merge", ["main", "base", "feature/lift"], Rc::new(regions(BASE, OURS, THEIRS)))
.takes(now_takes)
.on_take(move |conflict, side, _, cx| {
let mut next = take.read(cx).clone();
if next.len() <= conflict {
next.resize(conflict + 1, None);
}
next[conflict] = Some(side);
set(&take, next, cx)
}),
),
)
.child(
div().w(px(840.)).child(
ConflictResolver::new("git-conflicts", now_marked)
.on_resolve(move |text, _, cx| set(&settle, SharedString::from(text), cx)),
),
)
}
+31
View File
@@ -0,0 +1,31 @@
mod diffs;
mod people;
mod work;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 19,
slug: "git",
title: "Git",
summary: "Diffs and merges, changes and commits, the commit graph, branches, blame and review.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[Step::Rest];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(diffs::viewer(window, cx))
.child(diffs::merges(window, cx))
.child(work::changes(window, cx))
.child(work::commits(window, cx))
.child(work::refs(window, cx))
.child(people::blame(window, cx))
.child(people::review(window, cx))
.into_any_element()
}
+174
View File
@@ -0,0 +1,174 @@
use ely_gpui_component::{
forms::TextInput,
git::{
Blame, BlameView, DiffStat, FileHistory, GitStatusBadge, PullRequest, PullRequestCard,
PullState, ReviewComment, ReviewNote,
},
lists::GitStatus,
typography::Caption,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::work::history;
use crate::ui::{keep, row, section, set};
const CODE: &str = "impl Palette {\n pub fn lookup(&self, name: &str, lift: f32) -> Color {\n let base = self.colors.get(name).copied().unwrap_or(self.fallback);\n base.lift(lift)\n }\n\n pub fn len(&self) -> usize {\n self.colors.len()\n }\n}";
pub fn blame(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let said = keep("git-blame-said", || None::<SharedString>, window, cx);
let told = said.read(cx).clone();
let blame = |commit: usize, author: &str, when: &str, subject: &str, age: f32| Blame {
commit: history()[commit].id.clone(),
author: author.to_string().into(),
when: when.to_string().into(),
subject: subject.to_string().into(),
age,
};
let blames = [
blame(5, "Sam Ortiz", "3 days ago", "First palette", 1.0),
blame(2, "Mei Lin", "3 h ago", "Blend toward white by lift", 0.2),
blame(
1,
"Ada Park",
"1 h ago",
"Keep palette order with a BTreeMap",
0.1,
),
];
let history_rows: Vec<_> = history()
.into_iter()
.zip([(12, 4), (3, 3), (9, 1), (18, 0), (2, 1), (40, 0)])
.collect();
let (pick, select) = (said.clone(), said.clone());
section(
"BlameView / GitBlameAnnotation / FileHistory / DiffStat / GitStatusBadge",
"Who last changed each run of lines, the bar fading with age, the current line annotated; a file's commits on a line through them with what each changed; sizes as counts and five dots; statuses as letters.",
cx,
)
.child(
div().w(px(840.)).child(
BlameView::new("git-blame", CODE, [0, 1, 1, 1, 0, 0, 2, 2, 0, 0], blames)
.current(2)
.on_commit(move |commit, _, cx| set(&pick, Some(format!("Picked {}.", &commit[..7]).into()), cx)),
),
)
.children(told.map(Caption::new))
.child(
div().w(px(520.)).child(
FileHistory::new("git-file-history", history_rows)
.selected(history()[1].id.clone())
.on_pick(move |commit, _, cx| set(&select, Some(format!("Opened {}.", &commit[..7]).into()), cx)),
),
)
.child(
row()
.gap_6()
.child(DiffStat::new(120, 45))
.child(DiffStat::new(3, 0))
.child(DiffStat::new(0, 31))
.children(
[GitStatus::Modified, GitStatus::Added, GitStatus::Deleted, GitStatus::Untracked, GitStatus::Renamed, GitStatus::Conflicted]
.into_iter()
.enumerate()
.map(|(ix, status)| GitStatusBadge::new(("git-status", ix), status)),
),
)
}
pub fn review(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let reply = window.use_keyed_state("git-reply", cx, |window, cx| {
TextInput::new(window, cx).placeholder("Reply")
});
let resolved = keep("git-resolved", || false, window, cx);
let notes = keep(
"git-notes",
|| {
vec![
ReviewNote {
author: "Ada Park".into(),
when: "2 h ago".into(),
body: "Should a lift past one clamp, or panic? The docs say clamp.".into(),
},
ReviewNote {
author: "Mei Lin".into(),
when: "1 h ago".into(),
body: "Clamp. I added a test for 1.5 and for -0.2.".into(),
},
]
},
window,
cx,
);
let said = keep("git-pr-said", || None::<SharedString>, window, cx);
let (now_resolved, now_notes, told) = (
*resolved.read(cx),
notes.read(cx).clone(),
said.read(cx).clone(),
);
let (resolve, add, field, open, open_draft) = (
resolved.clone(),
notes.clone(),
reply.clone(),
said.clone(),
said.clone(),
);
let pull = |number, title: &str, state, checks, added, removed, when: &str| PullRequest {
number,
title: title.to_string().into(),
author: "Mei Lin".into(),
head: "feature/lift".into(),
base: "main".into(),
state,
checks,
reviewers: vec!["Ada Park".into(), "Sam Ortiz".into()],
comments: 4,
added,
removed,
when: when.to_string().into(),
};
section(
"PullRequestCard / ReviewComment",
"A pull request at a glance: state, branches, checks, reviewers, comments and size. A review thread on a line: each comment, a reply, and resolve.",
cx,
)
.child(
div()
.w(px(840.))
.flex()
.flex_col()
.gap_3()
.child(
PullRequestCard::new("git-pr-open", pull(128, "Blend palette colors toward white by lift", PullState::Open, (11, 1, 0), 120, 45, "2 h ago"))
.on_open(move |_, cx| set(&open, Some("Opened #128.".into()), cx)),
)
.child(
PullRequestCard::new("git-pr-draft", pull(131, "Try a gamma-aware lift", PullState::Draft, (3, 0, 2), 36, 8, "20 min ago"))
.on_open(move |_, cx| set(&open_draft, Some("Opened #131.".into()), cx)),
),
)
.children(told.map(Caption::new))
.child(
div().w(px(640.)).child(
ReviewComment::new(
"git-review",
"src/palette.rs",
48,
"pub fn lookup(&self, name: &str, lift: f32) -> Color {",
now_notes,
&reply,
)
.resolved(now_resolved)
.on_resolve(move |on, _, cx| set(&resolve, on, cx))
.on_reply(move |_, cx| {
let body = field.read(cx).text().trim().to_string();
if body.is_empty() {
return;
}
let mut next = add.read(cx).clone();
next.push(ReviewNote { author: "You".into(), when: "just now".into(), body: body.into() });
set(&add, next, cx);
field.update(cx, |field, cx| field.set_text("", cx));
}),
),
)
}
+390
View File
@@ -0,0 +1,390 @@
use ely_gpui_component::{
buttons::Button,
forms::TextInput,
git::{
Branch, BranchList, BranchSelector, ChangeAction, Changed, ChangesList, Commit,
CommitInput, CommitList, GitTag, Stash, StashAction, StashList, TagList,
},
lists::GitStatus,
typography::Caption,
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::ui::{keep, row, section, set};
fn changed(path: &str, status: GitStatus, added: usize, removed: usize) -> Changed {
Changed {
path: path.to_string().into(),
status,
added,
removed,
}
}
fn field(
key: &'static str,
placeholder: &'static str,
lines: Option<(usize, usize)>,
window: &mut Window,
cx: &mut App,
) -> Entity<TextInput> {
window.use_keyed_state(key, cx, move |window, cx| {
let field = TextInput::new(window, cx).placeholder(placeholder);
match lines {
Some((min, max)) => field.multi_line(min, max),
None => field,
}
})
}
pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let staged = keep(
"git-staged",
|| vec![changed("src/palette.rs", GitStatus::Modified, 12, 4)],
window,
cx,
);
let unstaged = keep(
"git-unstaged",
|| {
vec![
changed("src/theme/tokens.rs", GitStatus::Modified, 3, 1),
changed("src/lift.rs", GitStatus::Added, 48, 0),
changed("docs/old-palette.md", GitStatus::Deleted, 0, 31),
changed("tests/palette.rs", GitStatus::Untracked, 22, 0),
]
},
window,
cx,
);
let amend = keep("git-amend", || false, window, cx);
let said = keep("git-said", || None::<SharedString>, window, cx);
let subject = field("git-subject", "Summary of the change", None, window, cx);
let body = field("git-body", "What changed and why", Some((3, 6)), window, cx);
let (now_staged, now_unstaged, now_amend, told) = (
staged.read(cx).clone(),
unstaged.read(cx).clone(),
*amend.read(cx),
said.read(cx).clone(),
);
let (act_staged, act_unstaged, act_said) = (staged.clone(), unstaged.clone(), said.clone());
let (all_staged, all_unstaged) = (staged.clone(), unstaged.clone());
let (flip, done, commit_staged) = (amend.clone(), said.clone(), staged.clone());
let count = now_staged.len();
section(
"ChangesList / CommitInput",
"The work tree's changes, the staged apart, each with its status and size; hover a file to open, discard, stage or unstage it. The subject counts toward fifty; Cmd-Enter commits.",
cx,
)
.child(
div()
.flex()
.gap_8()
.child(
div().w(px(420.)).child(
ChangesList::new("git-changes", now_staged, now_unstaged)
.on_action(move |path, action, _, cx| {
let (from, to) = match action {
ChangeAction::Stage => (&act_unstaged, &act_staged),
ChangeAction::Unstage => (&act_staged, &act_unstaged),
ChangeAction::Discard => {
let left: Vec<Changed> = act_unstaged.read(cx).iter().filter(|file| file.path != *path).cloned().collect();
set(&act_unstaged, left, cx);
return;
}
ChangeAction::Open => {
set(&act_said, Some(format!("Opened {path}.").into()), cx);
return;
}
};
let moving: Vec<Changed> = from.read(cx).iter().filter(|file| file.path == *path).cloned().collect();
let left: Vec<Changed> = from.read(cx).iter().filter(|file| file.path != *path).cloned().collect();
let mut grown = to.read(cx).clone();
grown.extend(moving);
set(from, left, cx);
set(to, grown, cx);
})
.on_all(move |stage, _, cx| {
let (from, to) = if stage { (&all_unstaged, &all_staged) } else { (&all_staged, &all_unstaged) };
let mut grown = to.read(cx).clone();
grown.extend(from.read(cx).iter().cloned());
set(to, grown, cx);
set(from, Vec::new(), cx);
}),
),
)
.child(
div().w(px(380.)).child(
CommitInput::new("git-commit", &subject, &body, "main", count)
.amend(now_amend)
.on_amend(move |on, _, cx| set(&flip, on, cx))
.on_commit(move |_, cx| {
let files = commit_staged.read(cx).len();
set(&commit_staged, Vec::new(), cx);
set(&done, Some(format!("Committed {files} file{}.", if files == 1 { "" } else { "s" }).into()), cx);
}),
),
),
)
.children(told.map(Caption::new))
}
fn commit(
id: &str,
parents: &[&str],
subject: &str,
author: &str,
when: &str,
refs: &[&str],
) -> Commit {
Commit {
id: format!("{id}{}", "0".repeat(40 - id.len())).into(),
parents: parents
.iter()
.map(|parent| format!("{parent}{}", "0".repeat(40 - parent.len())).into())
.collect(),
subject: subject.to_string().into(),
author: author.to_string().into(),
when: when.to_string().into(),
refs: refs
.iter()
.map(|name| SharedString::from(name.to_string()))
.collect(),
}
}
/// A short history with a branch merged back.
pub(super) fn history() -> Vec<Commit> {
vec![
commit(
"f4c1",
&["e9a2", "d3b7"],
"Merge branch 'feature/lift'",
"Mei Lin",
"just now",
&["main", "origin/main"],
),
commit(
"e9a2",
&["c8d0"],
"Keep palette order with a BTreeMap",
"Ada Park",
"1 h ago",
&[],
),
commit(
"d3b7",
&["b21e"],
"Blend toward white by lift",
"Mei Lin",
"3 h ago",
&["feature/lift"],
),
commit(
"b21e",
&["c8d0"],
"Add Color::lift",
"Mei Lin",
"5 h ago",
&[],
),
commit(
"c8d0",
&["a6f3"],
"Count tints in the palette",
"Ada Park",
"yesterday",
&["v0.2.0"],
),
commit(
"a6f3",
&[],
"First palette",
"Sam Ortiz",
"3 days ago",
&["v0.1.0"],
),
]
}
pub fn commits(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let picked = keep("git-picked", || history()[0].id.clone(), window, cx);
let now = picked.read(cx).clone();
let pick = picked.clone();
section(
"CommitGraph / CommitList / CommitItem",
"Commits newest first beside the lanes they take: a branch forks off, merges join, each lane in its own color; refs sit on their commits.",
cx,
)
.child(
div().w(px(840.)).child(
CommitList::new("git-graph", history())
.graph()
.selected(now)
.on_pick(move |id, _, cx| set(&pick, id.clone(), cx)),
),
)
}
fn branch(
name: &str,
remote: bool,
current: bool,
ahead: usize,
behind: usize,
subject: &str,
when: &str,
) -> Branch {
Branch {
name: name.to_string().into(),
remote,
current,
ahead,
behind,
subject: subject.to_string().into(),
when: when.to_string().into(),
}
}
fn branches() -> Vec<Branch> {
vec![
branch(
"main",
false,
true,
2,
0,
"Merge branch 'feature/lift'",
"just now",
),
branch(
"feature/lift",
false,
false,
0,
1,
"Blend toward white by lift",
"3 h ago",
),
branch(
"fix/order",
false,
false,
1,
3,
"Sort names before hashing",
"2 days ago",
),
branch(
"origin/main",
true,
false,
0,
0,
"Keep palette order with a BTreeMap",
"1 h ago",
),
branch(
"origin/release",
true,
false,
0,
0,
"Count tints in the palette",
"yesterday",
),
]
}
pub fn refs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let open = keep("git-branch-open", || false, window, cx);
let said = keep("git-branch-said", || None::<SharedString>, window, cx);
let stashes = keep(
"git-stashes",
|| {
vec![
Stash {
message: "WIP on lift: try gamma".into(),
branch: "feature/lift".into(),
when: "2 h ago".into(),
},
Stash {
message: "half a theme toggle".into(),
branch: "main".into(),
when: "yesterday".into(),
},
]
},
window,
cx,
);
let (now_open, told, now_stashes) = (
*open.read(cx),
said.read(cx).clone(),
stashes.read(cx).clone(),
);
let (opener, close, pick, create, switch, delete, stash_said, drop) = (
open.clone(),
open.clone(),
said.clone(),
said.clone(),
said.clone(),
said.clone(),
said.clone(),
stashes.clone(),
);
let tags = [
GitTag {
name: "v0.2.0".into(),
message: Some("Tints and counts".into()),
commit: history()[4].id.clone(),
when: "yesterday".into(),
},
GitTag {
name: "v0.1.0".into(),
message: None,
commit: history()[5].id.clone(),
when: "3 days ago".into(),
},
];
section(
"BranchSelector / BranchList / TagList / StashList",
"Branches as a palette, local and remote apart, a new name offering a new branch; the lists show how far each branch is from its upstream, tags with their messages, and stashes to apply, pop or drop.",
cx,
)
.child(row().child(Button::new("git-branch-button", "Switch branch…").on_click(move |_, _, cx| set(&opener, true, cx))))
.children(told.map(Caption::new))
.child(
div()
.flex()
.gap_8()
.child(
div().w(px(420.)).child(
BranchList::new("git-branches", branches().into_iter().filter(|branch| !branch.remote))
.on_switch(move |name, _, cx| set(&switch, Some(format!("Switched to {name}.").into()), cx))
.on_delete(move |name, _, cx| set(&delete, Some(format!("Deleted {name}.").into()), cx)),
),
)
.child(
div()
.w(px(380.))
.flex()
.flex_col()
.gap_4()
.child(TagList::new("git-tags", tags))
.child(StashList::new("git-stash-list", now_stashes).on_action(move |ix, action, _, cx| {
let mut left = drop.read(cx).clone();
let stash = left.remove(ix);
if action != StashAction::Apply {
set(&drop, left, cx);
}
set(&stash_said, Some(format!("{action:?}: {}.", stash.message).into()), cx);
})),
),
)
.children(now_open.then(|| {
BranchSelector::new("git-branch-palette", branches(), move |_, cx| set(&close, false, cx))
.on_pick(move |name, _, cx| set(&pick, Some(format!("Switched to {name}.").into()), cx))
.on_create(move |name, _, cx| set(&create, Some(format!("Created {name}.").into()), cx))
}))
}
+2
View File
@@ -5,6 +5,7 @@ mod editor;
mod feedback;
mod finance;
mod forms;
mod git;
mod layout;
mod lists;
mod menus;
@@ -51,6 +52,7 @@ pub const ALL: &[Page] = &[
finance::PAGE,
editor::PAGE,
terminal::PAGE,
git::PAGE,
theme::PAGE,
];