Skip to content

Commit 4e32e7f

Browse files
committed
fix(files): preserve source drafts and image export fidelity
1 parent 78fad5f commit 4e32e7f

10 files changed

Lines changed: 445 additions & 23 deletions

File tree

apps/sim/app/api/files/export/[id]/route.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,26 @@ describe('markdown export bundling', () => {
166166
it('counts the document body against the export limit, not just its assets', async () => {
167167
// Assets alone sit under the cap; the body is what carries the bundle over it.
168168
embeds('a')
169-
mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB))
169+
mockDownloadFile.mockResolvedValue(Buffer.alloc(2 * MB))
170+
assetsResolveTo((id) => assetRecord(id, 249 * MB))
170171

171172
const response = await GET(request(), context)
172173

173174
expect(response.status).toBe(400)
174175
expect((await response.json()).error).toContain('document and its embedded files')
176+
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
177+
})
178+
179+
it('downloads large Markdown verbatim without parsing it or querying assets', async () => {
180+
const content = Buffer.alloc(11 * MB, 'a')
181+
mockDownloadFile.mockResolvedValue(content)
182+
const response = await GET(request(), context)
183+
expect(response.status).toBe(200)
184+
expect(Buffer.from(await response.arrayBuffer()).equals(content)).toBe(true)
185+
expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8')
186+
expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled()
187+
expect(mockGetFileMetadataById).toHaveBeenCalledTimes(1)
188+
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
175189
})
176190

177191
it('caps the document body read rather than loading it unbounded', async () => {

apps/sim/app/api/files/export/[id]/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { downloadFile } from '@/lib/uploads/core/storage-service'
1616
import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs'
1717
import {
1818
createMarkdownExport,
19+
MAX_EXPORT_MARKDOWN_PARSE_BYTES,
1920
MAX_EXPORT_TOTAL_BYTES,
2021
type MarkdownExportAsset,
2122
type MarkdownExportResult,
@@ -129,11 +130,12 @@ export const GET = withRouteHandler(
129130
{ status: 400 }
130131
)
131132
}
132-
const mdContent = mdBuffer.toString('utf-8')
133-
134133
// Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the
135134
// markdown against, so those images stay pointed at their original URL.
136-
const { ids: imageIds } = extractEmbeddedFileRefs(mdContent)
135+
const imageIds =
136+
mdBuffer.length <= MAX_EXPORT_MARKDOWN_PARSE_BYTES
137+
? extractEmbeddedFileRefs(mdBuffer.toString('utf-8')).ids
138+
: []
137139

138140
logger.info('Exporting markdown', { id, imageCount: imageIds.length })
139141

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor-sync.test.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { act, type ComponentProps, Suspense } from 'react'
4+
import { act, type ComponentProps, createRef, Suspense } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { FileDownloadSource } from '@/lib/uploads/client/download'
78
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
89
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
910
import { TextEditor } from '@/app/workspace/[workspaceId]/files/components/file-viewer/text-editor'
@@ -18,6 +19,8 @@ interface MockMonacoProps {
1819
const state = vi.hoisted(() => ({
1920
content: 'initial',
2021
streaming: false,
22+
loading: false,
23+
error: false,
2124
editorProps: null as MockMonacoProps | null,
2225
}))
2326

@@ -37,8 +40,8 @@ vi.mock(
3740
state.content = content
3841
},
3942
isStreamInteractionLocked: state.streaming,
40-
isContentLoading: false,
41-
hasContentError: false,
43+
isContentLoading: state.loading,
44+
hasContentError: state.error,
4245
saveImmediately: vi.fn(),
4346
}),
4447
})
@@ -161,10 +164,49 @@ describe('TextEditor content synchronization', () => {
161164
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
162165
state.content = 'initial'
163166
state.streaming = false
167+
state.loading = false
168+
state.error = false
164169
state.editorProps = null
165170
useFileViewerStore.getState().reset()
166171
})
167172

