.
This commit is contained in:
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Option<PathBuf>> {
|
||||
.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 -- <rel>` 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<Option<FileCommit>> {
|
||||
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<Option<FileCommit>> {
|
||||
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")]);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,8 +234,29 @@ 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, FileContent>,
|
||||
/// Reads in flight, to avoid duplicate loads.
|
||||
loading_files: HashSet<String>,
|
||||
/// Latest commit touching a previewed file (or the README), keyed by path.
|
||||
commits: HashMap<String, FileCommit>,
|
||||
/// Commit queries in flight, to avoid duplicate loads.
|
||||
loading_commits: HashSet<String>,
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
@@ -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<Self>) {
|
||||
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 {
|
||||
|
||||
@@ -21,23 +21,23 @@ pub struct Workspace {
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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,
|
||||
|
||||
Reference in New Issue
Block a user