add commit panel

This commit is contained in:
2026-08-14 08:49:08 +07:00
parent 9b1dd526a5
commit 080a026d3f
10 changed files with 1135 additions and 16 deletions
@@ -3,7 +3,7 @@
//! as a badge on the tab.
use gpui::prelude::*;
use gpui::{AnyElement, App, Context, div, px};
use gpui::{AnyElement, App, Context, WeakEntity, div, px};
use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
@@ -17,7 +17,16 @@ use super::helpers::placeholder;
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
/// One row of the commit list: id, summary, author and relative time.
fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement {
/// Clicking a row opens the diff of that commit in a new panel.
fn commit_row(
ix: usize,
commit: &FileCommit,
view: &WeakEntity<RepoDetailView>,
cx: &App,
) -> AnyElement {
let view = view.clone();
let commit = commit.clone();
h_flex()
.id(ix)
.px_4()
@@ -65,6 +74,11 @@ fn commit_row(ix: usize, commit: &FileCommit, cx: &App) -> AnyElement {
.child(relative_time_secs(commit.time)),
),
)
.on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_commit_diff(&commit, window, cx));
}
})
.into_any_element()
}
@@ -114,7 +128,10 @@ impl RepoDetailView {
.as_ref()
.map(|list| list.commits.as_slice())
.unwrap_or(&[]);
range.map(|ix| commit_row(ix, &commits[ix], cx)).collect()
let view = cx.entity().downgrade();
range
.map(|ix| commit_row(ix, &commits[ix], &view, cx))
.collect()
},
)
.track_scroll(&scroll_handle)
@@ -0,0 +1,544 @@
//! Commit diff viewer: a panel showing every file a commit changed, with a
//! tree of the changed files on the left and the line diff of the selected
//! file on the right. Opened from the repository detail view by clicking a
//! commit in the Commits tab or the latest-commit button in the header.
use std::path::PathBuf;
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
WeakEntity, Window, div, px,
};
use gpui_component::clipboard::Clipboard;
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner;
use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff};
use utils::relative_time_secs;
use super::helpers::{build_tree_items, placeholder, tree_items};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Width of one line-number gutter in a diff row.
const GUTTER_WIDTH: f32 = 44.;
/// Detail panel showing the diff of one commit.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown (header and tab title).
commit: FileCommit,
/// Loaded diff; `None` while loading or after a failure.
diff: Option<CommitDiff>,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// In-flight tasks; pruned on every push (see [`Self::track`]).
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
}
impl CommitDiffView {
pub fn new(
worktree: PathBuf,
repo_name: SharedString,
commit: FileCommit,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
// Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
focus_handle: cx.focus_handle(),
worktree,
repo_name,
commit,
diff: None,
loading: true,
error: None,
tree_state,
selected_file: None,
tasks: Vec::new(),
}
}
/// Load the commit diff on a background task and populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move { signed_git::worktree_commit_diff(&worktree, &id) })
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
match result {
Ok(diff) => {
let mut paths: Vec<PathBuf> = diff
.files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect();
paths.sort();
let items = tree_items(build_tree_items(&paths), true);
let first = diff
.files
.first()
.map(|file| SharedString::from(file.path.as_str()));
this.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx);
});
this.selected_file = first;
this.diff = Some(diff);
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.track(task);
}
/// Show the diff of the file at `path` (selected in the tree).
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
cx.notify();
}
/// Track `task` until it completes; finished tasks are pruned on every
/// push so the vec stays bounded by the number of in-flight loads.
fn track(&mut self, task: gpui::Task<Result<(), anyhow::Error>>) {
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
}
/// One row of the changed-files tree: icon + name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
selected: bool,
view: &WeakEntity<Self>,
) -> ListItem {
let item = entry.item();
let id = item.id.clone();
let is_folder = entry.is_folder();
let icon = if is_folder {
if entry.is_expanded() {
IconName::FolderOpen
} else {
IconName::FolderClosed
}
} else {
IconName::File
};
let view = view.clone();
ListItem::new(ix)
.pl(px(8.) + px(14.) * entry.depth() as f32)
.selected(selected)
.child(
h_flex()
.gap_2()
.overflow_hidden()
.child(Icon::new(icon).small())
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
)
.on_click(move |_event, _window, cx| {
// Folders expand/collapse via the tree itself.
if is_folder {
return;
}
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.select_file(&id, cx));
}
})
}
/// Left column: the changed-files tree.
fn render_tree_column(&mut self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.flex_none()
.border_r_1()
.border_color(cx.theme().border)
.child(
div()
.flex_1()
.min_h_0()
.when(self.diff.is_some(), |this| {
this.child(tree(
&tree_state,
move |ix, entry, selected, _window, _cx| {
Self::render_tree_item(ix, entry, selected, &view)
},
))
})
.when(self.diff.is_none() && !self.loading, |this| {
this.child(
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Failed to load diff"),
),
)
}),
)
.into_any_element()
}
/// Right column: header of the selected file plus its diff.
fn render_detail_column(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element();
}
if let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
let Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx);
};
let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() {
placeholder("No files changed in this commit", cx)
} else {
placeholder("Select a file", cx)
};
};
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
return placeholder("File not found", cx);
};
self.render_file_diff(file, cx)
}
/// The diff of one file: a header with status and stats, then the hunks.
fn render_file_diff(&self, file: &FileDiff, cx: &App) -> AnyElement {
let status_label = match file.status {
DiffStatus::Added => "A",
DiffStatus::Modified => "M",
DiffStatus::Deleted => "D",
DiffStatus::Renamed => "R",
DiffStatus::Copied => "C",
};
let status_color = match file.status {
DiffStatus::Added => cx.theme().success,
DiffStatus::Modified => cx.theme().info,
DiffStatus::Deleted => cx.theme().danger,
DiffStatus::Renamed | DiffStatus::Copied => cx.theme().muted_foreground,
};
let title = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let body: AnyElement = if file.binary {
placeholder("Binary file — diff not available", cx)
} else if file.hunks.is_empty() {
placeholder("No content changes", cx)
} else {
v_flex()
.w_full()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.children(file.hunks.iter().map(|hunk| Self::render_hunk(hunk, cx)))
.into_any_element()
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.items_center()
.child(
div()
.text_xs()
.font_semibold()
.text_color(status_color)
.child(status_label),
)
.child(
div()
.flex_1()
.min_w_0()
.text_xs()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.when(!file.binary, |this| {
this.child(
h_flex()
.gap_2()
.text_xs()
.child(
div()
.text_color(cx.theme().success)
.child(format!("+{}", file.insertions)),
)
.child(
div()
.text_color(cx.theme().danger)
.child(format!("-{}", file.deletions)),
),
)
}),
)
.child(
div()
.id("commit-diff-body")
.flex_1()
.min_h_0()
.overflow_scroll()
.child(body),
)
.into_any_element()
}
/// One hunk: the `@@ -a,b +c,d @@` header row followed by its lines.
fn render_hunk(hunk: &DiffHunk, cx: &App) -> AnyElement {
v_flex()
.w_full()
.child(
div()
.px_2()
.py_0p5()
.w_full()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines
))),
)
.children(
hunk.lines
.iter()
.map(|line| Self::render_diff_line(line, cx)),
)
.into_any_element()
}
/// One diff line: old and new line numbers in gutters, then the content,
/// tinted by kind (addition / deletion / context).
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
h_flex()
.w_full()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
/// Header: commit id, summary, author/time and overall change stats.
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit;
let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| {
(
diff.files.len(),
diff.files.iter().map(|file| file.insertions).sum(),
diff.files.iter().map(|file| file.deletions).sum(),
)
});
v_flex()
.px_4()
.py_2()
.w_full()
.gap_4()
.border_b_1()
.border_color(cx.theme().border)
.child(
v_flex()
.gap_2()
.child(
div()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
)
.child(
h_flex()
.gap_2p5()
.text_sm()
.child(h_flex().child(format!("{} committed", commit.author)))
.child(
h_flex()
.gap_0p5()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(&commit.id))
.child(Clipboard::new("commit").value(&commit.id)),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(relative_time_secs(commit.time)),
),
),
)
.when_some(commit.description.as_ref(), |this, description| {
this.child(div().text_sm().child(SharedString::from(description)))
})
.child(
h_flex()
.gap_2()
.text_xs()
.child(
Tag::primary()
.outline()
.small()
.child(format!("{files} files changed")),
)
.when(insertions > 0, |this| {
this.child(
Tag::success()
.outline()
.small()
.child(format!("+ {insertions}")),
)
})
.when(deletions > 0, |this| {
this.child(
Tag::success()
.outline()
.small()
.child(format!("- {insertions}")),
)
}),
)
.into_any_element()
}
}
/// Find a tree item by id, searching into nested children.
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
impl Panel for CommitDiffView {
fn panel_name(&self) -> &'static str {
"commit_diff"
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().text_sm().child(SharedString::from(format!(
"{}/{}",
self.repo_name, self.commit.id
)))
}
}
impl EventEmitter<PanelEvent> for CommitDiffView {}
impl Focusable for CommitDiffView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CommitDiffView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("commit-diff")
.size_full()
.child(self.render_header(cx))
.child(
h_flex()
.flex_1()
.w_full()
.min_h_0()
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx)),
)
}
}
@@ -28,6 +28,32 @@ impl From<TreeItemSeed> for TreeItem {
}
}
/// Convert tree seeds into [`TreeItem`]s, expanding every folder when
/// `expand_folders` is set.
///
/// The commit diff explorer shows only changed files, which is typically a
/// handful of paths, so its folders start expanded; the worktree explorer
/// starts collapsed instead.
pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<TreeItem> {
fn convert(seed: TreeItemSeed, expand_folders: bool) -> TreeItem {
let mut item = TreeItem::new(seed.id, seed.label);
if expand_folders && !seed.children.is_empty() {
item = item.expanded(true);
}
item.children = seed
.children
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect();
item
}
seeds
.into_iter()
.map(|seed| convert(seed, expand_folders))
.collect()
}
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
///
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
+53 -5
View File
@@ -1,17 +1,18 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use anyhow::Error;
use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{
AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
Render, SharedString, Size, Subscription, Task, Window, div, px, size,
Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size,
};
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
use gpui_component::combobox::{Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerCtx};
use gpui_component::dock::{Panel, PanelEvent};
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
use gpui_component::menu::PopupMenuItem;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::tab::{Tab, TabBar};
@@ -26,6 +27,7 @@ use signed_state::{GitStore, RepoStore};
mod browser;
mod commits;
mod diff;
mod helpers;
use browser::{
@@ -33,6 +35,7 @@ use browser::{
MarkdownView,
};
use commits::COMMIT_ROW_HEIGHT;
use diff::CommitDiffView;
use helpers::{build_tree_items, is_markdown_path};
/// What kind of ref the header selectors switch to.
@@ -47,6 +50,10 @@ enum RefKind {
/// Detail view of a repository: header, stats, a file explorer with README
/// preview (cloned from the announcement's `clone` URLs), and metadata.
pub struct RepoDetailView {
focus_handle: FocusHandle,
/// Dock area the detail view lives in; new panels (commit diffs) are
/// added there.
dock_area: WeakEntity<DockArea>,
/// Snapshot taken at open time, shown until the store's first refresh
/// completes (and as a fallback while the store has no announcement).
initial: Announcement,
@@ -107,7 +114,6 @@ pub struct RepoDetailView {
/// Bumped on every branch/tag switch; in-flight loads tagged with an
/// older generation are discarded when they complete.
ref_generation: u64,
focus_handle: FocusHandle,
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), Error>>>,
@@ -117,7 +123,12 @@ pub struct RepoDetailView {
}
impl RepoDetailView {
pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context<Self>) -> Self {
pub fn new(
dock_area: WeakEntity<DockArea>,
initial: Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
let tree_state = cx.new(|cx| TreeState::new(cx));
@@ -215,6 +226,7 @@ impl RepoDetailView {
Self {
initial,
dock_area,
announcement: None,
relays,
web,
@@ -568,6 +580,36 @@ impl RepoDetailView {
self.track(task);
}
/// Open a new panel showing the diff of `commit` (all files it changed,
/// with the line diff of each). Called from the Commits tab rows and the
/// latest-commit button in the header.
fn open_commit_diff(
&mut self,
commit: &FileCommit,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(worktree) = self.worktree.clone() else {
return;
};
// Same display name as the repo detail panel's title.
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
let repo_name = announcement
.name
.clone()
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
let panel =
cx.new(|cx| CommitDiffView::new(worktree, repo_name, commit.clone(), window, cx));
if let Some(dock_area) = self.dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
// The diff viewer lives in the bottom dock, leaving the
// central explorer open while browsing a commit.
dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx);
});
}
}
/// Check out `name` (a branch or tag picked in the header) and refresh
/// the explorer once the switch completes.
fn switch_ref(
@@ -1057,7 +1099,13 @@ impl Render for RepoDetailView {
.map_or_else(SharedString::default, |commit| {
commit.summary.clone().into()
}),
),
)
.on_click(cx.listener(|this, _event, window, cx| {
if let Some(commit) = &this.head_commit {
let commit = commit.clone();
this.open_commit_diff(&commit, window, cx);
}
})),
),
),
),
+3 -2
View File
@@ -63,7 +63,8 @@ impl RepoListView {
cx: &mut Context<Self>,
) {
let dock_area = self.dock_area.clone();
let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx));
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
@@ -171,7 +172,7 @@ impl Panel for RepoListView {
}
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
"Explore"
div().text_sm().child(SharedString::from("Explore"))
}
}
+2 -3
View File
@@ -4,7 +4,7 @@ use assets::CustomIconName;
use gpui::prelude::*;
use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dock::{DockArea, DockItem, PanelStyle};
use gpui_component::dock::{DockArea, DockItem};
use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex};
use signed_state::{Backend, BackendEvent};
@@ -21,8 +21,7 @@ pub struct Workspace {
impl Workspace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let style = PanelStyle::TabBar;
let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(style));
let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx));
let weak_dock = dock.downgrade();
let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));