Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/app/api/table/import-async/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

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

const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
Expand Down Expand Up @@ -111,6 +111,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
fileName,
delimiter,
mode: 'create',
deleteSourceFile,
}
if (isTriggerDevEnabled) {
// Trigger.dev runs the import outside the web container, so it survives app deploys.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('WorkspaceCsvPreviewAPI')

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const userId = authResult.userId

const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, fileId } = parsed.data.params
const { key } = parsed.data.query

const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}

// Resolve the file record (active, in this workspace) and read from its authoritative key —
// never the client-supplied one. This rejects archived/deleted files and keys with no live
// row, matching the access guarantees of /api/files/serve.
const record = await getWorkspaceFile(workspaceId, fileId)
if (!record || record.key !== key) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}

const slice = await getCsvPreviewSlice({
key: record.key,
context: 'workspace',
signal: request.signal,
})

logger.info('CSV preview served', {
workspaceId,
rows: slice.rows.length,
truncated: slice.truncated,
})

return NextResponse.json({ success: true, ...slice })
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client'

import { useCallback, useEffect, useRef } from 'react'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { toast } from '@/components/emcn'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useImportFileAsTable } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'

export type CsvImportFileDescriptor = Pick<WorkspaceFileRecord, 'key' | 'name'>

/**
* Wires the "Import as a table" affordance for a capped CSV preview. When the preview is
* `truncated`, raises a one-time warning toast whose action kicks off a background import of the
* existing workspace file — no re-upload, source preserved — and navigates to the new table.
*/
export function useCsvTruncationImport(
workspaceId: string,
file: CsvImportFileDescriptor,
truncated: boolean
) {
const router = useRouter()
const importFile = useImportFileAsTable()

// Guards against a double-tap on the toast action kicking off two parallel imports of the same
// file. Reset once the kickoff settles so a failed import can be retried.
const importingRef = useRef(false)

const importAsTable = useCallback(() => {
if (importingRef.current) return
importingRef.current = true
const pendingId = `pending_${generateId()}`
useImportTrayStore
.getState()
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
toast.success(`Importing "${file.name}" as a table`, {
description: 'This runs in the background.',
action: {
label: 'View tables',
onClick: () => router.push(`/workspace/${workspaceId}/tables`),
},
})
importFile.mutate(
{ workspaceId, fileKey: file.key, fileName: file.name },
{
onSettled: () => {
importingRef.current = false
useImportTrayStore.getState().endUpload(pendingId)
},
}
)
// importFile.mutate and router are stable references
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, file.key, file.name])
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Surface the cap as a warning toast with an import action, once per file.
const notifiedKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!truncated || notifiedKeyRef.current === file.key) return
notifiedKeyRef.current = file.key
toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, {
description: 'Import this file as a table to view all of its rows.',
action: { label: 'Import as a table', onClick: importAsTable },
})
}, [truncated, file.key, importAsTable])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client'

