Skip to content

Commit 63a3e6d

Browse files
feat(files): stream large CSV previews and add import-as-table (#5125)
* feat(files): stream large CSV previews and add import-as-table * fix(files): validate fileId in csv-preview route, guard double-import, fix sniff perf and toggle flash * fix(files): scope mothership preview-toggle loading guard to CSV files only
1 parent a028d07 commit 63a3e6d

17 files changed

Lines changed: 650 additions & 15 deletions

File tree

apps/sim/app/api/table/import-async/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3838

3939
const parsed = await parseRequest(importTableAsyncContract, request, {})
4040
if (!parsed.success) return parsed.response
41-
const { workspaceId, fileKey, fileName } = parsed.data.body
41+
const { workspaceId, fileKey, fileName, deleteSourceFile } = parsed.data.body
4242

4343
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
4444
if (permission !== 'write' && permission !== 'admin') {
@@ -111,6 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
111111
fileName,
112112
delimiter,
113113
mode: 'create',
114+
deleteSourceFile,
114115
}
115116
if (isTriggerDevEnabled) {
116117
// Trigger.dev runs the import outside the web container, so it survives app deploys.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
8+
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
9+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
10+
11+
const logger = createLogger('WorkspaceCsvPreviewAPI')
12+
13+
export const runtime = 'nodejs'
14+
export const dynamic = 'force-dynamic'
15+
16+
export const GET = withRouteHandler(
17+
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
18+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
19+
if (!authResult.success || !authResult.userId) {
20+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
21+
}
22+
const userId = authResult.userId
23+
24+
const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context)
25+
if (!parsed.success) return parsed.response
26+
const { id: workspaceId, fileId } = parsed.data.params
27+
const { key } = parsed.data.query
28+
29+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
30+
if (!permission) {
31+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
32+
}
33+
34+
// Resolve the file record (active, in this workspace) and read from its authoritative key —
35+
// never the client-supplied one. This rejects archived/deleted files and keys with no live
36+
// row, matching the access guarantees of /api/files/serve.
37+
const record = await getWorkspaceFile(workspaceId, fileId)
38+
if (!record || record.key !== key) {
39+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
40+
}
41+
42+
const slice = await getCsvPreviewSlice({
43+
key: record.key,
44+
context: 'workspace',
45+
signal: request.signal,
46+
})
47+
48+
logger.info('CSV preview served', {
49+
workspaceId,
50+
rows: slice.rows.length,
51+
truncated: slice.truncated,
52+
})
53+
54+
return NextResponse.json({ success: true, ...slice })
55+
}
56+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef } from 'react'
4+
import { generateId } from '@sim/utils/id'
5+
import { useRouter } from 'next/navigation'
6+
import { toast } from '@/components/emcn'
7+
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
8+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
9+
import { useImportFileAsTable } from '@/hooks/queries/tables'
10+
import { useImportTrayStore } from '@/stores/table/import-tray/store'
11+
12+
export type CsvImportFileDescriptor = Pick<WorkspaceFileRecord, 'key' | 'name'>
13+
14+
/**
15+
* Wires the "Import as a table" affordance for a capped CSV preview. When the preview is
16+
* `truncated`, raises a one-time warning toast whose action kicks off a background import of the
17+
* existing workspace file — no re-upload, source preserved — and navigates to the new table.
18+
*/
19+
export function useCsvTruncationImport(
20+
workspaceId: string,
21+
file: CsvImportFileDescriptor,
22+
truncated: boolean
23+
) {
24+
const router = useRouter()
25+
const importFile = useImportFileAsTable()
26+
27+
// Guards against a double-tap on the toast action kicking off two parallel imports of the same
28+
// file. Reset once the kickoff settles so a failed import can be retried.
29+
const importingRef = useRef(false)
30+
31+
const importAsTable = useCallback(() => {
32+
if (importingRef.current) return
33+
importingRef.current = true
34+
const pendingId = `pending_${generateId()}`
35+
useImportTrayStore
36+
.getState()
37+
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
38+
toast.success(`Importing "${file.name}" as a table`, {
39+
description: 'This runs in the background.',
40+
action: {
41+
label: 'View tables',
42+
onClick: () => router.push(`/workspace/${workspaceId}/tables`),
43+
},
44+
})
45+
importFile.mutate(
46+
{ workspaceId, fileKey: file.key, fileName: file.name },
47+
{
48+
onSettled: () => {
49+
importingRef.current = false
50+
useImportTrayStore.getState().endUpload(pendingId)
51+
},
52+
}
53+
)
54+
// importFile.mutate and router are stable references
55+
// eslint-disable-next-line react-hooks/exhaustive-deps
56+
}, [workspaceId, file.key, file.name])
57+
58+
// Surface the cap as a warning toast with an import action, once per file.
59+
const notifiedKeyRef = useRef<string | null>(null)
60+
useEffect(() => {
61+
if (!truncated || notifiedKeyRef.current === file.key) return
62+
notifiedKeyRef.current = file.key
63+
toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, {
64+
description: 'Import this file as a table to view all of its rows.',
65+
action: { label: 'Import as a table', onClick: importAsTable },
66+
})
67+
}, [truncated, file.key, importAsTable])
68+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client'
2+
3+
import { memo } from 'react'
4+
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
5+
import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
6+
import { useCsvTruncationImport } from './csv-import'
7+
import { DataTable } from './data-table'
8+
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
9+
10+
/**
11+
* Read-only preview for a CSV that is too large to load fully into the editor. Streams only the
12+
* first {@link CSV_PREVIEW_MAX_ROWS} rows from storage; when there are more, a warning toast offers
13+
* "Import as a table", which builds a full Table from the file (memory-safe streaming import).
14+
*/
15+
export const CsvTablePreview = memo(function CsvTablePreview({
16+
file,
17+
workspaceId,
18+
}: {
19+
file: WorkspaceFileRecord
20+
workspaceId: string
21+
}) {
22+
const version = Number(new Date(file.updatedAt)) || file.size
23+
const {
24+
data,
25+
isLoading,
26+
error: fetchError,
27+
} = useWorkspaceCsvPreview(workspaceId, file.id, file.key, version)
28+
useCsvTruncationImport(workspaceId, file, data?.truncated ?? false)
29+
30+
const error = resolvePreviewError((fetchError as Error | null) ?? null, null)
31+
if (error) return <PreviewError label='CSV' error={error} />
32+
if (isLoading || !data) {
33+
return <PreviewLoadingFrame className='flex flex-1 flex-col overflow-hidden' />
34+
}
35+
36+
if (data.headers.length === 0) {
37+
return (
38+
<div className='flex h-full items-center justify-center p-6'>
39+
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
40+
</div>
41+
)
42+
}
43+
44+
return (
45+
<div className='flex flex-1 flex-col overflow-auto p-6'>
46+
<DataTable headers={data.headers} rows={data.rows} />
47+
</div>
48+
)
49+
})

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useDocPreviewBinary } from './use-doc-preview-binary'
1313

