Skip to content

Commit 2414a0c

Browse files
authored
Add attachment diff previews in chat (#477)
- Preview file attachments as diff or text in a modal - Reuse shared patch parsing and diff tree helpers - Serve persisted attachment ids with diff MIME types
1 parent ff52c42 commit 2414a0c

10 files changed

Lines changed: 590 additions & 124 deletions

File tree

apps/server/src/wsServer.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
ProjectionSnapshotQuery,
6565
type ProjectionSnapshotQueryShape,
6666
} from "./orchestration/Services/ProjectionSnapshotQuery";
67+
import { createAttachmentId } from "./attachmentStore";
6768

6869
const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
6970
const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
@@ -687,6 +688,29 @@ describe("WebSocket Server", () => {
687688
expect(bytes).toEqual(Buffer.from("hello-attachment"));
688689
});
689690

691+
it("serves persisted attachments by attachment id", async () => {
692+
const baseDir = makeTempDir("okcode-state-attachment-id-");
693+
const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
694+
const attachmentId = createAttachmentId("thread-preview");
695+
if (!attachmentId) {
696+
throw new Error("Failed to create a safe test attachment id.");
697+
}
698+
const attachmentPath = path.join(attachmentsDir, `${attachmentId}.patch`);
699+
fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
700+
fs.writeFileSync(attachmentPath, Buffer.from("diff --git a/a.ts b/a.ts\n"));
701+
702+
const { cwd } = makeWorkspaceFixture("project");
703+
server = await createTestServer({ cwd, baseDir });
704+
const addr = server.address();
705+
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
706+
expect(port).toBeGreaterThan(0);
707+
708+
const response = await fetch(`http://127.0.0.1:${port}/attachments/${attachmentId}`);
709+
expect(response.status).toBe(200);
710+
expect(response.headers.get("content-type")).toContain("text/x-diff");
711+
expect(await response.text()).toContain("diff --git");
712+
});
713+
690714
it("serves persisted attachments for URL-encoded paths", async () => {
691715
const baseDir = makeTempDir("okcode-state-attachments-encoded-");
692716
const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);

apps/server/src/wsServer.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,26 @@ function isNewerSemver(a: string, b: string): boolean {
137137
return false;
138138
}
139139

140+
function inferAttachmentContentType(filePath: string): string {
141+
const mimeType = Mime.getType(filePath);
142+
if (mimeType) {
143+
return mimeType;
144+
}
145+
146+
const normalizedPath = filePath.toLowerCase();
147+
if (normalizedPath.endsWith(".patch") || normalizedPath.endsWith(".diff")) {
148+
return "text/x-diff; charset=utf-8";
149+
}
150+
if (normalizedPath.endsWith(".md")) {
151+
return "text/markdown; charset=utf-8";
152+
}
153+
if (normalizedPath.endsWith(".txt")) {
154+
return "text/plain; charset=utf-8";
155+
}
156+
157+
return "application/octet-stream";
158+
}
159+
140160
/**
141161
* Remote address from the HTTP upgrade (`request.socket`). The `ws` library often does not
142162
* expose a reliable `socket.remoteAddress` when handling messages, so we capture it here.
@@ -717,7 +737,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
717737
return;
718738
}
719739

720-
const contentType = Mime.getType(filePath) ?? "application/octet-stream";
740+
const contentType = inferAttachmentContentType(filePath);
721741
res.writeHead(200, {
722742
"Content-Type": contentType,
723743
"Cache-Control": "public, max-age=31536000, immutable",

apps/web/src/components/DiffPanel.tsx

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { parsePatchFiles } from "@pierre/diffs";
21
import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
32
import { useQuery } from "@tanstack/react-query";
43
import {
@@ -18,7 +17,8 @@ import { useTheme } from "../hooks/useTheme";
1817
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
1918
import { buildAcceptedDiffFileKey, filterAcceptedDiffFiles } from "../lib/diffPanelAcceptance";
2019
import { checkpointDiffQueryOptions } from "../lib/providerReactQuery";
21-
import { buildPatchCacheKey, resolveDiffThemeName } from "../lib/diffRendering";
20+
import { resolveDiffThemeName } from "../lib/diffRendering";
21+
import { parseRenderablePatch } from "../lib/renderablePatch";
2222
import { cn } from "../lib/utils";
2323
import { useStore } from "../store";
2424
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
@@ -95,42 +95,6 @@ const DIFF_PANEL_UNSAFE_CSS = `
9595
}
9696
`;
9797

98-
type RenderablePatch =
99-
| { kind: "files"; files: FileDiffMetadata[] }
100-
| { kind: "raw"; text: string; reason: string };
101-
102-
function getRenderablePatch(
103-
patch: string | undefined,
104-
cacheScope = "diff-panel",
105-
): RenderablePatch | null {
106-
if (!patch) return null;
107-
const normalizedPatch = patch.trim();
108-
if (normalizedPatch.length === 0) return null;
109-
110-
try {
111-
const parsedPatches = parsePatchFiles(
112-
normalizedPatch,
113-
buildPatchCacheKey(normalizedPatch, cacheScope),
114-
);
115-
const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files);
116-
if (files.length > 0) {
117-
return { kind: "files", files };
118-
}
119-
120-
return {
121-
kind: "raw",
122-
text: normalizedPatch,
123-
reason: "Unsupported diff format. Showing raw patch.",
124-
};
125-
} catch {
126-
return {
127-
kind: "raw",
128-
text: normalizedPatch,
129-
reason: "Failed to parse patch. Showing raw patch.",
130-
};
131-
}
132-
}
133-
13498
type FileDiffCategory = "all" | "added" | "modified" | "deleted" | "renamed";
13599

136100
const CATEGORY_ORDER: FileDiffCategory[] = ["all", "added", "modified", "deleted", "renamed"];
@@ -256,7 +220,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
256220
? "Failed to load checkpoint diff."
257221
: null;
258222
const renderablePatch = useMemo(
259-
() => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`),
223+
() => parseRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`),
260224
[resolvedTheme, selectedPatch],
261225
);
262226
const renderableFiles = useMemo(() => {
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { FileDiff } from "@pierre/diffs/react";
2+
import { TurnId } from "@okcode/contracts";
3+
import { ExternalLinkIcon, LoaderCircleIcon } from "lucide-react";
4+
import { useEffect, useMemo, useState } from "react";
5+
6+
import type { ChatFileAttachment } from "../../types";
7+
import {
8+
buildAttachmentPreviewModel,
9+
buildAttachmentPreviewTreeFiles,
10+
type AttachmentPreviewModel,
11+
} from "../../lib/attachmentPreview";
12+
import { resolveDiffThemeName } from "../../lib/diffRendering";
13+
import { ChangedFilesTree } from "./ChangedFilesTree";
14+
import { PR_REVIEW_DIFF_UNSAFE_CSS, resolveFileDiffPath } from "../pr-review/pr-review-utils";
15+
import {
16+
Dialog,
17+
DialogDescription,
18+
DialogFooter,
19+
DialogHeader,
20+
DialogPanel,
21+
DialogPopup,
22+
DialogTitle,
23+
} from "../ui/dialog";
24+
25+
interface AttachmentPreviewDialogProps {
26+
attachment: ChatFileAttachment;
27+
open: boolean;
28+
onOpenChange: (open: boolean) => void;
29+
resolvedTheme: "light" | "dark";
30+
}
31+
32+
interface AttachmentPreviewState {
33+
status: "idle" | "loading" | "ready" | "error";
34+
preview: AttachmentPreviewModel | null;
35+
error: string | null;
36+
}
37+
38+
function emptyPreviewState(): AttachmentPreviewState {
39+
return {
40+
status: "idle",
41+
preview: null,
42+
error: null,
43+
};
44+
}
45+
46+
export function AttachmentPreviewDialog({
47+
attachment,
48+
open,
49+
onOpenChange,
50+
resolvedTheme,
51+
}: AttachmentPreviewDialogProps) {
52+
const [state, setState] = useState<AttachmentPreviewState>(() => emptyPreviewState());
53+
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
54+
55+
useEffect(() => {
56+
if (!open) {
57+
setState(emptyPreviewState());
58+
setSelectedFilePath(null);
59+
return;
60+
}
61+
if (!attachment.url) {
62+
setState({
63+
status: "error",
64+
preview: null,
65+
error: "This attachment does not have a preview URL.",
66+
});
67+
return;
68+
}
69+
70+
const controller = new AbortController();
71+
setState({
72+
status: "loading",
73+
preview: null,
74+
error: null,
75+
});
76+
77+
void fetch(attachment.url, { signal: controller.signal })
78+
.then(async (response) => {
79+
if (!response.ok) {
80+
throw new Error(`Failed to load attachment (${response.status}).`);
81+
}
82+
const text = await response.text();
83+
if (controller.signal.aborted) return;
84+
const preview = buildAttachmentPreviewModel({
85+
name: attachment.name,
86+
mimeType: attachment.mimeType,
87+
text,
88+
});
89+
const firstDiffFilePath =
90+
preview.kind === "diff" && preview.files[0]
91+
? resolveFileDiffPath(preview.files[0])
92+
: null;
93+
setState({
94+
status: "ready",
95+
preview,
96+
error: null,
97+
});
98+
setSelectedFilePath(firstDiffFilePath);
99+
})
100+
.catch((error) => {
101+
if (controller.signal.aborted) return;
102+
setState({
103+
status: "error",
104+
preview: null,
105+
error: error instanceof Error ? error.message : "Failed to load attachment preview.",
106+
});
107+
});
108+
109+
return () => {
110+
controller.abort();
111+
};
112+
}, [attachment.mimeType, attachment.name, attachment.url, open]);
113+
114+
const diffFiles = useMemo(
115+
() =>
116+
state.preview?.kind === "diff" ? buildAttachmentPreviewTreeFiles(state.preview.files) : [],
117+
[state.preview],
118+
);
119+
const selectedDiffFile = useMemo(() => {
120+
if (state.preview?.kind !== "diff") {
121+
return null;
122+
}
123+
if (state.preview.files.length === 0) {
124+
return null;
125+
}
126+
return (
127+
state.preview.files.find((fileDiff) => resolveFileDiffPath(fileDiff) === selectedFilePath) ??
128+
state.preview.files[0] ??
129+
null
130+
);
131+
}, [selectedFilePath, state.preview]);
132+
133+
return (
134+
<Dialog open={open} onOpenChange={onOpenChange}>
135+
<DialogPopup className="max-w-6xl">
136+
<DialogHeader>
137+
<DialogTitle>{attachment.name}</DialogTitle>
138+
<DialogDescription>
139+
{attachment.mimeType} · {new Intl.NumberFormat().format(attachment.sizeBytes)} bytes
140+
</DialogDescription>
141+
</DialogHeader>
142+
<DialogPanel className="min-h-[28rem]">
143+
{state.status === "loading" && (
144+
<div className="flex h-full min-h-[22rem] items-center justify-center gap-2 text-sm text-muted-foreground">
145+
<LoaderCircleIcon className="size-4 animate-spin" />
146+
Loading attachment preview…
147+
</div>
148+
)}
149+
150+
{state.status === "error" && (
151+
<div className="flex h-full min-h-[22rem] flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/70 bg-muted/20 px-6 text-center">
152+
<p className="max-w-md text-sm text-muted-foreground">{state.error}</p>
153+
{attachment.url && (
154+
<a
155+
href={attachment.url}
156+
target="_blank"
157+
rel="noreferrer"
158+
className="inline-flex h-8 items-center justify-center gap-2 rounded-lg border border-foreground/12 bg-card px-3 text-xs font-medium text-foreground shadow-[inset_0_1px_0_hsl(0_0%_100%/0.06)] transition-all duration-200 hover:border-primary/35 hover:bg-accent/80"
159+
>
160+
<ExternalLinkIcon className="size-3.5" />
161+
Open raw attachment
162+
</a>
163+
)}
164+
</div>
165+
)}
166+
167+
{state.status === "ready" && state.preview?.kind === "text" && (
168+
<div className="rounded-xl border border-border/70 bg-background/70">
169+
<pre className="max-h-[70vh] overflow-auto whitespace-pre-wrap break-all p-4 font-mono text-[12px] leading-5 text-foreground/85">
170+
{state.preview.text}
171+
</pre>
172+
</div>
173+
)}
174+
175+
{state.status === "ready" && state.preview?.kind === "diff" && selectedDiffFile && (
176+
<div className="grid min-h-[28rem] gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
177+
<div className="rounded-xl border border-border/70 bg-background/70 p-2">
178+
<ChangedFilesTree
179+
turnId={TurnId.makeUnsafe(`attachment-${attachment.id}`)}
180+
files={diffFiles}
181+
allDirectoriesExpanded
182+
resolvedTheme={resolvedTheme}
183+
cwd={undefined}
184+
onSelectFile={setSelectedFilePath}
185+
selectedFilePath={resolveFileDiffPath(selectedDiffFile)}
186+
/>
187+
</div>
188+
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/70">
189+
<div className="border-b border-border/70 px-4 py-3">
190+
<p className="truncate font-mono text-xs text-muted-foreground">
191+
{resolveFileDiffPath(selectedDiffFile)}
192+
</p>
193+
</div>
194+
<div className="max-h-[70vh] overflow-auto p-2">
195+
<FileDiff
196+
fileDiff={selectedDiffFile}
197+
options={{
198+
diffStyle: "unified",
199+
lineDiffType: "none",
200+
overflow: "wrap",
201+
theme: resolveDiffThemeName(resolvedTheme),
202+
themeType: resolvedTheme,
203+
unsafeCSS: PR_REVIEW_DIFF_UNSAFE_CSS,
204+
}}
205+
/>
206+
</div>
207+
</div>
208+
</div>
209+
)}
210+
</DialogPanel>
211+
<DialogFooter variant="bare" className="justify-end gap-2">
212+
{attachment.url && (
213+
<a
214+
href={attachment.url}
215+
target="_blank"
216+
rel="noreferrer"
217+
className="inline-flex h-8 items-center justify-center gap-2 rounded-lg border border-foreground/12 bg-card px-3 text-xs font-medium text-foreground shadow-[inset_0_1px_0_hsl(0_0%_100%/0.06)] transition-all duration-200 hover:border-primary/35 hover:bg-accent/80"
218+
>
219+
<ExternalLinkIcon className="size-3.5" />
220+
Open raw attachment
221+
</a>
222+
)}
223+
</DialogFooter>
224+
</DialogPopup>
225+
</Dialog>
226+
);
227+
}

0 commit comments

Comments
 (0)