diff --git a/packages/plugins/plugin-workspace-diff/src/diff-model.ts b/packages/plugins/plugin-workspace-diff/src/diff-model.ts index ec43d56d767..4ceeba34272 100644 --- a/packages/plugins/plugin-workspace-diff/src/diff-model.ts +++ b/packages/plugins/plugin-workspace-diff/src/diff-model.ts @@ -10,6 +10,7 @@ export type DiffRenderMode = "unified" | "split"; export interface DiffPatchViewModel { kind: WorkspaceDiffFilePatch["kind"]; patch: string | null; + lineCount: number; additions: number; deletions: number; binary: boolean; @@ -31,6 +32,8 @@ export interface DiffFileViewModel { patchKinds: WorkspaceDiffFilePatch["kind"][]; patches: DiffPatchViewModel[]; patch: string | null; + lineCount: number; + longDiff: boolean; } export interface DiffSummaryViewModel { @@ -51,6 +54,8 @@ const STATUS_LABELS: Record = { unknown: "Changed", }; +export const LONG_DIFF_LINE_THRESHOLD = 400; + export function statusLabel(status: WorkspaceDiffFile["status"]) { return STATUS_LABELS[status] ?? "Changed"; } @@ -62,9 +67,11 @@ export function fileName(filePath: string) { export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] { return file.patches.map((patch) => { const textPatch = patch.patch?.trimEnd() ?? null; + const lineCount = textPatch ? textPatch.split("\n").length : 0; return { kind: patch.kind, patch: textPatch && textPatch.length > 0 ? textPatch : null, + lineCount, additions: patch.additions, deletions: patch.deletions, binary: patch.binary, @@ -79,10 +86,15 @@ export function buildFilePatch(file: WorkspaceDiffFile): string | null { return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null; } +export function isLongDiffFile(file: Pick) { + return file.lineCount > LONG_DIFF_LINE_THRESHOLD; +} + export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] { return (diff?.files ?? []).map((file) => { const patchWarnings = file.patches.flatMap((patch) => patch.warnings); const patches = buildFilePatches(file); + const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0); return { path: file.path, oldPath: file.oldPath, @@ -96,6 +108,8 @@ export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined) patchKinds: file.patches.map((patch) => patch.kind), patches, patch: patches.find((patch) => patch.patch)?.patch ?? null, + lineCount, + longDiff: isLongDiffFile({ lineCount }), }; }); } @@ -127,3 +141,7 @@ export function nextExpandedFileSet( else next.add(filePath); return next; } + +export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set { + return new Set(files.filter((file) => !file.longDiff).map((file) => file.path)); +} diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index cd3c8243eb5..b241813ea02 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -4,10 +4,11 @@ import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk"; import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs"; import type { PatchDiffProps } from "@pierre/diffs/react"; import { useFileDiffInstance } from "@pierre/diffs/react"; -import { createElement, useEffect, useMemo, useState } from "react"; +import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { diffSummary, fileName, + initialExpandedFileSet, nextExpandedFileSet, statusLabel, toFileViewModels, @@ -236,12 +237,45 @@ function FileDiffPanel({ ); } +function CollapsedFilePanel({ + file, + onExpand, +}: { + file: DiffFileViewModel; + onExpand: () => void; +}) { + const title = file.longDiff ? "Large diff folded" : "Diff folded"; + const details = file.lineCount > 0 + ? `${file.lineCount.toLocaleString()} lines` + : statusLabel(file.status); + + return ( +
+
+
+
{title}
+
{details}
+
+ +
+
+ ); +} + export function ChangesTab({ context }: PluginDetailTabProps) { const toast = usePluginToast(); const [mode, setMode] = useState("split"); - const [includeUntracked, setIncludeUntracked] = useState(true); + const [includeUntracked, setIncludeUntracked] = useState(false); const [expandedFiles, setExpandedFiles] = useState>(() => new Set()); const [selectedPath, setSelectedPath] = useState(null); + const fileSectionRefs = useRef(new Map()); const params = useMemo(() => ({ workspaceId: context.entityId, @@ -256,13 +290,28 @@ export function ChangesTab({ context }: PluginDetailTabProps) { const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null; const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`; + const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => { + if (node) fileSectionRefs.current.set(filePath, node); + else fileSectionRefs.current.delete(filePath); + }, []); + + const selectFile = useCallback((filePath: string) => { + setSelectedPath(filePath); + window.requestAnimationFrame(() => { + fileSectionRefs.current.get(filePath)?.scrollIntoView({ + block: "start", + behavior: "smooth", + }); + }); + }, []); + useEffect(() => { if (files.length === 0) { setExpandedFiles(new Set()); setSelectedPath(null); return; } - setExpandedFiles((current) => current.size > 0 ? current : new Set(files.map((file) => file.path))); + setExpandedFiles(initialExpandedFileSet(files)); setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null); }, [files]); @@ -309,7 +358,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) { className={buttonClass(includeUntracked)} onClick={() => setIncludeUntracked((value) => !value)} > - Untracked + {includeUntracked ? "Untracked shown" : "Show untracked"} - + {expandedFiles.has(file.path) ? ( + + ) : ( + setExpandedFiles((current) => nextExpandedFileSet(current, file.path))} + /> + )} ))} diff --git a/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts index a0056195d5b..dde2656eb86 100644 --- a/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts +++ b/packages/plugins/plugin-workspace-diff/tests/diff-model.spec.ts @@ -3,6 +3,8 @@ import { buildFilePatch, buildFilePatches, diffSummary, + initialExpandedFileSet, + LONG_DIFF_LINE_THRESHOLD, nextExpandedFileSet, statusLabel, toFileViewModels, @@ -23,6 +25,8 @@ describe("workspace diff UI model", () => { path: "src/app.ts", status: "modified", patchKinds: ["unstaged"], + lineCount: 7, + longDiff: false, }); }); @@ -106,6 +110,47 @@ describe("workspace diff UI model", () => { expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]); }); + it("marks long text diffs so the UI can fold them by default", () => { + const longPatch = [ + "diff --git a/src/large.ts b/src/large.ts", + "index 1111111..2222222 100644", + "--- a/src/large.ts", + "+++ b/src/large.ts", + "@@ -1,1 +1,1 @@", + ...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`), + "", + ].join("\n"); + const files = toFileViewModels(diffResponse({ + files: [ + changedFile({ path: "src/small.ts" }), + changedFile({ + path: "src/large.ts", + additions: LONG_DIFF_LINE_THRESHOLD, + deletions: 0, + patches: [ + { + kind: "unstaged", + patch: longPatch, + additions: LONG_DIFF_LINE_THRESHOLD, + deletions: 0, + binary: false, + oversized: false, + truncated: false, + warnings: [], + }, + ], + }), + ], + })); + const longFile = files.find((file) => file.path === "src/large.ts"); + const defaultExpanded = initialExpandedFileSet(files); + + expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD); + expect(longFile?.longDiff).toBe(true); + expect(defaultExpanded.has("src/small.ts")).toBe(true); + expect(defaultExpanded.has("src/large.ts")).toBe(false); + }); + it("toggles expanded file state without mutating the current set", () => { const current = new Set(["a.ts"]); const collapsed = nextExpandedFileSet(current, "a.ts");