Skip to content

Commit e021fb7

Browse files
authored
refactor: Centralize image and binary extension utilities (#2363)
## Problem Image extension sets and MIME mappings were duplicated across 9+ files, causing inconsistent behavior across paste, drag/drop, attachments, thumbnails and Claude API conversion. <!-- Who is this for and what problem does it solve? --> <!-- Closes #ISSUE_ID --> ## Changes 1. Add `@posthog/shared/image` as canonical source: `IMAGE_MIME_TYPES`, `ALLOWED_IMAGE_MIME_TYPES`, `CLAUDE_IMAGE_EXTENSIONS`, `ClaudeImageMimeType`, plus `isImageFile` / `isRasterImageFile` / `isClaudeImageFile` / `isGifFile` / `getImageMimeType` / `parseImageDataUrl` 2. Add `@posthog/shared/binary` with `BINARY_EXTENSIONS` and `isBinaryFile`, image portion derived from the canonical image set 3. Delete `apps/code/src/shared/constants/image.ts` and `apps/code/src/shared/utils/imageDataUrl.ts`; migrate 7 importers to `@posthog/shared` 4. Wire `@posthog/shared` into mobile (package.json dep + Metro alias to TS source) 5. Replace local `COMMON_IMAGE_EXTENSIONS` and `ImageMimeType` union in the Claude ACP adapter with shared exports 6. Switch `CodeEditorPanel` to `isRasterImageFile` so SVGs keep opening in CodeMirror (broad `isImageFile` now includes svg/heic) <!-- What did you change and why? --> <!-- If there are frontend changes, include screenshots. --> ## How did you test this? Manually <!-- Describe what you tested -- manual steps, automated tests, or both. --> <!-- If you're an agent, only list tests you actually ran. --> ## Publish to changelog? no <!-- For features only --> <!-- If publishing, you must provide changelog details in the #changelog Slack channel. You will receive a follow-up PR comment or notification. --> <!-- If not, write "no" or "do not publish to changelog" to explicitly opt-out of posting to #changelog. Removing this entire section will not prevent posting. -->
1 parent 7ebe5a1 commit e021fb7

25 files changed

Lines changed: 763 additions & 345 deletions

File tree

apps/code/src/main/trpc/routers/os.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import type { IAppMeta } from "@posthog/platform/app-meta";
55
import type { DialogSeverity, IDialog } from "@posthog/platform/dialog";
66
import type { IImageProcessor } from "@posthog/platform/image-processor";
77
import type { IUrlLauncher } from "@posthog/platform/url-launcher";
8-
import { IMAGE_MIME_TYPES } from "@shared/constants/image";
8+
import {
9+
ALLOWED_IMAGE_MIME_TYPES,
10+
IMAGE_MIME_TYPES,
11+
isRasterImageFile,
12+
} from "@posthog/shared";
913
import { z } from "zod";
1014
import { container } from "../../di/container";
1115
import { MAIN_TOKENS } from "../../di/tokens";
@@ -293,7 +297,8 @@ export const osRouter = router({
293297
if (stat.size > input.maxSizeBytes) return null;
294298

295299
const ext = path.extname(input.filePath).toLowerCase().slice(1);
296-
const mime = IMAGE_MIME_TYPES[ext] ?? "application/octet-stream";
300+
const mime = IMAGE_MIME_TYPES[ext];
301+
if (!mime || !ALLOWED_IMAGE_MIME_TYPES.has(mime)) return null;
297302

298303
const buffer = await fsPromises.readFile(input.filePath);
299304
return `data:${mime};base64,${buffer.toString("base64")}`;
@@ -354,7 +359,7 @@ export const osRouter = router({
354359
.input(z.object({ filePath: z.string().min(1) }))
355360
.mutation(async ({ input }) => {
356361
const ext = path.extname(input.filePath).toLowerCase().slice(1);
357-
if (!IMAGE_MIME_TYPES[ext]) {
362+
if (!isRasterImageFile(input.filePath)) {
358363
throw new Error(`Unsupported image type: .${ext}`);
359364
}
360365

apps/code/src/renderer/components/ui/SafeImagePreview.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Flex, Text } from "@radix-ui/themes";
21
import {
32
buildImageDataUrl,
43
isAllowedImageMimeType,
54
MAX_IMAGE_BASE64_LENGTH,
6-
} from "@shared/utils/imageDataUrl";
5+
} from "@posthog/shared";
6+
import { Flex, Text } from "@radix-ui/themes";
77
import { useState } from "react";
88

99
interface SafeImagePreviewProps {
@@ -12,6 +12,7 @@ interface SafeImagePreviewProps {
1212
mimeType: string;
1313
alt?: string;
1414
className?: string;
15+
style?: React.CSSProperties;
1516
/** Rendered when the image fails to decode or has a disallowed mime type. */
1617
fallback?: React.ReactNode;
1718
}
@@ -33,6 +34,7 @@ export function SafeImagePreview({
3334
mimeType,
3435
alt,
3536
className,
37+
style,
3638
fallback,
3739
}: SafeImagePreviewProps) {
3840
const [hasError, setHasError] = useState(false);
@@ -57,6 +59,7 @@ export function SafeImagePreview({
5759
src={buildImageDataUrl(mimeType, base64)}
5860
alt={alt ?? "image preview"}
5961
className={className ?? "max-h-full max-w-full object-contain"}
62+
style={style}
6063
onError={() => setHasError(true)}
6164
/>
6265
);

apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore";
1212
import { useCwd } from "@features/sidebar/hooks/useCwd";
1313
import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace";
1414
import { Check, Copy } from "@phosphor-icons/react";
15+
import {
16+
getImageMimeType,
17+
isRasterImageFile,
18+
parseImageDataUrl,
19+
} from "@posthog/shared";
1520
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
1621
import { trpcClient, useTRPC } from "@renderer/trpc/client";
17-
import { getImageMimeType, isImageFile } from "@shared/constants/image";
1822
import type { Task } from "@shared/types";
19-
import { parseImageDataUrl } from "@shared/utils/imageDataUrl";
2023

2124
import { useQuery } from "@tanstack/react-query";
2225
import { useCallback, useMemo, useState } from "react";
@@ -73,7 +76,7 @@ export function CodeEditorPanel({
7376
const repoPath = useCwd(taskId);
7477
const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath);
7578
const filePath = getRelativePath(absolutePath, repoPath);
76-
const isImage = isImageFile(absolutePath);
79+
const isImage = isRasterImageFile(absolutePath);
7780
const isMarkdown = isMarkdownFile(absolutePath);
7881
const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit);
7982
const expandToFile = useFileTreeStore((s) => s.expandToFile);

apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,6 @@ const mockFs = vi.hoisted(() => ({
77
readFileAsBase64: { query: vi.fn() },
88
}));
99

10-
vi.mock("@shared/constants/image", async () => {
11-
const actual = await vi.importActual<
12-
typeof import("@shared/constants/image")
13-
>("@shared/constants/image");
14-
return {
15-
...actual,
16-
getImageMimeType: (name: string) => {
17-
const ext = name.split(".").pop()?.toLowerCase();
18-
const map: Record<string, string> = {
19-
png: "image/png",
20-
jpg: "image/jpeg",
21-
jpeg: "image/jpeg",
22-
gif: "image/gif",
23-
webp: "image/webp",
24-
};
25-
return map[ext ?? ""] ?? "image/png";
26-
},
27-
};
28-
});
29-
3010
vi.mock("@renderer/trpc/client", () => ({
3111
trpcClient: {
3212
fs: mockFs,
@@ -172,6 +152,26 @@ describe("cloud-prompt", () => {
172152
).rejects.toThrow(/Unsupported image/);
173153
});
174154

155+
it("treats SVG attachments as text resource links", async () => {
156+
const blocks = await buildCloudPromptBlocks(
157+
'see <file path="/tmp/icon.svg" />',
158+
);
159+
expect(blocks[1]).toMatchObject({
160+
type: "resource_link",
161+
name: "icon.svg",
162+
});
163+
expect(mockFs.readFileAsBase64.query).not.toHaveBeenCalled();
164+
});
165+
166+
it("rejects HEIC and HEIF as unsupported attachments (not images)", async () => {
167+
await expect(
168+
buildCloudPromptBlocks('see <file path="/tmp/photo.heic" />'),
169+
).rejects.toThrow(/Unsupported attachment/);
170+
await expect(
171+
buildCloudPromptBlocks('see <file path="/tmp/photo.heif" />'),
172+
).rejects.toThrow(/Unsupported attachment/);
173+
});
174+
175175
it("does not rely on readAbsoluteFile for txt attachments", async () => {
176176
const blocks = await buildCloudPromptBlocks(
177177
'read <file path="/tmp/maybe-missing-on-disk.txt" />',

apps/code/src/renderer/features/editor/utils/cloud-prompt.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { ContentBlock } from "@agentclientprotocol/sdk";
2-
import { CLOUD_PROMPT_PREFIX, serializeCloudPrompt } from "@posthog/shared";
2+
import {
3+
CLOUD_PROMPT_PREFIX,
4+
getImageMimeType,
5+
isClaudeImageFile,
6+
isRasterImageFile,
7+
serializeCloudPrompt,
8+
} from "@posthog/shared";
39
import { trpcClient } from "@renderer/trpc/client";
4-
import { getImageMimeType, isImageFile } from "@shared/constants/image";
510
import {
611
getFileExtension,
712
getFileName,
@@ -61,8 +66,6 @@ const TEXT_FILENAMES = new Set([
6166
"README",
6267
"README.md",
6368
]);
64-
const CLOUD_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]);
65-
6669
const MAX_EMBEDDED_IMAGE_BYTES = 5 * 1024 * 1024;
6770

6871
function isTextAttachment(filePath: string): boolean {
@@ -71,10 +74,6 @@ function isTextAttachment(filePath: string): boolean {
7174
return TEXT_FILENAMES.has(fileName) || TEXT_EXTENSIONS.has(ext);
7275
}
7376

74-
export function isSupportedCloudImageAttachment(filePath: string): boolean {
75-
return CLOUD_IMAGE_EXTENSIONS.has(getFileExtension(filePath));
76-
}
77-
7877
export function isSupportedCloudTextAttachment(filePath: string): boolean {
7978
return isTextAttachment(filePath);
8079
}
@@ -163,7 +162,7 @@ async function buildAttachmentBlock(filePath: string): Promise<ContentBlock> {
163162
const fileName = getFileName(filePath);
164163
const uri = pathToFileUri(filePath);
165164

166-
if (isSupportedCloudImageAttachment(fileName)) {
165+
if (isClaudeImageFile(fileName)) {
167166
const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath });
168167
if (!base64) {
169168
throw new Error(`Unable to read attached image ${fileName}`);
@@ -183,7 +182,7 @@ async function buildAttachmentBlock(filePath: string): Promise<ContentBlock> {
183182
};
184183
}
185184

186-
if (isImageFile(fileName)) {
185+
if (isRasterImageFile(fileName)) {
187186
throw new Error(
188187
`Cloud image attachments currently support PNG, JPG, GIF, and WebP. Unsupported image: ${fileName}`,
189188
);

apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
DropdownMenuItem,
1313
DropdownMenuTrigger,
1414
} from "@posthog/quill";
15+
import { isRasterImageFile } from "@posthog/shared";
1516
import { trpcClient, useTRPC } from "@renderer/trpc/client";
1617
import { toast } from "@renderer/utils/toast";
17-
import { isImageFile } from "@shared/constants/image";
1818
import { useQuery } from "@tanstack/react-query";
1919
import { useRef, useState } from "react";
2020
import {
@@ -123,7 +123,7 @@ export function AttachmentMenu({
123123
try {
124124
const results = await trpcClient.os.selectAttachments.query({ mode });
125125
for (const { path: filePath, kind } of results) {
126-
if (kind === "file" && isImageFile(filePath)) {
126+
if (kind === "file" && isRasterImageFile(filePath)) {
127127
try {
128128
const attachment = await persistImageFilePath(filePath);
129129
onAddAttachment(attachment);

apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { File, X } from "@phosphor-icons/react";
2+
import { isGifFile, isRasterImageFile } from "@posthog/shared";
23
import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes";
34
import { useTRPC } from "@renderer/trpc/client";
4-
import { isGifFile, isImageFile } from "@shared/constants/image";
55
import { useQuery } from "@tanstack/react-query";
66
import { useEffect, useRef } from "react";
77
import type { FileAttachment } from "../utils/content";
@@ -151,7 +151,7 @@ export function AttachmentsBar({ attachments, onRemove }: AttachmentsBarProps) {
151151
return (
152152
<Flex gap="1" align="center" className="flex-wrap pb-1.5">
153153
{attachments.map((att) =>
154-
isImageFile(att.label) ? (
154+
isRasterImageFile(att.label) ? (
155155
<ImageThumbnail
156156
key={att.id}
157157
attachment={att}

apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@ vi.mock("@renderer/trpc/client", () => ({
2525
},
2626
}));
2727

28-
vi.mock("@shared/constants/image", async () => {
29-
const actual = await vi.importActual<
30-
typeof import("@shared/constants/image")
31-
>("@shared/constants/image");
28+
vi.mock("@posthog/shared", async () => {
29+
const actual =
30+
await vi.importActual<typeof import("@posthog/shared")>("@posthog/shared");
3231
return { ...actual, getImageMimeType: () => "image/png" };
3332
});
3433

apps/code/src/renderer/features/message-editor/utils/persistFile.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { getImageMimeType, isRasterImageFile } from "@posthog/shared";
12
import { trpcClient } from "@renderer/trpc/client";
23
import { toast } from "@renderer/utils/toast";
3-
import { getImageMimeType, isImageFile } from "@shared/constants/image";
44
import { getFilePath } from "@utils/getFilePath";
55
import type { FileAttachment } from "./content";
66

@@ -74,7 +74,7 @@ export async function resolveDroppedFile(
7474
const filePath = getFilePath(file);
7575
if (!filePath) return null;
7676

77-
if (isImageFile(file.name)) {
77+
if (isRasterImageFile(file.name)) {
7878
try {
7979
return await persistImageFilePath(filePath);
8080
} catch {

apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { EditorView } from "@codemirror/view";
22
import { SafeImagePreview } from "@components/ui/SafeImagePreview";
33
import { MultiFileDiff } from "@pierre/diffs/react";
4+
import { parseImageDataUrl } from "@posthog/shared";
45
import { Code } from "@radix-ui/themes";
5-
import { parseImageDataUrl } from "@shared/utils/imageDataUrl";
66
import { useThemeStore } from "@stores/themeStore";
77
import { compactHomePath } from "@utils/path";
88
import { useEffect, useMemo, useRef } from "react";

0 commit comments

Comments
 (0)