Files
paperclip/packages/plugins/plugin-workspace-diff/src/ui/index.tsx
T
Dotta 43c5bb81b6 [codex] Workspace diff polish (#6383)
## Thinking Path

> - Paperclip gives operators a workspace diff plugin so they can
inspect agent changes before review
> - The diff view needs reliable base-ref defaults and controls that
stay usable while scrolling large diffs
> - The working branch mixed those plugin improvements with unrelated
server and cloud work
> - Keeping the workspace diff plugin changes isolated makes them easy
to test and review
> - This pull request polishes the workspace diff plugin controls,
base-ref behavior, and sticky headers
> - The benefit is a more predictable diff review surface for agent
workspaces

## What Changed

- Fixed workspace diff default base-ref resolution.
- Improved split/unified and working-tree/against-ref pane controls.
- Made workspace diff headers stay sticky while scrolling.
- Added a review screenshot at
`screenshots/PAP-9841-workspace-diff.png`.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm --filter @paperclipai/plugin-sdk build`
- `pnpm --filter @paperclipai/plugin-workspace-diff exec vitest run
tests/plugin.spec.ts`
- Result: 9 tests passed.

## Risks

- UI-only plugin branch with low data risk.
- The default base-ref inference should be reviewed against unusual
worktree/upstream combinations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent with local shell/git/tool use.
Exact hosted model ID and context-window size are not exposed by the
local Paperclip adapter runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-19 15:51:13 -05:00

825 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
import type { PatchDiffProps } from "@pierre/diffs/react";
import { useFileDiffInstance } from "@pierre/diffs/react";
import {
createElement,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
diffSummary,
fileName,
initialExpandedFileSet,
nextExpandedFileSet,
statusLabel,
toFileViewModels,
type DiffFileViewModel,
type DiffPatchViewModel,
type DiffRenderMode,
} from "../diff-model.js";
import type { WorkspaceDiffResponse } from "../contracts.js";
type WorkspaceDiffData = WorkspaceDiffResponse;
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
type DiffViewMode = "working-tree" | "head";
type LucideIconProps = { size?: number };
const DEFAULT_FILE_SIDEBAR_WIDTH = 280;
const MIN_FILE_SIDEBAR_WIDTH = 220;
const MAX_FILE_SIDEBAR_WIDTH = 520;
const FILE_SIDEBAR_WIDTH_STEP = 16;
const FILE_SIDEBAR_WIDTH_STORAGE_KEY = "paperclip.workspace-diff.files-sidebar-width";
function makeLucideIcon(paths: ReactNode) {
return function LucideIcon({ size = 16 }: LucideIconProps) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: size, height: size, display: "block" }}
>
{paths}
</svg>
);
};
}
// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw.
const RefreshCwIcon = makeLucideIcon(
<>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</>,
);
function readInitialView(): DiffViewMode {
if (typeof window === "undefined") return "working-tree";
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
}
function hasInitialViewParam() {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).has("diffView");
}
function readInitialBaseRef() {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
}
function buttonClass(active = false) {
return [
"inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors",
active
? "border-foreground/20 bg-foreground text-background"
: "border-border bg-background text-muted-foreground hover:text-foreground",
].join(" ");
}
function iconButtonClass(active = false) {
return [
"inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors",
active
? "border-foreground/20 bg-foreground text-background"
: "border-border bg-background text-muted-foreground hover:text-foreground",
].join(" ");
}
function clampFileSidebarWidth(width: number) {
return Math.min(MAX_FILE_SIDEBAR_WIDTH, Math.max(MIN_FILE_SIDEBAR_WIDTH, width));
}
function readStoredFileSidebarWidth() {
if (typeof window === "undefined") return DEFAULT_FILE_SIDEBAR_WIDTH;
try {
const stored = window.localStorage.getItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY);
if (!stored) return DEFAULT_FILE_SIDEBAR_WIDTH;
const parsed = Number.parseInt(stored, 10);
return Number.isFinite(parsed) ? clampFileSidebarWidth(parsed) : DEFAULT_FILE_SIDEBAR_WIDTH;
} catch {
return DEFAULT_FILE_SIDEBAR_WIDTH;
}
}
function writeStoredFileSidebarWidth(width: number) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY, String(clampFileSidebarWidth(width)));
} catch {
// Storage can be unavailable; keep resize interactive even when persistence fails.
}
}
function useIsDesktopDiffLayout() {
const [isDesktop, setIsDesktop] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(min-width: 1024px)").matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(min-width: 1024px)");
const update = () => setIsDesktop(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return isDesktop;
}
function warningText(file: DiffFileViewModel) {
if (file.binary) return "Binary file";
if (file.oversized) return "Too large to render";
if (file.truncated) return "Patch truncated";
if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
if (file.patches.every((patch) => !patch.patch)) return "No text patch";
return null;
}
const PATCH_KIND_LABELS: Record<DiffPatchViewModel["kind"], string> = {
staged: "Staged",
unstaged: "Unstaged",
head: "Head",
untracked: "Untracked",
};
function patchKindLabel(kind: DiffPatchViewModel["kind"]) {
return PATCH_KIND_LABELS[kind] ?? "Patch";
}
function patchWarningText(patch: DiffPatchViewModel) {
if (patch.binary) return "Binary file";
if (patch.oversized) return "Too large to render";
if (patch.truncated) return "Patch truncated";
if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning";
if (!patch.patch) return "No text patch";
return null;
}
function FileRow({
file,
active,
expanded,
onSelect,
onToggle,
onCopy,
}: {
file: DiffFileViewModel;
active: boolean;
expanded: boolean;
onSelect: () => void;
onToggle: () => void;
onCopy: () => void;
}) {
const warning = warningText(file);
const expandLabel = expanded ? "Collapse file" : "Expand file";
const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`;
return (
<div
className={[
"group border-b border-border/70 px-3 py-2 last:border-b-0",
active ? "bg-accent/60" : "bg-background hover:bg-muted/45",
].join(" ")}
>
<div key="main" className="flex min-w-0 items-start gap-2">
<button
key="toggle"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggle}
title={expandLabel}
aria-label={fileAriaLabel}
>
{expanded ? "" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 flex-1 text-left"
onClick={onSelect}
>
<div key="name" className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
<div key="path" className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
</button>
<button
key="copy"
type="button"
className="text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
onClick={onCopy}
title="Copy path"
aria-label={`Copy ${file.path}`}
>
</button>
</div>
<div key="meta" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
<span key="status">{statusLabel(file.status)}</span>
<span key="additions" className="font-mono text-emerald-700 dark:text-emerald-300">{`+${file.additions}`}</span>
<span key="deletions" className="font-mono text-red-700 dark:text-red-300">{`-${file.deletions}`}</span>
{warning ? <span key="warning" className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
</div>
</div>
);
}
// The upstream React wrapper emits React 19 key warnings for its internal slot array.
// This mounts the same Diffs custom element through the exported imperative hook.
function WorkspacePatchDiff({
patch,
options,
}: {
patch: string;
options: WorkspacePatchDiffOptions;
}) {
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
const { ref } = useFileDiffInstance({
fileDiff,
options,
metrics: undefined,
lineAnnotations: undefined,
selectedLines: undefined,
prerenderedHTML: undefined,
hasGutterRenderUtility: false,
hasCustomHeader: false,
disableWorkerPool: false,
});
return createElement(DIFFS_TAG_NAME, { ref });
}
function EmptyState() {
return (
<div className="border border-dashed border-border bg-background px-4 py-8 text-center">
<div className="text-sm font-medium text-foreground">No workspace changes</div>
<div className="mt-1 text-sm text-muted-foreground">
The workspace matches its current comparison target.
</div>
</div>
);
}
function LoadingState() {
return (
<div className="border border-dashed border-border bg-background px-4 py-8 text-center text-sm text-muted-foreground">
Loading workspace changes
</div>
);
}
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" role="alert">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="font-medium text-foreground">Unable to load workspace changes.</div>
<div className="mt-1 text-muted-foreground">
Retry the request or open the details below for the technical error.
</div>
</div>
<button
type="button"
className={buttonClass(false)}
onClick={onRetry}
aria-label="Retry loading workspace changes"
>
Retry
</button>
</div>
<details className="mt-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
Troubleshooting details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words border border-border bg-background px-3 py-2 font-mono text-xs text-muted-foreground">
{message || "No error message was provided."}
</pre>
</details>
</div>
);
}
function FileDiffPanel({
file,
mode,
lineWrap,
}: {
file: DiffFileViewModel;
mode: DiffRenderMode;
lineWrap: boolean;
}) {
const warning = warningText(file);
if (warning) {
return (
<div className="border border-dashed border-border bg-background px-4 py-6 text-sm text-muted-foreground">
{warning ?? "No renderable patch is available for this file."}
</div>
);
}
return (
<div className="space-y-3">
{file.patches.map((patch, index) => {
const patchWarning = patchWarningText(patch);
return (
<div key={`${patch.kind}:${index}`} className="overflow-hidden border border-border bg-background">
{file.patches.length > 1 ? (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{patchKindLabel(patch.kind)}</span>
<span className="font-mono text-emerald-700 dark:text-emerald-300">{`+${patch.additions}`}</span>
<span className="font-mono text-red-700 dark:text-red-300">{`-${patch.deletions}`}</span>
</div>
) : null}
{patchWarning || !patch.patch ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
{patchWarning ?? "No renderable patch is available for this file."}
</div>
) : (
<WorkspacePatchDiff
patch={patch.patch}
options={{
diffStyle: mode,
overflow: lineWrap ? "wrap" : "scroll",
disableLineNumbers: false,
themeType: "system",
}}
/>
)}
</div>
);
})}
</div>
);
}
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 (
<div className="border border-dashed border-border bg-background px-4 py-5 text-sm text-muted-foreground">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="font-medium text-foreground">{title}</div>
<div className="mt-1 font-mono text-xs">{details}</div>
</div>
<button
type="button"
className={buttonClass(false)}
onClick={onExpand}
aria-label={`Show diff for ${file.path}`}
>
Show file
</button>
</div>
</div>
);
}
export function ChangesTab({ context }: PluginDetailTabProps) {
const toast = usePluginToast();
const [mode, setMode] = useState<DiffRenderMode>("split");
const [lineWrap, setLineWrap] = useState(false);
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
const viewTouchedRef = useRef(hasInitialViewParam());
const [includeUntracked, setIncludeUntracked] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [fileSidebarWidth, setFileSidebarWidth] = useState(() => readStoredFileSidebarWidth());
const [fileSidebarResizing, setFileSidebarResizing] = useState(false);
const fileSidebarWidthRef = useRef(fileSidebarWidth);
const fileSidebarDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
const diffScrollRef = useRef<HTMLElement | null>(null);
const scrollSyncFrameRef = useRef<number | null>(null);
const usesDesktopDiffLayout = useIsDesktopDiffLayout();
const requestedBaseRef = baseRef.trim();
const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view;
const fileSidebarStyle = useMemo(
() => usesDesktopDiffLayout ? { width: `${fileSidebarWidth}px` } : undefined,
[fileSidebarWidth, usesDesktopDiffLayout],
);
const params = useMemo(() => ({
workspaceId: context.entityId,
companyId: context.companyId ?? "",
projectId: context.projectId ?? "",
entityType: context.entityType,
view: effectiveView,
baseRef: requestedBaseRef || null,
includeUntracked,
}), [context.companyId, context.entityId, context.entityType, context.projectId, effectiveView, includeUntracked, requestedBaseRef]);
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
const files = useMemo(() => toFileViewModels(data), [data]);
const summary = useMemo(() => diffSummary(data), [data]);
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",
});
});
}, []);
const syncSelectedPathFromScroll = useCallback(() => {
const container = diffScrollRef.current;
if (!container || files.length === 0) return;
const containerTop = container.getBoundingClientRect().top;
let nextPath = files[0]?.path ?? null;
for (const file of files) {
const section = fileSectionRefs.current.get(file.path);
if (!section) continue;
const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop;
if (offsetFromScrollTop <= 48) {
nextPath = file.path;
} else {
break;
}
}
if (nextPath) {
setSelectedPath((current) => current === nextPath ? current : nextPath);
}
}, [files]);
const handleDiffScroll = useCallback(() => {
if (scrollSyncFrameRef.current !== null) return;
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
scrollSyncFrameRef.current = null;
syncSelectedPathFromScroll();
});
}, [syncSelectedPathFromScroll]);
const commitFileSidebarWidth = useCallback((nextWidth: number) => {
const clamped = clampFileSidebarWidth(nextWidth);
fileSidebarWidthRef.current = clamped;
setFileSidebarWidth(clamped);
writeStoredFileSidebarWidth(clamped);
}, []);
const handleFileSidebarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (!usesDesktopDiffLayout) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
fileSidebarDragRef.current = {
startX: event.clientX,
startWidth: fileSidebarWidthRef.current,
};
setFileSidebarResizing(true);
}, [usesDesktopDiffLayout]);
const handleFileSidebarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const drag = fileSidebarDragRef.current;
if (!drag) return;
const nextWidth = clampFileSidebarWidth(drag.startWidth + event.clientX - drag.startX);
fileSidebarWidthRef.current = nextWidth;
setFileSidebarWidth(nextWidth);
}, []);
const endFileSidebarResize = useCallback(() => {
if (!fileSidebarDragRef.current) return;
fileSidebarDragRef.current = null;
setFileSidebarResizing(false);
writeStoredFileSidebarWidth(fileSidebarWidthRef.current);
}, []);
const handleFileSidebarKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!usesDesktopDiffLayout) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
commitFileSidebarWidth(fileSidebarWidth - FILE_SIDEBAR_WIDTH_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
commitFileSidebarWidth(fileSidebarWidth + FILE_SIDEBAR_WIDTH_STEP);
} else if (event.key === "Home") {
event.preventDefault();
commitFileSidebarWidth(MIN_FILE_SIDEBAR_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
commitFileSidebarWidth(MAX_FILE_SIDEBAR_WIDTH);
}
}, [commitFileSidebarWidth, fileSidebarWidth, usesDesktopDiffLayout]);
useEffect(() => {
const defaultBaseRef = data?.defaultBaseRef?.trim();
if (!defaultBaseRef) return;
if (!baseRef.trim() && !baseRefTouchedRef.current) {
setBaseRef(defaultBaseRef);
}
if (view === "working-tree" && !viewTouchedRef.current) {
setView("head");
}
}, [baseRef, data?.defaultBaseRef, view]);
useEffect(() => {
if (files.length === 0) {
setExpandedFiles(new Set());
setSelectedPath(null);
return;
}
setExpandedFiles(initialExpandedFileSet(files));
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
}, [files]);
useEffect(() => {
return () => {
if (scrollSyncFrameRef.current !== null) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
};
}, []);
useEffect(() => {
if (!fileSidebarResizing || typeof document === "undefined") return;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
};
}, [fileSidebarResizing]);
const copyPath = async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath);
toast({ title: "Path copied", body: filePath });
} catch {
toast({ title: "Copy failed", body: filePath, tone: "error" });
}
};
return (
<div className="space-y-3">
<div key="toolbar" className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
<div key="summary" className="min-w-0">
<div key="summary-line" className="flex flex-wrap items-center gap-2 text-sm">
<span key="changed" className="font-medium text-foreground">{summary.changedLabel}</span>
<span key="lines" className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
{summary.truncated ? (
<span key="truncated" className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
) : null}
{summary.warningCount > 0 ? (
<span key="warnings" className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
) : null}
</div>
<div key="compare" className="mt-1 truncate font-mono text-xs text-muted-foreground">
{compareLabel}
</div>
</div>
<div key="actions" className="flex flex-wrap items-center gap-2">
<div key="layout" className="inline-flex gap-1" aria-label="Diff layout">
<button key="split" type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
Split
</button>
<button key="unified" type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
Unified
</button>
</div>
<button
key="line-wrap"
type="button"
className={buttonClass(lineWrap)}
onClick={() => setLineWrap((value) => !value)}
title={lineWrap ? "Disable line wrapping" : "Enable line wrapping"}
aria-pressed={lineWrap}
>
{lineWrap ? "Wrap on" : "Wrap lines"}
</button>
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
<button
key="working-tree"
type="button"
className={buttonClass(effectiveView === "working-tree")}
onClick={() => {
viewTouchedRef.current = true;
setView("working-tree");
}}
>
Working tree
</button>
<button
key="head"
type="button"
className={buttonClass(effectiveView === "head")}
onClick={() => {
viewTouchedRef.current = true;
setView("head");
}}
>
Against ref
</button>
</div>
{view === "head" ? (
<input
key="base-ref"
className="h-8 w-40 rounded-md border border-border bg-background px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
value={baseRef}
onChange={(event) => {
baseRefTouchedRef.current = true;
setBaseRef(event.target.value);
}}
placeholder="origin/master"
aria-label="Base ref"
/>
) : null}
{view === "working-tree" ? (
<button
key="untracked"
type="button"
className={buttonClass(includeUntracked)}
onClick={() => setIncludeUntracked((value) => !value)}
>
{includeUntracked ? "Untracked shown" : "Show untracked"}
</button>
) : null}
<button
key="refresh"
type="button"
className={iconButtonClass(false)}
onClick={() => refresh()}
title="Refresh changes"
aria-label="Refresh changes"
>
<RefreshCwIcon />
</button>
</div>
</div>
{loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error.message} onRetry={refresh} />
) : files.length === 0 ? (
<EmptyState />
) : (
<div key="content" className="flex flex-col gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:flex-row">
<aside
key="files"
className="relative flex min-w-0 flex-col border border-border bg-background lg:h-full lg:shrink-0 lg:overflow-hidden"
style={fileSidebarStyle}
>
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
Files
</div>
<div key="list" className="max-h-[70vh] overflow-auto lg:max-h-none lg:flex-1">
{files.map((file, index) => (
<FileRow
key={`${file.path}:${index}`}
file={file}
active={file.path === selectedFile?.path}
expanded={expandedFiles.has(file.path)}
onSelect={() => selectFile(file.path)}
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
onCopy={() => void copyPath(file.path)}
/>
))}
</div>
<div
role="separator"
aria-label="Resize file list"
aria-orientation="vertical"
aria-valuemin={MIN_FILE_SIDEBAR_WIDTH}
aria-valuemax={MAX_FILE_SIDEBAR_WIDTH}
aria-valuenow={fileSidebarWidth}
tabIndex={0}
className={[
"absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize touch-none outline-none lg:block",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
fileSidebarResizing ? "before:bg-ring" : "",
].join(" ")}
onPointerDown={handleFileSidebarPointerDown}
onPointerMove={handleFileSidebarPointerMove}
onPointerUp={endFileSidebarResize}
onPointerCancel={endFileSidebarResize}
onLostPointerCapture={endFileSidebarResize}
onKeyDown={handleFileSidebarKeyDown}
/>
</aside>
<main
key="diffs"
ref={diffScrollRef}
className="max-h-[70vh] min-w-0 flex-1 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
onScroll={handleDiffScroll}
>
{files
.map((file, index) => (
<section
key={`${file.path}:${index}`}
ref={setFileSectionRef(file.path)}
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
>
<div
key="header"
className="sticky top-0 z-30 flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-background px-3 py-2 shadow-sm"
>
<div key="left" className="flex min-w-0 items-start gap-2">
<button
key="collapse"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
>
{expandedFiles.has(file.path) ? "" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 text-left"
onClick={() => selectFile(file.path)}
>
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
{file.oldPath ? (
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
from {file.oldPath}
</div>
) : null}
</button>
</div>
<div key="actions" className="flex shrink-0 items-center gap-1">
<button
key="copy"
type="button"
className={iconButtonClass(false)}
title="Copy path"
aria-label={`Copy ${file.path}`}
onClick={() => void copyPath(file.path)}
>
</button>
</div>
</div>
{expandedFiles.has(file.path) ? (
<FileDiffPanel key="diff" file={file} mode={mode} lineWrap={lineWrap} />
) : (
<CollapsedFilePanel
key="collapsed"
file={file}
onExpand={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
/>
)}
</section>
))}
</main>
</div>
)}
</div>
);
}