From 6f381c68c3dc6c56ccb1520543ec5553ece825bc Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 11 Aug 2026 13:30:09 +0700 Subject: [PATCH] . --- Cargo.lock | 3 + crates/signed_git/Cargo.toml | 2 +- crates/signed_git/src/lib.rs | 151 ++++++++++++++++++ crates/utils/src/lib.rs | 2 +- crates/utils/src/time.rs | 5 + .../src/views/repo_detail/browser.rs | 42 ++++- crates/workspace/src/views/repo_detail/mod.rs | 44 +++++ crates/workspace/src/workspace.rs | 21 ++- 8 files changed, 261 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c06477..ca5895e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3068,13 +3068,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" dependencies = [ + "bitflags 2.13.1", "bstr", "gix-commitgraph", "gix-date", "gix-error", "gix-hash", + "gix-hashtable", "gix-object", "gix-revwalk", + "gix-trace", "nonempty", ] diff --git a/crates/signed_git/Cargo.toml b/crates/signed_git/Cargo.toml index a853dc1..35cbaa0 100644 --- a/crates/signed_git/Cargo.toml +++ b/crates/signed_git/Cargo.toml @@ -8,7 +8,7 @@ publish.workspace = true signed_core = { path = "../signed_core" } nostr.workspace = true -gix.workspace = true +gix = { workspace = true, features = ["revision"] } anyhow.workspace = true [dev-dependencies] diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 84b5300..f4b8bcd 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -140,6 +140,19 @@ fn sanitize_path_component(id: &str) -> String { sanitized } +/// Metadata of a commit, as shown in the repository browser's file header. +#[derive(Debug, Clone)] +pub struct FileCommit { + /// Shortened commit id (7+ hex chars, disambiguated if needed). + pub id: String, + /// First line of the commit message. + pub summary: String, + /// Author name. + pub author: String, + /// Author time, seconds since the Unix epoch. + pub time: i64, +} + /// Relative paths of all entries in the worktree (files and directories), /// directories first, then alphabetically within each group. The `.git` /// directory is skipped. @@ -209,6 +222,57 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { .and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf))) } +/// Find the most recent commit that changed `rel` (a path relative to the +/// worktree), like `git log -1 -- ` does for non-merge commits. +/// +/// Walks history from `HEAD` newest-first and returns the first commit whose +/// tree entry for `rel` differs from its first parent's; a merge that only +/// changed the file through its second parent is therefore not reported. +/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files). +pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result> { + use gix::traverse::commit::simple::CommitTimeOrder; + + let head = repo.head_id()?; + let walk = repo + .rev_walk([head]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + CommitTimeOrder::NewestFirst, + )); + + for info in walk.all()? { + let info = info?; + let commit = info.object()?; + let blob = commit.tree()?.lookup_entry_by_path(rel)?; + let parent_blob = match info.parent_ids().next() { + Some(parent) => parent + .object()? + .into_commit() + .tree()? + .lookup_entry_by_path(rel)?, + None => None, + }; + + if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) { + let author = commit.author()?; + let message = commit.message()?; + return Ok(Some(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(None) +} + +/// Like [`last_commit`], but opens the repository located at `workdir` +/// (for non-bare clones the clone root is the worktree) first. +pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result> { + last_commit(&gix::open(workdir)?, rel) +} + fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; @@ -330,6 +394,93 @@ mod tests { ); } + /// Stage everything and create a commit with the git CLI (like + /// [`apply_patch`], the crate already shells out to the CLI). + fn commit_all(repo: &gix::Repository, message: &str) { + let dir = repo.workdir().expect("workdir"); + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["add", "-A"]); + run(&["commit", "-m", message]); + } + + #[test] + fn last_commit_returns_most_recent_change() { + 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, "change a"); + + // A commit touching another file must not be reported for a.txt. + std::fs::write(dir.path().join("b.txt"), b"other").expect("write"); + commit_all(&repo, "add b"); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!(commit.summary, "change a"); + assert_eq!(commit.author, "Test Author"); + assert!(!commit.id.is_empty()); + assert!(commit.time > 0); + } + + #[test] + fn last_commit_returns_none_for_untracked_files() { + let (dir, repo) = fixture(&[("a.txt", b"one")]); + commit_all(&repo, "initial"); + std::fs::write(dir.path().join("untracked.txt"), b"x").expect("write"); + + let commit = last_commit(&repo, Path::new("untracked.txt")).expect("lookup"); + assert!(commit.is_none()); + } + + #[test] + fn last_commit_reports_merge_commits() { + let (dir, repo) = fixture(&[("a.txt", b"base")]); + commit_all(&repo, "initial"); + + let run = |args: &[&str]| { + let status = Command::new("git") + .current_dir(dir.path()) + .env("GIT_AUTHOR_NAME", "Test Author") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test Author") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .env("GIT_EDITOR", "true") + .args(args) + .status() + .expect("spawn git"); + assert!(status.success(), "git {args:?} failed"); + }; + run(&["checkout", "-b", "feature"]); + std::fs::write(dir.path().join("a.txt"), b"feature").expect("write"); + commit_all(&repo, "feature change"); + run(&["checkout", "-"]); + // --no-ff forces a merge commit; it is the latest commit changing a.txt. + run(&["merge", "--no-ff", "--no-edit", "feature"]); + + let commit = last_commit(&repo, Path::new("a.txt")) + .expect("lookup") + .expect("found"); + assert_eq!( + commit.id, + repo.head_id().expect("head").shorten_or_id().to_string() + ); + assert!(commit.summary.starts_with("Merge branch")); + } + #[test] fn find_readme_prefers_markdown() { let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 5ce1f4c..0fe56c3 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -2,4 +2,4 @@ mod pubkey; mod time; pub use pubkey::shorten_pubkey; -pub use time::relative_time; +pub use time::{relative_time, relative_time_secs}; diff --git a/crates/utils/src/time.rs b/crates/utils/src/time.rs index 3c350d9..b48310c 100644 --- a/crates/utils/src/time.rs +++ b/crates/utils/src/time.rs @@ -20,6 +20,11 @@ pub fn relative_time(timestamp: Timestamp) -> String { } } +/// Format a unix timestamp in seconds as a short relative time (e.g. "3h ago"). +pub fn relative_time_secs(secs: i64) -> String { + relative_time(Timestamp::from_secs(secs.max(0) as u64)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 1513b39..f40e981 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -4,6 +4,7 @@ use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; @@ -196,6 +197,23 @@ impl RepoDetailView { placeholder("No README found", cx) }; + // Latest commit for the current pane: the selected file, or the README + // while nothing is selected. Computed after the body above, which + // needs `&mut self`. + let commit = self + .selected_file + .as_ref() + .and_then(|path| self.commits.get(path.as_ref())) + .or_else(|| { + if self.selected_file.is_none() { + self.readme_name + .as_ref() + .and_then(|name| self.commits.get(name.as_ref())) + } else { + None + } + }); + v_flex() .flex_1() .min_w_0() @@ -204,6 +222,7 @@ impl RepoDetailView { h_flex() .px_3() .h_9() + .gap_2() .bg(cx.theme().muted) .border_b(px(1.)) .border_color(cx.theme().border) @@ -215,7 +234,28 @@ impl RepoDetailView { .text_ellipsis() .whitespace_nowrap() .child(pane_title), - ), + ) + .when_some(commit, |this, commit| { + this.child( + h_flex() + .flex_1() + .gap_1() + .child( + Button::new("commit") + .xsmall() + .text() + .label(commit.id.clone()), + ) + .child( + div() + .max_w(px(250.)) + .text_xs() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + }), ) .child(div().id("repo-content").flex_1().min_h_0().child(body)) } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index a3de9a3..4e13d56 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -14,6 +14,7 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::tree::TreeState; use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex}; use signed_core::Announcement; +use signed_git::FileCommit; use signed_state::{GitStore, RepoStore}; mod browser; @@ -43,6 +44,10 @@ pub struct RepoDetailView { files: HashMap, /// Reads in flight, to avoid duplicate loads. loading_files: HashSet, + /// Latest commit touching a previewed file (or the README), keyed by path. + commits: HashMap, + /// Commit queries in flight, to avoid duplicate loads. + loading_commits: HashSet, /// A clone/fetch is in flight. loading: bool, error: Option, @@ -71,6 +76,8 @@ impl RepoDetailView { selected_file: None, files: HashMap::new(), loading_files: HashSet::new(), + commits: HashMap::new(), + loading_commits: HashSet::new(), loading: true, error: None, focus_handle: cx.focus_handle(), @@ -113,6 +120,7 @@ impl RepoDetailView { }); 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); if let Ok(text) = String::from_utf8(bytes) { this.set_markdown(None, &text, cx); } @@ -165,6 +173,7 @@ impl RepoDetailView { self.loading_files.insert(path.to_string()); let path = path.to_string(); + self.load_commit(&path, cx); let task = cx.spawn(async move |this, cx| { let path_for_read = path.clone(); @@ -222,6 +231,41 @@ impl RepoDetailView { self.tasks.push(task); } + + /// Query the latest commit touching `path` on a background task and cache + /// it in [`Self::commits`], for the file header in the content column. + fn load_commit(&mut self, path: &str, cx: &mut Context) { + if self.commits.contains_key(path) || self.loading_commits.contains(path) { + return; + } + let Some(worktree) = self.worktree.clone() else { + return; + }; + + self.loading_commits.insert(path.to_string()); + let path = path.to_string(); + + let task = cx.spawn(async move |this, cx| { + let path_for_query = path.clone(); + let result = cx + .background_spawn(async move { + signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query)) + }) + .await; + + this.update(cx, |this, cx| { + this.loading_commits.remove(&path); + if let Ok(Some(commit)) = result { + this.commits.insert(path, commit); + } + cx.notify(); + })?; + + Ok(()) + }); + + self.tasks.push(task); + } } impl Panel for RepoDetailView { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 66bfe71..7ea4d16 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -21,23 +21,23 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let dock = - cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(PanelStyle::TabBar)); + let style = PanelStyle::TabBar; + let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx).panel_style(style)); let weak_dock = dock.downgrade(); + let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx)); + let weak_sidebar = sidebar.downgrade(); dock.update(cx, |dock_area, cx| { dock_area.set_left_dock( - DockItem::panel(Arc::new(sidebar.clone())), - Some(px(260.)), + DockItem::panel(Arc::new(sidebar)), + Some(px(240.)), true, window, cx, ); }); - sidebar.update(cx, |sidebar, cx| sidebar.open_explore(window, cx)); - let backend = Backend::global(cx); let connected = backend.read(cx).is_connected(); let sync_progress = backend.read(cx).sync_progress(); @@ -86,6 +86,15 @@ impl Workspace { passphrase_dialog::open(window, cx); } + // Open the explore panel after the sidebar has been initialized. + cx.defer_in(window, move |_, window, cx| { + weak_sidebar + .update(cx, |this, cx| { + this.open_explore(window, cx); + }) + .ok(); + }); + Self { dock, status,