diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..d1477f4bc6b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ "**/inbox-edit.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", + "**/project-file-tree.spec.ts", "**/project-inbox.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a3..17440a8057e 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -47,6 +47,7 @@ mod project_git; mod project_git_branches; mod project_git_diff; mod project_git_exec; +mod project_git_files; mod project_git_merge_error; mod project_git_push; mod project_git_workflow; diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a05079..a9eccf85525 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -2,11 +2,11 @@ use super::project_git_exec::{ build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, GitAuthConfig, }; +use super::project_git_files::{parse_ls_tree, parse_worktree_files, ParsedProjectRepoFiles}; use super::project_git_push::push_project_local_repository_blocking; use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; use crate::app_state::AppState; use serde::Serialize; -use std::time::UNIX_EPOCH; use tauri::State; #[derive(Clone, Serialize)] pub struct ProjectRepoCommitInfo { @@ -38,6 +38,8 @@ pub struct ProjectRepoSnapshotInfo { pub latest_commit: Option, pub commits: Vec, pub files: Vec, + /// Complete file count before the snapshot payload is capped. + pub total_file_count: usize, pub contributors: Vec, } #[derive(Serialize)] @@ -134,30 +136,6 @@ fn has_untracked_files(output: &str) -> bool { output.lines().any(|line| line.starts_with("??")) } -fn read_preview_content( - repo_dir: &std::path::Path, - path: &str, - size: Option, -) -> Option { - const MAX_PREVIEW_BYTES: u64 = 64 * 1024; - if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { - return None; - } - - let full_path = repo_dir.join(path); - let normalized = full_path.canonicalize().ok()?; - let repo_root = repo_dir.canonicalize().ok()?; - if !normalized.starts_with(repo_root) { - return None; - } - - let bytes = std::fs::read(normalized).ok()?; - if bytes.contains(&0) { - return None; - } - String::from_utf8(bytes).ok() -} - fn parse_commits(output: &str) -> Vec { output .lines() @@ -235,46 +213,6 @@ fn parse_latest_commit_by_path( result } -fn path_modified_at(path: &std::path::Path) -> Option { - let modified = std::fs::metadata(path).ok()?.modified().ok()?; - modified - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) -} - -fn parse_worktree_files( - repo_dir: &std::path::Path, - output: &str, - latest_commit_by_path: &std::collections::HashMap, -) -> Vec { - output - .split('\0') - .filter(|path| !path.trim().is_empty()) - .filter_map(|path| { - let full_path = repo_dir.join(path); - let metadata = std::fs::metadata(&full_path).ok()?; - if !metadata.is_file() { - return None; - } - let size = Some(metadata.len()); - let latest_commit = latest_commit_by_path.get(path).cloned(); - Some(ProjectRepoFileInfo { - path: path.to_string(), - kind: "blob".to_string(), - size, - preview_content: read_preview_content(repo_dir, path, size), - last_changed_at: latest_commit - .as_ref() - .map(|commit| commit.timestamp) - .or_else(|| path_modified_at(&full_path)), - latest_commit, - }) - }) - .take(250) - .collect() -} - fn normalize_branch_name(branch: &str) -> &str { branch .trim() @@ -309,40 +247,6 @@ fn branch_activity_range( Some(format!("origin/{base_branch}..HEAD")) } -fn parse_ls_tree( - repo_dir: &std::path::Path, - output: &str, - latest_commit_by_path: &std::collections::HashMap, -) -> Vec { - output - .lines() - .filter_map(|line| { - let (meta, path) = line.split_once('\t')?; - let mut parts = meta.split_whitespace(); - let _mode = parts.next()?; - 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 - }; - Some(ProjectRepoFileInfo { - path: path.to_string(), - kind, - size, - preview_content, - last_changed_at: latest_commit_by_path - .get(path) - .map(|commit| commit.timestamp), - latest_commit: latest_commit_by_path.get(path).cloned(), - }) - }) - .take(250) - .collect() -} - fn snapshot_from_repo( repo_dir: &std::path::Path, auth: &GitAuthConfig, @@ -383,7 +287,7 @@ fn snapshot_from_repo( (Vec::new(), Vec::new()) }; - let files = if latest_commit.is_some() { + let parsed_files = if latest_commit.is_some() { let latest_commit_by_path = run_git( &[ "log", @@ -402,13 +306,14 @@ fn snapshot_from_repo( .map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path)) .unwrap_or_default() } else { - Vec::new() + ParsedProjectRepoFiles::default() }; ProjectRepoSnapshotInfo { latest_commit, commits, - files, + files: parsed_files.files, + total_file_count: parsed_files.total_file_count, contributors, } } @@ -466,7 +371,7 @@ fn snapshot_from_worktree( (Vec::new(), Vec::new(), std::collections::HashMap::new()) }; - let files = run_git( + let parsed_files = run_git( &[ "ls-files", "--cached", @@ -483,7 +388,8 @@ fn snapshot_from_worktree( ProjectRepoSnapshotInfo { latest_commit, commits, - files, + files: parsed_files.files, + total_file_count: parsed_files.total_file_count, contributors, } } diff --git a/desktop/src-tauri/src/commands/project_git_files.rs b/desktop/src-tauri/src/commands/project_git_files.rs new file mode 100644 index 00000000000..a1bc47a0ea2 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_files.rs @@ -0,0 +1,152 @@ +use super::project_git::{ProjectRepoCommitInfo, ProjectRepoFileInfo}; +use std::{collections::HashMap, path::Path, time::UNIX_EPOCH}; + +const MAX_REPOSITORY_FILE_PAYLOAD: usize = 250; + +#[derive(Default)] +pub(super) struct ParsedProjectRepoFiles { + pub(super) files: Vec, + pub(super) total_file_count: usize, +} + +fn read_preview_content(repo_dir: &Path, path: &str, size: Option) -> Option { + const MAX_PREVIEW_BYTES: u64 = 64 * 1024; + if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { + return None; + } + + let normalized = repo_dir.join(path).canonicalize().ok()?; + let repo_root = repo_dir.canonicalize().ok()?; + if !normalized.starts_with(repo_root) { + return None; + } + + let bytes = std::fs::read(normalized).ok()?; + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +fn path_modified_at(path: &Path) -> Option { + let modified = std::fs::metadata(path).ok()?.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) +} + +pub(super) fn parse_worktree_files( + repo_dir: &Path, + output: &str, + latest_commit_by_path: &HashMap, +) -> ParsedProjectRepoFiles { + let mut parsed = ParsedProjectRepoFiles::default(); + + for path in output.split('\0').filter(|path| !path.trim().is_empty()) { + let full_path = repo_dir.join(path); + let Ok(metadata) = std::fs::metadata(&full_path) else { + continue; + }; + if !metadata.is_file() { + continue; + } + + parsed.total_file_count += 1; + if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD { + continue; + } + + let size = Some(metadata.len()); + let latest_commit = latest_commit_by_path.get(path).cloned(); + parsed.files.push(ProjectRepoFileInfo { + path: path.to_string(), + kind: "blob".to_string(), + size, + preview_content: read_preview_content(repo_dir, path, size), + last_changed_at: latest_commit + .as_ref() + .map(|commit| commit.timestamp) + .or_else(|| path_modified_at(&full_path)), + latest_commit, + }); + } + + parsed +} + +pub(super) fn parse_ls_tree( + repo_dir: &Path, + output: &str, + latest_commit_by_path: &HashMap, +) -> ParsedProjectRepoFiles { + let mut parsed = ParsedProjectRepoFiles::default(); + + for line in output.lines() { + let Some((meta, path)) = line.split_once('\t') else { + continue; + }; + let mut parts = meta.split_whitespace(); + let (Some(_mode), Some(kind), Some(_object)) = (parts.next(), parts.next(), parts.next()) + else { + continue; + }; + let size = parts.next().and_then(|value| value.parse::().ok()); + + parsed.total_file_count += 1; + if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD { + continue; + } + + let preview_content = (kind == "blob") + .then(|| read_preview_content(repo_dir, path, size)) + .flatten(); + parsed.files.push(ProjectRepoFileInfo { + path: path.to_string(), + kind: kind.to_string(), + size, + preview_content, + last_changed_at: latest_commit_by_path + .get(path) + .map(|commit| commit.timestamp), + latest_commit: latest_commit_by_path.get(path).cloned(), + }); + } + + parsed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ls_tree_reports_the_full_count_when_the_payload_is_capped() { + let output = (0..251) + .map(|index| format!("100644 blob {} 1\tfile-{index:03}.txt", "0".repeat(40))) + .collect::>() + .join("\n"); + + let parsed = parse_ls_tree(Path::new("/missing-repository"), &output, &HashMap::new()); + + assert_eq!(parsed.files.len(), 250); + assert_eq!(parsed.total_file_count, 251); + } + + #[test] + fn worktree_reports_the_full_count_when_the_payload_is_capped() { + let repo = tempfile::tempdir().expect("create temporary repository"); + let mut paths = Vec::new(); + for index in 0..251 { + let path = format!("file-{index:03}.txt"); + std::fs::write(repo.path().join(&path), "x").expect("write fixture file"); + paths.push(path); + } + let output = format!("{}\0", paths.join("\0")); + + let parsed = parse_worktree_files(repo.path(), &output, &HashMap::new()); + + assert_eq!(parsed.files.len(), 250); + assert_eq!(parsed.total_file_count, 251); + } +} diff --git a/desktop/src/features/projects/lib/projectFileTreeSummary.test.mjs b/desktop/src/features/projects/lib/projectFileTreeSummary.test.mjs new file mode 100644 index 00000000000..0d0033e9fd6 --- /dev/null +++ b/desktop/src/features/projects/lib/projectFileTreeSummary.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { projectFileTreeSummary } from "./projectFileTreeSummary.ts"; + +test("reports a complete repository tree normally", () => { + assert.deepEqual(projectFileTreeSummary(12, 12), { + countLabel: "12 files", + truncationNotice: null, + }); +}); + +test("reports the loaded and total counts when the repository tree is truncated", () => { + assert.deepEqual(projectFileTreeSummary(250, 661), { + countLabel: "250 of 661 files", + truncationNotice: + "Showing the first 250 of 661 files. Some files and folders are not included.", + }); +}); + +test("never reports fewer total files than were loaded", () => { + assert.deepEqual(projectFileTreeSummary(3, 0), { + countLabel: "3 files", + truncationNotice: null, + }); +}); diff --git a/desktop/src/features/projects/lib/projectFileTreeSummary.ts b/desktop/src/features/projects/lib/projectFileTreeSummary.ts new file mode 100644 index 00000000000..af9d0eb915a --- /dev/null +++ b/desktop/src/features/projects/lib/projectFileTreeSummary.ts @@ -0,0 +1,23 @@ +export type ProjectFileTreeSummary = { + countLabel: string; + truncationNotice: string | null; +}; + +/** Builds honest file-count copy for complete and capped repository snapshots. */ +export function projectFileTreeSummary( + loadedFileCount: number, + reportedTotalFileCount: number, +): ProjectFileTreeSummary { + const totalFileCount = Math.max(loadedFileCount, reportedTotalFileCount); + if (loadedFileCount >= totalFileCount) { + return { + countLabel: `${loadedFileCount} file${loadedFileCount === 1 ? "" : "s"}`, + truncationNotice: null, + }; + } + + return { + countLabel: `${loadedFileCount} of ${totalFileCount} files`, + truncationNotice: `Showing the first ${loadedFileCount} of ${totalFileCount} files. Some files and folders are not included.`, + }; +} diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index c4becd90576..471a3938f10 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -26,6 +26,7 @@ import type { ProjectRepoFile, ProjectRepoSnapshot, } from "@/features/projects/hooks"; +import { projectFileTreeSummary } from "@/features/projects/lib/projectFileTreeSummary"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -45,10 +46,6 @@ import { RepositoryBranchDropdown, } from "./ProjectRepositorySource"; -function pluralize(count: number, singular: string) { - return `${count} ${singular}${count === 1 ? "" : "s"}`; -} - export function formatLastChangedAt(timestamp: number | null) { if (!timestamp) return "—"; return new Date(timestamp * 1_000).toLocaleString(undefined, { @@ -639,6 +636,10 @@ export function RepositoryFilesPanel({ ); const visibleEntries = entries.slice(0, 200); const latestCommit = snapshot?.latestCommit ?? null; + const fileTreeSummary = projectFileTreeSummary( + files.length, + snapshot?.totalFileCount ?? files.length, + ); const knownLatestCommitProfile = React.useMemo( () => profileForCommitAuthor(latestCommit, profiles), [latestCommit, profiles], @@ -829,7 +830,7 @@ export function RepositoryFilesPanel({ {latestCommit.shortHash} - · {pluralize(files.length, "file")} + · {fileTreeSummary.countLabel}

