add commit browse
This commit is contained in:
@@ -273,6 +273,42 @@ pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result<Option<FileCom
|
|||||||
last_commit(&gix::open(workdir)?, rel)
|
last_commit(&gix::open(workdir)?, rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||||
|
/// Returns `Ok(vec![])` for a repository without any commits yet.
|
||||||
|
pub fn all_commits(repo: &gix::Repository) -> Result<Vec<FileCommit>> {
|
||||||
|
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<Vec<FileCommit>> {
|
||||||
|
all_commits(&gix::open(workdir)?)
|
||||||
|
}
|
||||||
|
|
||||||
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||||
for entry in std::fs::read_dir(dir)? {
|
for entry in std::fs::read_dir(dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
@@ -436,6 +472,34 @@ mod tests {
|
|||||||
assert!(commit.time > 0);
|
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]
|
#[test]
|
||||||
fn last_commit_returns_none_for_untracked_files() {
|
fn last_commit_returns_none_for_untracked_files() {
|
||||||
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
let (dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
|||||||
@@ -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<Self>) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render,
|
App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
SharedString, Task, Window, div,
|
SharedString, Size, Task, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
|
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
|
||||||
use gpui_component::dock::{Panel, PanelEvent};
|
use gpui_component::dock::{Panel, PanelEvent};
|
||||||
use gpui_component::menu::PopupMenuItem;
|
use gpui_component::menu::PopupMenuItem;
|
||||||
|
use gpui_component::tab::{Tab, TabBar};
|
||||||
|
use gpui_component::tag::Tag;
|
||||||
use gpui_component::tree::TreeState;
|
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_core::Announcement;
|
||||||
use signed_git::FileCommit;
|
use signed_git::FileCommit;
|
||||||
use signed_state::{GitStore, RepoStore};
|
use signed_state::{GitStore, RepoStore};
|
||||||
|
|
||||||
mod browser;
|
mod browser;
|
||||||
|
mod commits;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView};
|
use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView};
|
||||||
|
use commits::COMMIT_ROW_HEIGHT;
|
||||||
use helpers::{build_tree_items, is_markdown_path};
|
use helpers::{build_tree_items, is_markdown_path};
|
||||||
|
|
||||||
/// Detail view of a repository: header, stats, a file explorer with README
|
/// Detail view of a repository: header, stats, a file explorer with README
|
||||||
@@ -48,6 +55,16 @@ pub struct RepoDetailView {
|
|||||||
commits: HashMap<String, FileCommit>,
|
commits: HashMap<String, FileCommit>,
|
||||||
/// Commit queries in flight, to avoid duplicate loads.
|
/// Commit queries in flight, to avoid duplicate loads.
|
||||||
loading_commits: HashSet<String>,
|
loading_commits: HashSet<String>,
|
||||||
|
/// 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<Vec<FileCommit>>,
|
||||||
|
/// Commit walk in flight.
|
||||||
|
loading_all_commits: bool,
|
||||||
|
/// Virtual list state of the Commits tab.
|
||||||
|
scroll_handle: VirtualListScrollHandle,
|
||||||
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
/// A clone/fetch is in flight.
|
/// A clone/fetch is in flight.
|
||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
@@ -78,6 +95,11 @@ impl RepoDetailView {
|
|||||||
loading_files: HashSet::new(),
|
loading_files: HashSet::new(),
|
||||||
commits: HashMap::new(),
|
commits: HashMap::new(),
|
||||||
loading_commits: HashSet::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,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
@@ -118,6 +140,7 @@ impl RepoDetailView {
|
|||||||
this.tree_state.update(cx, |state, cx| {
|
this.tree_state.update(cx, |state, cx| {
|
||||||
state.set_items(build_tree_items(&entries), cx);
|
state.set_items(build_tree_items(&entries), cx);
|
||||||
});
|
});
|
||||||
|
this.load_all_commits(cx);
|
||||||
if let Some((path, bytes)) = readme_path.zip(readme) {
|
if let Some((path, bytes)) = readme_path.zip(readme) {
|
||||||
this.readme_name = Some(path.to_string_lossy().into());
|
this.readme_name = Some(path.to_string_lossy().into());
|
||||||
this.load_commit(&path.to_string_lossy(), cx);
|
this.load_commit(&path.to_string_lossy(), cx);
|
||||||
@@ -266,6 +289,40 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
self.tasks.push(task);
|
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<Self>) {
|
||||||
|
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 {
|
impl Panel for RepoDetailView {
|
||||||
@@ -326,21 +383,26 @@ impl Render for RepoDetailView {
|
|||||||
|
|
||||||
let relays = announcement.relays.clone();
|
let relays = announcement.relays.clone();
|
||||||
let web = announcement.web.clone();
|
let web = announcement.web.clone();
|
||||||
|
let commits_count = self.all_commits.as_ref().map(Vec::len);
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.id("repo")
|
.id("repo")
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
v_flex()
|
||||||
.px_4()
|
.px_4()
|
||||||
.pt_2()
|
.pt_2()
|
||||||
.pb_4()
|
.pb_2()
|
||||||
|
.w_full()
|
||||||
|
.gap_4()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
.w_full()
|
.w_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.items_start()
|
.items_start()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.border_b_1()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -351,6 +413,7 @@ impl Render for RepoDetailView {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(cx.theme().muted_foreground)
|
.text_color(cx.theme().muted_foreground)
|
||||||
.line_clamp(3)
|
.line_clamp(3)
|
||||||
|
.text_ellipsis()
|
||||||
.child(description),
|
.child(description),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -370,7 +433,8 @@ impl Render for RepoDetailView {
|
|||||||
let mut menu = menu;
|
let mut menu = menu;
|
||||||
if relays.is_empty() {
|
if relays.is_empty() {
|
||||||
return menu.item(
|
return menu.item(
|
||||||
PopupMenuItem::new("No relays").disabled(true),
|
PopupMenuItem::new("No relays")
|
||||||
|
.disabled(true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for relay in relays.iter() {
|
for relay in relays.iter() {
|
||||||
@@ -379,7 +443,9 @@ impl Render for RepoDetailView {
|
|||||||
PopupMenuItem::new(url.clone()).on_click(
|
PopupMenuItem::new(url.clone()).on_click(
|
||||||
move |_, _, cx| {
|
move |_, _, cx| {
|
||||||
cx.write_to_clipboard(
|
cx.write_to_clipboard(
|
||||||
ClipboardItem::new_string(url.clone()),
|
ClipboardItem::new_string(
|
||||||
|
url.clone(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -390,12 +456,17 @@ impl Render for RepoDetailView {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
DropdownButton::new("web")
|
DropdownButton::new("web")
|
||||||
.button(Button::new("web-trigger").label("Websites").ghost())
|
.button(
|
||||||
|
Button::new("web-trigger")
|
||||||
|
.label("Websites")
|
||||||
|
.ghost(),
|
||||||
|
)
|
||||||
.dropdown_menu(move |menu, _window, _cx| {
|
.dropdown_menu(move |menu, _window, _cx| {
|
||||||
let mut menu = menu;
|
let mut menu = menu;
|
||||||
if web.is_empty() {
|
if web.is_empty() {
|
||||||
return menu
|
return menu.item(
|
||||||
.item(PopupMenuItem::new("No web").disabled(true));
|
PopupMenuItem::new("No web").disabled(true),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for url in web.iter() {
|
for url in web.iter() {
|
||||||
let href = url.to_string();
|
let href = url.to_string();
|
||||||
@@ -426,11 +497,39 @@ impl Render for RepoDetailView {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.child(
|
||||||
|
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(div().flex_1()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(match self.active_tab {
|
||||||
|
0 => h_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.w_full()
|
.w_full()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(self.render_tree_column(tree_state, view, cx))
|
.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),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user