diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a0507..597ebaedd4 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -8,6 +8,14 @@ use crate::app_state::AppState; use serde::Serialize; use std::time::UNIX_EPOCH; use tauri::State; + +// Bound eager content without truncating the repository tree. +const MAX_EAGER_FILE_PREVIEWS: usize = 250; + +#[cfg(test)] +#[path = "project_git_tests.rs"] +mod tests; + #[derive(Clone, Serialize)] pub struct ProjectRepoCommitInfo { pub hash: String, @@ -254,24 +262,26 @@ fn parse_worktree_files( .filter_map(|path| { let full_path = repo_dir.join(path); let metadata = std::fs::metadata(&full_path).ok()?; - if !metadata.is_file() { - return None; - } + metadata.is_file().then_some((path, full_path, metadata)) + }) + .enumerate() + .map(|(index, (path, full_path, metadata))| { let size = Some(metadata.len()); let latest_commit = latest_commit_by_path.get(path).cloned(); - Some(ProjectRepoFileInfo { + ProjectRepoFileInfo { path: path.to_string(), kind: "blob".to_string(), size, - preview_content: read_preview_content(repo_dir, path, size), + preview_content: (index < MAX_EAGER_FILE_PREVIEWS) + .then(|| read_preview_content(repo_dir, path, size)) + .flatten(), last_changed_at: latest_commit .as_ref() .map(|commit| commit.timestamp) .or_else(|| path_modified_at(&full_path)), latest_commit, - }) + } }) - .take(250) .collect() } @@ -314,6 +324,7 @@ fn parse_ls_tree( output: &str, latest_commit_by_path: &std::collections::HashMap, ) -> Vec { + let mut blob_index = 0; output .lines() .filter_map(|line| { @@ -323,11 +334,12 @@ fn parse_ls_tree( let kind = parts.next()?.to_string(); let _object = parts.next()?; let size = parts.next().and_then(|value| value.parse::().ok()); - let preview_content = if kind == "blob" { - read_preview_content(repo_dir, path, size) - } else { - None - }; + if kind == "blob" { + blob_index += 1; + } + let preview_content = (kind == "blob" && blob_index <= MAX_EAGER_FILE_PREVIEWS) + .then(|| read_preview_content(repo_dir, path, size)) + .flatten(); Some(ProjectRepoFileInfo { path: path.to_string(), kind, @@ -339,7 +351,6 @@ fn parse_ls_tree( latest_commit: latest_commit_by_path.get(path).cloned(), }) }) - .take(250) .collect() } diff --git a/desktop/src-tauri/src/commands/project_git_tests.rs b/desktop/src-tauri/src/commands/project_git_tests.rs new file mode 100644 index 0000000000..d9ddd9b814 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_tests.rs @@ -0,0 +1,81 @@ +use super::*; + +#[test] +fn parse_ls_tree_keeps_paths_after_eager_preview_limit() { + let hidden_entries = (0..MAX_EAGER_FILE_PREVIEWS) + .map(|index| { + format!( + "100644 blob {} 1\t.agents/generated-{index:03}.txt", + "a".repeat(40) + ) + }) + .collect::>() + .join("\n"); + let output = format!( + "{hidden_entries}\n100644 blob {} 12\tsrc/application.rs", + "b".repeat(40) + ); + + let files = parse_ls_tree( + std::path::Path::new("/path/does/not/exist"), + &output, + &std::collections::HashMap::new(), + ); + + assert_eq!(files.len(), MAX_EAGER_FILE_PREVIEWS + 1); + assert_eq!( + files.last().map(|file| file.path.as_str()), + Some("src/application.rs") + ); +} + +#[test] +fn parse_ls_tree_counts_only_blobs_toward_eager_preview_limit() { + let repo_dir = tempfile::tempdir().expect("create temporary repository"); + std::fs::write(repo_dir.path().join("application.rs"), "fn main() {}") + .expect("write preview file"); + let non_blob_entries = (0..MAX_EAGER_FILE_PREVIEWS) + .map(|index| { + format!( + "160000 commit {} -\tvendor/dependency-{index:03}", + "a".repeat(40) + ) + }) + .collect::>() + .join("\n"); + let output = format!( + "{non_blob_entries}\n100644 blob {} 12\tapplication.rs", + "b".repeat(40) + ); + + let files = parse_ls_tree(repo_dir.path(), &output, &std::collections::HashMap::new()); + + assert_eq!( + files + .last() + .and_then(|file| file.preview_content.as_deref()), + Some("fn main() {}") + ); +} + +#[test] +fn parse_worktree_files_counts_only_files_toward_eager_preview_limit() { + let repo_dir = tempfile::tempdir().expect("create temporary repository"); + std::fs::create_dir(repo_dir.path().join("directory")).expect("create directory"); + let paths = (0..MAX_EAGER_FILE_PREVIEWS) + .map(|index| { + let path = format!("file-{index:03}.txt"); + std::fs::write(repo_dir.path().join(&path), "preview").expect("write preview file"); + path + }) + .collect::>(); + let output = std::iter::once("directory") + .chain(paths.iter().map(String::as_str)) + .collect::>() + .join("\0"); + + let files = parse_worktree_files(repo_dir.path(), &output, &std::collections::HashMap::new()); + + assert_eq!(files.len(), MAX_EAGER_FILE_PREVIEWS); + assert!(files.iter().all(|file| file.preview_content.is_some())); +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs b/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs index cd58f06a51..085feb3e32 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs +++ b/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { relativeTime } from "./projectsViewHelpers.ts"; +import { + nextRepositoryEntryLimit, + relativeTime, +} from "./projectsViewHelpers.ts"; const DAY_SECONDS = 24 * 60 * 60; @@ -43,3 +46,9 @@ test("relativeTime includes the year only across a year boundary", () => { crossYearExpected, ); }); + +test("repository entry pagination advances and clamps to the total", () => { + assert.equal(nextRepositoryEntryLimit(200, 450), 400); + assert.equal(nextRepositoryEntryLimit(400, 450), 450); + assert.equal(nextRepositoryEntryLimit(450, 450), 450); +}); diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 6084c7275f..e1afcacf90 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -26,6 +26,12 @@ export type ProjectsFilter = | "users"; export type ProjectsSort = "updated" | "created" | "name"; +export const REPOSITORY_ENTRY_PAGE_SIZE = 200; + +export function nextRepositoryEntryLimit(current: number, total: number) { + return Math.min(current + REPOSITORY_ENTRY_PAGE_SIZE, total); +} + const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode"; const PROJECTS_FILTER_STORAGE_KEY = "buzz.projects.filter"; const PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY = "buzz.projects.repositoryScope"; diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index c4becd9057..7ee3a98b1a 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -26,7 +26,11 @@ import type { ProjectRepoFile, ProjectRepoSnapshot, } from "@/features/projects/hooks"; -import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; +import { + nextRepositoryEntryLimit, + relativeTime, + REPOSITORY_ENTRY_PAGE_SIZE, +} from "@/features/projects/lib/projectsViewHelpers"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; @@ -633,11 +637,23 @@ export function RepositoryFilesPanel({ const [currentPath, setCurrentPath] = React.useState(""); const [selectedFile, setSelectedFile] = React.useState(null); + const [visibleEntryCount, setVisibleEntryCount] = React.useState( + REPOSITORY_ENTRY_PAGE_SIZE, + ); + const openPath = React.useCallback((path: string) => { + setCurrentPath(path); + setVisibleEntryCount(REPOSITORY_ENTRY_PAGE_SIZE); + }, []); const entries = React.useMemo( () => repositoryEntries(files, currentPath), [currentPath, files], ); - const visibleEntries = entries.slice(0, 200); + const visibleEntries = entries.slice(0, visibleEntryCount); + const nextVisibleEntryCount = nextRepositoryEntryLimit( + visibleEntryCount, + entries.length, + ); + const nextEntryCount = nextVisibleEntryCount - visibleEntries.length; const latestCommit = snapshot?.latestCommit ?? null; const knownLatestCommitProfile = React.useMemo( () => profileForCommitAuthor(latestCommit, profiles), @@ -679,6 +695,7 @@ export function RepositoryFilesPanel({ if (!filesKey) return; setCurrentPath(""); setSelectedFile(null); + setVisibleEntryCount(REPOSITORY_ENTRY_PAGE_SIZE); }, [filesKey]); // Loading/error/empty states keep the header controls visible — the @@ -737,7 +754,7 @@ export function RepositoryFilesPanel({ file={selectedFile} onOpenPath={(path) => { setSelectedFile(null); - setCurrentPath(path); + openPath(path); }} /> ); @@ -766,14 +783,14 @@ export function RepositoryFilesPanel({ /> ) : ( - setCurrentPath("")}> + openPath("")}> Files )} {sourceControls && pathSegments.length > 0 ? ( <> - setCurrentPath("")}> + openPath("")}> Files @@ -783,7 +800,7 @@ export function RepositoryFilesPanel({ return ( - setCurrentPath(nextPath)}> + openPath(nextPath)}> {segment} @@ -855,7 +872,7 @@ export function RepositoryFilesPanel({ const latestCommit = entry.latestCommit; const rowIsLast = index === visibleEntries.length - 1; const openEntry = () => - openRepositoryEntry(entry, setCurrentPath, setSelectedFile); + openRepositoryEntry(entry, openPath, setSelectedFile); return ( - {entries.length > 200 ? ( -

- Showing the first 200 entries in this folder. Open a folder to narrow - the list. -

+ {entries.length > visibleEntries.length ? ( +
+ + Showing {visibleEntries.length} of {entries.length} entries. + + +
) : null} );