@@ -844,7 +845,7 @@ export function RepositoryFilesPanel({ ) : (

- Repository files · {files.length} tracked files + Repository files · {fileTreeSummary.countLabel}

)} @@ -910,6 +911,11 @@ export function RepositoryFilesPanel({ the list.

) : null} + {fileTreeSummary.truncationNotice ? ( +

+ {fileTreeSummary.truncationNotice} +

+ ) : null} ); } diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts index 7293db19385..96a1bc93b89 100644 --- a/desktop/src/shared/api/projectGit.ts +++ b/desktop/src/shared/api/projectGit.ts @@ -52,6 +52,7 @@ type RawProjectRepoSnapshot = { latest_commit: RawProjectRepoCommit | null; commits?: RawProjectRepoCommit[]; files: RawProjectRepoFile[]; + total_file_count?: number; contributors?: RawProjectRepoContributor[]; }; @@ -137,6 +138,10 @@ function fromRawProjectRepoSnapshot( ? fromRawProjectRepoCommit(file.latest_commit) : null, })), + totalFileCount: Math.max( + snapshot.files.length, + snapshot.total_file_count ?? snapshot.files.length, + ), contributors: (snapshot.contributors ?? []).map((contributor) => ({ name: contributor.name, email: contributor.email, diff --git a/desktop/src/shared/api/projectGitTypes.ts b/desktop/src/shared/api/projectGitTypes.ts index 46854eb33e7..5da3cadc71e 100644 --- a/desktop/src/shared/api/projectGitTypes.ts +++ b/desktop/src/shared/api/projectGitTypes.ts @@ -27,6 +27,8 @@ export type ProjectRepoSnapshot = { latestCommit: ProjectRepoCommit | null; commits: ProjectRepoCommit[]; files: ProjectRepoFile[]; + /** Complete file count before the native snapshot payload is capped. */ + totalFileCount: number; contributors: ProjectRepoContributor[]; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6961488fefa..7de6cfb6e53 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -184,6 +184,8 @@ type E2eConfig = { pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; + /** Full repository file count reported alongside the capped snapshot payload. */ + projectRepoTotalFileCount?: number; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ builderlabAuth?: { email?: string; @@ -11372,6 +11374,7 @@ export function maybeInstallE2eTauriMocks() { last_commit_at: Math.floor(Date.now() / 1000) - 7_200, }, ], + total_file_count: activeConfig?.mock?.projectRepoTotalFileCount ?? 4, files: [ { path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", diff --git a/desktop/tests/e2e/project-file-tree.spec.ts b/desktop/tests/e2e/project-file-tree.spec.ts new file mode 100644 index 00000000000..8612444f735 --- /dev/null +++ b/desktop/tests/e2e/project-file-tree.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +async function enableProjectsFeature(page: import("@playwright/test").Page) { + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); +} + +async function openBuzzProject(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + await page.getByRole("button", { name: "Repositories", exact: true }).click(); + const projectEntry = page + .locator( + '[data-testid="repository-card-buzz"], [data-testid="repository-row-buzz"]', + ) + .first(); + await expect(projectEntry).toBeVisible({ timeout: 10_000 }); + await projectEntry.click(); +} + +test("repository files disclose when the backend tree is truncated", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page, { projectRepoTotalFileCount: 661 }); + await openBuzzProject(page); + + await page.getByRole("tab", { name: "Files", exact: true }).click(); + + await expect( + page.getByText("· 4 of 661 files", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText( + "Showing the first 4 of 661 files. Some files and folders are not included.", + { exact: true }, + ), + ).toBeVisible(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f7a6c4ccecc..d8c2d3e4afb 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -156,6 +156,8 @@ type MockBridgeOptions = { pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; + /** Full repository file count reported alongside the capped snapshot payload. */ + projectRepoTotalFileCount?: number; /** Relay NIP-11 identity used to sign authoritative repository state. */ relaySelf?: string | null; /** Native-like huddle state seeded from authoritative role-bearing membership. */