Files
Ely-GPUI-Components/src/git/review.rs
T
ZacharyZhang-NY 75656358cf T19 round 1 fixes
- merges keep each line's ending and the last newline; resolve keeps every other byte
- the split view numbers each side from its own file
- hunk headers come from similar's UnifiedHunkHeader: an empty side starts before its range
- the commit subject counter sits in the input's suffix
- clipped code rows in diffs, merges, blame and review set their line height
- gallery: scripted split view and a long subject
2026-09-26 02:19:11 -04:00

401 lines
14 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, Entity, FontWeight, Hsla, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
};
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, LEADING, 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()
.line_height(relative(LEADING))
.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))
}),
),
)
}
}