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
93 changes: 78 additions & 15 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/reac
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { ThreadId, type TurnId } from "@t3tools/contracts";
import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
import {
ChevronDownIcon,
ChevronUpIcon,
ChevronLeftIcon,
ChevronRightIcon,
Columns2Icon,
Rows3Icon,
} from "lucide-react";
import {
type WheelEvent as ReactWheelEvent,
useCallback,
Expand All @@ -20,6 +27,13 @@ import { readNativeApi } from "../nativeApi";
import { resolvePathLinkTarget } from "../terminal-links";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
import { useTheme } from "../hooks/useTheme";
import {
buildFileDiffRenderKey,
expandCollapsedDiffFileForPath,
resetCollapsedDiffFiles,
resolveFileDiffPath,
toggleCollapsedDiffFile,
} from "../lib/diffPanelCollapse";
import { buildPatchCacheKey } from "../lib/diffRendering";
import { resolveDiffThemeName } from "../lib/diffRendering";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
Expand Down Expand Up @@ -94,6 +108,11 @@ const DIFF_PANEL_UNSAFE_CSS = `
color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important;
text-decoration-color: currentColor;
}

:host(.diff-file-collapsed) pre,
:host(.diff-file-collapsed) [data-virtualizer-buffer] {
display: none !important;
}
`;

type RenderablePatch =
Expand Down Expand Up @@ -139,18 +158,6 @@ function getRenderablePatch(
}
}

function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
const raw = fileDiff.name ?? fileDiff.prevName ?? "";
if (raw.startsWith("a/") || raw.startsWith("b/")) {
return raw.slice(2);
}
return raw;
}

function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
}

interface DiffPanelProps {
mode?: DiffPanelMode;
}
Expand All @@ -162,6 +169,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const { resolvedTheme } = useTheme();
const { settings } = useAppSettings();
const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
const [collapsedFileKeys, setCollapsedFileKeys] =
useState<ReadonlySet<string>>(resetCollapsedDiffFiles);
const patchViewportRef = useRef<HTMLDivElement>(null);
const turnStripRef = useRef<HTMLDivElement>(null);
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
Expand Down Expand Up @@ -293,15 +302,41 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
);
}, [renderablePatch]);

useEffect(() => {
setCollapsedFileKeys(resetCollapsedDiffFiles());
}, [selectedPatch]);

useEffect(() => {
if (!selectedFilePath) {
return;
}

setCollapsedFileKeys((current) =>
expandCollapsedDiffFileForPath(current, renderableFiles, selectedFilePath),
);
}, [renderableFiles, selectedFilePath]);

useEffect(() => {
if (!selectedFilePath || !patchViewportRef.current) {
return;
}

const selectedFile = renderableFiles.find(
(fileDiff) => resolveFileDiffPath(fileDiff) === selectedFilePath,
);
if (!selectedFile) {
return;
}

if (collapsedFileKeys.has(buildFileDiffRenderKey(selectedFile))) {
return;
}

const target = Array.from(
patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"),
).find((element) => element.dataset.diffFilePath === selectedFilePath);
target?.scrollIntoView({ block: "nearest" });
}, [selectedFilePath, renderableFiles]);
}, [collapsedFileKeys, renderableFiles, selectedFilePath]);

const openDiffFileInEditor = useCallback(
(filePath: string) => {
Expand Down Expand Up @@ -561,6 +596,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
const filePath = resolveFileDiffPath(fileDiff);
const fileKey = buildFileDiffRenderKey(fileDiff);
const themedFileKey = `${fileKey}:${resolvedTheme}`;
const isCollapsed = collapsedFileKeys.has(fileKey);
return (
<div
key={themedFileKey}
Expand All @@ -573,19 +609,46 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
if (!(node instanceof Element)) return false;
return node.hasAttribute("data-title");
});
if (!clickedHeader) return;
const clickedCollapseToggle = composedPath.some((node) => {
if (!(node instanceof Element)) return false;
return node.hasAttribute("data-diff-collapse-toggle");
});
if (!clickedHeader || clickedCollapseToggle) return;
openDiffFileInEditor(filePath);
}}
>
<FileDiff
className={cn(isCollapsed && "diff-file-collapsed")}
fileDiff={fileDiff}
options={{
diffStyle: diffRenderMode === "split" ? "split" : "unified",
disableFileHeader: false,
lineDiffType: "none",
theme: resolveDiffThemeName(resolvedTheme),
themeType: resolvedTheme as DiffThemeType,
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
}}
renderHeaderMetadata={() => (
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground/75 transition-colors hover:bg-accent/50 hover:text-foreground"
data-diff-collapse-toggle=""
aria-label={isCollapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
aria-expanded={!isCollapsed}
onClick={(event) => {
event.stopPropagation();
setCollapsedFileKeys((current) =>
toggleCollapsedDiffFile(current, fileKey),
);
}}
>
{isCollapsed ? (
<ChevronUpIcon className="size-3.5" />
) : (
<ChevronDownIcon className="size-3.5" />
)}
</button>
)}
/>
</div>
);
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/lib/diffPanelCollapse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";

