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. */