Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions desktop/src-tauri/src/commands/project_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -314,6 +324,7 @@ fn parse_ls_tree(
output: &str,
latest_commit_by_path: &std::collections::HashMap<String, ProjectRepoCommitInfo>,
) -> Vec<ProjectRepoFileInfo> {
let mut blob_index = 0;
output
.lines()
.filter_map(|line| {
Expand All @@ -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::<u64>().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,
Expand All @@ -339,7 +351,6 @@ fn parse_ls_tree(
latest_commit: latest_commit_by_path.get(path).cloned(),
})
})
.take(250)
.collect()
Comment thread
evanchen7 marked this conversation as resolved.
}

Expand Down
81 changes: 81 additions & 0 deletions desktop/src-tauri/src/commands/project_git_tests.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>();
let output = std::iter::once("directory")
.chain(paths.iter().map(String::as_str))
.collect::<Vec<_>>()
.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()));
}
11 changes: 10 additions & 1 deletion desktop/src/features/projects/lib/projectsViewHelpers.test.mjs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
});
6 changes: 6 additions & 0 deletions desktop/src/features/projects/lib/projectsViewHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
54 changes: 42 additions & 12 deletions desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -633,11 +637,23 @@ export function RepositoryFilesPanel({
const [currentPath, setCurrentPath] = React.useState("");
const [selectedFile, setSelectedFile] =
React.useState<ProjectRepoFile | null>(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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -737,7 +754,7 @@ export function RepositoryFilesPanel({
file={selectedFile}
onOpenPath={(path) => {
setSelectedFile(null);
setCurrentPath(path);
openPath(path);
}}
/>
);
Expand Down Expand Up @@ -766,14 +783,14 @@ export function RepositoryFilesPanel({
/>
</>
) : (
<BreadcrumbButton onClick={() => setCurrentPath("")}>
<BreadcrumbButton onClick={() => openPath("")}>
Files
</BreadcrumbButton>
)}
{sourceControls && pathSegments.length > 0 ? (
<>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<BreadcrumbButton onClick={() => setCurrentPath("")}>
<BreadcrumbButton onClick={() => openPath("")}>
Files
</BreadcrumbButton>
</>
Expand All @@ -783,7 +800,7 @@ export function RepositoryFilesPanel({
return (
<React.Fragment key={nextPath}>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<BreadcrumbButton onClick={() => setCurrentPath(nextPath)}>
<BreadcrumbButton onClick={() => openPath(nextPath)}>
{segment}
</BreadcrumbButton>
</React.Fragment>
Expand Down Expand Up @@ -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 (
<tr
Expand Down Expand Up @@ -904,11 +921,24 @@ export function RepositoryFilesPanel({
</tbody>
</table>
</div>
{entries.length > 200 ? (
<p className="border-border/50 border-t px-4 py-3 text-2xs text-muted-foreground">
Showing the first 200 entries in this folder. Open a folder to narrow
the list.
</p>
{entries.length > visibleEntries.length ? (
<div className="flex items-center justify-between gap-3 border-border/50 border-t px-4 py-3 text-2xs text-muted-foreground">
<span aria-live="polite">
Showing {visibleEntries.length} of {entries.length} entries.
</span>
<button
aria-label={`Show next ${nextEntryCount} entries, ${nextVisibleEntryCount} of ${entries.length} total`}
className="shrink-0 font-medium text-foreground hover:underline"
onClick={() =>
setVisibleEntryCount((current) =>
nextRepositoryEntryLimit(current, entries.length),
)
}
type="button"
>
Show next {nextEntryCount}
</button>
</div>
) : null}
Comment thread
evanchen7 marked this conversation as resolved.
</div>
);
Expand Down