diff --git a/AGENTS.md b/AGENTS.md index 8375eba..b46a0dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Codes: `qrcode` (QR) and `barcoders` (Code 128, EAN-13), both MIT OR Apache-2.0, default features off. - macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off) for the grid, its parser and the pseudo-terminal; `futures` carries its events. +- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. - Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components. ## Commands diff --git a/Cargo.lock b/Cargo.lock index 8e9f0f2..b8b9883 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", + "regex-automata", "serde_core", ] @@ -1603,6 +1604,7 @@ dependencies = [ "rustix 1.1.5", "serde", "serde_json", + "similar", "smallvec", "unicode-segmentation", ] @@ -5327,6 +5329,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", + "unicode-segmentation", +] + [[package]] name = "simplecss" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 14e36da..37acfac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ regex = "1" rust-embed = "8.12" serde = { version = "1", features = ["derive"] } serde_json = "1" +similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] } smallvec = "1" unicode-segmentation = "1" diff --git a/TASKS.md b/TASKS.md index 7285478..bfd1707 100644 --- a/TASKS.md +++ b/TASKS.md @@ -242,3 +242,4 @@ Per-component lines live in `tasks/`. Tags there: | T18 | 1 | FAIL | a missing folder ran in the app's own; combining marks dropped; Tab skipped the terminal; its wheel scrolled the page too; clear kept find matches; New bash reused the old shell; the dark script typed into the replay | | T18 | 2 | FAIL | a folder it could not enter still started the shell elsewhere; links read cluster cells as spaces | | T18 | 3 | PASS | | +| T19 | 1 | — | pending review | diff --git a/assets/icons/git-pull-request-closed.svg b/assets/icons/git-pull-request-closed.svg new file mode 100644 index 0000000..1b34763 --- /dev/null +++ b/assets/icons/git-pull-request-closed.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/git-pull-request-draft.svg b/assets/icons/git-pull-request-draft.svg new file mode 100644 index 0000000..94ffe9f --- /dev/null +++ b/assets/icons/git-pull-request-draft.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/examples/gallery/pages/git/diffs.rs b/examples/gallery/pages/git/diffs.rs new file mode 100644 index 0000000..658f11f --- /dev/null +++ b/examples/gallery/pages/git/diffs.rs @@ -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, + 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, + 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::::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::>::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)), + ), + ) +} diff --git a/examples/gallery/pages/git/mod.rs b/examples/gallery/pages/git/mod.rs new file mode 100644 index 0000000..1adc80f --- /dev/null +++ b/examples/gallery/pages/git/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/git/people.rs b/examples/gallery/pages/git/people.rs new file mode 100644 index 0000000..dd33251 --- /dev/null +++ b/examples/gallery/pages/git/people.rs @@ -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::, 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::, 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)); + }), + ), + ) +} diff --git a/examples/gallery/pages/git/work.rs b/examples/gallery/pages/git/work.rs new file mode 100644 index 0000000..5f8c5ce --- /dev/null +++ b/examples/gallery/pages/git/work.rs @@ -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 { + 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::, 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 = 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 = from.read(cx).iter().filter(|file| file.path == *path).cloned().collect(); + let left: Vec = 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 { + 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 { + 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::, 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)) + })) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index c6fb1e5..1e4856c 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 1fb3c69..872d203 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -47,6 +47,7 @@ pub use hints::{CompletionItem, CompletionMenu, Signature, SignatureHelp, comple pub use hover::HoverInfo; pub use keybindings::{KeySource, Keybinding, KeybindingsEditor, conflicts}; pub(crate) use keys::bind_keys; +pub(crate) use layout::stack; pub use preview::ThemePreview; pub use problems::{Problem, ProblemsPanel}; pub use refactor::{ActionKind, CodeAction, CodeActionMenu, RenameInput}; @@ -58,5 +59,6 @@ pub use status::{ LspStatus, NotificationBell, }; pub use symbols::{Call, CallHierarchy, GoToSymbol, Symbol, SymbolKind, SymbolOutline}; +pub(crate) use syntax::colors as code_colors; pub use tasks::{Task, TaskRunner, TaskState}; pub use welcome::{Project, ProjectSwitcher, RecentProjects, WelcomePage}; diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 0e920d6..d9dc9f4 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -95,7 +95,7 @@ pub use structure::{ }; pub use switch::Switch; pub use tags::TagInput; -pub(crate) use text::{Down, Enter, History, Up, bind_keys, from_utf16, to_utf16}; +pub(crate) use text::{Down, Enter, History, Submit, Up, bind_keys, from_utf16, to_utf16}; pub use text::{Highlight, InputEvent, TextInput}; pub use transfer::TransferList; pub use unit::UnitInput; diff --git a/src/git/badges.rs b/src/git/badges.rs new file mode 100644 index 0000000..a4c1a8c --- /dev/null +++ b/src/git/badges.rs @@ -0,0 +1,104 @@ +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, + div, prelude::*, +}; + +use crate::{ + data_display::Badge, + lists::GitStatus, + primitives::Tooltip, + theme::{ActiveTheme, Radius, TextSize}, + typography::tabular, +}; + +/// A file's git status as its letter in its tone; the word shows on hover. +#[derive(IntoElement)] +pub struct GitStatusBadge { + id: ElementId, + status: GitStatus, +} + +impl GitStatusBadge { + pub fn new(id: impl Into, status: GitStatus) -> Self { + Self { + id: id.into(), + status, + } + } +} + +impl RenderOnce for GitStatusBadge { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + div() + .id(self.id) + .tooltip(Tooltip::text(self.status.word())) + .child(Badge::new(self.status.letter()).tone(self.status.tone())) + } +} + +/// Dots a diff stat shows. +const SQUARES: usize = 5; + +/// How many of the dots read as added, the rest as removed, when anything changed. +pub(crate) fn split(added: usize, removed: usize) -> Option { + let total = added + removed; + (total > 0).then(|| (added * SQUARES + total / 2) / total) +} + +/// Lines added and removed: the two counts, and five dots shared between them. +#[derive(IntoElement)] +pub struct DiffStat { + added: usize, + removed: usize, +} + +impl DiffStat { + pub fn new(added: usize, removed: usize) -> Self { + Self { added, removed } + } +} + +impl RenderOnce for DiffStat { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let green = split(self.added, self.removed); + let square = |ix: usize| { + let color = match green { + Some(green) if ix < green => colors.success, + Some(_) => colors.danger, + None => colors.border, + }; + div().size_2().rounded(theme.radius(Radius::Sm)).bg(color) + }; + div() + .flex() + .items_center() + .gap_1p5() + .text_size(theme.text_size(TextSize::Sm)) + .child(tabular( + div() + .text_color(colors.success) + .child(format!("+{}", self.added)), + )) + .child(tabular( + div() + .text_color(colors.danger) + .child(format!("−{}", self.removed)), + )) + .child(div().flex().gap_0p5().children((0..SQUARES).map(square))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squares_split_by_share() { + assert_eq!(split(0, 0), None); + assert_eq!(split(10, 0), Some(5)); + assert_eq!(split(120, 45), Some(4)); + assert_eq!(split(1, 9), Some(1), "a little still shows"); + } +} diff --git a/src/git/blame.rs b/src/git/blame.rs new file mode 100644 index 0000000..86cbdd8 --- /dev/null +++ b/src/git/blame.rs @@ -0,0 +1,244 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, + prelude::*, +}; + +use crate::{ + data_display::Avatar, + editor::code_colors, + theme::{ActiveTheme, AvatarSize, TextSize}, + typography::Ellipsis, +}; + +/// Who last changed lines: the commit, its author, when and its subject, and its age from 0, newest, to 1, oldest. +#[derive(Clone, Debug, PartialEq)] +pub struct Blame { + pub commit: SharedString, + pub author: SharedString, + pub when: SharedString, + pub subject: SharedString, + pub age: f32, +} + +/// Consecutive lines from one change, as the change's index and the lines. +pub(crate) fn runs(owners: &[usize]) -> Vec<(usize, Range)> { + let mut out: Vec<(usize, Range)> = Vec::new(); + for (line, owner) in owners.iter().enumerate() { + match out.last_mut() { + Some((last, lines)) if last == owner => lines.end = line + 1, + _ => out.push((*owner, line..line + 1)), + } + } + out +} + +/// Who last changed a line, set after its code in quiet words: the author, when, and the subject. +#[derive(IntoElement)] +pub struct GitBlameAnnotation { + blame: Blame, +} + +impl GitBlameAnnotation { + pub fn new(blame: Blame) -> Self { + Self { blame } + } +} + +impl RenderOnce for GitBlameAnnotation { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let colors = cx.theme().colors.clone(); + div() + .flex_none() + .pl_6() + .whitespace_nowrap() + .text_color(colors.fg_subtle) + .child(format!( + "{}, {} · {}", + self.blame.author, self.blame.when, self.blame.subject + )) + } +} + +type OnCommit = Rc; + +/// Code with who last changed each run of lines beside it: author, when and subject on a run's first line, a bar that fades with age. The current line shows its annotation; a press on a run picks its commit. +#[derive(IntoElement)] +pub struct BlameView { + id: ElementId, + code: SharedString, + owners: Vec, + blames: Vec, + current: Option, + on_commit: Option, +} + +impl BlameView { + /// `owners` holds, for each line of `code`, its entry in `blames`. + pub fn new( + id: impl Into, + code: impl Into, + owners: impl IntoIterator, + blames: impl IntoIterator, + ) -> Self { + let code: SharedString = code.into(); + let owners: Vec = owners.into_iter().collect(); + let blames: Vec = blames.into_iter().collect(); + assert_eq!( + owners.len(), + code.lines().count(), + "every line has an owner" + ); + assert!( + owners.iter().all(|owner| *owner < blames.len()), + "every owner is a blame" + ); + Self { + id: id.into(), + code, + owners, + blames, + current: None, + on_commit: None, + } + } + + /// The line with the cursor, from zero. + pub fn current(mut self, line: usize) -> Self { + self.current = Some(line); + self + } + + pub fn on_commit( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_commit = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BlameView { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let lines: Vec<&str> = self.code.lines().collect(); + let digits = lines.len().to_string().len(); + let blocks: Vec = runs(&self.owners) + .into_iter() + .map(|(owner, span)| { + let blame = &self.blames[owner]; + let fade = 1.0 - blame.age.clamp(0.0, 1.0) * 0.8; + let pick = self.on_commit.clone(); + let commit = blame.commit.clone(); + let gutter = div() + .id((self.id.clone(), format!("run-{}", span.start))) + .flex_none() + .w_64() + .flex() + .items_start() + .gap_2() + .px_2() + .border_l_2() + .border_color(colors.accent.opacity(fade)) + .font_family(theme.font_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .when_some(pick, |gutter, pick| { + gutter + .cursor_pointer() + .hover(|gutter| gutter.bg(colors.hover)) + .on_click(move |_, window, cx| pick(&commit, window, cx)) + }) + .child( + Avatar::new( + (self.id.clone(), format!("author-{}", span.start)), + blame.author.clone(), + ) + .size(AvatarSize::Xs), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .flex() + .gap_1() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(blame.author.clone()), + ) + .child( + div() + .text_color(colors.fg_subtle) + .child(blame.when.clone()), + ), + ) + .child( + div() + .text_color(colors.fg_muted) + .child(Ellipsis::new(blame.subject.clone())), + ), + ); + let code = div() + .flex_1() + .min_w_0() + .overflow_hidden() + .flex() + .flex_col() + .children(span.clone().map(|line| { + let text = lines[line].to_string(); + let styles = code_colors(&text, cx); + div() + .flex() + .gap_3() + .whitespace_nowrap() + .when(self.current == Some(line), |row| row.bg(colors.hover)) + .child( + div() + .flex_none() + .w(theme.text_size(TextSize::Xs) * (digits as f32 * 0.62)) + .text_right() + .text_color(colors.fg_subtle) + .child((line + 1).to_string()), + ) + .child(StyledText::new(text).with_highlights(styles)) + .when(self.current == Some(line), |row| { + row.child(GitBlameAnnotation::new(blame.clone())) + }) + })); + div() + .flex() + .border_t_1() + .border_color(colors.border) + .child(gutter) + .child(code) + .into_any_element() + }) + .collect(); + div() + .flex() + .flex_col() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg) + .children(blocks) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_from_one_change_run_together() { + assert_eq!(runs(&[0, 0, 1, 0, 0, 0]), [(0, 0..2), (1, 2..3), (0, 3..6)]); + assert!(runs(&[]).is_empty()); + } +} diff --git a/src/git/changes.rs b/src/git/changes.rs new file mode 100644 index 0000000..cf32617 --- /dev/null +++ b/src/git/changes.rs @@ -0,0 +1,359 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::badges::GitStatusBadge; +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + forms::{Checkbox, Input, Submit, TextInput}, + lists::GitStatus, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, + typography::{Ellipsis, tabular}, +}; + +/// A changed file: its path, its status, and the lines it added and removed. +#[derive(Clone, Debug, PartialEq)] +pub struct Changed { + pub path: SharedString, + pub status: GitStatus, + pub added: usize, + pub removed: usize, +} + +/// What a changes list asks for a file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChangeAction { + Stage, + Unstage, + Discard, + Open, +} + +type OnAction = Rc; +type OnAll = Rc; + +/// Files changed in the work tree, the staged apart: each with its status, its folder and what it changed; open, discard, stage or unstage it, or all at once. +#[derive(IntoElement)] +pub struct ChangesList { + id: ElementId, + staged: Vec, + unstaged: Vec, + on_action: Option, + on_all: Option, +} + +impl ChangesList { + pub fn new( + id: impl Into, + staged: impl IntoIterator, + unstaged: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + staged: staged.into_iter().collect(), + unstaged: unstaged.into_iter().collect(), + on_action: None, + on_all: None, + } + } + + pub fn on_action( + mut self, + handler: impl Fn(&SharedString, ChangeAction, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_action = Some(Rc::new(handler)); + self + } + + /// Called with true to stage every change, false to unstage every staged one. + pub fn on_all(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_all = Some(Rc::new(handler)); + self + } +} + +impl ChangesList { + fn row(&self, file: &Changed, staged: bool, cx: &App) -> AnyElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (folder, name) = match file.path.rsplit_once('/') { + Some((folder, name)) => (folder.to_string(), name.to_string()), + None => (String::new(), file.path.to_string()), + }; + let group = SharedString::from(format!("change-{staged}-{}", file.path)); + let action = |key: &str, icon, words: &'static str, act: ChangeAction| { + let (on_action, path) = (self.on_action.clone(), file.path.clone()); + IconButton::new( + (self.id.clone(), format!("{key}-{staged}-{}", file.path)), + icon, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(words) + .when_some(on_action, |button, on_action| { + button.on_click(move |_, window, cx| { + cx.stop_propagation(); + on_action(&path, act, window, cx) + }) + }) + }; + let (open, path) = (self.on_action.clone(), file.path.clone()); + div() + .id((self.id.clone(), format!("row-{staged}-{}", file.path))) + .group(group.clone()) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .when_some(open, |row, open| { + row.on_click(move |_, window, cx| open(&path, ChangeAction::Open, window, cx)) + }) + .child(GitStatusBadge::new( + (self.id.clone(), format!("status-{staged}-{}", file.path)), + file.status, + )) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .flex_none() + .text_color(colors.fg) + .when(file.status == GitStatus::Deleted, |name| { + name.line_through() + }) + .child(name), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_subtle) + .child(Ellipsis::new(folder)), + ), + ) + .child( + div() + .flex() + .opacity(0.) + .group_hover(group, |actions| actions.opacity(1.)) + .child(action( + "open", + IconName::FileText, + "Open file", + ChangeAction::Open, + )) + .when(!staged, |actions| { + actions.child(action( + "discard", + IconName::RotateCcw, + "Discard changes", + ChangeAction::Discard, + )) + }) + .child(if staged { + action("unstage", IconName::Minus, "Unstage", ChangeAction::Unstage) + } else { + action("stage", IconName::Plus, "Stage", ChangeAction::Stage) + }), + ) + .child( + tabular(div().flex_none().text_color(colors.fg_subtle)) + .child(format!("+{} −{}", file.added, file.removed)), + ) + .into_any_element() + } + + fn section( + &self, + title: &'static str, + files: &[Changed], + staged: bool, + cx: &App, + ) -> Option { + if files.is_empty() { + return None; + } + let theme = cx.theme(); + let colors = theme.colors.clone(); + let on_all = self.on_all.clone(); + let (icon, words) = if staged { + (IconName::Minus, "Unstage all") + } else { + (IconName::Plus, "Stage all") + }; + Some( + div() + .flex() + .flex_col() + .child( + div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(div().flex_1().font_weight(FontWeight::MEDIUM).child(title)) + .child(tabular(div()).child(files.len().to_string())) + .child( + IconButton::new((self.id.clone(), format!("all-{staged}")), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(words) + .when_some(on_all, |button, on_all| { + button + .on_click(move |_, window, cx| on_all(!staged, window, cx)) + }), + ), + ) + .children(files.iter().map(|file| self.row(file, staged, cx))) + .into_any_element(), + ) + } +} + +impl RenderOnce for ChangesList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let sections = [ + self.section("Staged changes", &self.staged, true, cx), + self.section("Changes", &self.unstaged, false, cx), + ]; + div() + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .children(sections.into_iter().flatten()) + } +} + +/// Characters a subject reads well within, and the most it should take. +const SUBJECT: (usize, usize) = (50, 72); + +type Run = Rc; + +/// A commit message: its subject counted against fifty, a body, and the commit button, which waits for a subject and something staged. Cmd-Enter commits; amend takes the last commit's place. +#[derive(IntoElement)] +pub struct CommitInput { + id: ElementId, + subject: Entity, + body: Entity, + branch: SharedString, + staged: usize, + amend: bool, + on_amend: Option, + on_commit: Option, +} + +impl CommitInput { + pub fn new( + id: impl Into, + subject: &Entity, + body: &Entity, + branch: impl Into, + staged: usize, + ) -> Self { + Self { + id: id.into(), + subject: subject.clone(), + body: body.clone(), + branch: branch.into(), + staged, + amend: false, + on_amend: None, + on_commit: None, + } + } + + pub fn amend(mut self, amend: bool) -> Self { + self.amend = amend; + self + } + + pub fn on_amend(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_amend = Some(Rc::new(handler)); + self + } + + pub fn on_commit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_commit = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CommitInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let length = self.subject.read(cx).text().chars().count(); + let ready = length > 0 && (self.staged > 0 || self.amend); + let count = match length { + length if length > SUBJECT.1 => colors.danger, + length if length > SUBJECT.0 => colors.warning, + _ => colors.fg_subtle, + }; + let commit = self.on_commit.filter(|_| ready); + let submit = commit.clone(); + let label = if self.amend { + format!("Amend {}", self.branch) + } else { + format!("Commit to {}", self.branch) + }; + let amend = + Checkbox::new((self.id.clone(), "amend"), self.amend).label("Amend last commit"); + let amend = match self.on_amend { + Some(on_amend) => amend.on_change(move |on, window, cx| on_amend(on, window, cx)), + None => amend, + }; + div() + .id(self.id.clone()) + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .capture_action(move |_: &Submit, window, cx| { + if let Some(submit) = &submit { + cx.stop_propagation(); + submit(window, cx) + } + }) + .child( + div().relative().child(Input::new(&self.subject)).child( + tabular(div().absolute().top_2().right_2().text_color(count)) + .child(format!("{length}/{}", SUBJECT.0)), + ), + ) + .child(Input::new(&self.body)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(div().flex_1().child(amend)) + .child( + Button::new((self.id.clone(), "commit"), label) + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .disabled(!ready) + .when_some(commit, |button, commit| { + button.on_click(move |_, window, cx| commit(window, cx)) + }), + ), + ) + } +} diff --git a/src/git/commits.rs b/src/git/commits.rs new file mode 100644 index 0000000..8d0c070 --- /dev/null +++ b/src/git/commits.rs @@ -0,0 +1,274 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Bounds, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, + PathBuilder, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, + canvas, div, point, prelude::*, size, +}; + +use super::graph::{GraphRow, Half, lanes}; +use crate::{ + data_display::{Avatar, Tag, Tone}, + theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// A commit as lists show it: id, parents first-parent first, subject, author, when, and the refs on it. +#[derive(Clone, Debug, PartialEq)] +pub struct Commit { + pub id: SharedString, + pub parents: Vec, + pub subject: SharedString, + pub author: SharedString, + pub when: SharedString, + pub refs: Vec, +} + +type OnPick = Rc; + +/// One commit in a row: its author's initials, its subject and the refs on it, its short id, and when. +#[derive(IntoElement)] +pub struct CommitItem { + id: ElementId, + commit: Commit, + selected: bool, + on_pick: Option, +} + +impl CommitItem { + pub fn new(id: impl Into, commit: Commit) -> Self { + Self { + id: id.into(), + commit, + selected: false, + on_pick: None, + } + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CommitItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let commit = self.commit; + let short: String = commit.id.chars().take(7).collect(); + let (pick, picked) = (self.on_pick, commit.id.clone()); + div() + .id(self.id.clone()) + .flex_1() + .min_w_0() + .h(theme.control_height(ControlSize::Lg)) + .flex() + .items_center() + .gap_2() + .px_2() + .rounded(theme.radius(Radius::Sm)) + .text_size(theme.text_size(TextSize::Sm)) + .when(self.selected, |row| row.bg(colors.active)) + .when(!self.selected, |row| row.hover(|row| row.bg(colors.hover))) + .when_some(pick, |row, pick| { + row.cursor_pointer() + .on_click(move |_, window, cx| pick(&picked, window, cx)) + }) + .child( + Avatar::new((self.id.clone(), "author"), commit.author.clone()) + .size(AvatarSize::Xs), + ) + .children(commit.refs.iter().enumerate().map(|(ix, name)| { + let tone = if name.starts_with('v') { + Tone::Warning + } else if name.contains('/') { + Tone::Neutral + } else { + Tone::Info + }; + Tag::new((self.id.clone(), format!("ref-{ix}")), name.clone()).tone(tone) + })) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(Ellipsis::new(commit.subject)), + ) + .child( + div() + .flex_none() + .text_color(colors.fg_muted) + .child(commit.author), + ) + .child( + div() + .flex_none() + .font_family(theme.mono_family.clone()) + .text_color(colors.fg_subtle) + .child(short), + ) + .child( + div() + .flex_none() + .text_color(colors.fg_subtle) + .child(commit.when), + ) + } +} + +/// Commits newest first, one to a row; a press picks one. +#[derive(IntoElement)] +pub struct CommitList { + id: ElementId, + commits: Vec, + selected: Option, + on_pick: Option, + graph: bool, +} + +impl CommitList { + pub fn new(id: impl Into, commits: impl IntoIterator) -> Self { + Self { + id: id.into(), + commits: commits.into_iter().collect(), + selected: None, + on_pick: None, + graph: false, + } + } + + pub fn selected(mut self, id: impl Into) -> Self { + self.selected = Some(id.into()); + self + } + + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } + + /// Draws the lanes beside the rows, as a commit graph. + pub fn graph(mut self) -> Self { + self.graph = true; + self + } +} + +/// Paints one row's lanes, `span` lanes wide so every row lines up: strokes into and out of its commit, lanes passing it, and the commit's dot. +fn lanes_cell( + row: GraphRow, + span: usize, + lane: Pixels, + height: Pixels, + stroke: Pixels, + palette: [Hsla; 8], +) -> AnyElement { + let width = lane * span as f32; + canvas( + |_, _, _| {}, + move |bounds: Bounds, _, window, _| { + let x = |at: usize| bounds.origin.x + lane * (at as f32 + 0.5); + let (top, middle, bottom) = ( + bounds.origin.y, + bounds.origin.y + height / 2.0, + bounds.origin.y + height, + ); + for piece in &row.strokes { + let (from, to) = match piece.half { + Half::Top => (point(x(piece.from), top), point(x(piece.to), middle)), + Half::Bottom => (point(x(piece.from), middle), point(x(piece.to), bottom)), + Half::Through => (point(x(piece.from), top), point(x(piece.to), bottom)), + }; + let color = palette[piece.from.max(piece.to) % palette.len()]; + let mut path = PathBuilder::stroke(stroke); + path.move_to(from); + path.line_to(to); + match path.build() { + Ok(path) => window.paint_path(path, color), + Err(error) => log::error!("commit graph: a stroke failed to build: {error:#}"), + } + } + let dot = lane * 0.45; + let center = point(x(row.lane), middle); + window.paint_quad( + gpui::fill( + Bounds::new(center - point(dot / 2.0, dot / 2.0), size(dot, dot)), + palette[row.lane % palette.len()], + ) + .corner_radii(dot / 2.0), + ); + }, + ) + .flex_none() + .w(width) + .h(height) + .into_any_element() +} + +impl RenderOnce for CommitList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let rem = window.rem_size(); + let height = theme.control_height(ControlSize::Lg).to_pixels(rem); + let lane = theme.icon_size(IconSize::Md).to_pixels(rem); + let stroke = theme.chart().hairline * 1.5; + let palette = theme.colors.chart; + let graph: Vec> = if self.graph { + let parents: Vec> = self + .commits + .iter() + .map(|commit| commit.parents.iter().map(|id| id.as_ref()).collect()) + .collect(); + lanes( + self.commits + .iter() + .zip(&parents) + .map(|(commit, parents)| (commit.id.as_ref(), parents.as_slice())), + ) + .into_iter() + .map(Some) + .collect() + } else { + vec![None; self.commits.len()] + }; + let span = graph + .iter() + .flatten() + .map(|row| row.width) + .max() + .unwrap_or(0); + div() + .flex() + .flex_col() + .children(self.commits.into_iter().zip(graph).map(|(commit, lanes)| { + let selected = self.selected.as_ref() == Some(&commit.id); + let item = CommitItem::new((self.id.clone(), commit.id.clone()), commit) + .selected(selected); + let item = match &self.on_pick { + Some(pick) => { + let pick = pick.clone(); + item.on_pick(move |id, window, cx| pick(id, window, cx)) + } + None => item, + }; + div() + .flex() + .items_center() + .children(lanes.map(|row| lanes_cell(row, span, lane, height, stroke, palette))) + .child(item) + })) + } +} diff --git a/src/git/diff.rs b/src/git/diff.rs new file mode 100644 index 0000000..c841f68 --- /dev/null +++ b/src/git/diff.rs @@ -0,0 +1,217 @@ +use std::ops::Range; + +use gpui::SharedString; +use similar::{ChangeTag, TextDiff}; + +/// Whether a diff line stayed, came in, or went. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineKind { + Same, + Added, + Removed, +} + +/// A line of a diff: its kind, its numbers in the old and new text from one, its text, and the words that changed. +#[derive(Clone, Debug, PartialEq)] +pub struct DiffLine { + pub kind: LineKind, + pub old: Option, + pub new: Option, + pub text: SharedString, + pub words: Vec>, +} + +/// A stretch of a diff: changed lines in their context, or unchanged lines folded away between. +#[derive(Clone, Debug, PartialEq)] +pub enum Stretch { + Hunk { + /// `@@ -old +new @@`, from one. + header: SharedString, + lines: Vec, + }, + Folded(Vec), +} + +fn same(text: &str, old: usize, new: usize) -> DiffLine { + DiffLine { + kind: LineKind::Same, + old: Some(old + 1), + new: Some(new + 1), + text: text.trim_end_matches(['\n', '\r']).to_string().into(), + words: Vec::new(), + } +} + +/// `old` against `new` line by line: hunks with `context` lines around each change, the rest folded. +pub fn diff(old: &str, new: &str, context: usize) -> Vec { + let diff = TextDiff::from_lines(old, new); + let (olds, news): (Vec<&str>, Vec<&str>) = ( + diff.iter_old_slices().collect(), + diff.iter_new_slices().collect(), + ); + let mut out = Vec::new(); + let mut at = (0, 0); + for group in diff.grouped_ops(context) { + let (first, last) = ( + group.first().expect("a group holds ops"), + group.last().expect("a group holds ops"), + ); + let (old_start, new_start) = (first.old_range().start, first.new_range().start); + if old_start > at.0 { + out.push(Stretch::Folded( + (at.0..old_start) + .zip(at.1..new_start) + .map(|(old, new)| same(olds[old], old, new)) + .collect(), + )); + } + let (old_end, new_end) = (last.old_range().end, last.new_range().end); + let mut lines = Vec::new(); + for op in &group { + for change in diff.iter_inline_changes(op) { + let (mut text, mut words) = (String::new(), Vec::new()); + for (emphasized, part) in change.iter_strings_lossy() { + let part = part.trim_end_matches(['\n', '\r']); + if emphasized && !part.is_empty() { + words.push(text.len()..text.len() + part.len()); + } + text.push_str(part); + } + let kind = match change.tag() { + ChangeTag::Equal => LineKind::Same, + ChangeTag::Insert => LineKind::Added, + ChangeTag::Delete => LineKind::Removed, + }; + lines.push(DiffLine { + kind, + old: change.old_index().map(|ix| ix + 1), + new: change.new_index().map(|ix| ix + 1), + text: text.into(), + words: if kind == LineKind::Same { + Vec::new() + } else { + words + }, + }); + } + } + let header = format!( + "@@ -{},{} +{},{} @@", + old_start + 1, + old_end - old_start, + new_start + 1, + new_end - new_start + ); + out.push(Stretch::Hunk { + header: header.into(), + lines, + }); + at = (old_end, new_end); + } + if at.0 < olds.len() { + out.push(Stretch::Folded( + (at.0..olds.len()) + .zip(at.1..news.len()) + .map(|(old, new)| same(olds[old], old, new)) + .collect(), + )); + } + out +} + +/// Lines added and removed from `old` to `new`. +pub fn stat(old: &str, new: &str) -> (usize, usize) { + let diff = TextDiff::from_lines(old, new); + diff.iter_all_changes() + .fold((0, 0), |(added, removed), change| match change.tag() { + ChangeTag::Insert => (added + 1, removed), + ChangeTag::Delete => (added, removed + 1), + ChangeTag::Equal => (added, removed), + }) +} + +/// A hunk's lines side by side: unchanged lines on both sides, each run of removals beside the additions after it. +pub fn pairs(lines: &[DiffLine]) -> Vec<(Option<&DiffLine>, Option<&DiffLine>)> { + let mut out = Vec::new(); + let mut ix = 0; + while ix < lines.len() { + if lines[ix].kind == LineKind::Same { + out.push((Some(&lines[ix]), Some(&lines[ix]))); + ix += 1; + continue; + } + let removed: Vec<&DiffLine> = lines[ix..] + .iter() + .take_while(|line| line.kind == LineKind::Removed) + .collect(); + ix += removed.len(); + let added: Vec<&DiffLine> = lines[ix..] + .iter() + .take_while(|line| line.kind == LineKind::Added) + .collect(); + ix += added.len(); + for row in 0..removed.len().max(added.len()) { + out.push((removed.get(row).copied(), added.get(row).copied())); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_change_shows_its_words_and_numbers() { + let stretches = diff("a\nlet x = 1;\nc\n", "a\nlet x = 2;\nc\nd\n", 1); + let [Stretch::Hunk { header, lines }] = stretches.as_slice() else { + panic!("one hunk, not {stretches:?}"); + }; + assert_eq!(header.as_ref(), "@@ -1,3 +1,4 @@"); + let kinds: Vec = lines.iter().map(|line| line.kind).collect(); + use LineKind::*; + assert_eq!(kinds, [Same, Removed, Added, Same, Added]); + assert_eq!(&lines[1].text[lines[1].words[0].clone()], "1"); + assert_eq!((lines[2].old, lines[2].new), (None, Some(2))); + assert_eq!((lines[4].old, lines[4].new), (None, Some(4))); + } + + #[test] + fn unchanged_lines_between_hunks_fold() { + let old: String = (1..=20).map(|n| format!("{n}\n")).collect(); + let new = old + .replacen("2\n", "two\n", 1) + .replace("19\n", "nineteen\n"); + let stretches = diff(&old, &new, 1); + let shape: Vec<&str> = stretches + .iter() + .map(|stretch| match stretch { + Stretch::Hunk { .. } => "hunk", + Stretch::Folded(_) => "folded", + }) + .collect(); + assert_eq!(shape, ["hunk", "folded", "hunk"]); + let Stretch::Folded(hidden) = &stretches[1] else { + unreachable!() + }; + assert_eq!((hidden.len(), hidden[0].old), (14, Some(4))); + assert_eq!(stat(&old, &new), (2, 2)); + } + + #[test] + fn removals_sit_beside_the_additions_after_them() { + let stretches = diff("a\nb\nc\n", "a\nB\nC\nD\n", 0); + let lines = stretches + .iter() + .find_map(|stretch| match stretch { + Stretch::Hunk { lines, .. } => Some(lines), + Stretch::Folded(_) => None, + }) + .expect("a hunk"); + let sides: Vec<(bool, bool)> = pairs(lines) + .iter() + .map(|(left, right)| (left.is_some(), right.is_some())) + .collect(); + assert_eq!(sides, [(true, true), (true, true), (false, true)]); + } +} diff --git a/src/git/graph.rs b/src/git/graph.rs new file mode 100644 index 0000000..dbe1fbc --- /dev/null +++ b/src/git/graph.rs @@ -0,0 +1,152 @@ +/// Which part of a row a graph line crosses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Half { + /// From the row's top into its commit. + Top, + /// From the commit down to the row's bottom. + Bottom, + /// Past the commit, top to bottom. + Through, +} + +/// A line of the graph in one row, from a lane to a lane. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stroke { + pub from: usize, + pub to: usize, + pub half: Half, +} + +/// A row of the graph: its commit's lane, the strokes through it, and how many lanes it spans. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphRow { + pub lane: usize, + pub strokes: Vec, + pub width: usize, +} + +/// Lanes for commits given newest first, each with its parents' ids, first parent first. A commit takes the lane that waits for it, else the first free one; its first parent carries on in its lane, other parents take a free one, and a parent some lane already waits for joins that lane. +pub fn lanes<'a>(commits: impl IntoIterator) -> Vec { + let mut waiting: Vec> = Vec::new(); + let mut rows = Vec::new(); + for (id, parents) in commits { + let before = waiting.clone(); + let lane = before + .iter() + .position(|wait| *wait == Some(id)) + .or_else(|| waiting.iter().position(Option::is_none)) + .unwrap_or_else(|| { + waiting.push(None); + waiting.len() - 1 + }); + for wait in waiting.iter_mut().filter(|wait| **wait == Some(id)) { + *wait = None; + } + let mut strokes: Vec = before + .iter() + .enumerate() + .filter_map(|(at, wait)| { + let wait = (*wait)?; + Some(if wait == id { + Stroke { + from: at, + to: lane, + half: Half::Top, + } + } else { + Stroke { + from: at, + to: at, + half: Half::Through, + } + }) + }) + .collect(); + for (ix, parent) in parents.iter().enumerate() { + let target = match waiting.iter().position(|wait| *wait == Some(*parent)) { + Some(joined) => joined, + None if ix == 0 => { + waiting[lane] = Some(parent); + lane + } + None => { + let free = waiting.iter().position(Option::is_none).unwrap_or_else(|| { + waiting.push(None); + waiting.len() - 1 + }); + waiting[free] = Some(parent); + free + } + }; + strokes.push(Stroke { + from: lane, + to: target, + half: Half::Bottom, + }); + } + let width = before.len().max(waiting.len()).max(lane + 1); + while waiting.last() == Some(&None) { + waiting.pop(); + } + rows.push(GraphRow { + lane, + strokes, + width, + }); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stroke(from: usize, to: usize, half: Half) -> Stroke { + Stroke { from, to, half } + } + + #[test] + fn a_line_of_commits_keeps_one_lane() { + let rows = lanes([("c", &["b"][..]), ("b", &["a"][..]), ("a", &[][..])]); + assert!(rows.iter().all(|row| row.lane == 0 && row.width == 1)); + assert_eq!( + rows[1].strokes, + [stroke(0, 0, Half::Top), stroke(0, 0, Half::Bottom)] + ); + } + + #[test] + fn a_merge_opens_a_lane_and_the_branch_joins_back() { + use Half::*; + let rows = lanes([ + ("m", &["a", "b"][..]), + ("a", &["c"][..]), + ("b", &["c"][..]), + ("c", &[][..]), + ]); + let lanes: Vec = rows.iter().map(|row| row.lane).collect(); + assert_eq!(lanes, [0, 0, 1, 0]); + assert_eq!( + rows[0].strokes, + [stroke(0, 0, Bottom), stroke(0, 1, Bottom)] + ); + assert_eq!( + rows[1].strokes, + [ + stroke(0, 0, Top), + stroke(1, 1, Through), + stroke(0, 0, Bottom) + ] + ); + assert_eq!( + rows[2].strokes, + [ + stroke(0, 0, Through), + stroke(1, 1, Top), + stroke(1, 0, Bottom) + ] + ); + assert_eq!(rows[3].strokes, [stroke(0, 0, Top)]); + assert_eq!(rows[2].width, 2); + } +} diff --git a/src/git/history.rs b/src/git/history.rs new file mode 100644 index 0000000..1b1252e --- /dev/null +++ b/src/git/history.rs @@ -0,0 +1,142 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{badges::DiffStat, commits::Commit}; +use crate::{ + theme::{ActiveTheme, Radius, TextSize}, + typography::Ellipsis, +}; + +type OnPick = Rc; + +/// A file's commits newest first on a line through them: subject, author and when, and what each changed; a press picks one. +#[derive(IntoElement)] +pub struct FileHistory { + id: ElementId, + commits: Vec<(Commit, (usize, usize))>, + selected: Option, + on_pick: Option, +} + +impl FileHistory { + /// Each commit with the lines it added and removed in the file. + pub fn new( + id: impl Into, + commits: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + commits: commits.into_iter().collect(), + selected: None, + on_pick: None, + } + } + + pub fn selected(mut self, id: impl Into) -> Self { + self.selected = Some(id.into()); + self + } + + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FileHistory { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let last = self.commits.len().saturating_sub(1); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(self.commits.into_iter().enumerate().map( + |(ix, (commit, (added, removed)))| { + let selected = self.selected.as_ref() == Some(&commit.id); + let (pick, id) = (self.on_pick.clone(), commit.id.clone()); + let short: String = commit.id.chars().take(7).collect(); + div() + .id((self.id.clone(), format!("commit-{ix}"))) + .flex() + .gap_3() + .px_2() + .rounded(theme.radius(Radius::Sm)) + .when(selected, |row| row.bg(colors.active)) + .when(!selected, |row| row.hover(|row| row.bg(colors.hover))) + .when_some(pick, |row, pick| { + row.cursor_pointer() + .on_click(move |_, window, cx| pick(&id, window, cx)) + }) + .child( + div() + .flex_none() + .flex() + .flex_col() + .items_center() + .child(div().w_0().h_2().when(ix > 0, |line| { + line.border_l_1().border_color(colors.border) + })) + .child( + div() + .size_2() + .rounded_full() + .border_1() + .border_color(if selected { + colors.focus + } else { + colors.border_strong + }) + .bg(if ix == 0 { colors.focus } else { colors.bg }), + ) + .child(div().w_0().flex_1().when(ix < last, |line| { + line.border_l_1().border_color(colors.border) + })), + ) + .child( + div() + .flex_1() + .min_w_0() + .py_1p5() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_color(colors.fg) + .child(Ellipsis::new(commit.subject.clone())), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(commit.author.clone()) + .child(commit.when.clone()) + .child( + div() + .font_family(theme.mono_family.clone()) + .child(short), + ), + ), + ) + .child( + div() + .flex_none() + .py_1p5() + .child(DiffStat::new(added, removed)), + ) + }, + )) + } +} diff --git a/src/git/merge.rs b/src/git/merge.rs new file mode 100644 index 0000000..a2598d9 --- /dev/null +++ b/src/git/merge.rs @@ -0,0 +1,349 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Div, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, prelude::*, +}; + +use super::merging::{Region, RegionKind, Take, conflicts, resolve, result}; +use crate::{ + buttons::{Button, ButtonVariant}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize}, + typography::format, +}; + +type OnTake = Rc; +type OnText = Rc; + +/// A column of code lines, padded to `rows` so neighbors line up. +fn column(lines: &[String], rows: usize, wash: Option) -> Div { + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .px_2() + .when_some(wash, |column, wash| column.bg(wash)) + .children((0..rows).map(|ix| { + div() + .whitespace_nowrap() + .overflow_hidden() + .child(lines.get(ix).cloned().unwrap_or_default()) + })) +} + +/// The three buttons that settle a conflict. +fn takes( + id: &ElementId, + conflict: usize, + on_take: &Option, + words: [&'static str; 3], +) -> Div { + let [ours, theirs, both] = words; + div().flex().gap_1().children( + [ + (Take::Ours, ours), + (Take::Theirs, theirs), + (Take::Both, both), + ] + .map(|(take, words)| { + let on_take = on_take.clone(); + Button::new((id.clone(), format!("take-{conflict}-{take:?}")), words) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .when_some(on_take, |button, on_take| { + button.on_click(move |_, window, cx| on_take(conflict, take, window, cx)) + }) + }), + ) +} + +/// Three versions side by side, ours, the base and theirs, region by region: clean changes merged and marked, each conflict held with a choice of side; the result below. +#[derive(IntoElement)] +pub struct ThreeWayMerge { + id: ElementId, + labels: [SharedString; 3], + regions: Rc>, + takes: Vec>, + on_take: Option, +} + +impl ThreeWayMerge { + /// `labels` name ours, the base and theirs. + pub fn new(id: impl Into, labels: [&str; 3], regions: Rc>) -> Self { + Self { + id: id.into(), + labels: labels.map(|label| label.to_string().into()), + regions, + takes: Vec::new(), + on_take: None, + } + } + + /// The side each conflict took so far, in order. + pub fn takes(mut self, takes: impl IntoIterator>) -> Self { + self.takes = takes.into_iter().collect(); + self + } + + pub fn on_take( + mut self, + handler: impl Fn(usize, Take, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_take = Some(Rc::new(handler)); + self + } +} + +fn washes(kind: RegionKind, colors: &Palette) -> [Option; 3] { + let changed = colors.success.opacity(0.1); + match kind { + RegionKind::Unchanged => [None, None, None], + RegionKind::Ours => [Some(changed), None, None], + RegionKind::Theirs => [None, None, Some(changed)], + RegionKind::Both => [Some(changed), None, Some(changed)], + RegionKind::Conflict => [Some(colors.warning.opacity(0.14)); 3], + } +} + +impl RenderOnce for ThreeWayMerge { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (merged, open) = result(&self.regions, &self.takes); + let total = self + .regions + .iter() + .filter(|region| region.kind == RegionKind::Conflict) + .count(); + let mut conflict = 0; + let blocks: Vec = self + .regions + .iter() + .map(|region| { + let rows = region + .ours + .len() + .max(region.base.len()) + .max(region.theirs.len()); + let [ours, base, theirs] = washes(region.kind, &colors); + let block = div().flex().flex_col().child( + div() + .flex() + .gap_px() + .child(column(®ion.ours, rows, ours)) + .child(column(®ion.base, rows, base)) + .child(column(®ion.theirs, rows, theirs)), + ); + if region.kind != RegionKind::Conflict { + return block.into_any_element(); + } + let at = conflict; + conflict += 1; + let settled = self.takes.get(at).copied().flatten(); + block + .child( + div() + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .border_b_1() + .border_color(colors.border) + .font_family(theme.font_family.clone()) + .child( + Icon::new(IconName::TriangleAlert) + .size(IconSize::Sm) + .color(colors.warning), + ) + .child(div().flex_1().text_color(colors.fg_muted).child( + match settled { + Some(take) => { + format!("Conflict {} took {}", at + 1, word(take)) + } + None => format!("Conflict {}", at + 1), + }, + )) + .child(takes( + &self.id, + at, + &self.on_take, + ["Take ours", "Take theirs", "Take both"], + )), + ) + .into_any_element() + }) + .collect(); + let header = |label: &SharedString| { + div() + .flex_1() + .px_2() + .py_1() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(label.clone()) + }; + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div() + .flex() + .gap_px() + .bg(colors.hover) + .children(self.labels.iter().map(header)), + ) + .child( + div() + .flex() + .flex_col() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .children(blocks), + ), + ) + .child(div().text_color(colors.fg_muted).child(if open == 0 { + format!( + "Merged: {}, all settled.", + format::plural(total as u64, "conflict", "conflicts") + ) + } else { + format!( + "{} of {} still open.", + open, + format::plural(total as u64, "conflict", "conflicts") + ) + })) + .child( + div() + .p_2() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg) + .children(merged.lines().map(|line| { + let marker = ["<<<<<<<", "=======", ">>>>>>>"] + .iter() + .any(|mark| line.starts_with(mark)); + div() + .whitespace_nowrap() + .when(marker, |row| row.text_color(colors.warning)) + .child(line.to_string()) + })), + ) + } +} + +fn word(take: Take) -> &'static str { + match take { + Take::Ours => "ours", + Take::Theirs => "theirs", + Take::Both => "both", + } +} + +/// A file with conflict markers, each conflict shown as the current and the incoming change, with actions to accept either or both. +#[derive(IntoElement)] +pub struct ConflictResolver { + id: ElementId, + text: SharedString, + on_resolve: Option, +} + +impl ConflictResolver { + pub fn new(id: impl Into, text: impl Into) -> Self { + Self { + id: id.into(), + text: text.into(), + on_resolve: None, + } + } + + /// Called with the whole text once a conflict is settled. + pub fn on_resolve(mut self, handler: impl Fn(String, &mut Window, &mut App) + 'static) -> Self { + self.on_resolve = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ConflictResolver { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let found = conflicts(&self.text); + let text = self.text.to_string(); + let on_take: Option = self.on_resolve.map(|on_resolve| { + Rc::new( + move |ix: usize, take: Take, window: &mut Window, cx: &mut App| { + on_resolve(resolve(&text, ix, take), window, cx) + }, + ) as OnTake + }); + let (current, incoming) = (colors.success.opacity(0.1), colors.info.opacity(0.1)); + let mut rows: Vec = Vec::new(); + for (ix, line) in self.text.lines().enumerate() { + if let Some(at) = found.iter().position(|conflict| conflict.start == ix) { + rows.push( + takes( + &self.id, + at, + &on_take, + ["Accept current", "Accept incoming", "Accept both"], + ) + .py_0p5() + .font_family(theme.font_family.clone()) + .into_any_element(), + ); + } + let wash = found.iter().find_map(|conflict| { + if ix == conflict.start { + Some(current.opacity(2.0)) + } else if ix > conflict.start && ix < conflict.middle { + Some(current) + } else if ix > conflict.middle && ix < conflict.end { + Some(incoming) + } else if ix == conflict.end { + Some(incoming.opacity(2.0)) + } else { + None + } + }); + let marker = found + .iter() + .any(|conflict| [conflict.start, conflict.middle, conflict.end].contains(&ix)); + rows.push( + div() + .px_2() + .whitespace_nowrap() + .when_some(wash, |row, wash| row.bg(wash)) + .when(marker, |row| row.text_color(colors.fg_muted)) + .child(line.to_string()) + .into_any_element(), + ); + } + div() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .py_1() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg) + .children(rows) + } +} diff --git a/src/git/merging.rs b/src/git/merging.rs new file mode 100644 index 0000000..1b44307 --- /dev/null +++ b/src/git/merging.rs @@ -0,0 +1,190 @@ +use similar::{MergeResolution, TextMerge}; + +/// Which side a conflict takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Take { + Ours, + Theirs, + Both, +} + +/// How a merge region stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RegionKind { + Unchanged, + Ours, + Theirs, + /// Both sides made the same change. + Both, + Conflict, +} + +/// A stretch of a three-way merge, with each side's lines. +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + pub kind: RegionKind, + pub base: Vec, + pub ours: Vec, + pub theirs: Vec, +} + +fn lines(text: &str, range: std::ops::Range) -> Vec { + text.lines() + .skip(range.start) + .take(range.len()) + .map(str::to_string) + .collect() +} + +/// `ours` and `theirs` merged against `base`, region by region. +pub fn regions(base: &str, ours: &str, theirs: &str) -> Vec { + TextMerge::from_lines(base, ours, theirs) + .regions() + .iter() + .map(|region| Region { + kind: match region.resolution() { + MergeResolution::Unchanged => RegionKind::Unchanged, + MergeResolution::Ours => RegionKind::Ours, + MergeResolution::Theirs => RegionKind::Theirs, + MergeResolution::Both => RegionKind::Both, + MergeResolution::Conflict => RegionKind::Conflict, + other => unreachable!("a merge resolution this crate does not know: {other:?}"), + }, + base: lines(base, region.base_range()), + ours: lines(ours, region.ours_range()), + theirs: lines(theirs, region.theirs_range()), + }) + .collect() +} + +/// The merged text with each conflict as taken, and how many are still open; an open one keeps its markers. +pub fn result(regions: &[Region], takes: &[Option]) -> (String, usize) { + let mut out: Vec = Vec::new(); + let mut conflicts = 0; + let mut open = 0; + for region in regions { + match region.kind { + RegionKind::Unchanged | RegionKind::Ours | RegionKind::Both => { + out.extend(region.ours.iter().cloned()) + } + RegionKind::Theirs => out.extend(region.theirs.iter().cloned()), + RegionKind::Conflict => { + match takes.get(conflicts).copied().flatten() { + Some(Take::Ours) => out.extend(region.ours.iter().cloned()), + Some(Take::Theirs) => out.extend(region.theirs.iter().cloned()), + Some(Take::Both) => { + out.extend(region.ours.iter().cloned()); + out.extend(region.theirs.iter().cloned()); + } + None => { + open += 1; + out.push("<<<<<<< ours".into()); + out.extend(region.ours.iter().cloned()); + out.push("=======".into()); + out.extend(region.theirs.iter().cloned()); + out.push(">>>>>>> theirs".into()); + } + } + conflicts += 1; + } + } + } + let mut text = out.join("\n"); + if !out.is_empty() { + text.push('\n'); + } + (text, open) +} + +/// A conflict in marked text: the lines of its markers, from zero. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Conflict { + pub start: usize, + pub middle: usize, + pub end: usize, +} + +/// The conflicts marked in `text`: `<<<<<<<`, `=======`, `>>>>>>>`, each at a line's start. +pub fn conflicts(text: &str) -> Vec { + let mut found = Vec::new(); + let (mut start, mut middle) = (None, None); + for (ix, line) in text.lines().enumerate() { + if line.starts_with("<<<<<<<") { + (start, middle) = (Some(ix), None); + } else if line.starts_with("=======") && start.is_some() { + middle = Some(ix); + } else if line.starts_with(">>>>>>>") + && let (Some(start), Some(middle)) = (start.take(), middle.take()) + { + found.push(Conflict { + start, + middle, + end: ix, + }); + } + } + found +} + +/// `text` with conflict `ix` settled as taken. +pub fn resolve(text: &str, ix: usize, take: Take) -> String { + let conflict = conflicts(text)[ix]; + let lines: Vec<&str> = text.lines().collect(); + let ours = &lines[conflict.start + 1..conflict.middle]; + let theirs = &lines[conflict.middle + 1..conflict.end]; + let kept: Vec<&str> = match take { + Take::Ours => ours.to_vec(), + Take::Theirs => theirs.to_vec(), + Take::Both => ours.iter().chain(theirs).copied().collect(), + }; + let mut out: Vec<&str> = lines[..conflict.start].to_vec(); + out.extend(kept); + out.extend(&lines[conflict.end + 1..]); + let mut settled = out.join("\n"); + if text.ends_with('\n') { + settled.push('\n'); + } + log::info!("conflict {ix} took {take:?}"); + settled +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "fn lift() {\n 0.5\n}\nfn keep() {}\n"; + const OURS: &str = "fn lift() {\n 0.6\n}\nfn keep() {}\n"; + const THEIRS: &str = "fn lift() {\n 0.75\n}\nfn keep() { log() }\n"; + + #[test] + fn a_merge_keeps_clean_changes_and_holds_conflicts() { + let all = regions(BASE, OURS, THEIRS); + let kinds: Vec = all.iter().map(|region| region.kind).collect(); + assert!(kinds.contains(&RegionKind::Conflict) && kinds.contains(&RegionKind::Theirs)); + let (open, count) = result(&all, &[]); + assert_eq!(count, 1); + assert!(open.contains("<<<<<<< ours\n 0.6\n=======\n 0.75\n>>>>>>> theirs")); + let (taken, count) = result(&all, &[Some(Take::Theirs)]); + assert_eq!(count, 0); + assert_eq!(taken, "fn lift() {\n 0.75\n}\nfn keep() { log() }\n"); + } + + #[test] + fn markers_resolve_to_one_side_or_both() { + let marked = "a\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> topic\nz\n"; + assert_eq!( + conflicts(marked), + [Conflict { + start: 1, + middle: 3, + end: 5 + }] + ); + assert_eq!(resolve(marked, 0, Take::Ours), "a\nours\nz\n"); + assert_eq!(resolve(marked, 0, Take::Both), "a\nours\ntheirs\nz\n"); + assert!( + conflicts("a\n=======\nb\n").is_empty(), + "a rule alone is no conflict" + ); + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..aa41610 --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,27 @@ +mod badges; +mod blame; +mod changes; +mod commits; +mod diff; +mod graph; +mod history; +mod merge; +mod merging; +mod refs; +mod review; +mod tags; +mod viewer; + +pub use badges::{DiffStat, GitStatusBadge}; +pub use blame::{Blame, BlameView, GitBlameAnnotation}; +pub use changes::{ChangeAction, Changed, ChangesList, CommitInput}; +pub use commits::{Commit, CommitItem, CommitList}; +pub use diff::{DiffLine, LineKind, Stretch, diff, pairs, stat}; +pub use graph::{GraphRow, Half, Stroke, lanes}; +pub use history::FileHistory; +pub use merge::{ConflictResolver, ThreeWayMerge}; +pub use merging::{Conflict, Region, RegionKind, Take, conflicts, regions, resolve, result}; +pub use refs::{Branch, BranchList, BranchSelector}; +pub use review::{PullRequest, PullRequestCard, PullState, ReviewComment, ReviewNote}; +pub use tags::{GitTag, Stash, StashAction, StashList, TagList}; +pub use viewer::{DiffLayout, DiffViewer}; diff --git a/src/git/refs.rs b/src/git/refs.rs new file mode 100644 index 0000000..f47fc9f --- /dev/null +++ b/src/git/refs.rs @@ -0,0 +1,381 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + navigation::{Group, Palette, Row, fuzzy, marked, query_field}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{Ellipsis, tabular}, +}; + +/// A branch: its name, remote or local, whether it is checked out, how far it is from its upstream, and its last commit. +#[derive(Clone, Debug, PartialEq)] +pub struct Branch { + pub name: SharedString, + pub remote: bool, + pub current: bool, + pub ahead: usize, + pub behind: usize, + pub subject: SharedString, + pub when: SharedString, +} + +/// `↑2 ↓1`, or nothing when even. +fn distance(branch: &Branch) -> Option { + let parts: Vec = [(branch.ahead, '↑'), (branch.behind, '↓')] + .iter() + .filter(|(count, _)| *count > 0) + .map(|(count, arrow)| format!("{arrow}{count}")) + .collect(); + (!parts.is_empty()).then(|| parts.join(" ")) +} + +/// The value a picked row carries: a branch's name, or a new one's after this mark. +const CREATE: &str = "\u{0}create:"; + +type OnName = Rc; +pub(super) type Run = Rc; + +/// Branches as a palette, local and remote apart, the current one checked; a query no branch is named becomes a new branch. +#[derive(IntoElement)] +pub struct BranchSelector { + id: ElementId, + branches: Vec, + on_pick: Option, + on_create: Option, + on_close: Run, +} + +impl BranchSelector { + pub fn new( + id: impl Into, + branches: impl IntoIterator, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + branches: branches.into_iter().collect(), + on_pick: None, + on_create: None, + on_close: Rc::new(on_close), + } + } + + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } + + pub fn on_create( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_create = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BranchSelector { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let input = query_field( + &self.id, + "Switch to a branch, or name a new one", + window, + cx, + ); + let query = input.read(cx).text().trim().to_string(); + let colors = cx.theme().colors.clone(); + let row = |branch: &Branch, cx: &App| -> Option<(i32, Row)> { + let (score, hits) = if query.is_empty() { + (0, Vec::new()) + } else { + let fit = fuzzy(&query, &branch.name)?; + (fit.score, fit.hits) + }; + Some(( + score, + Row { + value: branch.name.clone(), + icon: Some(if branch.current { + IconName::Check + } else { + IconName::GitBranch + }), + label: marked(branch.name.clone(), hits, cx), + detail: Some( + div() + .text_color(colors.fg_subtle) + .child(branch.subject.clone()) + .into_any_element(), + ), + end: distance(branch).map(|text| { + div() + .text_color(colors.fg_subtle) + .child(text) + .into_any_element() + }), + }, + )) + }; + let group = |title: &'static str, remote: bool, cx: &App| { + let mut rows: Vec<(i32, Row)> = self + .branches + .iter() + .filter(|branch| branch.remote == remote) + .filter_map(|branch| row(branch, cx)) + .collect(); + if !query.is_empty() { + rows.sort_by(|(a, _), (b, _)| b.cmp(a)); + } + Group { + title: Some(title.into()), + rows: rows.into_iter().map(|(_, row)| row).collect(), + } + }; + let mut groups = vec![group("Local", false, cx), group("Remote", true, cx)]; + let named = self + .branches + .iter() + .any(|branch| branch.name.as_ref() == query); + if !query.is_empty() && !named { + groups.insert( + 0, + Group { + title: None, + rows: vec![Row { + value: format!("{CREATE}{query}").into(), + icon: Some(IconName::Plus), + label: div() + .child(format!("Create branch “{query}”")) + .into_any_element(), + detail: None, + end: None, + }], + }, + ); + } + let (pick, create) = (self.on_pick, self.on_create); + let on_pick: OnName = Rc::new( + move |value: &SharedString, window: &mut Window, cx: &mut App| match value + .strip_prefix(CREATE) + { + Some(name) => { + if let Some(create) = &create { + create(&name.to_string().into(), window, cx) + } + } + None => { + if let Some(pick) = &pick { + pick(value, window, cx) + } + } + }, + ); + Palette { + id: self.id, + input, + groups, + start: 0, + empty: "No branch fits".into(), + on_pick: Some(on_pick), + on_close: self.on_close, + } + .overlay(window, cx) + } +} + +/// A row's name, detail and trailing words, the actions shown on hover. +pub(super) fn listed( + id: ElementId, + icon: IconName, + name: AnyElement, + detail: SharedString, + trailing: Vec, + actions: Vec, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let group = SharedString::from(format!("{id:?}")); + div() + .id(id) + .group(group.clone()) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .hover(|row| row.bg(colors.hover)) + .child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted)) + .child(div().flex_none().child(name)) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_subtle) + .child(Ellipsis::new(detail)), + ) + .children( + trailing + .into_iter() + .map(|words| tabular(div().flex_none().text_color(colors.fg_subtle)).child(words)), + ) + .child( + div() + .flex() + .opacity(0.) + .group_hover(group, |actions| actions.opacity(1.)) + .children(actions), + ) + .into_any_element() +} + +pub(super) fn action( + id: ElementId, + icon: IconName, + words: &'static str, + run: Option, +) -> AnyElement { + IconButton::new(id, icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(words) + .when_some(run, |button, run| { + button.on_click(move |_, window, cx| run(window, cx)) + }) + .into_any_element() +} + +/// Branches with how far each is from its upstream and its last commit; switch to one or delete it. +#[derive(IntoElement)] +pub struct BranchList { + id: ElementId, + branches: Vec, + on_switch: Option, + on_delete: Option, +} + +impl BranchList { + pub fn new(id: impl Into, branches: impl IntoIterator) -> Self { + Self { + id: id.into(), + branches: branches.into_iter().collect(), + on_switch: None, + on_delete: None, + } + } + + pub fn on_switch( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_switch = Some(Rc::new(handler)); + self + } + + pub fn on_delete( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_delete = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BranchList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rows: Vec = self + .branches + .iter() + .map(|branch| { + let name = div() + .when(branch.current, |name| { + name.font_weight(FontWeight::SEMIBOLD) + }) + .text_color(colors.fg) + .child(branch.name.clone()) + .into_any_element(); + let bind = |handler: &Option| { + handler.clone().map(|handler| { + let name = branch.name.clone(); + Rc::new(move |window: &mut Window, cx: &mut App| handler(&name, window, cx)) + as Run + }) + }; + let actions = if branch.current { + Vec::new() + } else { + vec![ + action( + (self.id.clone(), format!("switch-{}", branch.name)).into(), + IconName::ArrowRight, + "Switch to it", + bind(&self.on_switch), + ), + action( + (self.id.clone(), format!("delete-{}", branch.name)).into(), + IconName::Trash2, + "Delete", + bind(&self.on_delete), + ), + ] + }; + let trailing: Vec = distance(branch) + .into_iter() + .map(SharedString::from) + .chain([branch.when.clone()]) + .collect(); + listed( + (self.id.clone(), format!("branch-{}", branch.name)).into(), + if branch.current { + IconName::Check + } else { + IconName::GitBranch + }, + name, + branch.subject.clone(), + trailing, + actions, + cx, + ) + }) + .collect(); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distance_reads_ahead_then_behind() { + let branch = |ahead, behind| Branch { + name: "main".into(), + remote: false, + current: true, + ahead, + behind, + subject: "".into(), + when: "".into(), + }; + assert_eq!(distance(&branch(2, 1)).as_deref(), Some("↑2 ↓1")); + assert_eq!(distance(&branch(0, 3)).as_deref(), Some("↓3")); + assert_eq!(distance(&branch(0, 0)), None); + } +} diff --git a/src/git/review.rs b/src/git/review.rs new file mode 100644 index 0000000..21f62ef --- /dev/null +++ b/src/git/review.rs @@ -0,0 +1,399 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, Entity, FontWeight, Hsla, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, +}; + +use super::badges::DiffStat; +use crate::{ + buttons::{Button, ButtonVariant}, + data_display::{Avatar, AvatarGroup, Tag, Tone}, + forms::{Input, TextInput}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Palette, Radius, TextSize}, + typography::{Ellipsis, format}, +}; + +type Run = Rc; +type OnBool = Rc; + +/// Where a pull request stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PullState { + Open, + Draft, + Merged, + Closed, +} + +impl PullState { + fn look(self, colors: &Palette) -> (IconName, Hsla, &'static str) { + match self { + Self::Open => (IconName::GitPullRequest, colors.success, "Open"), + Self::Draft => (IconName::GitPullRequestDraft, colors.fg_muted, "Draft"), + Self::Merged => (IconName::GitMerge, colors.info, "Merged"), + Self::Closed => (IconName::GitPullRequestClosed, colors.danger, "Closed"), + } + } +} + +/// A pull request: number, title and author, the branch it merges and where, its state, checks, reviewers, comments and size. +#[derive(Clone, Debug, PartialEq)] +pub struct PullRequest { + pub number: u32, + pub title: SharedString, + pub author: SharedString, + pub head: SharedString, + pub base: SharedString, + pub state: PullState, + /// Checks passed, failed, and still running. + pub checks: (usize, usize, usize), + pub reviewers: Vec, + pub comments: usize, + pub added: usize, + pub removed: usize, + pub when: SharedString, +} + +/// A pull request at a glance: state and title, the branches, checks, reviewers, comments and size; a press opens it. +#[derive(IntoElement)] +pub struct PullRequestCard { + id: ElementId, + pull: PullRequest, + on_open: Option, +} + +impl PullRequestCard { + pub fn new(id: impl Into, pull: PullRequest) -> Self { + Self { + id: id.into(), + pull, + on_open: None, + } + } + + pub fn on_open(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for PullRequestCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let pull = self.pull; + let (icon, tint, state) = pull.state.look(&colors); + let (passed, failed, running) = pull.checks; + let (check_icon, check_tint) = match (failed, running) { + (0, 0) => (IconName::CircleCheck, colors.success), + (0, _) => (IconName::LoaderCircle, colors.warning), + _ => (IconName::CircleX, colors.danger), + }; + let checks = [(passed, "passed"), (failed, "failed"), (running, "running")] + .iter() + .filter(|(count, _)| *count > 0) + .map(|(count, word)| format!("{count} {word}")) + .collect::>() + .join(" · "); + let branch = |name: SharedString, key: &str| { + Tag::new((self.id.clone(), key.to_string()), name).tone(Tone::Neutral) + }; + let open = self.on_open; + div() + .id(self.id.clone()) + .flex() + .flex_col() + .gap_2() + .p_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .when_some(open, |card, open| { + card.cursor_pointer() + .hover(|card| card.border_color(colors.border_strong)) + .on_click(move |_, window, cx| open(window, cx)) + }) + .child( + div() + .flex() + .items_start() + .gap_2() + .child(Icon::new(icon).size(IconSize::Md).color(tint)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(Ellipsis::new(pull.title)), + ) + .child( + div() + .flex_none() + .text_color(colors.fg_subtle) + .child(format!("#{}", pull.number)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .text_color(colors.fg_muted) + .child(format!("{state} · {} wants to merge", pull.author)) + .child(branch(pull.head, "head")) + .child("into") + .child(branch(pull.base, "base")) + .child(div().text_color(colors.fg_subtle).child(pull.when)), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_4() + .pl_6() + .text_color(colors.fg_muted) + .child( + div() + .flex() + .items_center() + .gap_1() + .child(Icon::new(check_icon).size(IconSize::Sm).color(check_tint)) + .child(checks), + ) + .child( + AvatarGroup::new(pull.reviewers.iter().enumerate().map(|(ix, name)| { + Avatar::new((self.id.clone(), format!("reviewer-{ix}")), name.clone()) + })) + .size(AvatarSize::Xs), + ) + .child( + div() + .flex() + .items_center() + .gap_1() + .child( + Icon::new(IconName::MessageSquare) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + .child(pull.comments.to_string()), + ) + .child(div().flex_1()) + .child(DiffStat::new(pull.added, pull.removed)), + ) + } +} + +/// One comment in a review thread. +#[derive(Clone, Debug, PartialEq)] +pub struct ReviewNote { + pub author: SharedString, + pub when: SharedString, + pub body: SharedString, +} + +/// A review thread on a line: the line it is about, each comment, a reply field, and resolve or reopen; a resolved thread quiets down. +#[derive(IntoElement)] +pub struct ReviewComment { + id: ElementId, + path: SharedString, + line: usize, + code: SharedString, + notes: Vec, + resolved: bool, + reply: Entity, + on_reply: Option, + on_resolve: Option, +} + +impl ReviewComment { + /// `line` counts from one; `code` is its text. + pub fn new( + id: impl Into, + path: impl Into, + line: usize, + code: impl Into, + notes: impl IntoIterator, + reply: &Entity, + ) -> Self { + Self { + id: id.into(), + path: path.into(), + line, + code: code.into(), + notes: notes.into_iter().collect(), + resolved: false, + reply: reply.clone(), + on_reply: None, + on_resolve: None, + } + } + + pub fn resolved(mut self, resolved: bool) -> Self { + self.resolved = resolved; + self + } + + pub fn on_reply(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_reply = Some(Rc::new(handler)); + self + } + + /// Called with true to resolve the thread, false to reopen it. + pub fn on_resolve(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_resolve = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ReviewComment { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let notes: Vec = self + .notes + .iter() + .enumerate() + .map(|(ix, note)| { + div() + .flex() + .gap_2() + .child( + Avatar::new((self.id.clone(), format!("note-{ix}")), note.author.clone()) + .size(AvatarSize::Sm), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .gap_2() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(note.author.clone()), + ) + .child( + div().text_color(colors.fg_subtle).child(note.when.clone()), + ), + ) + .child(div().text_color(colors.fg).child(note.body.clone())), + ) + .into_any_element() + }) + .collect(); + let (resolve, resolved) = (self.on_resolve, self.resolved); + let reply = self.on_reply; + div() + .id(self.id.clone()) + .flex() + .flex_col() + .gap_3() + .p_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .when(resolved, |thread| thread.opacity(0.7)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Icon::new(IconName::MessageSquare) + .size(IconSize::Sm) + .color(colors.fg_muted), + ) + .child( + div() + .flex_1() + .text_color(colors.fg_muted) + .child(format!("{} line {}", self.path, self.line)), + ) + .when(resolved, |header| { + header.child( + Tag::new((self.id.clone(), "resolved"), "Resolved").tone(Tone::Success), + ) + }) + .child(format::plural( + self.notes.len() as u64, + "comment", + "comments", + )), + ) + .child( + div() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .whitespace_nowrap() + .overflow_hidden() + .child(format!("{} {}", self.line, self.code)), + ) + .children(notes) + .when(!resolved, |thread| { + thread.child( + div() + .flex() + .gap_2() + .child(div().flex_1().child(Input::new(&self.reply))) + .child( + Button::new((self.id.clone(), "reply"), "Reply") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .when_some(reply, |button, reply| { + button.on_click(move |_, window, cx| reply(window, cx)) + }), + ), + ) + }) + .child( + div().flex().child( + Button::new( + (self.id.clone(), "resolve"), + if resolved { + "Reopen conversation" + } else { + "Resolve conversation" + }, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .when_some(resolve, |button, resolve| { + button.on_click(move |_, window, cx| resolve(!resolved, window, cx)) + }), + ), + ) + } +} diff --git a/src/git/tags.rs b/src/git/tags.rs new file mode 100644 index 0000000..73504d3 --- /dev/null +++ b/src/git/tags.rs @@ -0,0 +1,161 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, +}; + +use super::refs::{Run, action, listed}; +use crate::{ + primitives::IconName, + theme::{ActiveTheme, TextSize}, +}; + +type OnStash = Rc; + +/// A tag: its name, its message when annotated, the commit it names, and when. +#[derive(Clone, Debug, PartialEq)] +pub struct GitTag { + pub name: SharedString, + pub message: Option, + pub commit: SharedString, + pub when: SharedString, +} + +/// Tags newest first, each with its message and the commit it names. +#[derive(IntoElement)] +pub struct TagList { + id: ElementId, + tags: Vec, +} + +impl TagList { + pub fn new(id: impl Into, tags: impl IntoIterator) -> Self { + Self { + id: id.into(), + tags: tags.into_iter().collect(), + } + } +} + +impl RenderOnce for TagList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rows: Vec = self + .tags + .iter() + .map(|tag| { + let short: String = tag.commit.chars().take(7).collect(); + listed( + (self.id.clone(), format!("tag-{}", tag.name)).into(), + IconName::Tag, + div() + .text_color(colors.fg) + .child(tag.name.clone()) + .into_any_element(), + tag.message.clone().unwrap_or_default(), + vec![short.into(), tag.when.clone()], + Vec::new(), + cx, + ) + }) + .collect(); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + } +} + +/// A stash: its message, the branch it came from, and when. +#[derive(Clone, Debug, PartialEq)] +pub struct Stash { + pub message: SharedString, + pub branch: SharedString, + pub when: SharedString, +} + +/// What a stash list does to a stash. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StashAction { + Apply, + Pop, + Drop, +} + +/// Stashes newest first, as `stash@{n}`: apply one, pop it, or drop it. +#[derive(IntoElement)] +pub struct StashList { + id: ElementId, + stashes: Vec, + on_action: Option, +} + +impl StashList { + pub fn new(id: impl Into, stashes: impl IntoIterator) -> Self { + Self { + id: id.into(), + stashes: stashes.into_iter().collect(), + on_action: None, + } + } + + pub fn on_action( + mut self, + handler: impl Fn(usize, StashAction, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_action = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for StashList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rows: Vec = self + .stashes + .iter() + .enumerate() + .map(|(ix, stash)| { + let act = |key: &str, icon, words: &'static str, what: StashAction| { + let run = self.on_action.clone().map(|on_action| { + Rc::new(move |window: &mut Window, cx: &mut App| { + on_action(ix, what, window, cx) + }) as Run + }); + action( + (self.id.clone(), format!("{key}-{ix}")).into(), + icon, + words, + run, + ) + }; + listed( + (self.id.clone(), format!("stash-{ix}")).into(), + IconName::Archive, + div() + .font_family(theme.mono_family.clone()) + .text_color(colors.fg) + .child(format!("stash@{{{ix}}}")) + .into_any_element(), + format!("{} · on {}", stash.message, stash.branch).into(), + vec![stash.when.clone()], + vec![ + act("apply", IconName::Download, "Apply", StashAction::Apply), + act("pop", IconName::ArrowUp, "Pop", StashAction::Pop), + act("drop", IconName::Trash2, "Drop", StashAction::Drop), + ], + cx, + ) + }) + .collect(); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + } +} diff --git a/src/git/viewer.rs b/src/git/viewer.rs new file mode 100644 index 0000000..c9fdea3 --- /dev/null +++ b/src/git/viewer.rs @@ -0,0 +1,332 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, HighlightStyle, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, + StyledText, Window, div, prelude::*, uniform_list, +}; + +use super::{ + badges::DiffStat, + diff::{DiffLine, LineKind, Stretch, diff, pairs}, +}; +use crate::{ + buttons::SegmentedControl, + editor::{code_colors, stack}, + theme::{ActiveTheme, ControlSize, Palette, TextSize}, + typography::format, +}; + +/// How a diff lays out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffLayout { + Unified, + Split, +} + +/// Unchanged lines kept around each change. +const CONTEXT: usize = 3; + +/// A row as the viewer draws it. +#[derive(Clone)] +enum Shown { + Header(SharedString), + Line(DiffLine), + Pair(Option, Option), + /// A folded stretch: its place among the stretches and how many lines it hides. + Fold(usize, usize), +} + +fn shown(stretches: &[Stretch], layout: DiffLayout, open: &[usize]) -> Vec { + let mut rows = Vec::new(); + for (ix, stretch) in stretches.iter().enumerate() { + match stretch { + Stretch::Hunk { header, lines } => { + rows.push(Shown::Header(header.clone())); + match layout { + DiffLayout::Unified => rows.extend(lines.iter().cloned().map(Shown::Line)), + DiffLayout::Split => rows.extend( + pairs(lines) + .into_iter() + .map(|(left, right)| Shown::Pair(left.cloned(), right.cloned())), + ), + } + } + Stretch::Folded(lines) if open.contains(&ix) => { + rows.extend(lines.iter().map(|line| match layout { + DiffLayout::Unified => Shown::Line(line.clone()), + DiffLayout::Split => Shown::Pair(Some(line.clone()), Some(line.clone())), + })) + } + Stretch::Folded(lines) => rows.push(Shown::Fold(ix, lines.len())), + } + } + rows +} + +type OnIndex = Rc; +type OnLayout = Rc; + +/// One file's changes: each change in its context with old and new numbers and its changed words marked, the stretches between folded until pressed; unified or side by side. Long diffs draw only the rows in view; it fills its box. +#[derive(IntoElement)] +pub struct DiffViewer { + id: ElementId, + path: SharedString, + stretches: Rc>, + stat: (usize, usize), + layout: DiffLayout, + open: Vec, + on_open: Option, + on_layout: Option, +} + +impl DiffViewer { + pub fn new( + id: impl Into, + path: impl Into, + old: &str, + new: &str, + ) -> Self { + Self { + id: id.into(), + path: path.into(), + stretches: Rc::new(diff(old, new, CONTEXT)), + stat: super::diff::stat(old, new), + layout: DiffLayout::Unified, + open: Vec::new(), + on_open: None, + on_layout: None, + } + } + + pub fn layout(mut self, layout: DiffLayout) -> Self { + self.layout = layout; + self + } + + /// Folded stretches shown in full, by their place among the stretches. + pub fn open(mut self, stretches: impl IntoIterator) -> Self { + self.open = stretches.into_iter().collect(); + self + } + + pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + pub fn on_layout( + mut self, + handler: impl Fn(DiffLayout, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_layout = Some(Rc::new(handler)); + self + } +} + +/// The wash behind a line, and behind its changed words. +fn washes(kind: LineKind, colors: &Palette) -> (Option, Hsla) { + match kind { + LineKind::Same => (None, colors.hover), + LineKind::Added => ( + Some(colors.success.opacity(0.08)), + colors.success.opacity(0.25), + ), + LineKind::Removed => ( + Some(colors.danger.opacity(0.08)), + colors.danger.opacity(0.25), + ), + } +} + +/// A line's code, colored as code, its changed words washed. +fn code(line: &DiffLine, colors: &Palette, cx: &App) -> StyledText { + let (_, word) = washes(line.kind, colors); + let mut styles: Vec<(Range, HighlightStyle)> = code_colors(&line.text, cx); + styles.extend(line.words.iter().map(|range| { + ( + range.clone(), + HighlightStyle { + background_color: Some(word), + ..HighlightStyle::default() + }, + ) + })); + styles.sort_by_key(|(range, _)| range.start); + StyledText::new(line.text.clone()).with_highlights(stack(styles)) +} + +impl RenderOnce for DiffViewer { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let rows = Rc::new(shown(&self.stretches, self.layout, &self.open)); + let layout = SegmentedControl::new( + (self.id.clone(), "layout"), + match self.layout { + DiffLayout::Unified => "unified", + DiffLayout::Split => "split", + }, + ) + .size(ControlSize::Sm) + .segment("unified", "Unified", None) + .segment("split", "Split", None); + let layout = match self.on_layout { + Some(on_layout) => layout.on_change(move |key, window, cx| { + let next = if key.as_ref() == "split" { + DiffLayout::Split + } else { + DiffLayout::Unified + }; + on_layout(next, window, cx) + }), + None => layout, + }; + let digits = self + .stretches + .iter() + .flat_map(|stretch| match stretch { + Stretch::Hunk { lines, .. } | Stretch::Folded(lines) => lines.iter(), + }) + .flat_map(|line| [line.old, line.new]) + .flatten() + .max() + .unwrap_or(1) + .to_string() + .len() + .max(2); + let (id, on_open) = (self.id.clone(), self.on_open.clone()); + let list = uniform_list( + (self.id.clone(), "rows"), + rows.len(), + move |range, _, cx| { + let colors = cx.theme().colors.clone(); + let number = |value: Option| { + div() + .flex_none() + .w(cx.theme().text_size(TextSize::Xs) * (digits as f32 * 0.62 + 0.8)) + .text_right() + .text_color(colors.fg_subtle) + .children(value.map(|value| value.to_string())) + }; + let side = + |line: Option<&DiffLine>, cx: &App| -> AnyElement { + let (wash, _) = line + .map_or((Some(colors.hover.opacity(0.5)), colors.hover), |line| { + washes(line.kind, &colors) + }); + div() + .flex_1() + .min_w_0() + .flex() + .gap_2() + .overflow_hidden() + .when_some(wash, |side, wash| side.bg(wash)) + .child(number(line.and_then(|line| line.old.or(line.new)))) + .children(line.map(|line| { + div().whitespace_nowrap().child(code(line, &colors, cx)) + })) + .into_any_element() + }; + range + .map(|ix| { + let row = div().id(ix).w_full().flex().px_2().whitespace_nowrap(); + match &rows[ix] { + Shown::Header(header) => row + .bg(colors.hover) + .text_color(colors.fg_muted) + .child(header.clone()) + .into_any_element(), + Shown::Line(line) => { + let (wash, _) = washes(line.kind, &colors); + let sign = match line.kind { + LineKind::Same => " ", + LineKind::Added => "+", + LineKind::Removed => "−", + }; + row.gap_2() + .when_some(wash, |row, wash| row.bg(wash)) + .child(number(line.old)) + .child(number(line.new)) + .child( + div().flex_none().text_color(colors.fg_subtle).child(sign), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .child(code(line, &colors, cx)), + ) + .into_any_element() + } + Shown::Pair(left, right) => row + .gap_1() + .child(side(left.as_ref(), cx)) + .child(side(right.as_ref(), cx)) + .into_any_element(), + Shown::Fold(stretch, count) => { + let (open, stretch) = (on_open.clone(), *stretch); + row.id((id.clone(), format!("fold-{stretch}"))) + .justify_center() + .bg(colors.hover.opacity(0.5)) + .text_color(colors.fg_subtle) + .cursor_pointer() + .hover(|row| row.text_color(colors.fg)) + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .when_some(open, |row, open| { + row.on_click(move |_, window, cx| open(stretch, window, cx)) + }) + .child(format!( + "⋯ {}", + format::plural( + *count as u64, + "unchanged line", + "unchanged lines" + ) + )) + .into_any_element() + } + } + }) + .collect() + }, + ) + .size_full(); + div() + .size_full() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(self.path), + ) + .child(DiffStat::new(self.stat.0, self.stat.1)) + .child(layout), + ) + .child( + div() + .flex_1() + .min_h_0() + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .child(list), + ) + } +} diff --git a/src/lib.rs b/src/lib.rs index 728c7dc..b3a90a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod editor; pub mod feedback; pub mod finance; pub mod forms; +pub mod git; pub mod layout; pub mod lists; pub mod menus; diff --git a/src/lists/files.rs b/src/lists/files.rs index 7e48df3..57ddb05 100644 --- a/src/lists/files.rs +++ b/src/lists/files.rs @@ -25,7 +25,18 @@ pub enum GitStatus { } impl GitStatus { - fn letter(self) -> &'static str { + pub(crate) fn word(self) -> &'static str { + match self { + Self::Modified => "Modified", + Self::Added => "Added", + Self::Deleted => "Deleted", + Self::Untracked => "Untracked", + Self::Renamed => "Renamed", + Self::Conflicted => "Conflicted", + } + } + + pub(crate) fn letter(self) -> &'static str { match self { Self::Modified => "M", Self::Added => "A", @@ -36,7 +47,7 @@ impl GitStatus { } } - fn tone(self) -> Tone { + pub(crate) fn tone(self) -> Tone { match self { Self::Modified => Tone::Warning, Self::Added | Self::Untracked => Tone::Success, diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index bed741c..f91e75d 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -135,6 +135,8 @@ icons! { GitCommitHorizontal => "git-commit-horizontal", GitMerge => "git-merge", GitPullRequest => "git-pull-request", + GitPullRequestClosed => "git-pull-request-closed", + GitPullRequestDraft => "git-pull-request-draft", Globe => "globe", GripHorizontal => "grip-horizontal", GripVertical => "grip-vertical", diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index f1eb851..1ddb455 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -358,24 +358,24 @@ ## T19 Git — `src/git` -- [ ] DiffViewer — split, unified -- [ ] InlineDiff → `editor::InlineDiff` -- [ ] ThreeWayMerge -- [ ] ConflictResolver -- [ ] ChangesList -- [ ] CommitInput -- [ ] CommitGraph -- [ ] CommitList / CommitItem -- [ ] BranchSelector -- [ ] BranchList -- [ ] TagList -- [ ] StashList -- [ ] BlameView / GitBlameAnnotation -- [ ] FileHistory -- [ ] PullRequestCard -- [ ] ReviewComment -- [ ] DiffStat -- [ ] GitStatusBadge +- [x] DiffViewer — split, unified +- [x] InlineDiff → `editor::CodeEditor::set_diff` +- [x] ThreeWayMerge +- [x] ConflictResolver +- [x] ChangesList +- [x] CommitInput +- [x] CommitGraph → `git::CommitList::graph` +- [x] CommitList / CommitItem +- [x] BranchSelector +- [x] BranchList +- [x] TagList +- [x] StashList +- [x] BlameView / GitBlameAnnotation +- [x] FileHistory +- [x] PullRequestCard +- [x] ReviewComment +- [x] DiffStat +- [x] GitStatusBadge ## T20 Debug — `src/debug`