diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 17c16f7..b2cd14c 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -2,6 +2,7 @@ //! //! All functions may block; call them inside `cx.background_spawn`. +use std::collections::HashSet; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -140,6 +141,11 @@ fn sanitize_path_component(id: &str) -> String { 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. #[derive(Debug, Clone)] pub struct FileCommit { @@ -222,6 +228,26 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { .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 { + 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 { + 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 /// worktree), like `git log -1 -- ` does for non-merge commits. /// @@ -230,56 +256,110 @@ pub fn find_readme(repo: &gix::Repository) -> Result> { /// 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> { + 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 -- ` 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> { + 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> { 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 = 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 .rev_walk([head]) .sorting(gix::revision::walk::Sorting::ByCommitTime( CommitTimeOrder::NewestFirst, )); + let mut found = Vec::new(); for info in walk.all()? { + if pending.is_empty() { + break; + } 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)?, + let tree = commit.tree()?; + let parent_tree = match info.parent_ids().next() { + Some(parent) => Some(parent.object()?.into_commit().tree()?), 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, - })); + // Compare each still-unresolved path against this commit and its + // first parent; resolved paths leave the pending set. + let mut ix = 0; + while ix < pending.len() { + let rel = &pending[ix]; + let blob = tree.lookup_entry_by_path(rel)?; + let parent_blob = match &parent_tree { + 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` -/// (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) +/// Cap on [`CommitList::commits`]: the virtual list renders a window at a +/// time and the tab badge shows the real count, so a huge history is never +/// fully materialized in memory. +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, } /// 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> { +/// Returns an empty list for a repository without any commits yet. +pub fn all_commits(repo: &gix::Repository) -> Result { use gix::traverse::commit::simple::CommitTimeOrder; let Some(head) = repo.head_id().ok() else { - return Ok(Vec::new()); + return Ok(CommitList { + total: 0, + commits: Vec::new(), + }); }; let walk = repo .rev_walk([head]) @@ -288,25 +368,21 @@ pub fn all_commits(repo: &gix::Repository) -> Result> { )); let mut commits = Vec::new(); + let mut total = 0; 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, - }); + total += 1; + if commits.len() < MAX_LISTED_COMMITS { + commits.push(file_commit(&info.object()?)?); + } } - Ok(commits) + Ok(CommitList { total, commits }) } /// Like [`all_commits`], but opens the repository located at `workdir` /// (for non-bare clones the clone root is the worktree) first. -pub fn worktree_all_commits(workdir: &Path) -> Result> { - all_commits(&gix::open(workdir)?) +pub fn worktree_all_commits(workdir: &Path) -> Result { + all_commits(&open_with_cache(workdir)?) } /// 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> { /// Short names of local branches (`refs/heads/*`), sorted alphabetically. pub fn worktree_branches(workdir: &Path) -> Result> { - let repo = gix::open(workdir)?; + let repo = open_with_cache(workdir)?; let mut names = Vec::new(); for reference in repo.references()?.local_branches()? { let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; @@ -340,7 +416,7 @@ pub fn worktree_branches(workdir: &Path) -> Result> { /// Short names of tags (`refs/tags/*`), sorted alphabetically. pub fn worktree_tags(workdir: &Path) -> Result> { - let repo = gix::open(workdir)?; + let repo = open_with_cache(workdir)?; let mut names = Vec::new(); for reference in repo.references()?.tags()? { 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 /// branch HEAD points to and its commit, opening the repository once. pub fn worktree_snapshot(workdir: &Path) -> Result { - let repo = gix::open(workdir)?; + let repo = open_with_cache(workdir)?; let readme_path = find_readme(&repo)?; let readme = match &readme_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)] mod tests { + use std::collections::HashMap; + use nostr::prelude::*; use signed_core::repo_addr; @@ -601,12 +679,13 @@ mod tests { 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(); + let list = all_commits(&repo).expect("commits"); + assert_eq!(list.total, 3); + let mut summaries: Vec<&str> = list.commits.iter().map(|c| c.summary.as_str()).collect(); summaries.sort(); assert_eq!(summaries, vec!["initial", "second", "third"]); assert!( - commits + list.commits .iter() .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() { 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] @@ -664,6 +745,35 @@ mod tests { 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] fn find_readme_prefers_markdown() { let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]); diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index baecad4..0202456 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -135,6 +135,7 @@ impl RepoStore { }) }); + self.tasks.retain(|task| !task.is_ready()); 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| { let (announcement, state, issues, patches, pull_requests, statuses) = match work.await { Ok(data) => data, diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 5429e67..2df4c47 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -20,6 +20,10 @@ use super::helpers::{code_language, is_markdown_path, placeholder}; const TREE_WIDTH: f32 = 240.; /// Files larger than this are not previewed. 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. pub(super) enum FileContent { diff --git a/crates/workspace/src/views/repo_detail/commits.rs b/crates/workspace/src/views/repo_detail/commits.rs index 89d83d3..c1b051f 100644 --- a/crates/workspace/src/views/repo_detail/commits.rs +++ b/crates/workspace/src/views/repo_detail/commits.rs @@ -72,7 +72,7 @@ impl RepoDetailView { /// Full-height body of the Commits tab: all commits in a virtual /// list, or a status message while loading / when there are none. pub(super) fn render_commits_tab(&mut self, cx: &mut Context) -> AnyElement { - let Some(commits) = self.all_commits.clone() else { + let Some(list) = self.all_commits.as_ref() else { return if self.loading_all_commits { v_flex() .size_full() @@ -85,13 +85,18 @@ impl RepoDetailView { }; }; - if commits.is_empty() { + if list.commits.is_empty() { 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 sizes = self.item_sizes.clone(); let scroll_handle = self.scroll_handle.clone(); + let shown = list.commits.len(); + let total = list.total; v_flex() .relative() @@ -103,17 +108,29 @@ impl RepoDetailView { 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 + move |this, range, _window, cx| { + let commits = this + .all_commits + .as_ref() + .map(|list| list.commits.as_slice()) + .unwrap_or(&[]); + range.map(|ix| commit_row(ix, &commits[ix], cx)).collect() }, ) .track_scroll(&scroll_handle) .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( div() .absolute() diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index cf6b7f4..93b6a6e 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -1,6 +1,7 @@ //! Pure helpers for the repository detail view: file-tree building, code //! preview helpers and small element builders. +use std::collections::HashMap; use std::path::{Path, PathBuf}; use gpui::prelude::*; @@ -8,43 +9,75 @@ use gpui::{AnyElement, App, div}; use gpui_component::tree::TreeItem; use gpui_component::{ActiveTheme, v_flex}; -/// Build nested tree items from a flat, sorted (dirs-first) entry list. -pub(super) fn build_tree_items(entries: &[PathBuf]) -> Vec { - let mut roots: Vec = Vec::new(); - - for entry in entries { - let parts: Vec = entry - .components() - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect(); - insert_path(&mut roots, &parts, ""); - } - - roots +/// 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, } -/// 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, parts: &[String], prefix: &str) { - let Some((head, rest)) = parts.split_first() else { - return; - }; - - let id = if prefix.is_empty() { - head.clone() - } else { - format!("{prefix}/{head}") - }; - - if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) { - insert_path(&mut existing.children, rest, &id); - } else { - let mut item = TreeItem::new(id.clone(), head.clone()); - insert_path(&mut item.children, rest, &id); - items.push(item); +impl From 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. +/// +/// 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 { + // Node indices by full path, for O(1) parent lookup while inserting. + let mut index: HashMap = HashMap::new(); + let mut nodes: Vec<(String, String, Vec)> = Vec::new(); + let mut roots: Vec = Vec::new(); + + for entry in entries { + let mut parent: Option = None; + let mut path = String::new(); + for part in entry.components() { + let label = part.as_os_str().to_string_lossy().into_owned(); + path = if path.is_empty() { + label.clone() + } else { + format!("{path}/{label}") + }; + let ix = *index.entry(path.clone()).or_insert_with(|| { + let ix = nodes.len(); + nodes.push((path.clone(), label.clone(), Vec::new())); + match parent { + Some(parent) => nodes[parent].2.push(ix), + None => roots.push(ix), + } + ix + }); + parent = Some(ix); + } + } + + fn assemble(ix: usize, nodes: &[(String, String, Vec)]) -> 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. /// /// Names are chosen so `gpui_component`'s highlighter can resolve them @@ -174,6 +207,42 @@ mod tests { 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 = 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] fn code_language_maps_extensions_and_names() { assert_eq!(code_language("src/main.rs"), Some("rust")); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 0912548..46a00e8 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::rc::Rc; @@ -16,19 +16,22 @@ use gpui_component::menu::PopupMenuItem; use gpui_component::searchable_list::SearchableVec; use gpui_component::tab::{Tab, TabBar}; use gpui_component::tag::Tag; -use gpui_component::tree::TreeState; +use gpui_component::tree::{TreeItem, TreeState}; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, }; use signed_core::Announcement; -use signed_git::FileCommit; +use signed_git::{CommitList, FileCommit}; use signed_state::{GitStore, RepoStore}; mod browser; mod commits; 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 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 /// preview (cloned from the announcement's `clone` URLs), and metadata. pub struct RepoDetailView { - /// Live per-repository store, refreshed from the local database. - store: Entity, - /// Snapshot taken at open time, shown until the store's first refresh completes. + /// Snapshot taken at open time, shown until the store's first refresh + /// completes (and as a fallback while the store has no 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, + /// Relay/web URLs of [`Self::announcement`] as display strings, for the + /// header dropdowns; `Rc` so the menu builders clone cheaply per frame. + relays: Rc>, + web: Rc>, /// File explorer state (worktree of the local clone). tree_state: Entity, /// 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. selected_file: Option, files: HashMap, + /// Paths of cached previews, oldest first; feeds the eviction caps in + /// [`Self::evict_previews`]. + file_order: VecDeque, + /// Total text bytes held by [`Self::files`]. + preview_bytes: usize, /// 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, + /// Paths queued for the next batched commit query (see [`Self::load_commits`]). + pending_commits: Vec, + /// A batched commit query is in flight. + loading_commits: bool, /// 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>, + /// Commits reachable from HEAD, newest first; `None` until the walk + /// finishes (or fails). `commits` may be capped by + /// [`CommitList`]; `total` feeds the tab badge. + all_commits: Option, /// Commit walk in flight. loading_all_commits: bool, /// 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 /// older generation are discarded when they complete. ref_generation: u64, - /// Subscriptions keeping the selectors' confirm events alive. - _subscriptions: Vec, 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>>, + /// Subscriptions keeping the selectors' confirm events and the store's + /// refreshes alive. + _subscriptions: Vec, } impl RepoDetailView { @@ -121,7 +141,36 @@ impl RepoDetailView { .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| { // `Change` fires only when the selection actually changed // (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. cx.defer_in(window, |this, 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 { - store, initial, + announcement: None, + relays, + web, tree_state, worktree: None, md: None, @@ -156,9 +225,12 @@ impl RepoDetailView { readme_name: None, selected_file: None, files: HashMap::new(), + file_order: VecDeque::new(), + preview_bytes: 0, loading_files: HashSet::new(), commits: HashMap::new(), - loading_commits: HashSet::new(), + pending_commits: Vec::new(), + loading_commits: false, active_tab: 0, all_commits: None, loading_all_commits: false, @@ -190,6 +262,10 @@ impl RepoDetailView { let load = cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; 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 = match &readme_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); Ok::<_, Error>(( - entries, + tree, readme_path, readme, worktree, @@ -226,7 +302,7 @@ impl RepoDetailView { this.update_in(cx, |this, window, cx| { match result { Ok(( - entries, + tree, readme_path, readme, Some(worktree), @@ -238,7 +314,10 @@ impl RepoDetailView { this.worktree = Some(worktree); this.head_commit = head_commit; 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::>(), + cx, + ); }); // Populate the branch/tag selectors with the local @@ -280,7 +359,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// Preview the file at `path` (relative to the worktree root). @@ -321,20 +400,24 @@ impl RepoDetailView { let content = cx .background_spawn(async move { 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) { Ok(bytes) => bytes, Err(error) => return Err(anyhow::anyhow!("{}", error)), }; - - let kind = if bytes.len() > MAX_PREVIEW_BYTES { - FileContent::TooLarge - } else { - match String::from_utf8(bytes) { - Ok(text) => FileContent::Text(text), - Err(_) => FileContent::Binary, - } - }; - Ok::<_, Error>(kind) + match String::from_utf8(bytes) { + Ok(text) => Ok(FileContent::Text(text)), + Err(_) => Ok(FileContent::Binary), + } }) .await; @@ -361,8 +444,11 @@ impl RepoDetailView { 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) => { this.files @@ -375,38 +461,64 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(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. + /// Queue `path` for the per-file commit query; requests are batched into + /// one history walk (see [`Self::load_commits`]). fn load_commit(&mut self, path: &str, cx: &mut Context) { - 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) { + if self.pending_commits.is_empty() || self.loading_commits { return; } let Some(worktree) = self.worktree.clone() else { + self.pending_commits.clear(); return; }; - self.loading_commits.insert(path.to_string()); - let path = path.to_string(); + self.loading_commits = true; + let paths = std::mem::take(&mut self.pending_commits); let generation = self.ref_generation; let task = cx.spawn(async move |this, cx| { - let path_for_query = path.clone(); + let rels: Vec = paths.iter().map(PathBuf::from).collect(); let result = cx - .background_spawn(async move { - signed_git::worktree_last_commit(&worktree, Path::new(&path_for_query)) - }) + .background_spawn( + async move { signed_git::worktree_last_commits(&worktree, &rels) }, + ) .await; this.update(cx, |this, cx| { if generation != this.ref_generation { return; } - this.loading_commits.remove(&path); - if let Ok(Some(commit)) = result { - this.commits.insert(path, commit); + this.loading_commits = false; + if let Ok(found) = result { + 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(); })?; @@ -414,11 +526,12 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// 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) { if self.loading_all_commits || self.all_commits.is_some() { return; @@ -440,10 +553,10 @@ impl RepoDetailView { if generation != this.ref_generation { return; } - if let Ok(commits) = result { - let count = commits.len(); + if let Ok(list) = result { + let count = list.commits.len(); 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; cx.notify(); @@ -452,7 +565,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// Check out `name` (a branch or tag picked in the header) and refresh @@ -524,7 +637,7 @@ impl RepoDetailView { Ok(()) }); - self.tasks.push(task); + self.track(task); } /// 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 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; this.update(cx, |this, cx| { this.switching_ref = false; match result { - Ok(snapshot) => { + Ok((snapshot, tree)) => { this.head_commit = snapshot.head_commit; // Rebuild the tree from scratch: entries of the // previous branch are gone, and with them the // expansion state. 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::>(), + cx, + ); }); // Drop cached previews and commits of the old branch. this.selected_file = None; this.files.clear(); + this.file_order.clear(); + this.preview_bytes = 0; this.loading_files.clear(); this.commits.clear(); - this.loading_commits.clear(); + this.pending_commits.clear(); + this.loading_commits = false; this.md = None; this.code = None; this.readme_name = None; @@ -640,8 +764,45 @@ impl RepoDetailView { 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>) { + self.tasks.retain(|task| !task.is_ready()); 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 { @@ -649,13 +810,8 @@ impl Panel for RepoDetailView { "repo_detail" } - fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let announcement = self - .store - .read(cx) - .announcement - .clone() - .unwrap_or_else(|| self.initial.clone()); + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let announcement = self.announcement.as_ref().unwrap_or(&self.initial); announcement .name @@ -677,12 +833,9 @@ impl Render for RepoDetailView { let tree_state = self.tree_state.clone(); let view = cx.entity().downgrade(); - let announcement = self - .store - .read(cx) - .announcement - .clone() - .unwrap_or_else(|| self.initial.clone()); + let announcement = self.announcement.as_ref().unwrap_or(&self.initial); + let relays = self.relays.clone(); + let web = self.web.clone(); let name = announcement .name @@ -700,9 +853,7 @@ impl Render for RepoDetailView { .or_else(|| self.readme_name.clone()) .unwrap_or_else(|| "Overview".into()); - let relays = announcement.relays.clone(); - let web = announcement.web.clone(); - let commits_count = self.all_commits.as_ref().map(Vec::len); + let commits_count = self.all_commits.as_ref().map(|list| list.total); let worktree_empty = self.switching_ref || self.worktree.is_none(); v_flex()