1414
export type { StreamingMode } from './text-editor-state'
1515

16+
import { CsvTablePreview } from './csv-table-preview'
1617
import { DocxPreview } from './docx-preview'
1718
import { ImagePreview } from './image-preview'
1819
import type { PdfDocumentSource } from './pdf-viewer'
@@ -34,6 +35,13 @@ const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfView
3435

3536
const logger = createLogger('FileViewer')
3637

38+
/**
39+
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
40+
* Larger CSVs would OOM the browser on `response.text()`, so they render a read-only,
41+
* server-streamed preview of the first rows instead (see {@link CsvTablePreview}).
42+
*/
43+
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024
44+
3745
export function isTextEditable(file: { type: string; name: string }): boolean {
3846
return resolveFileCategory(file.type, file.name) === 'text-editable'
3947
}
@@ -42,6 +50,22 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
4250
return resolvePreviewType(file.type, file.name) !== null
4351
}
4452

53+
/**
54+
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
55+
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
56+
* and toolbars use this to hide the edit/split/save controls (there is no editor to switch to).
57+
*/
58+
export function isCsvStreamOnly(file: {
59+
type: string | null
60+
name: string
61+
size?: number | null
62+
}): boolean {
63+
return (
64+
resolvePreviewType(file.type, file.name) === 'csv' &&
65+
(file.size ?? 0) > CSV_INLINE_EDIT_MAX_BYTES
66+
)
67+
}
68+
4569
export type PreviewMode = 'editor' | 'split' | 'preview'
4670

