T19 Git: diffs unified and split with word marks and folds, three-way merge and conflict resolver, changes list and commit input, commit graph and list, branch selector, branch, tag and stash lists, blame with annotation, file history, pull request card, review thread, diff stat and status badge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de1c03746e
commit
d083e87493
31 files changed
+4166
-21
No files matched your search
@@ -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
|
||||
|
||||
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 |
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-git-pull-request-closed"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15.5 3.5 5 5" />
|
||||
<path d="m15.5 8.5 5-5" />
|
||||
<path d="M18 11.62V15" />
|
||||
<path d="M6 9v12" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-git-pull-request-draft"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M18 6V5" />
|
||||
<path d="M18 11v-1" />
|
||||
<line x1="6" x2="6" y1="9" y2="21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 457 B |
@@ -0,0 +1,147 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_gpui_component::{
|
||||
git::{ConflictResolver, DiffLayout, DiffViewer, Take, ThreeWayMerge, regions},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::{keep, section, set};
|
||||
|
||||
const OLD: &str = r#"use std::collections::HashMap;
|
||||
|
||||
/// A palette of named colors.
|
||||
pub struct Palette {
|
||||
colors: HashMap<String, Color>,
|
||||
fallback: Color,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
pub fn new(fallback: Color) -> Self {
|
||||
Self { colors: HashMap::new(), fallback }
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, name: &str, color: Color) {
|
||||
self.colors.insert(name.to_string(), color);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.colors.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.colors.is_empty()
|
||||
}
|
||||
|
||||
/// The color for `name`.
|
||||
pub fn lookup(&self, name: &str) -> Color {
|
||||
self.colors[name]
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const NEW: &str = r#"use std::collections::BTreeMap;
|
||||
|
||||
/// A palette of named colors, in a steady order.
|
||||
pub struct Palette {
|
||||
colors: BTreeMap<String, Color>,
|
||||
fallback: Color,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
pub fn new(fallback: Color) -> Self {
|
||||
Self { colors: BTreeMap::new(), fallback }
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, name: &str, color: Color) {
|
||||
self.colors.insert(name.to_string(), color);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.colors.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.colors.is_empty()
|
||||
}
|
||||
|
||||
/// The color for `name`, blended toward white by `lift`.
|
||||
pub fn lookup(&self, name: &str, lift: f32) -> Color {
|
||||
let base = self.colors.get(name).copied().unwrap_or(self.fallback);
|
||||
base.lift(lift)
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
pub fn viewer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let layout = keep("git-layout", || DiffLayout::Unified, window, cx);
|
||||
let open = keep("git-open", Vec::<usize>::new, window, cx);
|
||||
let (now_layout, now_open) = (*layout.read(cx), open.read(cx).clone());
|
||||
let (flip, unfold) = (layout.clone(), open.clone());
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"DiffViewer / InlineDiff",
|
||||
"One file's changes, unified or side by side: each change in its context with old and new numbers, the words that changed washed, the stretches between folded until pressed. InlineDiff inside an editor is CodeEditor::set_diff.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(840.))
|
||||
.h(px(360.))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
DiffViewer::new("git-diff", "src/palette.rs", OLD, NEW)
|
||||
.layout(now_layout)
|
||||
.open(now_open)
|
||||
.on_layout(move |next, _, cx| set(&flip, next, cx))
|
||||
.on_open(move |stretch, _, cx| {
|
||||
let mut next = unfold.read(cx).clone();
|
||||
next.push(stretch);
|
||||
set(&unfold, next, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const BASE: &str =
|
||||
"fn lift(color: Color) -> Color {\n color.mix(WHITE, 0.5)\n}\n\nfn keep() {}\n";
|
||||
const OURS: &str =
|
||||
"fn lift(color: Color) -> Color {\n color.mix(WHITE, 0.6)\n}\n\nfn keep() {}\n";
|
||||
const THEIRS: &str = "fn lift(color: Color) -> Color {\n color.mix(WHITE, LIFT)\n}\n\nfn keep() { log::info!(\"kept\") }\n";
|
||||
|
||||
const MARKED: &str = "fn lift(color: Color) -> Color {\n<<<<<<< HEAD\n color.mix(WHITE, 0.6)\n=======\n color.mix(WHITE, LIFT)\n>>>>>>> feature/lift\n}\n\nfn keep() { log::info!(\"kept\") }\n";
|
||||
|
||||
pub fn merges(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let takes = keep("git-takes", Vec::<Option<Take>>::new, window, cx);
|
||||
let marked = keep("git-marked", || SharedString::from(MARKED), window, cx);
|
||||
let (now_takes, now_marked) = (takes.read(cx).clone(), marked.read(cx).clone());
|
||||
let (take, settle) = (takes.clone(), marked.clone());
|
||||
section(
|
||||
"ThreeWayMerge / ConflictResolver",
|
||||
"Ours, the base and theirs side by side: clean changes merge themselves, each conflict waits for a side, and the result shows what is still open. In a file with markers, each conflict offers the current change, the incoming one, or both.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
ThreeWayMerge::new("git-merge", ["main", "base", "feature/lift"], Rc::new(regions(BASE, OURS, THEIRS)))
|
||||
.takes(now_takes)
|
||||
.on_take(move |conflict, side, _, cx| {
|
||||
let mut next = take.read(cx).clone();
|
||||
if next.len() <= conflict {
|
||||
next.resize(conflict + 1, None);
|
||||
}
|
||||
next[conflict] = Some(side);
|
||||
set(&take, next, cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
ConflictResolver::new("git-conflicts", now_marked)
|
||||
.on_resolve(move |text, _, cx| set(&settle, SharedString::from(text), cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use ely_gpui_component::{
|
||||
forms::TextInput,
|
||||
git::{
|
||||
Blame, BlameView, DiffStat, FileHistory, GitStatusBadge, PullRequest, PullRequestCard,
|
||||
PullState, ReviewComment, ReviewNote,
|
||||
},
|
||||
lists::GitStatus,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::work::history;
|
||||
use crate::ui::{keep, row, section, set};
|
||||
|
||||
const CODE: &str = "impl Palette {\n pub fn lookup(&self, name: &str, lift: f32) -> Color {\n let base = self.colors.get(name).copied().unwrap_or(self.fallback);\n base.lift(lift)\n }\n\n pub fn len(&self) -> usize {\n self.colors.len()\n }\n}";
|
||||
|
||||
pub fn blame(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let said = keep("git-blame-said", || None::<SharedString>, window, cx);
|
||||
let told = said.read(cx).clone();
|
||||
let blame = |commit: usize, author: &str, when: &str, subject: &str, age: f32| Blame {
|
||||
commit: history()[commit].id.clone(),
|
||||
author: author.to_string().into(),
|
||||
when: when.to_string().into(),
|
||||
subject: subject.to_string().into(),
|
||||
age,
|
||||
};
|
||||
let blames = [
|
||||
blame(5, "Sam Ortiz", "3 days ago", "First palette", 1.0),
|
||||
blame(2, "Mei Lin", "3 h ago", "Blend toward white by lift", 0.2),
|
||||
blame(
|
||||
1,
|
||||
"Ada Park",
|
||||
"1 h ago",
|
||||
"Keep palette order with a BTreeMap",
|
||||
0.1,
|
||||
),
|
||||
];
|
||||
let history_rows: Vec<_> = history()
|
||||
.into_iter()
|
||||
.zip([(12, 4), (3, 3), (9, 1), (18, 0), (2, 1), (40, 0)])
|
||||
.collect();
|
||||
let (pick, select) = (said.clone(), said.clone());
|
||||
section(
|
||||
"BlameView / GitBlameAnnotation / FileHistory / DiffStat / GitStatusBadge",
|
||||
"Who last changed each run of lines, the bar fading with age, the current line annotated; a file's commits on a line through them with what each changed; sizes as counts and five dots; statuses as letters.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
BlameView::new("git-blame", CODE, [0, 1, 1, 1, 0, 0, 2, 2, 0, 0], blames)
|
||||
.current(2)
|
||||
.on_commit(move |commit, _, cx| set(&pick, Some(format!("Picked {}.", &commit[..7]).into()), cx)),
|
||||
),
|
||||
)
|
||||
.children(told.map(Caption::new))
|
||||
.child(
|
||||
div().w(px(520.)).child(
|
||||
FileHistory::new("git-file-history", history_rows)
|
||||
.selected(history()[1].id.clone())
|
||||
.on_pick(move |commit, _, cx| set(&select, Some(format!("Opened {}.", &commit[..7]).into()), cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(DiffStat::new(120, 45))
|
||||
.child(DiffStat::new(3, 0))
|
||||
.child(DiffStat::new(0, 31))
|
||||
.children(
|
||||
[GitStatus::Modified, GitStatus::Added, GitStatus::Deleted, GitStatus::Untracked, GitStatus::Renamed, GitStatus::Conflicted]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, status)| GitStatusBadge::new(("git-status", ix), status)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn review(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let reply = window.use_keyed_state("git-reply", cx, |window, cx| {
|
||||
TextInput::new(window, cx).placeholder("Reply")
|
||||
});
|
||||
let resolved = keep("git-resolved", || false, window, cx);
|
||||
let notes = keep(
|
||||
"git-notes",
|
||||
|| {
|
||||
vec![
|
||||
ReviewNote {
|
||||
author: "Ada Park".into(),
|
||||
when: "2 h ago".into(),
|
||||
body: "Should a lift past one clamp, or panic? The docs say clamp.".into(),
|
||||
},
|
||||
ReviewNote {
|
||||
author: "Mei Lin".into(),
|
||||
when: "1 h ago".into(),
|
||||
body: "Clamp. I added a test for 1.5 and for -0.2.".into(),
|
||||
},
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let said = keep("git-pr-said", || None::<SharedString>, window, cx);
|
||||
let (now_resolved, now_notes, told) = (
|
||||
*resolved.read(cx),
|
||||
notes.read(cx).clone(),
|
||||
said.read(cx).clone(),
|
||||
);
|
||||
let (resolve, add, field, open, open_draft) = (
|
||||
resolved.clone(),
|
||||
notes.clone(),
|
||||
reply.clone(),
|
||||
said.clone(),
|
||||
said.clone(),
|
||||
);
|
||||
let pull = |number, title: &str, state, checks, added, removed, when: &str| PullRequest {
|
||||
number,
|
||||
title: title.to_string().into(),
|
||||
author: "Mei Lin".into(),
|
||||
head: "feature/lift".into(),
|
||||
base: "main".into(),
|
||||
state,
|
||||
checks,
|
||||
reviewers: vec!["Ada Park".into(), "Sam Ortiz".into()],
|
||||
comments: 4,
|
||||
added,
|
||||
removed,
|
||||
when: when.to_string().into(),
|
||||
};
|
||||
section(
|
||||
"PullRequestCard / ReviewComment",
|
||||
"A pull request at a glance: state, branches, checks, reviewers, comments and size. A review thread on a line: each comment, a reply, and resolve.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(840.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
PullRequestCard::new("git-pr-open", pull(128, "Blend palette colors toward white by lift", PullState::Open, (11, 1, 0), 120, 45, "2 h ago"))
|
||||
.on_open(move |_, cx| set(&open, Some("Opened #128.".into()), cx)),
|
||||
)
|
||||
.child(
|
||||
PullRequestCard::new("git-pr-draft", pull(131, "Try a gamma-aware lift", PullState::Draft, (3, 0, 2), 36, 8, "20 min ago"))
|
||||
.on_open(move |_, cx| set(&open_draft, Some("Opened #131.".into()), cx)),
|
||||
),
|
||||
)
|
||||
.children(told.map(Caption::new))
|
||||
.child(
|
||||
div().w(px(640.)).child(
|
||||
ReviewComment::new(
|
||||
"git-review",
|
||||
"src/palette.rs",
|
||||
48,
|
||||
"pub fn lookup(&self, name: &str, lift: f32) -> Color {",
|
||||
now_notes,
|
||||
&reply,
|
||||
)
|
||||
.resolved(now_resolved)
|
||||
.on_resolve(move |on, _, cx| set(&resolve, on, cx))
|
||||
.on_reply(move |_, cx| {
|
||||
let body = field.read(cx).text().trim().to_string();
|
||||
if body.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut next = add.read(cx).clone();
|
||||
next.push(ReviewNote { author: "You".into(), when: "just now".into(), body: body.into() });
|
||||
set(&add, next, cx);
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::Button,
|
||||
forms::TextInput,
|
||||
git::{
|
||||
Branch, BranchList, BranchSelector, ChangeAction, Changed, ChangesList, Commit,
|
||||
CommitInput, CommitList, GitTag, Stash, StashAction, StashList, TagList,
|
||||
},
|
||||
lists::GitStatus,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::ui::{keep, row, section, set};
|
||||
|
||||
fn changed(path: &str, status: GitStatus, added: usize, removed: usize) -> Changed {
|
||||
Changed {
|
||||
path: path.to_string().into(),
|
||||
status,
|
||||
added,
|
||||
removed,
|
||||
}
|
||||
}
|
||||
|
||||
fn field(
|
||||
key: &'static str,
|
||||
placeholder: &'static str,
|
||||
lines: Option<(usize, usize)>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
let field = TextInput::new(window, cx).placeholder(placeholder);
|
||||
match lines {
|
||||
Some((min, max)) => field.multi_line(min, max),
|
||||
None => field,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let staged = keep(
|
||||
"git-staged",
|
||||
|| vec![changed("src/palette.rs", GitStatus::Modified, 12, 4)],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let unstaged = keep(
|
||||
"git-unstaged",
|
||||
|| {
|
||||
vec![
|
||||
changed("src/theme/tokens.rs", GitStatus::Modified, 3, 1),
|
||||
changed("src/lift.rs", GitStatus::Added, 48, 0),
|
||||
changed("docs/old-palette.md", GitStatus::Deleted, 0, 31),
|
||||
changed("tests/palette.rs", GitStatus::Untracked, 22, 0),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let amend = keep("git-amend", || false, window, cx);
|
||||
let said = keep("git-said", || None::<SharedString>, window, cx);
|
||||
let subject = field("git-subject", "Summary of the change", None, window, cx);
|
||||
let body = field("git-body", "What changed and why", Some((3, 6)), window, cx);
|
||||
let (now_staged, now_unstaged, now_amend, told) = (
|
||||
staged.read(cx).clone(),
|
||||
unstaged.read(cx).clone(),
|
||||
*amend.read(cx),
|
||||
said.read(cx).clone(),
|
||||
);
|
||||
let (act_staged, act_unstaged, act_said) = (staged.clone(), unstaged.clone(), said.clone());
|
||||
let (all_staged, all_unstaged) = (staged.clone(), unstaged.clone());
|
||||
let (flip, done, commit_staged) = (amend.clone(), said.clone(), staged.clone());
|
||||
let count = now_staged.len();
|
||||
section(
|
||||
"ChangesList / CommitInput",
|
||||
"The work tree's changes, the staged apart, each with its status and size; hover a file to open, discard, stage or unstage it. The subject counts toward fifty; Cmd-Enter commits.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_8()
|
||||
.child(
|
||||
div().w(px(420.)).child(
|
||||
ChangesList::new("git-changes", now_staged, now_unstaged)
|
||||
.on_action(move |path, action, _, cx| {
|
||||
let (from, to) = match action {
|
||||
ChangeAction::Stage => (&act_unstaged, &act_staged),
|
||||
ChangeAction::Unstage => (&act_staged, &act_unstaged),
|
||||
ChangeAction::Discard => {
|
||||
let left: Vec<Changed> = act_unstaged.read(cx).iter().filter(|file| file.path != *path).cloned().collect();
|
||||
set(&act_unstaged, left, cx);
|
||||
return;
|
||||
}
|
||||
ChangeAction::Open => {
|
||||
set(&act_said, Some(format!("Opened {path}.").into()), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let moving: Vec<Changed> = from.read(cx).iter().filter(|file| file.path == *path).cloned().collect();
|
||||
let left: Vec<Changed> = from.read(cx).iter().filter(|file| file.path != *path).cloned().collect();
|
||||
let mut grown = to.read(cx).clone();
|
||||
grown.extend(moving);
|
||||
set(from, left, cx);
|
||||
set(to, grown, cx);
|
||||
})
|
||||
.on_all(move |stage, _, cx| {
|
||||
let (from, to) = if stage { (&all_unstaged, &all_staged) } else { (&all_staged, &all_unstaged) };
|
||||
let mut grown = to.read(cx).clone();
|
||||
grown.extend(from.read(cx).iter().cloned());
|
||||
set(to, grown, cx);
|
||||
set(from, Vec::new(), cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div().w(px(380.)).child(
|
||||
CommitInput::new("git-commit", &subject, &body, "main", count)
|
||||
.amend(now_amend)
|
||||
.on_amend(move |on, _, cx| set(&flip, on, cx))
|
||||
.on_commit(move |_, cx| {
|
||||
let files = commit_staged.read(cx).len();
|
||||
set(&commit_staged, Vec::new(), cx);
|
||||
set(&done, Some(format!("Committed {files} file{}.", if files == 1 { "" } else { "s" }).into()), cx);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.children(told.map(Caption::new))
|
||||
}
|
||||
|
||||
fn commit(
|
||||
id: &str,
|
||||
parents: &[&str],
|
||||
subject: &str,
|
||||
author: &str,
|
||||
when: &str,
|
||||
refs: &[&str],
|
||||
) -> Commit {
|
||||
Commit {
|
||||
id: format!("{id}{}", "0".repeat(40 - id.len())).into(),
|
||||
parents: parents
|
||||
.iter()
|
||||
.map(|parent| format!("{parent}{}", "0".repeat(40 - parent.len())).into())
|
||||
.collect(),
|
||||
subject: subject.to_string().into(),
|
||||
author: author.to_string().into(),
|
||||
when: when.to_string().into(),
|
||||
refs: refs
|
||||
.iter()
|
||||
.map(|name| SharedString::from(name.to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A short history with a branch merged back.
|
||||
pub(super) fn history() -> Vec<Commit> {
|
||||
vec![
|
||||
commit(
|
||||
"f4c1",
|
||||
&["e9a2", "d3b7"],
|
||||
"Merge branch 'feature/lift'",
|
||||
"Mei Lin",
|
||||
"just now",
|
||||
&["main", "origin/main"],
|
||||
),
|
||||
commit(
|
||||
"e9a2",
|
||||
&["c8d0"],
|
||||
"Keep palette order with a BTreeMap",
|
||||
"Ada Park",
|
||||
"1 h ago",
|
||||
&[],
|
||||
),
|
||||
commit(
|
||||
"d3b7",
|
||||
&["b21e"],
|
||||
"Blend toward white by lift",
|
||||
"Mei Lin",
|
||||
"3 h ago",
|
||||
&["feature/lift"],
|
||||
),
|
||||
commit(
|
||||
"b21e",
|
||||
&["c8d0"],
|
||||
"Add Color::lift",
|
||||
"Mei Lin",
|
||||
"5 h ago",
|
||||
&[],
|
||||
),
|
||||
commit(
|
||||
"c8d0",
|
||||
&["a6f3"],
|
||||
"Count tints in the palette",
|
||||
"Ada Park",
|
||||
"yesterday",
|
||||
&["v0.2.0"],
|
||||
),
|
||||
commit(
|
||||
"a6f3",
|
||||
&[],
|
||||
"First palette",
|
||||
"Sam Ortiz",
|
||||
"3 days ago",
|
||||
&["v0.1.0"],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn commits(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let picked = keep("git-picked", || history()[0].id.clone(), window, cx);
|
||||
let now = picked.read(cx).clone();
|
||||
let pick = picked.clone();
|
||||
section(
|
||||
"CommitGraph / CommitList / CommitItem",
|
||||
"Commits newest first beside the lanes they take: a branch forks off, merges join, each lane in its own color; refs sit on their commits.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div().w(px(840.)).child(
|
||||
CommitList::new("git-graph", history())
|
||||
.graph()
|
||||
.selected(now)
|
||||
.on_pick(move |id, _, cx| set(&pick, id.clone(), cx)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn branch(
|
||||
name: &str,
|
||||
remote: bool,
|
||||
current: bool,
|
||||
ahead: usize,
|
||||
behind: usize,
|
||||
subject: &str,
|
||||
when: &str,
|
||||
) -> Branch {
|
||||
Branch {
|
||||
name: name.to_string().into(),
|
||||
remote,
|
||||
current,
|
||||
ahead,
|
||||
behind,
|
||||
subject: subject.to_string().into(),
|
||||
when: when.to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn branches() -> Vec<Branch> {
|
||||
vec![
|
||||
branch(
|
||||
"main",
|
||||
false,
|
||||
true,
|
||||
2,
|
||||
0,
|
||||
"Merge branch 'feature/lift'",
|
||||
"just now",
|
||||
),
|
||||
branch(
|
||||
"feature/lift",
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
"Blend toward white by lift",
|
||||
"3 h ago",
|
||||
),
|
||||
branch(
|
||||
"fix/order",
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
3,
|
||||
"Sort names before hashing",
|
||||
"2 days ago",
|
||||
),
|
||||
branch(
|
||||
"origin/main",
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
"Keep palette order with a BTreeMap",
|
||||
"1 h ago",
|
||||
),
|
||||
branch(
|
||||
"origin/release",
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
"Count tints in the palette",
|
||||
"yesterday",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn refs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = keep("git-branch-open", || false, window, cx);
|
||||
let said = keep("git-branch-said", || None::<SharedString>, window, cx);
|
||||
let stashes = keep(
|
||||
"git-stashes",
|
||||
|| {
|
||||
vec![
|
||||
Stash {
|
||||
message: "WIP on lift: try gamma".into(),
|
||||
branch: "feature/lift".into(),
|
||||
when: "2 h ago".into(),
|
||||
},
|
||||
Stash {
|
||||
message: "half a theme toggle".into(),
|
||||
branch: "main".into(),
|
||||
when: "yesterday".into(),
|
||||
},
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (now_open, told, now_stashes) = (
|
||||
*open.read(cx),
|
||||
said.read(cx).clone(),
|
||||
stashes.read(cx).clone(),
|
||||
);
|
||||
let (opener, close, pick, create, switch, delete, stash_said, drop) = (
|
||||
open.clone(),
|
||||
open.clone(),
|
||||
said.clone(),
|
||||
said.clone(),
|
||||
said.clone(),
|
||||
said.clone(),
|
||||
said.clone(),
|
||||
stashes.clone(),
|
||||
);
|
||||
let tags = [
|
||||
GitTag {
|
||||
name: "v0.2.0".into(),
|
||||
message: Some("Tints and counts".into()),
|
||||
commit: history()[4].id.clone(),
|
||||
when: "yesterday".into(),
|
||||
},
|
||||
GitTag {
|
||||
name: "v0.1.0".into(),
|
||||
message: None,
|
||||
commit: history()[5].id.clone(),
|
||||
when: "3 days ago".into(),
|
||||
},
|
||||
];
|
||||
section(
|
||||
"BranchSelector / BranchList / TagList / StashList",
|
||||
"Branches as a palette, local and remote apart, a new name offering a new branch; the lists show how far each branch is from its upstream, tags with their messages, and stashes to apply, pop or drop.",
|
||||
cx,
|
||||
)
|
||||
.child(row().child(Button::new("git-branch-button", "Switch branch…").on_click(move |_, _, cx| set(&opener, true, cx))))
|
||||
.children(told.map(Caption::new))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_8()
|
||||
.child(
|
||||
div().w(px(420.)).child(
|
||||
BranchList::new("git-branches", branches().into_iter().filter(|branch| !branch.remote))
|
||||
.on_switch(move |name, _, cx| set(&switch, Some(format!("Switched to {name}.").into()), cx))
|
||||
.on_delete(move |name, _, cx| set(&delete, Some(format!("Deleted {name}.").into()), cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(380.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(TagList::new("git-tags", tags))
|
||||
.child(StashList::new("git-stash-list", now_stashes).on_action(move |ix, action, _, cx| {
|
||||
let mut left = drop.read(cx).clone();
|
||||
let stash = left.remove(ix);
|
||||
if action != StashAction::Apply {
|
||||
set(&drop, left, cx);
|
||||
}
|
||||
set(&stash_said, Some(format!("{action:?}: {}.", stash.message).into()), cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.children(now_open.then(|| {
|
||||
BranchSelector::new("git-branch-palette", branches(), move |_, cx| set(&close, false, cx))
|
||||
.on_pick(move |name, _, cx| set(&pick, Some(format!("Switched to {name}.").into()), cx))
|
||||
.on_create(move |name, _, cx| set(&create, Some(format!("Created {name}.").into()), cx))
|
||||
}))
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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};
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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<ElementId>, 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<usize> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<usize>)> {
|
||||
let mut out: Vec<(usize, Range<usize>)> = 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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<usize>,
|
||||
blames: Vec<Blame>,
|
||||
current: Option<usize>,
|
||||
on_commit: Option<OnCommit>,
|
||||
}
|
||||
|
||||
impl BlameView {
|
||||
/// `owners` holds, for each line of `code`, its entry in `blames`.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
code: impl Into<SharedString>,
|
||||
owners: impl IntoIterator<Item = usize>,
|
||||
blames: impl IntoIterator<Item = Blame>,
|
||||
) -> Self {
|
||||
let code: SharedString = code.into();
|
||||
let owners: Vec<usize> = owners.into_iter().collect();
|
||||
let blames: Vec<Blame> = 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<AnyElement> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(&SharedString, ChangeAction, &mut Window, &mut App)>;
|
||||
type OnAll = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<Changed>,
|
||||
unstaged: Vec<Changed>,
|
||||
on_action: Option<OnAction>,
|
||||
on_all: Option<OnAll>,
|
||||
}
|
||||
|
||||
impl ChangesList {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
staged: impl IntoIterator<Item = Changed>,
|
||||
unstaged: impl IntoIterator<Item = Changed>,
|
||||
) -> 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<AnyElement> {
|
||||
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<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// 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<TextInput>,
|
||||
body: Entity<TextInput>,
|
||||
branch: SharedString,
|
||||
staged: usize,
|
||||
amend: bool,
|
||||
on_amend: Option<OnAll>,
|
||||
on_commit: Option<Run>,
|
||||
}
|
||||
|
||||
impl CommitInput {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
subject: &Entity<TextInput>,
|
||||
body: &Entity<TextInput>,
|
||||
branch: impl Into<SharedString>,
|
||||
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))
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<SharedString>,
|
||||
pub subject: SharedString,
|
||||
pub author: SharedString,
|
||||
pub when: SharedString,
|
||||
pub refs: Vec<SharedString>,
|
||||
}
|
||||
|
||||
type OnPick = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<OnPick>,
|
||||
}
|
||||
|
||||
impl CommitItem {
|
||||
pub fn new(id: impl Into<ElementId>, 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<Commit>,
|
||||
selected: Option<SharedString>,
|
||||
on_pick: Option<OnPick>,
|
||||
graph: bool,
|
||||
}
|
||||
|
||||
impl CommitList {
|
||||
pub fn new(id: impl Into<ElementId>, commits: impl IntoIterator<Item = Commit>) -> 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<SharedString>) -> 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<Pixels>, _, 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<Option<GraphRow>> = if self.graph {
|
||||
let parents: Vec<Vec<&str>> = 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)
|
||||
}))
|
||||
}
|
||||
}
|
||||
+217
@@ -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<usize>,
|
||||
pub new: Option<usize>,
|
||||
pub text: SharedString,
|
||||
pub words: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
/// 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<DiffLine>,
|
||||
},
|
||||
Folded(Vec<DiffLine>),
|
||||
}
|
||||
|
||||
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<Stretch> {
|
||||
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<LineKind> = 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)]);
|
||||
}
|
||||
}
|
||||
@@ -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<Stroke>,
|
||||
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<Item = (&'a str, &'a [&'a str])>) -> Vec<GraphRow> {
|
||||
let mut waiting: Vec<Option<&str>> = 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<Stroke> = 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<usize> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
on_pick: Option<OnPick>,
|
||||
}
|
||||
|
||||
impl FileHistory {
|
||||
/// Each commit with the lines it added and removed in the file.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
commits: impl IntoIterator<Item = (Commit, (usize, usize))>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
commits: commits.into_iter().collect(),
|
||||
selected: None,
|
||||
on_pick: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(mut self, id: impl Into<SharedString>) -> 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)),
|
||||
)
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(usize, Take, &mut Window, &mut App)>;
|
||||
type OnText = Rc<dyn Fn(String, &mut Window, &mut App)>;
|
||||
|
||||
/// A column of code lines, padded to `rows` so neighbors line up.
|
||||
fn column(lines: &[String], rows: usize, wash: Option<Hsla>) -> 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<OnTake>,
|
||||
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<Vec<Region>>,
|
||||
takes: Vec<Option<Take>>,
|
||||
on_take: Option<OnTake>,
|
||||
}
|
||||
|
||||
impl ThreeWayMerge {
|
||||
/// `labels` name ours, the base and theirs.
|
||||
pub fn new(id: impl Into<ElementId>, labels: [&str; 3], regions: Rc<Vec<Region>>) -> 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<Item = Option<Take>>) -> 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<Hsla>; 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<AnyElement> = 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<OnText>,
|
||||
}
|
||||
|
||||
impl ConflictResolver {
|
||||
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> 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<OnTake> = 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<AnyElement> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub ours: Vec<String>,
|
||||
pub theirs: Vec<String>,
|
||||
}
|
||||
|
||||
fn lines(text: &str, range: std::ops::Range<usize>) -> Vec<String> {
|
||||
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<Region> {
|
||||
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<Take>]) -> (String, usize) {
|
||||
let mut out: Vec<String> = 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<Conflict> {
|
||||
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<RegionKind> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
+381
@@ -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<String> {
|
||||
let parts: Vec<String> = [(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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
pub(super) type Run = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// 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<Branch>,
|
||||
on_pick: Option<OnName>,
|
||||
on_create: Option<OnName>,
|
||||
on_close: Run,
|
||||
}
|
||||
|
||||
impl BranchSelector {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
branches: impl IntoIterator<Item = Branch>,
|
||||
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<SharedString>,
|
||||
actions: Vec<AnyElement>,
|
||||
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<Run>,
|
||||
) -> 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<Branch>,
|
||||
on_switch: Option<OnName>,
|
||||
on_delete: Option<OnName>,
|
||||
}
|
||||
|
||||
impl BranchList {
|
||||
pub fn new(id: impl Into<ElementId>, branches: impl IntoIterator<Item = Branch>) -> 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<AnyElement> = 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<OnName>| {
|
||||
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<SharedString> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(&mut Window, &mut App)>;
|
||||
type OnBool = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
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<Run>,
|
||||
}
|
||||
|
||||
impl PullRequestCard {
|
||||
pub fn new(id: impl Into<ElementId>, 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::<Vec<_>>()
|
||||
.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<ReviewNote>,
|
||||
resolved: bool,
|
||||
reply: Entity<TextInput>,
|
||||
on_reply: Option<Run>,
|
||||
on_resolve: Option<OnBool>,
|
||||
}
|
||||
|
||||
impl ReviewComment {
|
||||
/// `line` counts from one; `code` is its text.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
path: impl Into<SharedString>,
|
||||
line: usize,
|
||||
code: impl Into<SharedString>,
|
||||
notes: impl IntoIterator<Item = ReviewNote>,
|
||||
reply: &Entity<TextInput>,
|
||||
) -> 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<AnyElement> = 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))
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+161
@@ -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<dyn Fn(usize, StashAction, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
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<GitTag>,
|
||||
}
|
||||
|
||||
impl TagList {
|
||||
pub fn new(id: impl Into<ElementId>, tags: impl IntoIterator<Item = GitTag>) -> 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<AnyElement> = 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<Stash>,
|
||||
on_action: Option<OnStash>,
|
||||
}
|
||||
|
||||
impl StashList {
|
||||
pub fn new(id: impl Into<ElementId>, stashes: impl IntoIterator<Item = Stash>) -> 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<AnyElement> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<DiffLine>, Option<DiffLine>),
|
||||
/// 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<Shown> {
|
||||
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<dyn Fn(usize, &mut Window, &mut App)>;
|
||||
type OnLayout = Rc<dyn Fn(DiffLayout, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<Vec<Stretch>>,
|
||||
stat: (usize, usize),
|
||||
layout: DiffLayout,
|
||||
open: Vec<usize>,
|
||||
on_open: Option<OnIndex>,
|
||||
on_layout: Option<OnLayout>,
|
||||
}
|
||||
|
||||
impl DiffViewer {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
path: impl Into<SharedString>,
|
||||
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<Item = usize>) -> 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>, 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<usize>, 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<usize>| {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+13
-2
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+18
-18
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user