diff --git a/apps/code/package.json b/apps/code/package.json index 78a4c1ab1..528cb19a0 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -126,7 +126,7 @@ "@opentelemetry/semantic-conventions": "^1.39.0", "@parcel/watcher": "^2.5.6", "@phosphor-icons/react": "^2.1.10", - "@pierre/diffs": "^1.1.7", + "@pierre/diffs": "^1.1.21", "@posthog/agent": "workspace:*", "@posthog/electron-trpc": "workspace:*", "@posthog/enricher": "workspace:*", diff --git a/apps/code/src/main/services/fs/schemas.ts b/apps/code/src/main/services/fs/schemas.ts index 7f8730b92..971ddedb6 100644 --- a/apps/code/src/main/services/fs/schemas.ts +++ b/apps/code/src/main/services/fs/schemas.ts @@ -11,6 +11,34 @@ export const readRepoFileInput = z.object({ filePath: z.string(), }); +export const readRepoFilesInput = z.object({ + repoPath: z.string(), + filePaths: z.array(z.string()), +}); + +export const readRepoFileBoundedInput = z.object({ + repoPath: z.string(), + filePath: z.string(), + maxLines: z.number().int().positive(), +}); + +export const readRepoFilesBoundedInput = z.object({ + repoPath: z.string(), + filePaths: z.array(z.string()), + maxLines: z.number().int().positive(), +}); + +export const boundedReadResult = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("content"), content: z.string() }), + z.object({ kind: z.literal("missing") }), + z.object({ kind: z.literal("too-large") }), +]); + +export const readRepoFilesBoundedOutput = z.record( + z.string(), + boundedReadResult, +); + export const readAbsoluteFileInput = z.object({ filePath: z.string(), }); @@ -32,9 +60,12 @@ const fileEntry = z.object({ export const listRepoFilesOutput = z.array(fileEntry); export const readRepoFileOutput = z.string().nullable(); +export const readRepoFilesOutput = z.record(z.string(), readRepoFileOutput); export type ListRepoFilesInput = z.infer; export type ReadRepoFileInput = z.infer; +export type ReadRepoFilesInput = z.infer; export type WriteRepoFileInput = z.infer; export type FileEntry = z.infer; export type FileEntryKind = z.infer; +export type BoundedReadResult = z.infer; diff --git a/apps/code/src/main/services/fs/service.ts b/apps/code/src/main/services/fs/service.ts index 2dcf62847..189a5a739 100644 --- a/apps/code/src/main/services/fs/service.ts +++ b/apps/code/src/main/services/fs/service.ts @@ -6,13 +6,14 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { FileWatcherEvent } from "../file-watcher/schemas"; import type { FileWatcherService } from "../file-watcher/service"; -import type { FileEntry } from "./schemas"; +import type { BoundedReadResult, FileEntry } from "./schemas"; const log = logger.scope("fs"); @injectable() export class FsService { private static readonly CACHE_TTL = 30000; + private static readonly READ_REPO_FILES_CONCURRENCY = 24; private cache = new Map(); constructor( @@ -101,13 +102,70 @@ export class FsService { "utf-8", ); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "EISDIR") { log.error(`Failed to read file ${filePath}:`, error); } return null; } } + async readRepoFiles( + repoPath: string, + filePaths: string[], + ): Promise> { + const uniqueFilePaths = [...new Set(filePaths)]; + const entries = await this.mapWithConcurrency( + uniqueFilePaths, + FsService.READ_REPO_FILES_CONCURRENCY, + async (filePath) => + [filePath, await this.readRepoFile(repoPath, filePath)] as const, + ); + return Object.fromEntries(entries); + } + + async readRepoFileBounded( + repoPath: string, + filePath: string, + maxLines: number, + ): Promise { + try { + const content = await fs.promises.readFile( + this.resolvePath(repoPath, filePath), + "utf-8", + ); + if (exceedsLineLimit(content, maxLines)) { + return { kind: "too-large" }; + } + return { kind: "content", content }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "EISDIR") { + return { kind: "missing" }; + } + log.error(`Failed to read file ${filePath}:`, error); + return { kind: "missing" }; + } + } + + async readRepoFilesBounded( + repoPath: string, + filePaths: string[], + maxLines: number, + ): Promise> { + const uniqueFilePaths = [...new Set(filePaths)]; + const entries = await this.mapWithConcurrency( + uniqueFilePaths, + FsService.READ_REPO_FILES_CONCURRENCY, + async (filePath) => + [ + filePath, + await this.readRepoFileBounded(repoPath, filePath, maxLines), + ] as const, + ); + return Object.fromEntries(entries); + } + async readAbsoluteFile(filePath: string): Promise { try { return await fs.promises.readFile(path.resolve(filePath), "utf-8"); @@ -166,12 +224,12 @@ export class FsService { } private resolvePath(repoPath: string, filePath: string): string { - const fullPath = path.join(repoPath, filePath); - const resolved = path.resolve(fullPath); - if (!resolved.startsWith(path.resolve(repoPath))) { + const base = path.resolve(repoPath); + const resolved = path.resolve(base, filePath); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { throw new Error("Access denied: path outside repository"); } - return fullPath; + return resolved; } private toFileEntries( @@ -206,4 +264,43 @@ export class FsService { } return Array.from(dirs).sort(); } + + private async mapWithConcurrency( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise, + ): Promise { + if (items.length === 0) return []; + + const results = new Array(items.length); + let index = 0; + + const worker = async () => { + while (index < items.length) { + const currentIndex = index++; + results[currentIndex] = await mapper(items[currentIndex]); + } + }; + + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, () => + worker(), + ), + ); + + return results; + } +} + +function exceedsLineLimit(content: string, maxLines: number): boolean { + let lineCount = 1; + for (let i = 0; i < content.length; i++) { + if (content.charCodeAt(i) === 10) { + lineCount++; + if (lineCount > maxLines) { + return true; + } + } + } + return false; } diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 90d789d0b..4ab8709f7 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -310,14 +310,34 @@ export class GitService extends TypedEventEmitter { const files = await getChangedFilesDetailed(directoryPath, { excludePatterns: [".claude", "CLAUDE.local.md"], }); - return files.map((f) => ({ - path: f.path, - status: f.status, - originalPath: f.originalPath, - linesAdded: f.linesAdded, - linesRemoved: f.linesRemoved, - staged: f.staged, - })); + type HeadChangedFile = Omit; + const filteredFiles: Array = await Promise.all( + files.map(async (file) => { + if (file.status === "untracked") { + try { + const stats = await fs.promises.stat( + path.join(directoryPath, file.path), + ); + if (!stats.isFile()) return null; + } catch { + return null; + } + } + + return { + path: file.path, + status: file.status, + originalPath: file.originalPath, + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + staged: file.staged, + }; + }), + ); + + return filteredFiles.filter( + (file): file is HeadChangedFile => file !== null, + ); } public async getFileAtHead( diff --git a/apps/code/src/main/trpc/routers/fs.ts b/apps/code/src/main/trpc/routers/fs.ts index 1aede0b35..eaff0fb42 100644 --- a/apps/code/src/main/trpc/routers/fs.ts +++ b/apps/code/src/main/trpc/routers/fs.ts @@ -1,11 +1,17 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; import { + boundedReadResult, listRepoFilesInput, listRepoFilesOutput, readAbsoluteFileInput, + readRepoFileBoundedInput, readRepoFileInput, readRepoFileOutput, + readRepoFilesBoundedInput, + readRepoFilesBoundedOutput, + readRepoFilesInput, + readRepoFilesOutput, writeRepoFileInput, } from "../../services/fs/schemas"; import type { FsService } from "../../services/fs/service"; @@ -28,6 +34,35 @@ export const fsRouter = router({ getService().readRepoFile(input.repoPath, input.filePath), ), + readRepoFiles: publicProcedure + .input(readRepoFilesInput) + .output(readRepoFilesOutput) + .query(({ input }) => + getService().readRepoFiles(input.repoPath, input.filePaths), + ), + + readRepoFileBounded: publicProcedure + .input(readRepoFileBoundedInput) + .output(boundedReadResult) + .query(({ input }) => + getService().readRepoFileBounded( + input.repoPath, + input.filePath, + input.maxLines, + ), + ), + + readRepoFilesBounded: publicProcedure + .input(readRepoFilesBoundedInput) + .output(readRepoFilesBoundedOutput) + .query(({ input }) => + getService().readRepoFilesBounded( + input.repoPath, + input.filePaths, + input.maxLines, + ), + ), + readAbsoluteFile: publicProcedure .input(readAbsoluteFileInput) .output(readRepoFileOutput) diff --git a/apps/code/src/renderer/components/TreeDirectoryRow.tsx b/apps/code/src/renderer/components/TreeDirectoryRow.tsx index 7dee21d32..8e62497bb 100644 --- a/apps/code/src/renderer/components/TreeDirectoryRow.tsx +++ b/apps/code/src/renderer/components/TreeDirectoryRow.tsx @@ -71,6 +71,7 @@ interface TreeFileRowProps { fileName: string; depth: number; isActive?: boolean; + title?: string; onClick?: () => void; onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent) => void; @@ -84,6 +85,7 @@ export function TreeFileRow({ fileName, depth, isActive = false, + title, onClick, onDoubleClick, onContextMenu, @@ -95,6 +97,7 @@ export function TreeFileRow({ { @@ -60,6 +63,45 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { return diffs; }, [remoteFiles.length, toolCalls, reviewFiles]); + const items = useMemo(() => { + return reviewFiles.map((file) => { + const isCollapsed = collapsedFiles.has(file.path); + const githubFileUrl = prUrl + ? `${prUrl}/files#diff-${file.path.replaceAll("/", "-")}` + : undefined; + + return { + key: file.path, + scrollKey: file.path, + node: ( + toggleFile(file.path)} + commentThreads={showReviewComments ? commentThreads : undefined} + fallback={toolCallFallbacks?.get(file.path) ?? null} + externalUrl={githubFileUrl} + /> + ), + }; + }); + }, [ + collapsedFiles, + commentThreads, + diffOptions, + prUrl, + reviewFiles, + showReviewComments, + taskId, + toggleFile, + toolCallFallbacks, + ]); + + const itemIndexByFilePath = useMemo(() => buildItemIndex(items), [items]); + if (!prUrl && !effectiveBranch && reviewFiles.length === 0) { if (isRunActive) { return ( @@ -91,19 +133,8 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { onExpandAll={expandAll} onCollapseAll={collapseAll} onUncollapseFile={uncollapseFile} - > - - + items={items} + itemIndexByFilePath={itemIndexByFilePath} + /> ); } diff --git a/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx b/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx index 655fa0726..1b8ac3a1d 100644 --- a/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx +++ b/apps/code/src/renderer/features/code-review/components/DiffStatsBadge.tsx @@ -6,29 +6,16 @@ import { formatHotkey, SHORTCUTS, } from "@renderer/constants/keyboard-shortcuts"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; import type { Task } from "@shared/types"; -import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; +import { useDiffStatsToggle } from "../hooks/useDiffStatsToggle"; interface DiffStatsBadgeProps { task: Task; } export function DiffStatsBadge({ task }: DiffStatsBadgeProps) { - const taskId = task.id; - const { filesChanged, linesAdded, linesRemoved } = useTaskDiffStats(task); - - const reviewMode = useReviewNavigationStore( - (s) => s.reviewModes[taskId] ?? "closed", - ); - const setReviewMode = useReviewNavigationStore((s) => s.setReviewMode); - - const hasChanges = filesChanged > 0; - const isOpen = reviewMode !== "closed"; - - const handleClick = () => { - setReviewMode(taskId, isOpen ? "closed" : "split"); - }; + const { linesAdded, linesRemoved, hasChanges, isOpen, toggle } = + useDiffStatsToggle(task, "split"); return (