import { memo } from 'react'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
import { useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'

/**
* Read-only preview for a CSV that is too large to load fully into the editor. Streams only the
* first {@link CSV_PREVIEW_MAX_ROWS} rows from storage; when there are more, a warning toast offers
* "Import as a table", which builds a full Table from the file (memory-safe streaming import).
*/
export const CsvTablePreview = memo(function CsvTablePreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const version = Number(new Date(file.updatedAt)) || file.size
const {
data,
isLoading,
error: fetchError,
} = useWorkspaceCsvPreview(workspaceId, file.id, file.key, version)
useCsvTruncationImport(workspaceId, file, data?.truncated ?? false)

const error = resolvePreviewError((fetchError as Error | null) ?? null, null)
if (error) return <PreviewError label='CSV' error={error} />
if (isLoading || !data) {
return <PreviewLoadingFrame className='flex flex-1 flex-col overflow-hidden' />
}

if (data.headers.length === 0) {
return (
<div className='flex h-full items-center justify-center p-6'>
<p className='text-[13px] text-[var(--text-muted)]'>No data to display</p>
</div>
)
}

return (
<div className='flex flex-1 flex-col overflow-auto p-6'>
<DataTable headers={data.headers} rows={data.rows} />
</div>
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useDocPreviewBinary } from './use-doc-preview-binary'

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

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

const logger = createLogger('FileViewer')

/**
* CSVs at or below this size load fully into the editor (editable, with an inline preview).
* Larger CSVs would OOM the browser on `response.text()`, so they render a read-only,
* server-streamed preview of the first rows instead (see {@link CsvTablePreview}).
*/
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024

export function isTextEditable(file: { type: string; name: string }): boolean {
return resolveFileCategory(file.type, file.name) === 'text-editable'
}
Expand All @@ -42,6 +50,22 @@ export function isPreviewable(file: { type: string; name: string }): boolean {
return resolvePreviewType(file.type, file.name) !== null
}

/**
* A CSV larger than {@link CSV_INLINE_EDIT_MAX_BYTES} is shown as a streamed, read-only preview —
* the editor would OOM loading the whole file. The viewer renders {@link CsvTablePreview} for it,
* and toolbars use this to hide the edit/split/save controls (there is no editor to switch to).
*/
export function isCsvStreamOnly(file: {
type: string | null
name: string
size?: number | null
}): boolean {
return (
resolvePreviewType(file.type, file.name) === 'csv' &&
(file.size ?? 0) > CSV_INLINE_EDIT_MAX_BYTES
)
}

export type PreviewMode = 'editor' | 'split' | 'preview'

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

if (category === 'text-editable') {
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
if (isCsvStreamOnly(file)) {
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
}

return (
<TextEditor
file={file}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { resolveFileCategory } from './file-category'
export type { PreviewMode } from './file-viewer'
export { FileViewer, isPreviewable, isTextEditable } from './file-viewer'
export { FileViewer, isCsvStreamOnly, isPreviewable, isTextEditable } from './file-viewer'
export { RICH_PREVIEWABLE_EXTENSIONS } from './preview-panel'
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-yaml'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-python'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import { cn } from '@/lib/core/utils/cn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useScrollAnchor } from '@/hooks/use-scroll-anchor'
import { RESUME_SKIP_THRESHOLD, useSmoothText } from '@/hooks/use-smooth-text'
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { PreviewLoadingFrame } from './preview-shared'
import { ZoomablePreview } from './zoomable-preview'
Expand Down Expand Up @@ -76,6 +78,8 @@ interface PreviewPanelProps {
content: string
mimeType: string | null
filename: string
workspaceId: string
fileKey: string
isStreaming?: boolean
disableAutoScroll?: boolean
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
Expand All @@ -85,6 +89,8 @@ export const PreviewPanel = memo(function PreviewPanel({
content,
mimeType,
filename,
workspaceId,
fileKey,
isStreaming,
disableAutoScroll,
onCheckboxToggle,
Expand All @@ -101,7 +107,14 @@ export const PreviewPanel = memo(function PreviewPanel({
/>
)
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'csv')
return (
<CsvPreview
content={content}
workspaceId={workspaceId}
file={{ key: fileKey, name: filename }}
/>
)
if (previewType === 'svg') return <SvgPreview content={content} />
if (previewType === 'mermaid')
return <MermaidFilePreview content={content} isStreaming={isStreaming} />
Expand Down Expand Up @@ -1150,8 +1163,17 @@ function MermaidFilePreview({ content, isStreaming }: { content: string; isStrea
)
}

const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
const { headers, rows } = useMemo(() => parseCsv(content), [content])
const CsvPreview = memo(function CsvPreview({
content,
workspaceId,
file,
}: {
content: string
workspaceId: string
file: CsvImportFileDescriptor
}) {
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
useCsvTruncationImport(workspaceId, file, truncated)

if (headers.length === 0) {
return (
Expand All @@ -1168,15 +1190,22 @@ const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
)
})

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

const delimiter = detectDelimiter(lines[0])
const headers = parseCsvLine(lines[0], delimiter)
const rows = lines.slice(1).map((line) => parseCsvLine(line, delimiter))
const dataLines = lines.slice(1)
const truncated = dataLines.length > CSV_PREVIEW_MAX_ROWS
const rows = dataLines.slice(0, CSV_PREVIEW_MAX_ROWS).map((line) => parseCsvLine(line, delimiter))

return { headers, rows }
return { headers, rows, truncated }
}

function detectDelimiter(line: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,8 @@ export const TextEditor = memo(function TextEditor({
content={content}
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
fileKey={file.key}
isStreaming={isStreaming}
disableAutoScroll={disableStreamingAutoScroll}
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/componen
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import {
FileViewer,
isCsvStreamOnly,
isPreviewable,
isTextEditable,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
Expand Down Expand Up @@ -1389,8 +1390,11 @@ export function Files() {

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

const saveLabel =
Expand Down
Loading
Loading