4771
interface FileViewerProps {
@@ -76,6 +100,12 @@ export function FileViewer({
76100
const category = resolveFileCategory(file.type, file.name)
77101

78102
if (category === 'text-editable') {
103+
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
104+
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
105+
if (isCsvStreamOnly(file)) {
106+
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
107+
}
108+
79109
return (
80110
<TextEditor
81111
file={file}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { resolveFileCategory } from './file-category'
22
export type { PreviewMode } from './file-viewer'
3-
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
3+
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
44
export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ import 'prismjs/components/prism-typescript'
3131
import 'prismjs/components/prism-yaml'
3232
import 'prismjs/components/prism-sql'
3333
import 'prismjs/components/prism-python'
34+
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
3435
import { cn } from '@/lib/core/utils/cn'
3536
import { extractTextContent } from '@/lib/core/utils/react-node-text'
3637
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
3738
import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
3839
import { RESUME_SKIP_THRESHOLD, useSmoothText } from '@/hooks/use-smooth-text'
40+
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
3941
import { DataTable } from './data-table'
4042
import { PreviewLoadingFrame } from './preview-shared'
4143
import { ZoomablePreview } from './zoomable-preview'
@@ -76,6 +78,8 @@ interface PreviewPanelProps {
7678
content: string
7779
mimeType: string | null
7880
filename: string
81+
workspaceId: string
82+
fileKey: string
7983
isStreaming?: boolean
8084
disableAutoScroll?: boolean
8185
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
@@ -85,6 +89,8 @@ export const PreviewPanel = memo(function PreviewPanel({
8589
content,
8690
mimeType,
8791
filename,
92+
workspaceId,
93+
fileKey,
8894
isStreaming,
8995
disableAutoScroll,
9096
onCheckboxToggle,
@@ -101,7 +107,14 @@ export const PreviewPanel = memo(function PreviewPanel({
101107
/>
102108
)
103109
if (previewType === 'html') return <HtmlPreview content={content} />
104-
if (previewType === 'csv') return <CsvPreview content={content} />
110+
if (previewType === 'csv')
111+
return (
112+
<CsvPreview
113+
content={content}
114+
workspaceId={workspaceId}
115+
file={{ key: fileKey, name: filename }}
116+
/>
117+
)
105118
if (previewType === 'svg') return <SvgPreview content={content} />
106119
if (previewType === 'mermaid')
107120
return <MermaidFilePreview content={content} isStreaming={isStreaming} />
@@ -1150,8 +1163,17 @@ function MermaidFilePreview({ content, isStreaming }: { content: string; isStrea
11501163
)
11511164
}
11521165

1153-
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
1154-
const { headers, rows } = useMemo(() => parseCsv(content), [content])
1166+
const CsvPreview = memo(function CsvPreview({
1167+
content,
1168+
workspaceId,
1169+
file,
1170+
}: {
1171+
content: string
1172+
workspaceId: string
1173+
file: CsvImportFileDescriptor
1174+
}) {
1175+
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
1176+
useCsvTruncationImport(workspaceId, file, truncated)
11551177

11561178
if (headers.length === 0) {
11571179
return (
@@ -1168,15 +1190,22 @@ const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
11681190
)
11691191
})
11701192

1171-
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
1193+
/**
1194+
* Parses CSV text for the inline preview, capping at {@link CSV_PREVIEW_MAX_ROWS} rows so a
1195+
* small-but-many-rows file doesn't render thousands of `<tr>`s. Slices before parsing so only
1196+
* the capped rows are processed; `truncated` drives the "Import as a table" footer.
1197+
*/
1198+
function parseCsv(text: string): { headers: string[]; rows: string[][]; truncated: boolean } {
11721199
const lines = text.split('\n').filter((line) => line.trim().length > 0)
1173-
if (lines.length === 0) return { headers: [], rows: [] }
1200+
if (lines.length === 0) return { headers: [], rows: [], truncated: false }
11741201

11751202
const delimiter = detectDelimiter(lines[0])
11761203
const headers = parseCsvLine(lines[0], delimiter)
1177-
const rows = lines.slice(1).map((line) => parseCsvLine(line, delimiter))
1204+
const dataLines = lines.slice(1)
1205+
const truncated = dataLines.length > CSV_PREVIEW_MAX_ROWS
1206+
const rows = dataLines.slice(0, CSV_PREVIEW_MAX_ROWS).map((line) => parseCsvLine(line, delimiter))
11781207

1179-
return { headers, rows }
1208+
return { headers, rows, truncated }
11801209
}
11811210

11821211
function detectDelimiter(line: string): string {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,8 @@ export const TextEditor = memo(function TextEditor({
755755
content={content}
756756
mimeType={file.type}
757757
filename={file.name}
758+
workspaceId={workspaceId}
759+
fileKey={file.key}
758760
isStreaming={isStreaming}
759761
disableAutoScroll={disableStreamingAutoScroll}
760762
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/componen
6565
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
6666
import {
6767
FileViewer,
68+
isCsvStreamOnly,
6869
isPreviewable,
6970
isTextEditable,
7071
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -1389,8 +1390,11 @@ export function Files() {
13891390

13901391
const fileActions = useMemo<ResourceAction[]>(() => {
13911392
if (!selectedFile) return []
1392-
const canEditText = isTextEditable(selectedFile)
1393-
const canPreview = isPreviewable(selectedFile)
1393+
// A large CSV renders as a read-only streamed preview (no editor), so it gets neither the
1394+
// Save action nor the edit/split/preview toggle — just like a non-editable file.
1395+
const streamOnly = isCsvStreamOnly(selectedFile)
1396+
const canEditText = isTextEditable(selectedFile) && !streamOnly
1397+
const canPreview = isPreviewable(selectedFile) && !streamOnly
13941398
const hasSplitView = canEditText && canPreview
13951399

13961400
const saveLabel =

0 commit comments

Comments
 (0)