.
This commit is contained in:
+154
-44
@@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! All functions may block; call them inside `cx.background_spawn`.
|
//! All functions may block; call them inside `cx.background_spawn`.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
@@ -140,6 +141,11 @@ fn sanitize_path_component(id: &str) -> String {
|
|||||||
sanitized
|
sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-memory object cache for history walks (see [`open_with_cache`]).
|
||||||
|
/// Without one, every walk re-decodes the same commit objects from the
|
||||||
|
/// object database.
|
||||||
|
const OBJECT_CACHE_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
|
||||||
/// Metadata of a commit, as shown in the repository browser's file header.
|
/// Metadata of a commit, as shown in the repository browser's file header.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FileCommit {
|
pub struct FileCommit {
|
||||||
@@ -222,6 +228,26 @@ pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
|||||||
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the repository at `workdir` with an in-memory object cache sized for
|
||||||
|
/// history walks.
|
||||||
|
fn open_with_cache(workdir: &Path) -> Result<gix::Repository> {
|
||||||
|
let mut repo = gix::open(workdir)?;
|
||||||
|
repo.object_cache_size_if_unset(OBJECT_CACHE_BYTES);
|
||||||
|
Ok(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`FileCommit`] from a walk commit: author, message title and shortened id.
|
||||||
|
fn file_commit(commit: &gix::Commit<'_>) -> Result<FileCommit> {
|
||||||
|
let author = commit.author()?;
|
||||||
|
let message = commit.message()?;
|
||||||
|
Ok(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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the most recent commit that changed `rel` (a path relative to the
|
/// Find the most recent commit that changed `rel` (a path relative to the
|
||||||
/// worktree), like `git log -1 -- <rel>` does for non-merge commits.
|
/// worktree), like `git log -1 -- <rel>` does for non-merge commits.
|
||||||
///
|
///
|
||||||
@@ -230,56 +256,110 @@ pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
|||||||
/// changed the file through its second parent is therefore not reported.
|
/// changed the file through its second parent is therefore not reported.
|
||||||
/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files).
|
/// 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>> {
|
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
|
||||||
|
let rel = rel.to_path_buf();
|
||||||
|
Ok(last_commits(repo, std::slice::from_ref(&rel))?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|(_, commit)| commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest commit touching each of `rels` (relative to the worktree), like
|
||||||
|
/// `git log -1 -- <rel>` per path, found in a single history walk: every
|
||||||
|
/// commit is decoded once and shared across all paths. Paths without any
|
||||||
|
/// commit (e.g. untracked files) are absent from the result.
|
||||||
|
pub fn worktree_last_commits(
|
||||||
|
workdir: &Path,
|
||||||
|
rels: &[PathBuf],
|
||||||
|
) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||||
|
last_commits(&open_with_cache(workdir)?, rels)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk behind [`last_commit`] and [`worktree_last_commits`], stopping as
|
||||||
|
/// soon as every pending path has its commit.
|
||||||
|
fn last_commits(repo: &gix::Repository, rels: &[PathBuf]) -> Result<Vec<(PathBuf, FileCommit)>> {
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
let head = repo.head_id()?;
|
let Some(head) = repo.head_id().ok() else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
// De-duplicate while preserving order.
|
||||||
|
let mut pending: Vec<PathBuf> = Vec::with_capacity(rels.len());
|
||||||
|
let mut seen: HashSet<&Path> = HashSet::with_capacity(rels.len());
|
||||||
|
for rel in rels {
|
||||||
|
if seen.insert(rel.as_path()) {
|
||||||
|
pending.push(rel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let walk = repo
|
let walk = repo
|
||||||
.rev_walk([head])
|
.rev_walk([head])
|
||||||
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
.sorting(gix::revision::walk::Sorting::ByCommitTime(
|
||||||
CommitTimeOrder::NewestFirst,
|
CommitTimeOrder::NewestFirst,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let mut found = Vec::new();
|
||||||
for info in walk.all()? {
|
for info in walk.all()? {
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let info = info?;
|
let info = info?;
|
||||||
let commit = info.object()?;
|
let commit = info.object()?;
|
||||||
let blob = commit.tree()?.lookup_entry_by_path(rel)?;
|
let tree = commit.tree()?;
|
||||||
let parent_blob = match info.parent_ids().next() {
|
let parent_tree = match info.parent_ids().next() {
|
||||||
Some(parent) => parent
|
Some(parent) => Some(parent.object()?.into_commit().tree()?),
|
||||||
.object()?
|
|
||||||
.into_commit()
|
|
||||||
.tree()?
|
|
||||||
.lookup_entry_by_path(rel)?,
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach()) {
|
// Compare each still-unresolved path against this commit and its
|
||||||
let author = commit.author()?;
|
// first parent; resolved paths leave the pending set.
|
||||||
let message = commit.message()?;
|
let mut ix = 0;
|
||||||
return Ok(Some(FileCommit {
|
while ix < pending.len() {
|
||||||
id: commit.id().shorten_or_id().to_string(),
|
let rel = &pending[ix];
|
||||||
summary: String::from_utf8_lossy(message.title).trim().to_string(),
|
let blob = tree.lookup_entry_by_path(rel)?;
|
||||||
author: String::from_utf8_lossy(author.name).trim().to_string(),
|
let parent_blob = match &parent_tree {
|
||||||
time: author.time()?.seconds,
|
Some(tree) => tree.lookup_entry_by_path(rel)?,
|
||||||
}));
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if blob.map(|entry| entry.id().detach()) != parent_blob.map(|entry| entry.id().detach())
|
||||||
|
{
|
||||||
|
found.push((rel.clone(), file_commit(&commit)?));
|
||||||
|
pending.swap_remove(ix);
|
||||||
|
} else {
|
||||||
|
ix += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [`last_commit`], but opens the repository located at `workdir`
|
/// Cap on [`CommitList::commits`]: the virtual list renders a window at a
|
||||||
/// (for non-bare clones the clone root is the worktree) first.
|
/// time and the tab badge shows the real count, so a huge history is never
|
||||||
pub fn worktree_last_commit(workdir: &Path, rel: &Path) -> Result<Option<FileCommit>> {
|
/// fully materialized in memory.
|
||||||
last_commit(&gix::open(workdir)?, rel)
|
pub const MAX_LISTED_COMMITS: usize = 20_000;
|
||||||
|
|
||||||
|
/// Commits reachable from `HEAD`, newest first, possibly capped: `commits`
|
||||||
|
/// holds at most [`MAX_LISTED_COMMITS`] entries and `total` is the real
|
||||||
|
/// count (for the tab badge).
|
||||||
|
pub struct CommitList {
|
||||||
|
/// Number of commits reachable from HEAD.
|
||||||
|
pub total: usize,
|
||||||
|
/// Newest commits, capped at [`MAX_LISTED_COMMITS`].
|
||||||
|
pub commits: Vec<FileCommit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
/// All commits reachable from `HEAD`, newest first, with author and summary.
|
||||||
/// Returns `Ok(vec![])` for a repository without any commits yet.
|
/// Returns an empty list for a repository without any commits yet.
|
||||||
pub fn all_commits(repo: &gix::Repository) -> Result<Vec<FileCommit>> {
|
pub fn all_commits(repo: &gix::Repository) -> Result<CommitList> {
|
||||||
use gix::traverse::commit::simple::CommitTimeOrder;
|
use gix::traverse::commit::simple::CommitTimeOrder;
|
||||||
|
|
||||||
let Some(head) = repo.head_id().ok() else {
|
let Some(head) = repo.head_id().ok() else {
|
||||||
return Ok(Vec::new());
|
return Ok(CommitList {
|
||||||
|
total: 0,
|
||||||
|
commits: Vec::new(),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
let walk = repo
|
let walk = repo
|
||||||
.rev_walk([head])
|
.rev_walk([head])
|
||||||
@@ -288,25 +368,21 @@ pub fn all_commits(repo: &gix::Repository) -> Result<Vec<FileCommit>> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let mut commits = Vec::new();
|
let mut commits = Vec::new();
|
||||||
|
let mut total = 0;
|
||||||
for info in walk.all()? {
|
for info in walk.all()? {
|
||||||
let info = info?;
|
let info = info?;
|
||||||
let commit = info.object()?;
|
total += 1;
|
||||||
let author = commit.author()?;
|
if commits.len() < MAX_LISTED_COMMITS {
|
||||||
let message = commit.message()?;
|
commits.push(file_commit(&info.object()?)?);
|
||||||
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)
|
}
|
||||||
|
Ok(CommitList { total, commits })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like [`all_commits`], but opens the repository located at `workdir`
|
/// Like [`all_commits`], but opens the repository located at `workdir`
|
||||||
/// (for non-bare clones the clone root is the worktree) first.
|
/// (for non-bare clones the clone root is the worktree) first.
|
||||||
pub fn worktree_all_commits(workdir: &Path) -> Result<Vec<FileCommit>> {
|
pub fn worktree_all_commits(workdir: &Path) -> Result<CommitList> {
|
||||||
all_commits(&gix::open(workdir)?)
|
all_commits(&open_with_cache(workdir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a
|
/// The commit HEAD points to, like `git log -1`. Returns `Ok(None)` for a
|
||||||
@@ -328,7 +404,7 @@ pub fn head_commit(repo: &gix::Repository) -> Result<Option<FileCommit>> {
|
|||||||
|
|
||||||
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
|
/// Short names of local branches (`refs/heads/*`), sorted alphabetically.
|
||||||
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
||||||
let repo = gix::open(workdir)?;
|
let repo = open_with_cache(workdir)?;
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
for reference in repo.references()?.local_branches()? {
|
for reference in repo.references()?.local_branches()? {
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
@@ -340,7 +416,7 @@ pub fn worktree_branches(workdir: &Path) -> Result<Vec<String>> {
|
|||||||
|
|
||||||
/// Short names of tags (`refs/tags/*`), sorted alphabetically.
|
/// Short names of tags (`refs/tags/*`), sorted alphabetically.
|
||||||
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
pub fn worktree_tags(workdir: &Path) -> Result<Vec<String>> {
|
||||||
let repo = gix::open(workdir)?;
|
let repo = open_with_cache(workdir)?;
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
for reference in repo.references()?.tags()? {
|
for reference in repo.references()?.tags()? {
|
||||||
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
@@ -377,7 +453,7 @@ pub struct WorktreeSnapshot {
|
|||||||
/// Snapshot the worktree after a branch/tag switch: entries, README, the
|
/// Snapshot the worktree after a branch/tag switch: entries, README, the
|
||||||
/// branch HEAD points to and its commit, opening the repository once.
|
/// branch HEAD points to and its commit, opening the repository once.
|
||||||
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
pub fn worktree_snapshot(workdir: &Path) -> Result<WorktreeSnapshot> {
|
||||||
let repo = gix::open(workdir)?;
|
let repo = open_with_cache(workdir)?;
|
||||||
let readme_path = find_readme(&repo)?;
|
let readme_path = find_readme(&repo)?;
|
||||||
let readme = match &readme_path {
|
let readme = match &readme_path {
|
||||||
Some(path) => worktree_read(&repo, path)?,
|
Some(path) => worktree_read(&repo, path)?,
|
||||||
@@ -448,6 +524,8 @@ fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> R
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use signed_core::repo_addr;
|
use signed_core::repo_addr;
|
||||||
|
|
||||||
@@ -601,12 +679,13 @@ mod tests {
|
|||||||
std::fs::write(dir.path().join("b.txt"), b"b").expect("write");
|
std::fs::write(dir.path().join("b.txt"), b"b").expect("write");
|
||||||
commit_all(&repo, "third");
|
commit_all(&repo, "third");
|
||||||
|
|
||||||
let commits = all_commits(&repo).expect("commits");
|
let list = all_commits(&repo).expect("commits");
|
||||||
let mut summaries: Vec<&str> = commits.iter().map(|c| c.summary.as_str()).collect();
|
assert_eq!(list.total, 3);
|
||||||
|
let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect();
|
||||||
summaries.sort();
|
summaries.sort();
|
||||||
assert_eq!(summaries, vec!["initial", "second", "third"]);
|
assert_eq!(summaries, vec!["initial", "second", "third"]);
|
||||||
assert!(
|
assert!(
|
||||||
commits
|
list.commits
|
||||||
.iter()
|
.iter()
|
||||||
.all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0)
|
.all(|c| c.author == "Test Author" && !c.id.is_empty() && c.time > 0)
|
||||||
);
|
);
|
||||||
@@ -616,7 +695,9 @@ mod tests {
|
|||||||
fn all_commits_returns_empty_without_head() {
|
fn all_commits_returns_empty_without_head() {
|
||||||
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
let (_dir, repo) = fixture(&[("a.txt", b"one")]);
|
||||||
|
|
||||||
assert!(all_commits(&repo).expect("commits").is_empty());
|
let list = all_commits(&repo).expect("commits");
|
||||||
|
assert!(list.commits.is_empty());
|
||||||
|
assert_eq!(list.total, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -664,6 +745,35 @@ mod tests {
|
|||||||
assert!(commit.summary.starts_with("Merge branch"));
|
assert!(commit.summary.starts_with("Merge branch"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_commits_batches_multiple_paths() {
|
||||||
|
let (dir, repo) = fixture(&[("a.txt", b"one"), ("b.txt", b"b")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
|
||||||
|
std::fs::write(dir.path().join("a.txt"), b"two").expect("write");
|
||||||
|
commit_all(&repo, "change a");
|
||||||
|
std::fs::write(dir.path().join("b.txt"), b"bb").expect("write");
|
||||||
|
commit_all(&repo, "change b");
|
||||||
|
|
||||||
|
let found = worktree_last_commits(
|
||||||
|
dir.path(),
|
||||||
|
&[
|
||||||
|
PathBuf::from("a.txt"),
|
||||||
|
PathBuf::from("b.txt"),
|
||||||
|
// Untracked paths are simply absent from the result.
|
||||||
|
PathBuf::from("missing.txt"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("commits");
|
||||||
|
let by_path: HashMap<&Path, &FileCommit> = found
|
||||||
|
.iter()
|
||||||
|
.map(|(path, commit)| (path.as_path(), commit))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(by_path.len(), 2);
|
||||||
|
assert_eq!(by_path[Path::new("a.txt")].summary, "change a");
|
||||||
|
assert_eq!(by_path[Path::new("b.txt")].summary, "change b");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_readme_prefers_markdown() {
|
fn find_readme_prefers_markdown() {
|
||||||
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
|
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ impl RepoStore {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.tasks.retain(|task| !task.is_ready());
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +202,7 @@ impl RepoStore {
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.tasks.retain(|task| !task.is_ready());
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let (announcement, state, issues, patches, pull_requests, statuses) = match work.await {
|
let (announcement, state, issues, patches, pull_requests, statuses) = match work.await {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ use super::helpers::{code_language, is_markdown_path, placeholder};
|
|||||||
const TREE_WIDTH: f32 = 240.;
|
const TREE_WIDTH: f32 = 240.;
|
||||||
/// Files larger than this are not previewed.
|
/// Files larger than this are not previewed.
|
||||||
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||||
|
/// Preview cache caps: at most this many files (or this many text bytes)
|
||||||
|
/// are kept in memory at once; the oldest previews are evicted beyond that.
|
||||||
|
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
|
||||||
|
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/// Preview state of a browsed file.
|
/// Preview state of a browsed file.
|
||||||
pub(super) enum FileContent {
|
pub(super) enum FileContent {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ impl RepoDetailView {
|
|||||||
/// Full-height body of the Commits tab: all commits in a virtual
|
/// Full-height body of the Commits tab: all commits in a virtual
|
||||||
/// list, or a status message while loading / when there are none.
|
/// list, or a status message while loading / when there are none.
|
||||||
pub(super) fn render_commits_tab(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
pub(super) fn render_commits_tab(&mut self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(commits) = self.all_commits.clone() else {
|
let Some(list) = self.all_commits.as_ref() else {
|
||||||
return if self.loading_all_commits {
|
return if self.loading_all_commits {
|
||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -85,13 +85,18 @@ impl RepoDetailView {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if commits.is_empty() {
|
if list.commits.is_empty() {
|
||||||
return placeholder("No commits found", cx);
|
return placeholder("No commits found", cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy only the values the element tree needs; the list itself is
|
||||||
|
// borrowed inside the renderer below instead of being cloned per
|
||||||
|
// frame (a full history can be tens of thousands of commits).
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
let sizes = self.item_sizes.clone();
|
let sizes = self.item_sizes.clone();
|
||||||
let scroll_handle = self.scroll_handle.clone();
|
let scroll_handle = self.scroll_handle.clone();
|
||||||
|
let shown = list.commits.len();
|
||||||
|
let total = list.total;
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.relative()
|
.relative()
|
||||||
@@ -103,17 +108,29 @@ impl RepoDetailView {
|
|||||||
view,
|
view,
|
||||||
"repo-commits",
|
"repo-commits",
|
||||||
sizes,
|
sizes,
|
||||||
move |_this, range, _window, cx| {
|
move |this, range, _window, cx| {
|
||||||
let mut rows = Vec::with_capacity(range.len());
|
let commits = this
|
||||||
for ix in range {
|
.all_commits
|
||||||
rows.push(commit_row(ix, &commits[ix], cx));
|
.as_ref()
|
||||||
}
|
.map(|list| list.commits.as_slice())
|
||||||
rows
|
.unwrap_or(&[]);
|
||||||
|
range.map(|ix| commit_row(ix, &commits[ix], cx)).collect()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.track_scroll(&scroll_handle)
|
.track_scroll(&scroll_handle)
|
||||||
.size_full(),
|
.size_full(),
|
||||||
)
|
)
|
||||||
|
.when(shown < total, |this| {
|
||||||
|
// The history is capped; tell the user the list is truncated.
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.py_2()
|
||||||
|
.w_full()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(format!("Showing {shown} of {total} commits")),
|
||||||
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Pure helpers for the repository detail view: file-tree building, code
|
//! Pure helpers for the repository detail view: file-tree building, code
|
||||||
//! preview helpers and small element builders.
|
//! preview helpers and small element builders.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
@@ -8,41 +9,73 @@ use gpui::{AnyElement, App, div};
|
|||||||
use gpui_component::tree::TreeItem;
|
use gpui_component::tree::TreeItem;
|
||||||
use gpui_component::{ActiveTheme, v_flex};
|
use gpui_component::{ActiveTheme, v_flex};
|
||||||
|
|
||||||
|
/// A `Send` file-tree node: the tree is built on a background thread and
|
||||||
|
/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot
|
||||||
|
/// cross threads) on the main thread.
|
||||||
|
pub(super) struct TreeItemSeed {
|
||||||
|
/// Path of the node, relative to the worktree root.
|
||||||
|
id: String,
|
||||||
|
/// File or directory name.
|
||||||
|
label: String,
|
||||||
|
children: Vec<TreeItemSeed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TreeItemSeed> for TreeItem {
|
||||||
|
fn from(seed: TreeItemSeed) -> Self {
|
||||||
|
let mut item = TreeItem::new(seed.id, seed.label);
|
||||||
|
item.children = seed.children.into_iter().map(Into::into).collect();
|
||||||
|
item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
||||||
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItem> {
|
///
|
||||||
let mut roots: Vec<TreeItem> = Vec::new();
|
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
|
||||||
|
/// worktree walk can yield tens of thousands of entries. Nodes live in an
|
||||||
|
/// arena and parents are found via a path -> index map, which keeps the
|
||||||
|
/// build linear in the number of path components.
|
||||||
|
pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItemSeed> {
|
||||||
|
// Node indices by full path, for O(1) parent lookup while inserting.
|
||||||
|
let mut index: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut nodes: Vec<(String, String, Vec<usize>)> = Vec::new();
|
||||||
|
let mut roots: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let parts: Vec<String> = entry
|
let mut parent: Option<usize> = None;
|
||||||
.components()
|
let mut path = String::new();
|
||||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
for part in entry.components() {
|
||||||
.collect();
|
let label = part.as_os_str().to_string_lossy().into_owned();
|
||||||
insert_path(&mut roots, &parts, "");
|
path = if path.is_empty() {
|
||||||
}
|
label.clone()
|
||||||
|
|
||||||
roots
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert `parts` (path components) into the tree rooted at `items`.
|
|
||||||
/// `prefix` is the path of `items`' parent, used to build item ids.
|
|
||||||
fn insert_path(items: &mut Vec<TreeItem>, parts: &[String], prefix: &str) {
|
|
||||||
let Some((head, rest)) = parts.split_first() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = if prefix.is_empty() {
|
|
||||||
head.clone()
|
|
||||||
} else {
|
} else {
|
||||||
format!("{prefix}/{head}")
|
format!("{path}/{label}")
|
||||||
};
|
};
|
||||||
|
let ix = *index.entry(path.clone()).or_insert_with(|| {
|
||||||
if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) {
|
let ix = nodes.len();
|
||||||
insert_path(&mut existing.children, rest, &id);
|
nodes.push((path.clone(), label.clone(), Vec::new()));
|
||||||
} else {
|
match parent {
|
||||||
let mut item = TreeItem::new(id.clone(), head.clone());
|
Some(parent) => nodes[parent].2.push(ix),
|
||||||
insert_path(&mut item.children, rest, &id);
|
None => roots.push(ix),
|
||||||
items.push(item);
|
|
||||||
}
|
}
|
||||||
|
ix
|
||||||
|
});
|
||||||
|
parent = Some(ix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assemble(ix: usize, nodes: &[(String, String, Vec<usize>)]) -> TreeItemSeed {
|
||||||
|
let (id, label, children) = &nodes[ix];
|
||||||
|
TreeItemSeed {
|
||||||
|
id: id.clone(),
|
||||||
|
label: label.clone(),
|
||||||
|
children: children
|
||||||
|
.iter()
|
||||||
|
.map(|child| assemble(*child, nodes))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots.iter().map(|root| assemble(*root, &nodes)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The markdown fence language for a file path, or `None` for plain text.
|
/// The markdown fence language for a file path, or `None` for plain text.
|
||||||
@@ -174,6 +207,42 @@ mod tests {
|
|||||||
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_builder_merges_shared_prefixes() {
|
||||||
|
// File children of a directory arrive after other directories'
|
||||||
|
// entries (the worktree list is dirs-first globally); the shared
|
||||||
|
// prefix must still resolve to one node.
|
||||||
|
let entries = vec![
|
||||||
|
PathBuf::from("a/x.txt"),
|
||||||
|
PathBuf::from("b/y.txt"),
|
||||||
|
PathBuf::from("a/z.txt"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let items = build_tree_items(&entries);
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(items[0].label, "a");
|
||||||
|
assert_eq!(items[0].children.len(), 2);
|
||||||
|
assert_eq!(items[1].label, "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_seeds_convert_to_tree_items() {
|
||||||
|
let entries = vec![
|
||||||
|
PathBuf::from("src"),
|
||||||
|
PathBuf::from("src/main.rs"),
|
||||||
|
PathBuf::from("README.md"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let items: Vec<TreeItem> = build_tree_items(&entries)
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(items[0].label, "src");
|
||||||
|
assert_eq!(items[0].children.len(), 1);
|
||||||
|
assert_eq!(items[0].children[0].label, "main.rs");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn code_language_maps_extensions_and_names() {
|
fn code_language_maps_extensions_and_names() {
|
||||||
assert_eq!(code_language("src/main.rs"), Some("rust"));
|
assert_eq!(code_language("src/main.rs"), Some("rust"));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -16,19 +16,22 @@ use gpui_component::menu::PopupMenuItem;
|
|||||||
use gpui_component::searchable_list::SearchableVec;
|
use gpui_component::searchable_list::SearchableVec;
|
||||||
use gpui_component::tab::{Tab, TabBar};
|
use gpui_component::tab::{Tab, TabBar};
|
||||||
use gpui_component::tag::Tag;
|
use gpui_component::tag::Tag;
|
||||||
use gpui_component::tree::TreeState;
|
use gpui_component::tree::{TreeItem, TreeState};
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_git::FileCommit;
|
use signed_git::{CommitList, FileCommit};
|
||||||
use signed_state::{GitStore, RepoStore};
|
use signed_state::{GitStore, RepoStore};
|
||||||
|
|
||||||
mod browser;
|
mod browser;
|
||||||
mod commits;
|
mod commits;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView};
|
use browser::{
|
||||||
|
CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES,
|
||||||
|
MarkdownView,
|
||||||
|
};
|
||||||
use commits::COMMIT_ROW_HEIGHT;
|
use commits::COMMIT_ROW_HEIGHT;
|
||||||
use helpers::{build_tree_items, is_markdown_path};
|
use helpers::{build_tree_items, is_markdown_path};
|
||||||
|
|
||||||
@@ -44,10 +47,16 @@ enum RefKind {
|
|||||||
/// Detail view of a repository: header, stats, a file explorer with README
|
/// Detail view of a repository: header, stats, a file explorer with README
|
||||||
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
||||||
pub struct RepoDetailView {
|
pub struct RepoDetailView {
|
||||||
/// Live per-repository store, refreshed from the local database.
|
/// Snapshot taken at open time, shown until the store's first refresh
|
||||||
store: Entity<RepoStore>,
|
/// completes (and as a fallback while the store has no announcement).
|
||||||
/// Snapshot taken at open time, shown until the store's first refresh completes.
|
|
||||||
initial: Announcement,
|
initial: Announcement,
|
||||||
|
/// Latest announcement from the store, cached so `render` (which runs
|
||||||
|
/// every frame) does not re-read and re-clone the store's copy.
|
||||||
|
announcement: Option<Announcement>,
|
||||||
|
/// Relay/web URLs of [`Self::announcement`] as display strings, for the
|
||||||
|
/// header dropdowns; `Rc` so the menu builders clone cheaply per frame.
|
||||||
|
relays: Rc<Vec<SharedString>>,
|
||||||
|
web: Rc<Vec<SharedString>>,
|
||||||
/// File explorer state (worktree of the local clone).
|
/// File explorer state (worktree of the local clone).
|
||||||
tree_state: Entity<TreeState>,
|
tree_state: Entity<TreeState>,
|
||||||
/// Root of the local clone, for reading files on demand.
|
/// Root of the local clone, for reading files on demand.
|
||||||
@@ -60,17 +69,25 @@ pub struct RepoDetailView {
|
|||||||
/// Currently previewed file (relative path) and its contents.
|
/// Currently previewed file (relative path) and its contents.
|
||||||
selected_file: Option<SharedString>,
|
selected_file: Option<SharedString>,
|
||||||
files: HashMap<String, FileContent>,
|
files: HashMap<String, FileContent>,
|
||||||
|
/// Paths of cached previews, oldest first; feeds the eviction caps in
|
||||||
|
/// [`Self::evict_previews`].
|
||||||
|
file_order: VecDeque<String>,
|
||||||
|
/// Total text bytes held by [`Self::files`].
|
||||||
|
preview_bytes: usize,
|
||||||
/// Reads in flight, to avoid duplicate loads.
|
/// Reads in flight, to avoid duplicate loads.
|
||||||
loading_files: HashSet<String>,
|
loading_files: HashSet<String>,
|
||||||
/// Latest commit touching a previewed file (or the README), keyed by path.
|
/// Latest commit touching a previewed file (or the README), keyed by path.
|
||||||
commits: HashMap<String, FileCommit>,
|
commits: HashMap<String, FileCommit>,
|
||||||
/// Commit queries in flight, to avoid duplicate loads.
|
/// Paths queued for the next batched commit query (see [`Self::load_commits`]).
|
||||||
loading_commits: HashSet<String>,
|
pending_commits: Vec<String>,
|
||||||
|
/// A batched commit query is in flight.
|
||||||
|
loading_commits: bool,
|
||||||
/// Active header tab: 0 = Files (tree), 1 = Commits.
|
/// Active header tab: 0 = Files (tree), 1 = Commits.
|
||||||
active_tab: usize,
|
active_tab: usize,
|
||||||
/// All commits reachable from HEAD, newest first; `None` until the
|
/// Commits reachable from HEAD, newest first; `None` until the walk
|
||||||
/// walk finishes (or fails).
|
/// finishes (or fails). `commits` may be capped by
|
||||||
all_commits: Option<Vec<FileCommit>>,
|
/// [`CommitList`]; `total` feeds the tab badge.
|
||||||
|
all_commits: Option<CommitList>,
|
||||||
/// Commit walk in flight.
|
/// Commit walk in flight.
|
||||||
loading_all_commits: bool,
|
loading_all_commits: bool,
|
||||||
/// Virtual list state of the Commits tab.
|
/// Virtual list state of the Commits tab.
|
||||||
@@ -90,10 +107,13 @@ pub struct RepoDetailView {
|
|||||||
/// Bumped on every branch/tag switch; in-flight loads tagged with an
|
/// Bumped on every branch/tag switch; in-flight loads tagged with an
|
||||||
/// older generation are discarded when they complete.
|
/// older generation are discarded when they complete.
|
||||||
ref_generation: u64,
|
ref_generation: u64,
|
||||||
/// Subscriptions keeping the selectors' confirm events alive.
|
|
||||||
_subscriptions: Vec<Subscription>,
|
|
||||||
focus_handle: FocusHandle,
|
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>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
/// Subscriptions keeping the selectors' confirm events and the store's
|
||||||
|
/// refreshes alive.
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepoDetailView {
|
impl RepoDetailView {
|
||||||
@@ -121,7 +141,36 @@ impl RepoDetailView {
|
|||||||
.searchable(true)
|
.searchable(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
let subscriptions = vec![
|
// Cache the announcement for the header: the store only changes it
|
||||||
|
// during debounced refreshes, but `render` runs every frame. The
|
||||||
|
// observe subscription owns the store for the view's lifetime.
|
||||||
|
let subscription = cx.observe(&store, |this, store, cx| {
|
||||||
|
let fresh = store.read(cx).announcement.clone();
|
||||||
|
if this.announcement == fresh {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.announcement = fresh;
|
||||||
|
// The header falls back to the open-time snapshot while the
|
||||||
|
// store has no announcement; keep its dropdown lists in sync.
|
||||||
|
let announcement = this.announcement.as_ref().unwrap_or(&this.initial);
|
||||||
|
this.relays = Rc::new(
|
||||||
|
announcement
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.map(|relay| relay.to_string().into())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
this.web = Rc::new(
|
||||||
|
announcement
|
||||||
|
.web
|
||||||
|
.iter()
|
||||||
|
.map(|url| url.to_string().into())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut subscriptions = vec![
|
||||||
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
|
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
|
||||||
// `Change` fires only when the selection actually changed
|
// `Change` fires only when the selection actually changed
|
||||||
// (picking the already-selected branch emits nothing), so a
|
// (picking the already-selected branch emits nothing), so a
|
||||||
@@ -140,15 +189,35 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
subscriptions.push(subscription);
|
||||||
|
|
||||||
// Defer loading the repository until the window is ready.
|
// Defer loading the repository until the window is ready.
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.load_repo(window, cx);
|
this.load_repo(window, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Header dropdowns of the open-time snapshot, until the store's
|
||||||
|
// first refresh replaces them.
|
||||||
|
let relays = Rc::new(
|
||||||
|
initial
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.map(|relay| relay.to_string().into())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
let web = Rc::new(
|
||||||
|
initial
|
||||||
|
.web
|
||||||
|
.iter()
|
||||||
|
.map(|url| url.to_string().into())
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
store,
|
|
||||||
initial,
|
initial,
|
||||||
|
announcement: None,
|
||||||
|
relays,
|
||||||
|
web,
|
||||||
tree_state,
|
tree_state,
|
||||||
worktree: None,
|
worktree: None,
|
||||||
md: None,
|
md: None,
|
||||||
@@ -156,9 +225,12 @@ impl RepoDetailView {
|
|||||||
readme_name: None,
|
readme_name: None,
|
||||||
selected_file: None,
|
selected_file: None,
|
||||||
files: HashMap::new(),
|
files: HashMap::new(),
|
||||||
|
file_order: VecDeque::new(),
|
||||||
|
preview_bytes: 0,
|
||||||
loading_files: HashSet::new(),
|
loading_files: HashSet::new(),
|
||||||
commits: HashMap::new(),
|
commits: HashMap::new(),
|
||||||
loading_commits: HashSet::new(),
|
pending_commits: Vec::new(),
|
||||||
|
loading_commits: false,
|
||||||
active_tab: 0,
|
active_tab: 0,
|
||||||
all_commits: None,
|
all_commits: None,
|
||||||
loading_all_commits: false,
|
loading_all_commits: false,
|
||||||
@@ -190,6 +262,10 @@ impl RepoDetailView {
|
|||||||
let load = cx.background_spawn(async move {
|
let load = cx.background_spawn(async move {
|
||||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||||
let entries = signed_git::worktree_entries(&repo)?;
|
let entries = signed_git::worktree_entries(&repo)?;
|
||||||
|
// The tree is built off the main thread; the seeds are plain
|
||||||
|
// owned strings and convert to `TreeItem`s (which hold `Rc`
|
||||||
|
// state) on the main thread.
|
||||||
|
let tree = build_tree_items(&entries);
|
||||||
let readme_path = signed_git::find_readme(&repo)?;
|
let readme_path = signed_git::find_readme(&repo)?;
|
||||||
let readme = match &readme_path {
|
let readme = match &readme_path {
|
||||||
Some(path) => signed_git::worktree_read(&repo, path)?,
|
Some(path) => signed_git::worktree_read(&repo, path)?,
|
||||||
@@ -209,7 +285,7 @@ impl RepoDetailView {
|
|||||||
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
|
let head_commit = signed_git::head_commit(&repo).unwrap_or(None);
|
||||||
|
|
||||||
Ok::<_, Error>((
|
Ok::<_, Error>((
|
||||||
entries,
|
tree,
|
||||||
readme_path,
|
readme_path,
|
||||||
readme,
|
readme,
|
||||||
worktree,
|
worktree,
|
||||||
@@ -226,7 +302,7 @@ impl RepoDetailView {
|
|||||||
this.update_in(cx, |this, window, cx| {
|
this.update_in(cx, |this, window, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok((
|
Ok((
|
||||||
entries,
|
tree,
|
||||||
readme_path,
|
readme_path,
|
||||||
readme,
|
readme,
|
||||||
Some(worktree),
|
Some(worktree),
|
||||||
@@ -238,7 +314,10 @@ impl RepoDetailView {
|
|||||||
this.worktree = Some(worktree);
|
this.worktree = Some(worktree);
|
||||||
this.head_commit = head_commit;
|
this.head_commit = head_commit;
|
||||||
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(
|
||||||
|
tree.into_iter().map(Into::into).collect::<Vec<TreeItem>>(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate the branch/tag selectors with the local
|
// Populate the branch/tag selectors with the local
|
||||||
@@ -280,7 +359,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
self.track(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preview the file at `path` (relative to the worktree root).
|
/// Preview the file at `path` (relative to the worktree root).
|
||||||
@@ -321,20 +400,24 @@ impl RepoDetailView {
|
|||||||
let content = cx
|
let content = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(async move {
|
||||||
let full = worktree.join(&path_for_read);
|
let full = worktree.join(&path_for_read);
|
||||||
|
// Refuse oversized files before reading them: reading a
|
||||||
|
// multi-gigabyte file just to classify it as too large
|
||||||
|
// would waste the disk and memory bandwidth.
|
||||||
|
let metadata = match std::fs::metadata(&full) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||||
|
};
|
||||||
|
if metadata.len() > MAX_PREVIEW_BYTES as u64 {
|
||||||
|
return Ok(FileContent::TooLarge);
|
||||||
|
}
|
||||||
let bytes = match std::fs::read(&full) {
|
let bytes = match std::fs::read(&full) {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let kind = if bytes.len() > MAX_PREVIEW_BYTES {
|
|
||||||
FileContent::TooLarge
|
|
||||||
} else {
|
|
||||||
match String::from_utf8(bytes) {
|
match String::from_utf8(bytes) {
|
||||||
Ok(text) => FileContent::Text(text),
|
Ok(text) => Ok(FileContent::Text(text)),
|
||||||
Err(_) => FileContent::Binary,
|
Err(_) => Ok(FileContent::Binary),
|
||||||
}
|
}
|
||||||
};
|
|
||||||
Ok::<_, Error>(kind)
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -361,8 +444,11 @@ impl RepoDetailView {
|
|||||||
this.set_code(path.clone().into(), text, window, cx);
|
this.set_code(path.clone().into(), text, window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.preview_bytes += text.len();
|
||||||
}
|
}
|
||||||
this.files.insert(path, kind);
|
this.files.insert(path.clone(), kind);
|
||||||
|
this.file_order.push_back(path);
|
||||||
|
this.evict_previews();
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
this.files
|
this.files
|
||||||
@@ -375,38 +461,64 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
self.track(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query the latest commit touching `path` on a background task and cache
|
/// Queue `path` for the per-file commit query; requests are batched into
|
||||||
/// it in [`Self::commits`], for the file header in the content column.
|
/// one history walk (see [`Self::load_commits`]).
|
||||||
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
fn load_commit(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||||
if self.commits.contains_key(path) || self.loading_commits.contains(path) {
|
if self.commits.contains_key(path) || self.pending_commits.iter().any(|p| p == path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pending_commits.push(path.to_string());
|
||||||
|
if !self.loading_commits {
|
||||||
|
self.load_commits(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk history once for every queued path on a background task, and
|
||||||
|
/// cache the latest commit touching each of them in [`Self::commits`]
|
||||||
|
/// (for the file header in the content column).
|
||||||
|
///
|
||||||
|
/// Batching shares one walk (and its object decodes) across all paths
|
||||||
|
/// queued while the previous walk was in flight, instead of walking the
|
||||||
|
/// full history per file.
|
||||||
|
fn load_commits(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.pending_commits.is_empty() || self.loading_commits {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(worktree) = self.worktree.clone() else {
|
let Some(worktree) = self.worktree.clone() else {
|
||||||
|
self.pending_commits.clear();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.loading_commits.insert(path.to_string());
|
self.loading_commits = true;
|
||||||
let path = path.to_string();
|
let paths = std::mem::take(&mut self.pending_commits);
|
||||||
let generation = self.ref_generation;
|
let generation = self.ref_generation;
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
let path_for_query = path.clone();
|
let rels: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move {
|
.background_spawn(
|
||||||
signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query))
|
async move { signed_git::worktree_last_commits(&worktree, &rels) },
|
||||||
})
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
if generation != this.ref_generation {
|
if generation != this.ref_generation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading_commits.remove(&path);
|
this.loading_commits = false;
|
||||||
if let Ok(Some(commit)) = result {
|
if let Ok(found) = result {
|
||||||
this.commits.insert(path, commit);
|
for (path, commit) in found {
|
||||||
|
this.commits
|
||||||
|
.insert(path.to_string_lossy().into_owned(), commit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Paths queued while the walk was in flight start the next
|
||||||
|
// batch.
|
||||||
|
if !this.pending_commits.is_empty() {
|
||||||
|
this.load_commits(cx);
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
@@ -414,11 +526,12 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
self.track(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk all commits reachable from HEAD on a background task, for the
|
/// Walk all commits reachable from HEAD on a background task, for the
|
||||||
/// Commits tab and its total-count badge.
|
/// Commits tab and its total-count badge. The list is capped by
|
||||||
|
/// [`CommitList`]; only the newest commits are materialized.
|
||||||
fn load_all_commits(&mut self, cx: &mut Context<Self>) {
|
fn load_all_commits(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.loading_all_commits || self.all_commits.is_some() {
|
if self.loading_all_commits || self.all_commits.is_some() {
|
||||||
return;
|
return;
|
||||||
@@ -440,10 +553,10 @@ impl RepoDetailView {
|
|||||||
if generation != this.ref_generation {
|
if generation != this.ref_generation {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(commits) = result {
|
if let Ok(list) = result {
|
||||||
let count = commits.len();
|
let count = list.commits.len();
|
||||||
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
|
||||||
this.all_commits = Some(commits);
|
this.all_commits = Some(list);
|
||||||
}
|
}
|
||||||
this.loading_all_commits = false;
|
this.loading_all_commits = false;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -452,7 +565,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
self.track(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check out `name` (a branch or tag picked in the header) and refresh
|
/// Check out `name` (a branch or tag picked in the header) and refresh
|
||||||
@@ -524,7 +637,7 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
self.track(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore a selector to `previous`, or clear it (after a failed switch).
|
/// Restore a selector to `previous`, or clear it (after a failed switch).
|
||||||
@@ -589,27 +702,38 @@ impl RepoDetailView {
|
|||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
let task = cx.spawn(async move |this, cx| {
|
||||||
let result = cx
|
let result = cx
|
||||||
.background_spawn(async move { signed_git::worktree_snapshot(&worktree) })
|
.background_spawn(async move {
|
||||||
|
let snapshot = signed_git::worktree_snapshot(&worktree)?;
|
||||||
|
// Build the tree off the main thread, like [`Self::load_repo`].
|
||||||
|
let tree = build_tree_items(&snapshot.entries);
|
||||||
|
Ok::<_, Error>((snapshot, tree))
|
||||||
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.switching_ref = false;
|
this.switching_ref = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(snapshot) => {
|
Ok((snapshot, tree)) => {
|
||||||
this.head_commit = snapshot.head_commit;
|
this.head_commit = snapshot.head_commit;
|
||||||
// Rebuild the tree from scratch: entries of the
|
// Rebuild the tree from scratch: entries of the
|
||||||
// previous branch are gone, and with them the
|
// previous branch are gone, and with them the
|
||||||
// expansion state.
|
// expansion state.
|
||||||
this.tree_state.update(cx, |state, cx| {
|
this.tree_state.update(cx, |state, cx| {
|
||||||
state.set_items(build_tree_items(&snapshot.entries), cx);
|
state.set_items(
|
||||||
|
tree.into_iter().map(Into::into).collect::<Vec<TreeItem>>(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drop cached previews and commits of the old branch.
|
// Drop cached previews and commits of the old branch.
|
||||||
this.selected_file = None;
|
this.selected_file = None;
|
||||||
this.files.clear();
|
this.files.clear();
|
||||||
|
this.file_order.clear();
|
||||||
|
this.preview_bytes = 0;
|
||||||
this.loading_files.clear();
|
this.loading_files.clear();
|
||||||
this.commits.clear();
|
this.commits.clear();
|
||||||
this.loading_commits.clear();
|
this.pending_commits.clear();
|
||||||
|
this.loading_commits = false;
|
||||||
this.md = None;
|
this.md = None;
|
||||||
this.code = None;
|
this.code = None;
|
||||||
this.readme_name = None;
|
this.readme_name = None;
|
||||||
@@ -640,8 +764,45 @@ impl RepoDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
self.track(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: Task<Result<(), Error>>) {
|
||||||
|
self.tasks.retain(|task| !task.is_ready());
|
||||||
self.tasks.push(task);
|
self.tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the oldest previews beyond the cache caps, keeping the currently
|
||||||
|
/// selected file. The parsed editor state of an evicted file is dropped
|
||||||
|
/// along with its entry, so re-opening it re-parses on a background task.
|
||||||
|
fn evict_previews(&mut self) {
|
||||||
|
while (self.files.len() > MAX_PREVIEWED_FILES
|
||||||
|
|| self.preview_bytes > MAX_PREVIEW_CACHE_BYTES)
|
||||||
|
&& self.file_order.len() > 1
|
||||||
|
{
|
||||||
|
let path = self.file_order.pop_front().expect("non-empty");
|
||||||
|
if Some(path.as_str()) == self.selected_file.as_deref() {
|
||||||
|
self.file_order.push_back(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(FileContent::Text(text)) = self.files.remove(&path) {
|
||||||
|
self.preview_bytes -= text.len();
|
||||||
|
}
|
||||||
|
if self.md.as_ref().map(|md| md.path.as_deref()) == Some(Some(path.as_str())) {
|
||||||
|
self.md = None;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.code
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|code| code.path.as_ref() == path.as_str())
|
||||||
|
{
|
||||||
|
self.code = None;
|
||||||
|
}
|
||||||
|
self.commits.remove(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for RepoDetailView {
|
impl Panel for RepoDetailView {
|
||||||
@@ -649,13 +810,8 @@ impl Panel for RepoDetailView {
|
|||||||
"repo_detail"
|
"repo_detail"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let announcement = self
|
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
|
||||||
.store
|
|
||||||
.read(cx)
|
|
||||||
.announcement
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| self.initial.clone());
|
|
||||||
|
|
||||||
announcement
|
announcement
|
||||||
.name
|
.name
|
||||||
@@ -677,12 +833,9 @@ impl Render for RepoDetailView {
|
|||||||
let tree_state = self.tree_state.clone();
|
let tree_state = self.tree_state.clone();
|
||||||
let view = cx.entity().downgrade();
|
let view = cx.entity().downgrade();
|
||||||
|
|
||||||
let announcement = self
|
let announcement = self.announcement.as_ref().unwrap_or(&self.initial);
|
||||||
.store
|
let relays = self.relays.clone();
|
||||||
.read(cx)
|
let web = self.web.clone();
|
||||||
.announcement
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| self.initial.clone());
|
|
||||||
|
|
||||||
let name = announcement
|
let name = announcement
|
||||||
.name
|
.name
|
||||||
@@ -700,9 +853,7 @@ impl Render for RepoDetailView {
|
|||||||
.or_else(|| self.readme_name.clone())
|
.or_else(|| self.readme_name.clone())
|
||||||
.unwrap_or_else(|| "Overview".into());
|
.unwrap_or_else(|| "Overview".into());
|
||||||
|
|
||||||
let relays = announcement.relays.clone();
|
let commits_count = self.all_commits.as_ref().map(|list| list.total);
|
||||||
let web = announcement.web.clone();
|
|
||||||
let commits_count = self.all_commits.as_ref().map(Vec::len);
|
|
||||||
let worktree_empty = self.switching_ref || self.worktree.is_none();
|
let worktree_empty = self.switching_ref || self.worktree.is_none();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
|||||||
Reference in New Issue
Block a user