From 2c887921ff0bc00c5dd592f7af59e62650cee630 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 18:58:34 -0700 Subject: [PATCH 1/2] fix(files): render image previews inline --- .../file-viewer/image-preview.test.tsx | 19 +++++++++++- .../components/file-viewer/image-preview.tsx | 6 ++-- apps/sim/hooks/use-file-content-source.tsx | 31 ++++++++++++++++--- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx index 17f3885d081..40942cddb17 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.test.tsx @@ -5,6 +5,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + createWorkspaceFileContentSource, + FileContentSourceProvider, +} from '@/hooks/use-file-content-source' import { ImagePreview } from './image-preview' const file = { @@ -43,7 +47,13 @@ afterEach(() => { }) function render(record: WorkspaceFileRecord = file) { - act(() => root.render()) + act(() => + root.render( + + + + ) + ) } describe('ImagePreview', () => { @@ -55,6 +65,13 @@ describe('ImagePreview', () => { expect(src).not.toContain('raw=1') }) + it('streams browser-renderable images through the workspace inline endpoint', () => { + render({ ...file, name: 'photo.png', key: 'workspace/ws-1/photo.png', type: 'image/png' }) + + const src = container.querySelector('img')?.getAttribute('src') ?? '' + expect(src).toBe('/api/workspaces/ws-1/files/inline?key=workspace%2Fws-1%2Fphoto.png') + }) + it('falls back to the unsupported state when the image fails to decode', () => { render() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx index 4c6659c2975..6f719ba2dbb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx @@ -8,9 +8,9 @@ import { ZoomablePreview } from './zoomable-preview' export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) { const source = useFileContentSource() - /** `v` busts the browser cache across content writes; `preview` lets the server - * substitute a renderable JPEG for a HEIC. */ - const serveUrl = source.buildUrl(file.key, { + /** Workspace images use their content-addressed inline URL. Derivative-backed + * sources use the version and preview flag to render formats such as HEIC. */ + const serveUrl = source.buildImageUrl(file, { version: Number(new Date(file.updatedAt)) || file.size, preview: true, }) diff --git a/apps/sim/hooks/use-file-content-source.tsx b/apps/sim/hooks/use-file-content-source.tsx index bcdd1d3f493..44f421874b0 100644 --- a/apps/sim/hooks/use-file-content-source.tsx +++ b/apps/sim/hooks/use-file-content-source.tsx @@ -56,6 +56,10 @@ export interface ImageDimensionsSource { */ export interface FileContentSource { buildUrl: (key: string, opts?: FileContentUrlOptions) => string + buildImageUrl: ( + file: { key: string; name: string; type: string }, + opts?: FileContentUrlOptions + ) => string /** * Map an embedded image `src` to a display URL scoped to the current context: the in-app source * points at the workspace-scoped inline route, the public source at the token-scoped cascade route. @@ -81,7 +85,7 @@ function buildServeUrl(key: string, opts?: FileContentUrlOptions): string { function inlineImageSource( buildUrl: FileContentSource['buildUrl'], inlineBase: string -): FileContentSource { +): Pick { return { buildUrl, resolveImageSrc: (src) => { @@ -103,6 +107,16 @@ export function createWorkspaceFileContentSource( ): FileContentSource { return { ...inlineImageSource(buildServeUrl, `/api/workspaces/${workspaceId}/files/inline`), + buildImageUrl: (file, opts) => { + const heic = + file.type === 'image/heic' || + file.type === 'image/heif' || + /\.(?:heic|heif)$/i.test(file.name) + if (heic) return buildServeUrl(file.key, { ...opts, preview: true }) + + const params = new URLSearchParams({ key: file.key }) + return `/api/workspaces/${encodeURIComponent(workspaceId)}/files/inline?${params}` + }, ...imageDimensions, } } @@ -116,11 +130,17 @@ export function createPublicFileContentSource( token: string, contentUrl: string ): FileContentSource { - return inlineImageSource( - (_key, opts) => + return { + ...inlineImageSource( + (_key, opts) => + opts?.preview + ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1` + : contentUrl, + `/api/files/public/${token}/inline` + ), + buildImageUrl: (_file, opts) => opts?.preview ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1` : contentUrl, - `/api/files/public/${token}/inline` - ) + } } /** @@ -130,6 +150,7 @@ export function createPublicFileContentSource( */ export const workspaceFileContentSource: FileContentSource = { buildUrl: buildServeUrl, + buildImageUrl: (file, opts) => buildServeUrl(file.key, { ...opts, preview: true }), resolveImageSrc: (src) => src, } From a40d4de22b8931d85a3ea49d2fc2726e3310227a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 23 Aug 2026 19:04:19 -0700 Subject: [PATCH 2/2] fix(files): preserve generic image previews --- .../api/workspaces/[id]/files/inline/route.test.ts | 13 +++++++++++++ .../app/api/workspaces/[id]/files/inline/route.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 2b95cd7e007..e0d9f4cf41e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -60,6 +60,19 @@ describe('GET /api/workspaces/[id]/files/inline', () => { }) }) + it('derives a renderable image type when storage recorded generic bytes', async () => { + mockReadInline.mockResolvedValue({ + file: { name: 'photo.png', type: 'application/octet-stream', size: PNG.length }, + stream: new Blob([new Uint8Array(PNG)]).stream(), + contentAddressed: true, + }) + + const res = await GET(req('key=workspace%2Fws-1%2Fphoto.png'), params) + + expect(res.headers.get('Content-Type')).toBe('image/png') + expect(res.headers.get('Content-Disposition')).toBe('inline; filename="photo.png"') + }) + /** * A storage key names one object and a content write never rewrites one, so these bytes can never * change. Revalidating them meant re-downloading every embedded image on every open — a document is diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index 0a3f20bf4ec..a00d828f2c2 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -4,6 +4,7 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file-utils' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils' @@ -48,7 +49,7 @@ export const GET = defineInternalBinaryRoute({ }), useCase: readWorkspaceInlineFile, present: ({ file, stream, contentAddressed }) => { - const secure = getSecureFileHeaders(file.name, file.type) + const secure = getSecureFileHeaders(file.name, resolveEffectiveMimeType(file.type, file.name)) const headers = new Headers({ 'Content-Type': secure.contentType, 'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`,