From 5f38c083311aa3416956e80b8a12664c2fe68c92 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 12 Aug 2026 08:29:00 +0700 Subject: [PATCH] add commit browse --- crates/signed_git/src/lib.rs | 64 +++++ .../src/views/repo_detail/commits.rs | 128 +++++++++ crates/workspace/src/views/repo_detail/mod.rs | 269 ++++++++++++------ 3 files changed, 376 insertions(+), 85 deletions(-) create mode 100644 crates/workspace/src/views/repo_detail/commits.rs diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index f4b8bcd..d9e0435 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -273,6 +273,42 @@ pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let Some(head) = repo.head_id().ok() else { + return Ok(Vec::new()); + }; + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + let mut commits = Vec::new(); + for info in walk.all()? { + let info = info?; + let commit = info.object()?; + let author = commit.author()?; + let message = commit.message()?; + commits.push(FileCommit { + id: commit.id().shorten_or_id().to_string(), + summary: String::from_utf8_lossy(message.title).trim().to_string(), + author: String::from_utf8_lossy(author.name).trim().to_string(), + time: author.time()?.seconds, + }); + } + Ok(commits) +} + +/// Like [`all_commits`], but opens the repository located at `workdir` +/// (for non-bare clones the clone root is the worktree) first. +pub fn worktree_all_commits(workdir: &Path) -> Result> { + all_commits(&gix::open(workdir)?) +} + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; @@ -436,6 +472,34 @@ mod tests { assert!(commit.time > 0); } + #[test] + fn all_commits_lists_every_commit() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + + std::fs::write(dir.path().join("a.txt"), b"two").expect("write"); + commit_all(&repo, "second"); + std::fs::write(dir.path().join("b.txt"), b"b").expect("write"); + commit_all(&repo, "third"); + + let commits = all_commits(&repo).expect("commits"); + let mut summaries: Vec<&str> = commits.iter().map(|c| c.summary.as_str()).collect(); + summaries.sort(); + assert_eq!(summaries, vec!["initial", "second", "third"]); + assert!( + commits + .iter() + .all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0) + ); + } + + #[test] + fn all_commits_returns_empty_without_head() { + let (_dir, repo) = fixture(&[("a.txt", b"one")]); + + assert!(all_commits(&repo).expect("commits").is_empty()); + } + #[test] fn last_commit_returns_none_for_untracked_files() { let (dir, repo) = fixture(&[("a.txt", b"one")]); diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs new file mode 100644 index 0000000..89d83d3 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -0,0 +1,128 @@ +//! Commits tab of the repository detail view: a virtual list of all +//! commits reachable from HEAD, newest first, with the total count shown +//! as a badge on the tab. + +use gpui::prelude::*; +use gpui::{AnyElement, App, Context, div, px}; +use gpui_component::scroll::Scrollbar; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; +use signed_git::FileCommit; +use utils::relative_time_secs; + +use super::RepoDetailView; +use super::helpers::placeholder; + +/// Height of one commit row in the virtual list. +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 { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .items_center() + .border_b(px(1.)) + .border_color(cx.theme().border) + .hover(|this| this.bg(cx.theme().list_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_0p5() + .justify_center() + .child( + h_flex() + .gap_2() + .items_center() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .into_any_element() +} + +impl RepoDetailView { + /// Full-height body of the Commits tab: all commits in a virtual + /// list, or a status message while loading / when there are none. + pub(super) fn render_commits_tab(&mut self, cx: &mut Context) -> AnyElement { + let Some(commits) = self.all_commits.clone() else { + return if self.loading_all_commits { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() + } else { + placeholder("Failed to load commits", cx) + }; + }; + + if commits.is_empty() { + return placeholder("No commits found", cx); + } + + let view = cx.entity().clone(); + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + + v_flex() + .relative() + .flex_1() + .w_full() + .min_h_0() + .child( + v_virtual_list( + view, + "repo-commits", + sizes, + move |_this, range, _window, cx| { + let mut rows = Vec::with_capacity(range.len()); + for ix in range { + rows.push(commit_row(ix, &commits[ix], cx)); + } + rows + }, + ) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&self.scroll_handle)), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 4e13d56..59a6e61 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,26 +1,33 @@ use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; +use std::rc::Rc; use anyhow::Error; use assets::CustomIconName; use gpui::prelude::*; use gpui::{ - App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, - SharedString, Task, Window, div, + App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Task, Window, div, px, size, }; use gpui_component::button::{Button, ButtonVariants, DropdownButton}; use gpui_component::dock::{Panel, PanelEvent}; use gpui_component::menu::PopupMenuItem; +use gpui_component::tab::{Tab, TabBar}; +use gpui_component::tag::Tag; use gpui_component::tree::TreeState; -use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, +}; use signed_core::Announcement; use signed_git::FileCommit; use signed_state::{GitStore, RepoStore}; mod browser; +mod commits; mod helpers; use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use commits::COMMIT_ROW_HEIGHT; use helpers::{build_tree_items, is_markdown_path}; /// Detail view of a repository: header, stats, a file explorer with README @@ -48,6 +55,16 @@ pub struct RepoDetailView { commits: HashMap, /// Commit queries in flight, to avoid duplicate loads. loading_commits: HashSet, + /// Active header tab: 0 = Files (tree), 1 = Commits. + active_tab: usize, + /// All commits reachable from HEAD, newest first; `None` until the + /// walk finishes (or fails). + all_commits: Option>, + /// Commit walk in flight. + loading_all_commits: bool, + /// Virtual list state of the Commits tab. + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, /// A clone/fetch is in flight. loading: bool, error: Option, @@ -78,6 +95,11 @@ impl RepoDetailView { loading_files: HashSet::new(), commits: HashMap::new(), loading_commits: HashSet::new(), + active_tab: 0, + all_commits: None, + loading_all_commits: false, + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(Vec::new()), loading: true, error: None, focus_handle: cx.focus_handle(), @@ -118,6 +140,7 @@ impl RepoDetailView { this.tree_state.update(cx, |state, cx| { state.set_items(build_tree_items(&entries), cx); }); + this.load_all_commits(cx); if let Some((path, bytes)) = readme_path.zip(readme) { this.readme_name = Some(path.to_string_lossy().into()); this.load_commit(&path.to_string_lossy(), cx); @@ -266,6 +289,40 @@ impl RepoDetailView { self.tasks.push(task); } + + /// Walk all commits reachable from HEAD on a background task, for the + /// Commits tab and its total-count badge. + fn load_all_commits(&mut self, cx: &mut Context) { + if self.loading_all_commits || self.all_commits.is_some() { + return; + } + + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_all_commits = true; + + let task = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { signed_git::worktree_all_commits(&worktree) }) + .await; + + this.update(cx, |this, cx| { + if let Ok(commits) = result { + let count = commits.len(); + this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]); + this.all_commits = Some(commits); + } + this.loading_all_commits = false; + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } } impl Panel for RepoDetailView { @@ -326,111 +383,153 @@ impl Render for RepoDetailView { let relays = announcement.relays.clone(); let web = announcement.web.clone(); + let commits_count = self.all_commits.as_ref().map(Vec::len); v_flex() .id("repo") .size_full() .child( - h_flex() + v_flex() .px_4() .pt_2() - .pb_4() + .pb_2() .w_full() - .gap_2() - .items_start() - .justify_between() + .gap_4() .border_b_1() .border_color(cx.theme().border) .child( - v_flex() - .flex_1() - .min_w_0() - .child(div().font_semibold().child(name)) + h_flex() + .w_full() + .gap_2() + .items_start() + .justify_between() .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .line_clamp(3) - .child(description), + v_flex() + .flex_1() + .min_w_0() + .child(div().font_semibold().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .line_clamp(3) + .text_ellipsis() + .child(description), + ), + ) + .child( + h_flex() + .flex_none() + .gap_2() + .justify_end() + .child( + DropdownButton::new("relays") + .button( + Button::new("relay-trigger") + .label(format!("{} relays", relays.len())) + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if relays.is_empty() { + return menu.item( + PopupMenuItem::new("No relays") + .disabled(true), + ); + } + for relay in relays.iter() { + let url = relay.to_string(); + menu = menu.item( + PopupMenuItem::new(url.clone()).on_click( + move |_, _, cx| { + cx.write_to_clipboard( + ClipboardItem::new_string( + url.clone(), + ), + ); + }, + ), + ); + } + menu + }), + ) + .child( + DropdownButton::new("web") + .button( + Button::new("web-trigger") + .label("Websites") + .ghost(), + ) + .dropdown_menu(move |menu, _window, _cx| { + let mut menu = menu; + if web.is_empty() { + return menu.item( + PopupMenuItem::new("No web").disabled(true), + ); + } + for url in web.iter() { + let href = url.to_string(); + menu = menu.item( + PopupMenuItem::new(href.clone()).on_click( + move |_, _, cx| { + cx.open_url(&href); + }, + ), + ); + } + menu + }), + ) + .child( + Button::new("link") + .icon(IconName::ExternalLink) + .tooltip("Open in gitworkshop.dev") + .secondary(), + ) + .child( + Button::new("clone") + .icon(CustomIconName::GitClone) + .tooltip("Clone") + .primary(), + ), ), ) .child( h_flex() - .flex_none() - .gap_2() - .justify_end() .child( - DropdownButton::new("relays") - .button( - Button::new("relay-trigger") - .label(format!("{} relays", relays.len())) - .ghost(), - ) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if relays.is_empty() { - return menu.item( - PopupMenuItem::new("No relays").disabled(true), - ); - } - for relay in relays.iter() { - let url = relay.to_string(); - menu = menu.item( - PopupMenuItem::new(url.clone()).on_click( - move |_, _, cx| { - cx.write_to_clipboard( - ClipboardItem::new_string(url.clone()), - ); - }, - ), - ); - } - menu - }), + TabBar::new("repo-tabs") + .segmented() + .selected_index(self.active_tab) + .child(Tab::new().label("Files")) + .child(Tab::new().label("Commits").when_some( + commits_count, + |this, count| { + this.suffix( + Tag::secondary() + .xsmall() + .mr_1() + .child(SharedString::from(count.to_string())), + ) + }, + )) + .on_click(cx.listener(|this, index, _window, cx| { + this.active_tab = *index; + cx.notify(); + })), ) - .child( - DropdownButton::new("web") - .button(Button::new("web-trigger").label("Websites").ghost()) - .dropdown_menu(move |menu, _window, _cx| { - let mut menu = menu; - if web.is_empty() { - return menu - .item(PopupMenuItem::new("No web").disabled(true)); - } - for url in web.iter() { - let href = url.to_string(); - menu = menu.item( - PopupMenuItem::new(href.clone()).on_click( - move |_, _, cx| { - cx.open_url(&href); - }, - ), - ); - } - menu - }), - ) - .child( - Button::new("link") - .icon(IconName::ExternalLink) - .tooltip("Open in gitworkshop.dev") - .secondary(), - ) - .child( - Button::new("clone") - .icon(CustomIconName::GitClone) - .tooltip("Clone") - .primary(), - ), + .child(div().flex_1()), ), ) - .child( - h_flex() + .child(match self.active_tab { + 0 => h_flex() .flex_1() .w_full() .overflow_hidden() .child(self.render_tree_column(tree_state, view, cx)) - .child(self.render_content_column(pane_title, cx)), - ) + .child(self.render_content_column(pane_title, cx)) + .into_any_element(), + _ => self.render_commits_tab(cx), + }) } }