import {
buildFileDiffRenderKey,
expandCollapsedDiffFileForPath,
resetCollapsedDiffFiles,
toggleCollapsedDiffFile,
} from "./diffPanelCollapse";

describe("diffPanelCollapse", () => {
const firstFile = {
name: "src/app.ts",
cacheKey: "file-1",
};
const secondFile = {
name: "src/routes.ts",
cacheKey: "file-2",
};

it("defaults files to expanded", () => {
const collapsed = resetCollapsedDiffFiles();

expect(collapsed.has(buildFileDiffRenderKey(firstFile))).toBe(false);
});

it("toggles one file without affecting others", () => {
const collapsedFirst = toggleCollapsedDiffFile(
resetCollapsedDiffFiles(),
buildFileDiffRenderKey(firstFile),
);

expect(collapsedFirst.has(buildFileDiffRenderKey(firstFile))).toBe(true);
expect(collapsedFirst.has(buildFileDiffRenderKey(secondFile))).toBe(false);
});

it("resets all collapsed files for a new patch selection", () => {
const collapsed = toggleCollapsedDiffFile(
resetCollapsedDiffFiles(),
buildFileDiffRenderKey(firstFile),
);

expect(collapsed.size).toBe(1);
expect(resetCollapsedDiffFiles().size).toBe(0);
});

it("auto-expands the selected file path when it was collapsed", () => {
const renamedFile = {
name: "b/src/new-name.ts",
prevName: "a/src/old-name.ts",
cacheKey: "file-rename",
};
const collapsed = toggleCollapsedDiffFile(
resetCollapsedDiffFiles(),
buildFileDiffRenderKey(renamedFile),
);

const expanded = expandCollapsedDiffFileForPath(collapsed, [renamedFile], "src/new-name.ts");

expect(expanded.has(buildFileDiffRenderKey(renamedFile))).toBe(false);
});
});
52 changes: 52 additions & 0 deletions apps/web/src/lib/diffPanelCollapse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { FileDiffMetadata } from "@pierre/diffs/react";

export function resolveFileDiffPath(fileDiff: Pick<FileDiffMetadata, "name" | "prevName">): string {
const raw = fileDiff.name ?? fileDiff.prevName ?? "";
if (raw.startsWith("a/") || raw.startsWith("b/")) {
return raw.slice(2);
}
return raw;
}

export function buildFileDiffRenderKey(
fileDiff: Pick<FileDiffMetadata, "cacheKey" | "name" | "prevName">,
): string {
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
}

export function toggleCollapsedDiffFile(
current: ReadonlySet<string>,
fileKey: string,
): ReadonlySet<string> {
const next = new Set(current);
if (next.has(fileKey)) {
next.delete(fileKey);
} else {
next.add(fileKey);
}
return next;
}

export function resetCollapsedDiffFiles(): ReadonlySet<string> {
return new Set<string>();
}

export function expandCollapsedDiffFileForPath(
current: ReadonlySet<string>,
files: ReadonlyArray<Pick<FileDiffMetadata, "cacheKey" | "name" | "prevName">>,
filePath: string,
): ReadonlySet<string> {
const match = files.find((fileDiff) => resolveFileDiffPath(fileDiff) === filePath);
if (!match) {
return current;
}

const fileKey = buildFileDiffRenderKey(match);
if (!current.has(fileKey)) {
return current;
}

const next = new Set(current);
next.delete(fileKey);
return next;
}