173+
it('exports the current source draft synchronously without saving and clears it on unmount', () => {
174+
const downloadSourceRef = createRef<FileDownloadSource | null>()
175+
const root = createRoot(document.createElement('div'))
176+
act(() => root.render(<TextEditor {...props} downloadSourceRef={downloadSourceRef} />))
177+
expect(downloadSourceRef.current).toMatchObject({
178+
fileId: file.id,
179+
workspaceId: file.workspaceId,
180+
})
181+
expect(downloadSourceRef.current?.getContent()).toBe('initial')
182+
const draft = '# Source ![image](image.png)\n\n| a | b |\n| - | - |\n'
183+
act(() => state.editorProps?.onChange?.(draft))
184+
expect(downloadSourceRef.current?.getContent()).toBe(draft)
185+
act(() => state.editorProps?.onChange?.(''))
186+
expect(downloadSourceRef.current?.getContent()).toBe('')
187+
act(() => root.unmount())
188+
expect(downloadSourceRef.current).toBeNull()
189+
})
190+
191+
it.each(['loading', 'error'] as const)(
192+
'does not expose unavailable source content: %s',
193+
(condition) => {
194+
state[condition] = true
195+
const downloadSourceRef = createRef<FileDownloadSource | null>()
196+
const root = createRoot(document.createElement('div'))
197+
act(() => root.render(<TextEditor {...props} downloadSourceRef={downloadSourceRef} />))
198+
expect(downloadSourceRef.current).toBeNull()
199+
state[condition] = false
200+
act(() =>
201+
root.render(
202+
<TextEditor {...props} file={{ ...file }} downloadSourceRef={downloadSourceRef} />
203+
)
204+
)
205+
expect(downloadSourceRef.current?.getContent()).toBe('initial')
206+
act(() => root.unmount())
207+
}
208+
)
209+
168210
it('shares page recognition with already mounted viewers and keeps it across remounts', () => {
169211
const pageFile = { ...file, id: 'page-a', name: 'page.html', type: 'text/html' }
170212
const container = document.createElement('div')

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useCallback,
88
useEffect,
99
useId,
10+
useImperativeHandle,
1011
useLayoutEffect,
1112
useMemo,
1213
useRef,
@@ -21,6 +22,7 @@ import {
2122
buildFileSelectionLabel,
2223
truncateSelectionText,
2324
} from '@/lib/copilot/chat/selection-context'
25+
import type { FileDownloadSource } from '@/lib/uploads/client/download'
2426
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
2527
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
2628
import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
@@ -403,6 +405,7 @@ interface TextEditorProps {
403405
retry?: () => Promise<void>
404406
) => void
405407
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
408+
downloadSourceRef?: React.MutableRefObject<FileDownloadSource | null>
406409
discardRef?: React.MutableRefObject<(() => void) | null>
407410
streamingContent?: string
408411
isAgentEditing?: boolean
@@ -419,6 +422,7 @@ export const TextEditor = memo(function TextEditor({
419422
onDirtyChange,
420423
onSaveStatusChange,
421424
saveRef,
425+
downloadSourceRef,
422426
discardRef,
423427
streamingContent,
424428
isAgentEditing,
@@ -504,6 +508,19 @@ export const TextEditor = memo(function TextEditor({
504508
contentRef.current = content
505509
}, [content])
506510

511+
useImperativeHandle<FileDownloadSource | null, FileDownloadSource | null>(
512+
downloadSourceRef,
513+
() =>
514+
isContentLoading || hasContentError
515+
? null
516+
: {
517+
fileId: file.id,
518+
workspaceId,
519+
getContent: () => contentRef.current,
520+
},
521+
[file.id, workspaceId, isContentLoading, hasContentError]
522+
)
523+
507524
// Enable once content has loaded — the container (and Monaco) only mount after
508525
// the `isContentLoading` early return below, so the bridge must (re-)attach then.
509526
useSelectionCopyBridge(containerRef, buildSelectionContext, workspaceId, !isContentLoading)

apps/sim/lib/uploads/client/download.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
/** @vitest-environment jsdom */
2+
3+
import { PASTE_LIMITS } from '@sim/utils/paste'
24
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
35
import { requestRaw } from '@/lib/api/client/request'
46
import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files'
@@ -20,7 +22,7 @@ const file: WorkspaceFileRecord = {
2022
}
2123

2224
const fetchMock = vi.fn<typeof fetch>()
23-
const createObjectURL = vi.fn(() => 'blob:download')
25+
const createObjectURL = vi.fn((_blob: Blob) => 'blob:download')
2426
const click = vi.fn()
2527
let downloadedName = ''
2628

@@ -56,6 +58,22 @@ function source(content = 'latest visible content'): FileDownloadSource {
5658
}
5759

5860
describe('file download snapshots', () => {
61+
it('downloads an oversized source draft directly instead of rejecting it or using stale storage', async () => {
62+
const content = '😀'.repeat(Math.floor(PASTE_LIMITS.RICH_MARKDOWN_BYTES / 4) + 1)
63+
await triggerFileDownload(file, source(content))
64+
expect(requestRaw).not.toHaveBeenCalled()
65+
expect(fetchMock).not.toHaveBeenCalled()
66+
const blob = createObjectURL.mock.calls[0]![0]
67+
const reader = new FileReader()
68+
const read = new Promise<string>((resolve) => {
69+
reader.onload = () => resolve(reader.result as string)
70+
})
71+
reader.readAsText(blob)
72+
await vi.runAllTimersAsync()
73+
expect(await read).toBe(content)
74+
expect(downloadedName).toBe(file.name)
75+
expect(click).toHaveBeenCalledOnce()
76+
})
5977
it.each(['latest local and peer text', ''])(
6078
'captures the mounted content immediately: %j',
6179
async (content) => {

apps/sim/lib/uploads/client/download.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste'
12
import { requestRaw } from '@/lib/api/client/request'
23
import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders'
34
import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files'
@@ -56,6 +57,13 @@ export async function triggerFileDownload(
5657
: null
5758

5859
if (content !== null) {
60+
/** Source editing accepts larger drafts than the bounded image-bundling endpoint. */
61+
if (
62+
utf8ByteLength(content, PASTE_LIMITS.RICH_MARKDOWN_BYTES) > PASTE_LIMITS.RICH_MARKDOWN_BYTES
63+
) {
64+
saveBlob(new Blob([content], { type: 'text/markdown; charset=utf-8' }), record.name)
65+
return
66+
}
5967
const response = await requestRaw(
6068
exportWorkspaceFileSnapshotContract,
6169
{

0 commit comments

Comments
 (0)