diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 606978a9279..155f426607b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,8 +1,8 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -17,7 +17,6 @@ const logger = createLogger('WorkspaceFileContentAPI') */ export const PUT = withRouteHandler( async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() const { id: workspaceId, fileId } = await params try { @@ -32,20 +31,19 @@ export const PUT = withRouteHandler( workspaceId ) if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) + logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } const body = await request.json() - const { content } = body as { content: string } + const { content, encoding } = body as { content: string; encoding?: 'base64' | 'utf-8' } if (typeof content !== 'string') { return NextResponse.json({ error: 'Content must be a string' }, { status: 400 }) } - const buffer = Buffer.from(content, 'utf-8') + const buffer = + encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') const maxFileSizeBytes = 50 * 1024 * 1024 if (buffer.length > maxFileSizeBytes) { @@ -62,7 +60,7 @@ export const PUT = withRouteHandler( buffer ) - logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`) + logger.info(`Updated content for workspace file: ${updatedFile.name}`) recordAudit({ workspaceId, @@ -83,15 +81,15 @@ export const PUT = withRouteHandler( file: updatedFile, }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to update file content' + const errorMessage = toError(error).message || 'Failed to update file content' const isNotFound = errorMessage.includes('File not found') const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 if (status === 500) { - logger.error(`[${requestId}] Error updating file content:`, error) + logger.error('Error updating file content:', error) } else { - logger.warn(`[${requestId}] ${errorMessage}`) + logger.warn(errorMessage) } return NextResponse.json({ success: false, error: errorMessage }, { status }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx index 81398b7f17b..2bc2bef02d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { Files } from '../files' @@ -6,4 +7,10 @@ export const metadata: Metadata = { robots: { index: false }, } -export default Files +export default function FilesFilePage() { + return ( + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx index 5e31edcfb55..672811de475 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/data-table.tsx @@ -1,11 +1,98 @@ -import { memo } from 'react' +'use client' + +import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react' +import { cn } from '@/lib/core/utils/cn' + +interface EditConfig { + onCellChange: (row: number, col: number, value: string) => void + onHeaderChange: (col: number, value: string) => void +} interface DataTableProps { headers: string[] rows: string[][] + editConfig?: EditConfig +} + +export interface DataTableHandle { + commitEdit: () => void } -export const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) { +type EditingCell = { row: number; col: number } | null + +const DataTableBase = forwardRef(function DataTable( + { headers, rows, editConfig }, + ref +) { + const [editingCell, setEditingCell] = useState(null) + const [editValue, setEditValue] = useState('') + + const editStateRef = useRef({ editingCell, editValue, editConfig }) + editStateRef.current = { editingCell, editValue, editConfig } + + // Prevents double-commit if onBlur and imperative commitEdit fire concurrently + const isCommittedRef = useRef(false) + + useImperativeHandle( + ref, + () => ({ + commitEdit: () => { + if (isCommittedRef.current) return + const { editingCell, editValue, editConfig } = editStateRef.current + if (!editingCell || !editConfig) return + isCommittedRef.current = true + const { row, col } = editingCell + if (row === -1) { + editConfig.onHeaderChange(col, editValue) + } else { + editConfig.onCellChange(row, col, editValue) + } + setEditingCell(null) + }, + }), + [] + ) + + const setInputRef = useCallback((node: HTMLInputElement | null) => { + if (node) { + node.focus() + node.select() + } + }, []) + + const startEdit = (row: number, col: number, currentValue: string) => { + if (!editConfig) return + isCommittedRef.current = false + setEditingCell({ row, col }) + setEditValue(currentValue) + } + + const commitEdit = () => { + if (isCommittedRef.current || !editingCell || !editConfig) return + isCommittedRef.current = true + const { row, col } = editingCell + if (row === -1) { + editConfig.onHeaderChange(col, editValue) + } else { + editConfig.onCellChange(row, col, editValue) + } + setEditingCell(null) + } + + const cancelEdit = () => setEditingCell(null) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault() + commitEdit() + } else if (e.key === 'Escape') { + cancelEdit() + } + } + + const isEditing = (row: number, col: number) => + editingCell?.row === row && editingCell?.col === col + return (
@@ -14,9 +101,24 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro {headers.map((header, i) => ( ))} @@ -25,8 +127,26 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro {rows.map((row, ri) => ( {headers.map((_, ci) => ( - ))} @@ -36,3 +156,5 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro ) }) + +export const DataTable = memo(DataTableBase) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx new file mode 100644 index 00000000000..a8228dfc084 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -0,0 +1,183 @@ +'use client' + +import { memo, useEffect, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { cn } from '@/lib/core/utils/cn' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' +import { + PDF_PAGE_SKELETON, + PreviewError, + resolvePreviewError, + shouldSuppressStreamingDocumentError, +} from './preview-shared' + +const logger = createLogger('DocxPreview') + +export const DocxPreview = memo(function DocxPreview({ + file, + workspaceId, + streamingContent, +}: { + file: WorkspaceFileRecord + workspaceId: string + streamingContent?: string +}) { + const containerRef = useRef(null) + const lastSuccessfulHtmlRef = useRef('') + const { + data: fileData, + isLoading, + error: fetchError, + } = useWorkspaceFileBinary(workspaceId, file.id, file.key) + const [renderError, setRenderError] = useState(null) + const [rendering, setRendering] = useState(false) + const [hasRenderedPreview, setHasRenderedPreview] = useState(false) + + useEffect(() => { + if (!containerRef.current || !fileData || streamingContent !== undefined) return + + let cancelled = false + + async function render() { + try { + setRendering(true) + const { renderAsync } = await import('docx-preview') + if (cancelled || !containerRef.current) return + setRenderError(null) + containerRef.current.innerHTML = '' + await renderAsync(fileData, containerRef.current, undefined, { + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + }) + if (!cancelled && containerRef.current) { + const wrapper = containerRef.current.querySelector('.docx-wrapper') + if (wrapper) wrapper.style.background = 'transparent' + containerRef.current.querySelectorAll('section.docx').forEach((page) => { + page.style.boxShadow = 'var(--shadow-medium)' + }) + lastSuccessfulHtmlRef.current = containerRef.current.innerHTML + setHasRenderedPreview(true) + } + } catch (err) { + if (!cancelled) { + const msg = toError(err).message || 'Failed to render document' + logger.error('DOCX render failed', { error: msg }) + setRenderError(msg) + } + } finally { + if (!cancelled) { + setRendering(false) + } + } + } + + render() + return () => { + cancelled = true + } + }, [fileData, streamingContent]) + + useEffect(() => { + if (streamingContent === undefined || !containerRef.current) return + + let cancelled = false + const controller = new AbortController() + + const debounceTimer = setTimeout(async () => { + const container = containerRef.current + if (!container || cancelled) return + + const previousHtml = lastSuccessfulHtmlRef.current + + try { + setRendering(true) + setRenderError(null) + + const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: streamingContent }), + signal: controller.signal, + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Preview failed' })) + throw new Error(err.error || 'Preview failed') + } + + const arrayBuffer = await response.arrayBuffer() + if (cancelled || !containerRef.current) return + + const { renderAsync } = await import('docx-preview') + if (cancelled || !containerRef.current) return + + containerRef.current.innerHTML = '' + await renderAsync(new Uint8Array(arrayBuffer), containerRef.current, undefined, { + inWrapper: true, + ignoreWidth: false, + ignoreHeight: false, + }) + + if (!cancelled && containerRef.current) { + const wrapper = containerRef.current.querySelector('.docx-wrapper') + if (wrapper) wrapper.style.background = 'transparent' + containerRef.current.querySelectorAll('section.docx').forEach((page) => { + page.style.boxShadow = 'var(--shadow-medium)' + }) + lastSuccessfulHtmlRef.current = containerRef.current.innerHTML + setHasRenderedPreview(true) + } + } catch (err) { + if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { + if (containerRef.current && previousHtml) { + containerRef.current.innerHTML = previousHtml + setHasRenderedPreview(true) + } + const msg = toError(err).message || 'Failed to render document' + if (previousHtml || shouldSuppressStreamingDocumentError(msg)) { + logger.info('Suppressing transient DOCX streaming preview error', { error: msg }) + } else { + logger.error('DOCX render failed', { error: msg }) + setRenderError(msg) + } + } + } finally { + if (!cancelled) { + setRendering(false) + } + } + }, 500) + + return () => { + cancelled = true + clearTimeout(debounceTimer) + controller.abort() + } + }, [streamingContent, workspaceId]) + + const error = + hasRenderedPreview && streamingContent !== undefined + ? null + : streamingContent !== undefined + ? renderError + : resolvePreviewError(fetchError, renderError) + if (error) return + + const showSkeleton = + !hasRenderedPreview && + ((streamingContent !== undefined && rendering) || (streamingContent === undefined && isLoading)) + + return ( +
+ {showSkeleton && ( +
{PDF_PAGE_SKELETON}
+ )} +
+
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts new file mode 100644 index 00000000000..5cfd8498edc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts @@ -0,0 +1,233 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/utils/validation', () => ({ + SUPPORTED_CODE_EXTENSIONS: ['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'], +})) + +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + getFileExtension: (filename: string): string => { + const lastDot = filename.lastIndexOf('.') + return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' + }, +})) + +import { resolveFileCategory } from './file-category' + +describe('resolveFileCategory — MIME type routing', () => { + describe('text-editable', () => { + it.each([ + 'text/plain', + 'text/markdown', + 'application/json', + 'application/x-yaml', + 'text/csv', + 'text/html', + 'text/xml', + 'application/xml', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/typescript', + 'application/toml', + 'text/x-python', + 'text/x-sh', + 'text/x-sql', + 'image/svg+xml', + 'text/x-mermaid', + ])('%s → text-editable', (mime) => { + expect(resolveFileCategory(mime, 'file.txt')).toBe('text-editable') + }) + }) + + describe('iframe-previewable (PDF)', () => { + it('application/pdf → iframe-previewable', () => { + expect(resolveFileCategory('application/pdf', 'doc.pdf')).toBe('iframe-previewable') + }) + + it('text/x-pdflibjs → iframe-previewable', () => { + expect(resolveFileCategory('text/x-pdflibjs', 'generated.pdf')).toBe('iframe-previewable') + }) + }) + + describe('image-previewable', () => { + it.each(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])( + '%s → image-previewable', + (mime) => { + expect(resolveFileCategory(mime, 'img.png')).toBe('image-previewable') + } + ) + }) + + describe('audio-previewable', () => { + it.each([ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/webm', + 'audio/ogg', + 'audio/flac', + 'audio/aac', + 'audio/opus', + 'audio/x-m4a', + ])('%s → audio-previewable', (mime) => { + expect(resolveFileCategory(mime, 'audio.mp3')).toBe('audio-previewable') + }) + }) + + describe('video-previewable', () => { + it.each(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm'])( + '%s → video-previewable', + (mime) => { + expect(resolveFileCategory(mime, 'video.mp4')).toBe('video-previewable') + } + ) + }) + + describe('docx-previewable', () => { + it('application/vnd.openxmlformats-officedocument.wordprocessingml.document → docx-previewable', () => { + expect( + resolveFileCategory( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'doc.docx' + ) + ).toBe('docx-previewable') + }) + + it('text/x-docxjs → docx-previewable', () => { + expect(resolveFileCategory('text/x-docxjs', 'doc.docx')).toBe('docx-previewable') + }) + }) + + describe('pptx-previewable', () => { + it('application/vnd.openxmlformats-officedocument.presentationml.presentation → pptx-previewable', () => { + expect( + resolveFileCategory( + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'deck.pptx' + ) + ).toBe('pptx-previewable') + }) + + it('text/x-pptxgenjs → pptx-previewable', () => { + expect(resolveFileCategory('text/x-pptxgenjs', 'deck.pptx')).toBe('pptx-previewable') + }) + }) + + describe('xlsx-previewable', () => { + it('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet → xlsx-previewable', () => { + expect( + resolveFileCategory( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'data.xlsx' + ) + ).toBe('xlsx-previewable') + }) + }) +}) + +describe('resolveFileCategory — extension fallback', () => { + describe('text-editable extensions', () => { + it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])( + '.%s → text-editable', + (ext) => { + expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable') + } + ) + }) + + describe('code extensions from SUPPORTED_CODE_EXTENSIONS', () => { + it.each(['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'])('.%s → text-editable', (ext) => { + expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable') + }) + }) + + describe('pdf extension', () => { + it('.pdf → iframe-previewable', () => { + expect(resolveFileCategory(null, 'document.pdf')).toBe('iframe-previewable') + }) + }) + + describe('image extensions', () => { + it.each(['png', 'jpg', 'jpeg', 'gif', 'webp'])('.%s → image-previewable', (ext) => { + expect(resolveFileCategory(null, `image.${ext}`)).toBe('image-previewable') + }) + }) + + describe('audio extensions', () => { + it.each(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])( + '.%s → audio-previewable', + (ext) => { + expect(resolveFileCategory(null, `audio.${ext}`)).toBe('audio-previewable') + } + ) + }) + + describe('video extensions', () => { + it.each(['mp4', 'mov', 'avi', 'mkv', 'webm'])('.%s → video-previewable', (ext) => { + expect(resolveFileCategory(null, `video.${ext}`)).toBe('video-previewable') + }) + }) + + describe('docx extension', () => { + it('.docx → docx-previewable', () => { + expect(resolveFileCategory(null, 'doc.docx')).toBe('docx-previewable') + }) + }) + + describe('pptx extension', () => { + it('.pptx → pptx-previewable', () => { + expect(resolveFileCategory(null, 'deck.pptx')).toBe('pptx-previewable') + }) + }) + + describe('xlsx extension', () => { + it('.xlsx → xlsx-previewable', () => { + expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable') + }) + }) + + describe('unsupported', () => { + it('unknown extension → unsupported', () => { + expect(resolveFileCategory(null, 'file.xyz')).toBe('unsupported') + }) + + it('unknown mime with unknown extension → unsupported', () => { + expect(resolveFileCategory('application/octet-stream', 'file.bin')).toBe('unsupported') + }) + + it('no extension, no mime → unsupported', () => { + expect(resolveFileCategory(null, 'LICENSE')).toBe('unsupported') + }) + }) +}) + +describe('resolveFileCategory — MIME priority', () => { + it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => { + expect(resolveFileCategory('text/plain', 'notes.pdf')).toBe('text-editable') + }) + + it('application/pdf MIME + .txt extension → iframe-previewable (MIME wins)', () => { + expect(resolveFileCategory('application/pdf', 'disguised.txt')).toBe('iframe-previewable') + }) + + it('null MIME falls through to extension routing', () => { + expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable') + }) + + it('unknown MIME falls through to extension routing', () => { + expect(resolveFileCategory('application/octet-stream', 'data.xlsx')).toBe('xlsx-previewable') + }) +}) + +describe('resolveFileCategory — extension case', () => { + it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => { + expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable') + }) + + it('handles mixed-case correctly for json', () => { + expect(resolveFileCategory(null, 'config.JSON')).toBe('text-editable') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts new file mode 100644 index 00000000000..2eb2c96810b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts @@ -0,0 +1,117 @@ +import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' + +const TEXT_EDITABLE_MIME_TYPES = new Set([ + 'text/markdown', + 'text/plain', + 'application/json', + 'application/x-yaml', + 'text/csv', + 'text/html', + 'text/xml', + 'application/xml', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/typescript', + 'application/toml', + 'text/x-python', + 'text/x-sh', + 'text/x-sql', + 'image/svg+xml', + 'text/x-mermaid', +]) + +const TEXT_EDITABLE_EXTENSIONS = new Set([ + 'md', + 'txt', + 'json', + 'yaml', + 'yml', + 'csv', + 'html', + 'htm', + 'svg', + 'mmd', + ...SUPPORTED_CODE_EXTENSIONS, +]) + +const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs']) +const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) + +const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) +const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']) + +const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([ + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/webm', + 'audio/ogg', + 'audio/flac', + 'audio/aac', + 'audio/opus', + 'audio/x-m4a', +]) +const AUDIO_PREVIEWABLE_EXTENSIONS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus']) + +const VIDEO_PREVIEWABLE_MIME_TYPES = new Set([ + 'video/mp4', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-matroska', + 'video/webm', +]) +const VIDEO_PREVIEWABLE_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm']) + +const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/x-pptxgenjs', +]) +const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx']) + +const DOCX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/x-docxjs', +]) +const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx']) + +const XLSX_PREVIEWABLE_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]) +const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx']) + +export type FileCategory = + | 'text-editable' + | 'iframe-previewable' + | 'image-previewable' + | 'audio-previewable' + | 'video-previewable' + | 'pptx-previewable' + | 'docx-previewable' + | 'xlsx-previewable' + | 'unsupported' + +export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { + if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' + if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' + if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' + if (mimeType && AUDIO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'audio-previewable' + if (mimeType && VIDEO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'video-previewable' + if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable' + if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' + if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable' + + const ext = getFileExtension(filename) + const nameKey = ext || filename.toLowerCase() + if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable' + if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable' + if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable' + if (AUDIO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'audio-previewable' + if (VIDEO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'video-previewable' + if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable' + if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' + if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable' + + return 'unsupported' +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 02e3c683ae9..6ac5593405d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -1,189 +1,37 @@ 'use client' -import { - memo, - type ReactElement, - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react' -import Editor from 'react-simple-code-editor' -import 'prismjs/components/prism-bash' -import 'prismjs/components/prism-css' -import 'prismjs/components/prism-markup' -import 'prismjs/components/prism-sql' -import 'prismjs/components/prism-typescript' -import 'prismjs/components/prism-yaml' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { ZoomIn, ZoomOut } from 'lucide-react' -import { - CODE_LINE_HEIGHT_PX, - Code as CodeEditor, - calculateGutterWidth, - getCodeEditorProps, - highlight, - languages, - Skeleton, -} from '@/components/emcn' -import { cn } from '@/lib/core/utils/cn' +import { toError } from '@sim/utils/errors' +import dynamic from 'next/dynamic' +import { Skeleton } from '@/components/emcn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' -import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' -import { - useUpdateWorkspaceFileContent, - useWorkspaceFileBinary, - useWorkspaceFileContent, -} from '@/hooks/queries/workspace-files' -import { useAutosave } from '@/hooks/use-autosave' -import { DataTable } from './data-table' -import { PreviewPanel, resolvePreviewType } from './preview-panel' - -const logger = createLogger('FileViewer') - -const SPLIT_MIN_PCT = 20 -const SPLIT_MAX_PCT = 80 -const SPLIT_DEFAULT_PCT = 50 - -const TEXT_EDITABLE_MIME_TYPES = new Set([ - 'text/markdown', - 'text/plain', - 'application/json', - 'application/x-yaml', - 'text/csv', - 'text/html', - 'text/xml', - 'application/xml', - 'text/css', - 'text/javascript', - 'application/javascript', - 'application/typescript', - 'application/toml', - 'text/x-python', - 'text/x-sh', - 'text/x-sql', - 'image/svg+xml', -]) - -const TEXT_EDITABLE_EXTENSIONS = new Set([ - 'md', - 'txt', - 'json', - 'yaml', - 'yml', - 'csv', - 'html', - 'htm', - 'svg', - ...SUPPORTED_CODE_EXTENSIONS, -]) - -const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs']) -const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) - -const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) -const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']) - -const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'text/x-pptxgenjs', -]) -const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx']) - -const DOCX_PREVIEWABLE_MIME_TYPES = new Set([ - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'text/x-docxjs', -]) -const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx']) - -const XLSX_PREVIEWABLE_MIME_TYPES = new Set([ - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', -]) -const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx']) - -type FileCategory = - | 'text-editable' - | 'iframe-previewable' - | 'image-previewable' - | 'pptx-previewable' - | 'docx-previewable' - | 'xlsx-previewable' - | 'unsupported' - -type CodeEditorLanguage = - | 'javascript' - | 'json' - | 'python' - | 'typescript' - | 'bash' - | 'css' - | 'markup' - | 'sql' - | 'yaml' - -const CODE_EDITOR_LANGUAGE_BY_EXTENSION: Partial> = { - js: 'javascript', - jsx: 'javascript', - ts: 'typescript', - tsx: 'typescript', - py: 'python', - json: 'json', - sh: 'bash', - bash: 'bash', - zsh: 'bash', - fish: 'bash', - css: 'css', - scss: 'css', - less: 'css', - html: 'markup', - htm: 'markup', - xml: 'markup', - svg: 'markup', - sql: 'sql', - yaml: 'yaml', - yml: 'yaml', -} - -const CODE_EDITOR_LANGUAGE_BY_MIME: Partial> = { - 'text/javascript': 'javascript', - 'application/javascript': 'javascript', - 'text/typescript': 'typescript', - 'application/typescript': 'typescript', - 'text/x-python': 'python', - 'application/json': 'json', - 'text/x-shellscript': 'bash', - 'text/css': 'css', - 'text/html': 'markup', - 'text/xml': 'markup', - 'application/xml': 'markup', - 'image/svg+xml': 'markup', - 'text/x-sql': 'sql', - 'application/x-yaml': 'yaml', -} - -const CODE_EDITOR_LINE_HEIGHT_PX = CODE_LINE_HEIGHT_PX +import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' +import { resolveFileCategory } from './file-category' +import type { StreamingMode } from './text-editor-state' -function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { - if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable' - if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable' - if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable' - if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable' - if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable' - if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable' +export type { StreamingMode } from './text-editor-state' - const ext = getFileExtension(filename) - const nameKey = ext || filename.toLowerCase() - if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable' - if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable' - if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable' - if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable' - if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable' - if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable' +import { DocxPreview } from './docx-preview' +import { ImagePreview } from './image-preview' +import type { PdfDocumentSource } from './pdf-viewer' +import { PptxPreview } from './pptx-preview' +import { resolvePreviewType } from './preview-panel' +import { + PDF_PAGE_SKELETON, + PreviewError, + resolvePreviewError, + shouldSuppressStreamingDocumentError, +} from './preview-shared' +import { TextEditor } from './text-editor' +import { XlsxPreview } from './xlsx-preview' + +const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), { + ssr: false, +}) - return 'unsupported' -} +const logger = createLogger('FileViewer') export function isTextEditable(file: { type: string; name: string }): boolean { return resolveFileCategory(file.type, file.name) === 'text-editable' @@ -194,13 +42,11 @@ export function isPreviewable(file: { type: string; name: string }): boolean { } export type PreviewMode = 'editor' | 'split' | 'preview' -type StreamingMode = 'append' | 'replace' interface FileViewerProps { file: WorkspaceFileRecord workspaceId: string canEdit: boolean - showPreview?: boolean previewMode?: PreviewMode autoFocus?: boolean onDirtyChange?: (isDirty: boolean) => void @@ -209,291 +55,13 @@ interface FileViewerProps { streamingContent?: string streamingMode?: StreamingMode disableStreamingAutoScroll?: boolean - useCodeRendererForCodeFiles?: boolean previewContextKey?: string } -function isCodeFile(file: { type: string; name: string }): boolean { - const ext = getFileExtension(file.name) - return ( - SUPPORTED_CODE_EXTENSIONS.includes(ext as (typeof SUPPORTED_CODE_EXTENSIONS)[number]) || - ext === 'html' || - ext === 'htm' || - ext === 'xml' || - ext === 'svg' - ) -} - -function resolveCodeEditorLanguage(file: { type: string; name: string }): CodeEditorLanguage { - const ext = getFileExtension(file.name) - return ( - CODE_EDITOR_LANGUAGE_BY_EXTENSION[ext] ?? - CODE_EDITOR_LANGUAGE_BY_MIME[file.type] ?? - (ext === 'json' ? 'json' : 'javascript') - ) -} - -function areNumberArraysEqual(a: number[], b: number[]): boolean { - if (a === b) return true - if (a.length !== b.length) return false - for (let index = 0; index < a.length; index++) { - if (a[index] !== b[index]) { - return false - } - } - return true -} - -type TextEditorContentPhase = 'uninitialized' | 'ready' | 'streaming' | 'reconciling' - -interface TextEditorContentState { - phase: TextEditorContentPhase - content: string - savedContent: string - lastStreamedContent: string | null -} - -interface SyncTextEditorContentStateOptions { - canReconcileToFetchedContent: boolean - fetchedContent?: string - streamingContent?: string - streamingMode: StreamingMode -} - -type TextEditorContentAction = - | ({ type: 'sync-external' } & SyncTextEditorContentStateOptions) - | { type: 'edit'; content: string } - | { type: 'save-success'; content: string } - -const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = { - phase: 'uninitialized', - content: '', - savedContent: '', - lastStreamedContent: null, -} - -function resolveStreamingEditorContent( - fetchedContent: string | undefined, - streamingContent: string, - streamingMode: StreamingMode -): string { - if (streamingMode === 'replace' || fetchedContent === undefined) { - return streamingContent - } - - if ( - fetchedContent.endsWith(streamingContent) || - fetchedContent.endsWith(`\n${streamingContent}`) - ) { - return fetchedContent - } - - return `${fetchedContent}\n${streamingContent}` -} - -function finalizeTextEditorContentState( - state: TextEditorContentState, - nextContent: string -): TextEditorContentState { - if ( - state.phase === 'ready' && - state.content === nextContent && - state.savedContent === nextContent && - state.lastStreamedContent === null - ) { - return state - } - - return { - phase: 'ready', - content: nextContent, - savedContent: nextContent, - lastStreamedContent: null, - } -} - -function moveTextEditorContentStateToStreaming( - state: TextEditorContentState, - nextContent: string -): TextEditorContentState { - if ( - state.phase === 'streaming' && - state.content === nextContent && - state.lastStreamedContent === nextContent - ) { - return state - } - - return { - ...state, - phase: 'streaming', - content: nextContent, - lastStreamedContent: nextContent, - } -} - -function moveTextEditorContentStateToReconcile( - state: TextEditorContentState -): TextEditorContentState { - if (state.phase === 'reconciling') { - return state - } - - return { - ...state, - phase: 'reconciling', - } -} - -function syncTextEditorContentState( - state: TextEditorContentState, - options: SyncTextEditorContentStateOptions -): TextEditorContentState { - const { canReconcileToFetchedContent, fetchedContent, streamingContent, streamingMode } = options - - if (streamingContent !== undefined) { - const nextContent = resolveStreamingEditorContent( - fetchedContent, - streamingContent, - streamingMode - ) - const fetchedMatchesNextContent = fetchedContent !== undefined && fetchedContent === nextContent - const fetchedMatchesLastStreamedContent = - fetchedContent !== undefined && - state.lastStreamedContent !== null && - fetchedContent === state.lastStreamedContent - const hasFetchedAdvanced = fetchedContent !== undefined && fetchedContent !== state.savedContent - - if ( - (state.phase === 'streaming' || state.phase === 'reconciling') && - (hasFetchedAdvanced || fetchedMatchesLastStreamedContent || fetchedMatchesNextContent) - ) { - return finalizeTextEditorContentState(state, fetchedContent) - } - - if ( - state.phase === 'ready' && - state.content === state.savedContent && - fetchedMatchesNextContent && - fetchedContent !== undefined - ) { - return finalizeTextEditorContentState(state, fetchedContent) - } - - return moveTextEditorContentStateToStreaming(state, nextContent) - } - - if (state.phase === 'streaming' || state.phase === 'reconciling') { - if (!canReconcileToFetchedContent) { - return finalizeTextEditorContentState(state, state.content) - } - - if (fetchedContent !== undefined) { - const hasFetchedAdvanced = fetchedContent !== state.savedContent - const fetchedMatchesLastStreamedContent = - state.lastStreamedContent !== null && fetchedContent === state.lastStreamedContent - - if (hasFetchedAdvanced || fetchedMatchesLastStreamedContent) { - return finalizeTextEditorContentState(state, fetchedContent) - } - } - - return moveTextEditorContentStateToReconcile(state) - } - - if (fetchedContent === undefined) { - return state - } - - if (state.phase === 'uninitialized') { - return finalizeTextEditorContentState(state, fetchedContent) - } - - if (fetchedContent === state.savedContent) { - return state - } - - if (state.content === state.savedContent) { - return finalizeTextEditorContentState(state, fetchedContent) - } - - return state -} - -function textEditorContentReducer( - state: TextEditorContentState, - action: TextEditorContentAction -): TextEditorContentState { - switch (action.type) { - case 'sync-external': - return syncTextEditorContentState(state, action) - case 'edit': - if (state.phase !== 'ready' || action.content === state.content) { - return state - } - return { - ...state, - content: action.content, - } - case 'save-success': - if ( - state.phase === 'ready' && - state.content === action.content && - state.savedContent === action.content && - state.lastStreamedContent === null - ) { - return state - } - return { - ...state, - phase: 'ready', - content: action.content, - savedContent: action.content, - lastStreamedContent: null, - } - default: - return state - } -} - -function useTextEditorContentState(options: SyncTextEditorContentStateOptions) { - const [state, dispatch] = useReducer(textEditorContentReducer, INITIAL_TEXT_EDITOR_CONTENT_STATE) - - useEffect(() => { - dispatch({ - type: 'sync-external', - ...options, - }) - }, [ - options.canReconcileToFetchedContent, - options.fetchedContent, - options.streamingContent, - options.streamingMode, - ]) - - const setDraftContent = useCallback((content: string) => { - dispatch({ type: 'edit', content }) - }, []) - - const markSavedContent = useCallback((content: string) => { - dispatch({ type: 'save-success', content }) - }, []) - - return { - content: state.content, - savedContent: state.savedContent, - isInitialized: state.phase !== 'uninitialized', - isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling', - setDraftContent, - markSavedContent, - } -} - export function FileViewer({ file, workspaceId, canEdit, - showPreview, previewMode, autoFocus, onDirtyChange, @@ -502,7 +70,6 @@ export function FileViewer({ streamingContent, streamingMode, disableStreamingAutoScroll = false, - useCodeRendererForCodeFiles = false, previewContextKey, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -513,7 +80,7 @@ export function FileViewer({ file={file} workspaceId={workspaceId} canEdit={canEdit} - previewMode={previewMode ?? (showPreview ? 'preview' : 'editor')} + previewMode={previewMode ?? 'editor'} autoFocus={autoFocus} onDirtyChange={onDirtyChange} onSaveStatusChange={onSaveStatusChange} @@ -521,7 +88,6 @@ export function FileViewer({ streamingContent={streamingContent} streamingMode={streamingMode} disableStreamingAutoScroll={disableStreamingAutoScroll} - useCodeRendererForCodeFiles={useCodeRendererForCodeFiles} previewContextKey={previewContextKey} /> ) @@ -529,16 +95,36 @@ export function FileViewer({ if (category === 'iframe-previewable') { return ( - + ) } if (category === 'image-previewable') { - return + return + } + + if (category === 'audio-previewable') { + return + } + + if (category === 'video-previewable') { + return } if (category === 'docx-previewable') { - return + return ( + + ) } if (category === 'pptx-previewable') { @@ -546,650 +132,186 @@ export function FileViewer({ } if (category === 'xlsx-previewable') { - return + return ( + + ) } return } -interface TextEditorProps { +const IframePreview = memo(function IframePreview({ + file, + workspaceId, + streamingContent, +}: { file: WorkspaceFileRecord workspaceId: string - canEdit: boolean - previewMode: PreviewMode - autoFocus?: boolean - onDirtyChange?: (isDirty: boolean) => void - onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void - saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string - streamingMode?: StreamingMode - disableStreamingAutoScroll: boolean - useCodeRendererForCodeFiles?: boolean - previewContextKey?: string -} +}) { + const [streamingBuffer, setStreamingBuffer] = useState(null) + const streamingBufferRef = useRef(null) + const streamingBufferSeqRef = useRef(0) + const [streamingBufferSeq, setStreamingBufferSeq] = useState(0) + const [rendering, setRendering] = useState(false) + const [renderError, setRenderError] = useState(null) -function TextEditor({ - file, - workspaceId, - canEdit, - previewMode, - autoFocus, - onDirtyChange, - onSaveStatusChange, - saveRef, - streamingContent, - streamingMode = 'append', - disableStreamingAutoScroll, - useCodeRendererForCodeFiles = false, - previewContextKey, -}: TextEditorProps) { - const textareaRef = useRef(null) - const containerRef = useRef(null) - const codeEditorRef = useRef(null) - const codeScrollRef = useRef(null) - const hasAutoFocusedRef = useRef(false) + useEffect(() => { + if (streamingContent === undefined) return - const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT_PCT) - const [isResizing, setIsResizing] = useState(false) - const [visualLineHeights, setVisualLineHeights] = useState([]) - const [activeLineNumber, setActiveLineNumber] = useState(1) + let cancelled = false + const controller = new AbortController() - const { - data: fetchedContent, - isLoading, - error, - } = useWorkspaceFileContent( - workspaceId, - file.id, - file.key, - file.type === 'text/x-pptxgenjs' || - file.type === 'text/x-docxjs' || - file.type === 'text/x-pdflibjs' - ) + const debounceTimer = setTimeout(async () => { + if (cancelled) return - const updateContent = useUpdateWorkspaceFileContent() - const updateContentRef = useRef(updateContent) - updateContentRef.current = updateContent + try { + setRendering(true) + setRenderError(null) - const shouldUseCodeRenderer = useCodeRendererForCodeFiles && isCodeFile(file) - const codeLanguage = useMemo(() => resolveCodeEditorLanguage(file), [file.name, file.type]) - const onDirtyChangeRef = useRef(onDirtyChange) - const onSaveStatusChangeRef = useRef(onSaveStatusChange) - onDirtyChangeRef.current = onDirtyChange - onSaveStatusChangeRef.current = onSaveStatusChange + const response = await fetch(`/api/workspaces/${workspaceId}/pdf/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: streamingContent }), + signal: controller.signal, + }) + if (!response.ok) { + const err = await response.json().catch(() => ({ error: 'Preview failed' })) + throw new Error(err.error || 'Preview failed') + } - const { - content, - savedContent, - isInitialized, - isStreamInteractionLocked, - setDraftContent, - markSavedContent, - } = useTextEditorContentState({ - canReconcileToFetchedContent: file.key.length > 0, - fetchedContent, - streamingContent, - streamingMode, - }) + const buf = await response.arrayBuffer() + if (cancelled) return - useEffect(() => { - if (!autoFocus || !isInitialized || hasAutoFocusedRef.current) { - return + streamingBufferRef.current = buf + streamingBufferSeqRef.current += 1 + setStreamingBuffer(buf) + setStreamingBufferSeq(streamingBufferSeqRef.current) + } catch (err) { + if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) { + const msg = toError(err).message || 'Failed to render PDF' + if (streamingBufferRef.current || shouldSuppressStreamingDocumentError(msg)) { + logger.info('Suppressing transient PDF streaming preview error', { error: msg }) + } else { + logger.error('PDF render failed', { error: msg }) + setRenderError(msg) + } + } + } finally { + if (!cancelled) setRendering(false) + } + }, 500) + + return () => { + cancelled = true + clearTimeout(debounceTimer) + controller.abort() } + }, [streamingContent, workspaceId]) - hasAutoFocusedRef.current = true - requestAnimationFrame(() => { - const editorTextarea = codeEditorRef.current?.querySelector('textarea') - if (editorTextarea instanceof HTMLTextAreaElement) { - editorTextarea.focus() - return - } - textareaRef.current?.focus() - }) - }, [autoFocus, isInitialized]) + const staticSource = useMemo( + () => ({ + kind: 'url', + url: `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, + }), + [file.key] + ) - const handleContentChange = useCallback( - (value: string) => { - if (value === content) { - return - } - setDraftContent(value) - }, - [content, setDraftContent] + const streamingSource = useMemo( + () => (streamingBuffer ? { kind: 'buffer', buffer: streamingBuffer } : null), + [streamingBuffer] ) - const onSave = useCallback(async () => { - if (content === savedContent) return + if (renderError) return - await updateContentRef.current.mutateAsync({ - workspaceId, - fileId: file.id, - content, - }) - markSavedContent(content) - }, [content, file.id, markSavedContent, savedContent, workspaceId]) + if (streamingContent !== undefined) { + if (!streamingSource) { + return
{PDF_PAGE_SKELETON}
+ } + return + } - const { saveStatus, saveImmediately, isDirty } = useAutosave({ - content, - savedContent, - onSave, - enabled: canEdit && isInitialized && !isStreamInteractionLocked, - }) + return +}) - useEffect(() => { - onDirtyChangeRef.current?.(isDirty) - }, [isDirty]) +function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) { + const { data: fileData, isLoading, error } = useWorkspaceFileBinary(workspaceId, fileId, fileKey) + const [blobUrl, setBlobUrl] = useState(null) + const blobUrlRef = useRef(null) - useEffect(() => { - onSaveStatusChangeRef.current?.(saveStatus) - }, [saveStatus]) + const replaceBlobUrl = useCallback((nextUrl: string | null) => { + const previousUrl = blobUrlRef.current + blobUrlRef.current = nextUrl + setBlobUrl(nextUrl) + if (previousUrl && previousUrl !== nextUrl) URL.revokeObjectURL(previousUrl) + }, []) useEffect(() => { - if (!saveRef) { - return - } - - saveRef.current = saveImmediately - return () => { - if (saveRef.current === saveImmediately) { - saveRef.current = null + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current) + blobUrlRef.current = null } } - }, [saveImmediately, saveRef]) + }, []) - useEffect(() => { - if (!isResizing) return + return { fileData, isLoading, error, blobUrl, replaceBlobUrl } +} - const handleMouseMove = (e: MouseEvent) => { - const container = containerRef.current - if (!container) return - const rect = container.getBoundingClientRect() - const pct = ((e.clientX - rect.left) / rect.width) * 100 - setSplitPct(Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, pct))) - } +const AudioPreview = memo(function AudioPreview({ + file, + workspaceId, +}: { + file: WorkspaceFileRecord + workspaceId: string +}) { + const { + fileData, + isLoading, + error: fetchError, + blobUrl, + replaceBlobUrl, + } = useBlobUrl(workspaceId, file.id, file.key) - const handleMouseUp = () => setIsResizing(false) + useEffect(() => { + if (!fileData) return + replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'audio/mpeg' }))) + }, [file.type, fileData, replaceBlobUrl]) - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - document.body.style.cursor = 'ew-resize' - document.body.style.userSelect = 'none' + const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null) + if (error) return - return () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - document.body.style.cursor = '' - document.body.style.userSelect = '' - } - }, [isResizing]) + if (isLoading && !blobUrl) { + return ( +
+ + + +
+ ) + } - const handleCheckboxToggle = useCallback( - (checkboxIndex: number, checked: boolean) => { - const toggled = toggleMarkdownCheckbox(content, checkboxIndex, checked) - if (toggled !== content) { - handleContentChange(toggled) - } - }, - [content, handleContentChange] + return ( +
+
+
🎵
+

{file.name}

+
+ {blobUrl && ( + // biome-ignore lint/a11y/useMediaCaption: audio from workspace files +
) +}) - const isStreaming = isStreamInteractionLocked - const isEditorReadOnly = isStreamInteractionLocked || !canEdit - const renderedContent = content - const gutterWidthPx = useMemo(() => { - const lineCount = renderedContent.split('\n').length - return calculateGutterWidth(lineCount) - }, [renderedContent]) - const sharedCodeEditorProps = useMemo( - () => - getCodeEditorProps({ - disabled: isEditorReadOnly, - isStreaming: isStreaming, - }), - [isEditorReadOnly, isStreaming] - ) - const highlightCode = useMemo(() => { - return (value: string) => { - const grammar = languages[codeLanguage] || languages.javascript - return highlight(value, grammar, codeLanguage) - } - }, [codeLanguage]) - const handleCodeContentChange = useCallback( - (value: string) => { - if (isEditorReadOnly) return - handleContentChange(value) - }, - [handleContentChange, isEditorReadOnly] - ) - - const textareaStuckRef = useRef(true) - const renderedContentRef = useRef(renderedContent) - renderedContentRef.current = renderedContent - - useEffect(() => { - if (!shouldUseCodeRenderer) return - const textarea = codeEditorRef.current?.querySelector('textarea') - if (!(textarea instanceof HTMLTextAreaElement)) return - - const updateActiveLineNumber = () => { - const pos = textarea.selectionStart - const textBeforeCursor = renderedContentRef.current.substring(0, pos) - const nextActiveLineNumber = textBeforeCursor.split('\n').length - setActiveLineNumber((currentLineNumber) => - currentLineNumber === nextActiveLineNumber ? currentLineNumber : nextActiveLineNumber - ) - } - - textarea.addEventListener('click', updateActiveLineNumber) - textarea.addEventListener('keyup', updateActiveLineNumber) - textarea.addEventListener('focus', updateActiveLineNumber) - - return () => { - textarea.removeEventListener('click', updateActiveLineNumber) - textarea.removeEventListener('keyup', updateActiveLineNumber) - textarea.removeEventListener('focus', updateActiveLineNumber) - } - }, [shouldUseCodeRenderer]) - - const calculateVisualLinesRef = useRef(() => {}) - calculateVisualLinesRef.current = () => { - const preElement = codeEditorRef.current?.querySelector('pre') - if (!(preElement instanceof HTMLElement)) return - - const lines = renderedContentRef.current.split('\n') - const newVisualLineHeights: number[] = [] - - const tempContainer = document.createElement('div') - tempContainer.style.cssText = ` - position: absolute; - visibility: hidden; - height: auto; - width: ${preElement.clientWidth}px; - font-family: ${window.getComputedStyle(preElement).fontFamily}; - font-size: ${window.getComputedStyle(preElement).fontSize}; - line-height: ${CODE_EDITOR_LINE_HEIGHT_PX}px; - padding: 8px; - white-space: pre-wrap; - word-break: break-word; - box-sizing: border-box; - ` - document.body.appendChild(tempContainer) - - lines.forEach((line) => { - const lineDiv = document.createElement('div') - lineDiv.textContent = line || ' ' - tempContainer.appendChild(lineDiv) - const actualHeight = lineDiv.getBoundingClientRect().height - const lineUnits = Math.max(1, Math.ceil(actualHeight / CODE_EDITOR_LINE_HEIGHT_PX)) - newVisualLineHeights.push(lineUnits) - tempContainer.removeChild(lineDiv) - }) - - document.body.removeChild(tempContainer) - setVisualLineHeights((currentVisualLineHeights) => - areNumberArraysEqual(currentVisualLineHeights, newVisualLineHeights) - ? currentVisualLineHeights - : newVisualLineHeights - ) - } - - useEffect(() => { - if (!shouldUseCodeRenderer || !codeEditorRef.current) return - - const resizeObserver = new ResizeObserver(() => calculateVisualLinesRef.current()) - resizeObserver.observe(codeEditorRef.current) - - return () => { - resizeObserver.disconnect() - } - }, [shouldUseCodeRenderer]) - - useEffect(() => { - if (!shouldUseCodeRenderer) return - calculateVisualLinesRef.current() - }, [renderedContent, shouldUseCodeRenderer]) - - const renderCodeLineNumbers = useCallback((): ReactElement[] => { - const numbers: ReactElement[] = [] - let lineNumber = 1 - - visualLineHeights.forEach((height) => { - const isActive = lineNumber === activeLineNumber - numbers.push( -
- {lineNumber} -
- ) - - for (let i = 1; i < height; i++) { - numbers.push( -
- {lineNumber} -
- ) - } - - lineNumber++ - }) - - if (numbers.length === 0) { - numbers.push( -
- 1 -
- ) - } - - return numbers - }, [activeLineNumber, visualLineHeights]) - - useEffect(() => { - if (!isStreaming) return - if (disableStreamingAutoScroll) { - textareaStuckRef.current = false - return - } - textareaStuckRef.current = true - - const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null - if (!el) return - - const onWheel = (e: Event) => { - if ((e as WheelEvent).deltaY < 0) textareaStuckRef.current = false - } - - const onScroll = () => { - const dist = el.scrollHeight - el.scrollTop - el.clientHeight - if (dist <= 5) textareaStuckRef.current = true - } - - el.addEventListener('wheel', onWheel, { passive: true }) - el.addEventListener('scroll', onScroll, { passive: true }) - - return () => { - el.removeEventListener('wheel', onWheel) - el.removeEventListener('scroll', onScroll) - } - }, [disableStreamingAutoScroll, isStreaming, shouldUseCodeRenderer]) - - useEffect(() => { - if (!isStreaming || !textareaStuckRef.current || disableStreamingAutoScroll) return - const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null - if (!el) return - el.scrollTop = el.scrollHeight - }, [disableStreamingAutoScroll, isStreaming, renderedContent, shouldUseCodeRenderer]) - - const previewType = resolvePreviewType(file.type, file.name) - const isIframeRendered = previewType === 'html' || previewType === 'svg' - const effectiveMode = isStreaming && isIframeRendered ? 'editor' : previewMode - const showEditor = effectiveMode !== 'preview' - const showPreviewPane = effectiveMode !== 'editor' - - if (streamingContent === undefined) { - if (isLoading) return DOCUMENT_SKELETON - - if (error && !isInitialized) { - return ( -
-

Failed to load file content

-
- ) - } - } - - return ( -
- {showEditor && - (shouldUseCodeRenderer ? ( -
-
- - - {renderCodeLineNumbers()} - - - - - -
-
- ) : ( -
editConfig && startEdit(-1, i, String(header ?? ''))} > - {String(header ?? '')} + {isEditing(-1, i) ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleKeyDown} + className='w-full min-w-[60px] bg-transparent font-semibold text-[12px] text-[var(--text-primary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' + /> + ) : ( + String(header ?? '') + )}
- {String(row[ci] ?? '')} + editConfig && startEdit(ri, ci, String(row[ci] ?? ''))} + > + {isEditing(ri, ci) ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleKeyDown} + className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset' + /> + ) : ( + String(row[ci] ?? '') + )}