From 3250264de64db8a70343a5a5bdbd90f8ec24511a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 27 Apr 2026 19:50:21 -0700 Subject: [PATCH] feat(files): extract PDF viewer behind SSR boundary and polish file preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Core architectural fix Move all react-pdf / pdfjs-dist code into a new pdf-viewer.tsx module and import it exclusively via next/dynamic({ ssr: false }). pdfjs-dist v5 references DOMMatrix at module evaluation time, which crashed SSR. The previous workaround (a DOMMatrix polyfill in instrumentation.ts) is removed in favour of this proper hard module boundary. ## PDF viewer improvements - Cursor-anchored zoom: Ctrl/⌘+wheel and trackpad-pinch now zoom toward the cursor instead of the top-left corner. Toolbar ± buttons anchor to the viewport centre. Uses the canonical scroll-adjust formula used by map and canvas viewers. - Horizontal scroll: dropping flex-col from the scroll container lets the zoomed pages wrapper overflow naturally and produces a horizontal scrollbar at zoom > 1×. - Loading skeleton: replaced the conditional inline skeleton with an absolute inset-0 overlay so it fills the scroll container correctly in all layout contexts. - Shadow tokens: fixed shadow-[var(--shadow-medium)] and shadow-[var(--shadow-card)] to use the Tailwind utility classes shadow-medium and shadow-card directly. ## File viewer cleanup - data-table.tsx: wrap setInputRef in useCallback([]) so the ref callback has a stable identity across renders. Previously the inline function got a new identity on every keystroke (because editValue state changed), causing React to teardown/remount the ref and re-run node.select() on every character typed. - preview-panel.tsx: keep useMemo on ctxValue passed to Context.Provider — Context uses Object.is, so a new object every render causes unnecessary consumer re-renders. - resource-content.tsx: remove unnecessary useCallback/useMemo wrappers on handlers and derived values that have no memoization observers. ## API route - Wrap content route with withRouteHandler for automatic request-ID tracking via AsyncLocalStorage; remove manual generateRequestId() calls. - Add resourceName to audit record; add encoding param support (base64 / utf-8). ## Query hooks - Include key (storage object key) in both useWorkspaceFileContent and useWorkspaceFileBinary query key tuples so the cache is correctly busted when a file is re-uploaded with a new storage key. ## Other - Add Suspense boundaries to files/page.tsx and files/[fileId]/page.tsx (required for useSearchParams inside the Files component). - Add mmd to SUPPORTED_CODE_EXTENSIONS (Mermaid diagrams). - Add https: to CSP img-src. - Remove ==== separator comments from lib/copilot/constants.ts. - New dependencies: pdfjs-dist 5.4.296, mermaid 11.14.0, monaco-editor 0.55.1, @monaco-editor/react 4.7.0. --- .../[id]/files/[fileId]/content/route.ts | 17 +- .../[workspaceId]/files/[fileId]/page.tsx | 9 +- .../components/file-viewer/data-table.tsx | 97 +- .../components/file-viewer/file-viewer.tsx | 1340 +++++++++-------- .../components/file-viewer/pdf-viewer.tsx | 308 ++++ .../components/file-viewer/preview-panel.tsx | 478 ++++-- .../workspace/[workspaceId]/files/files.tsx | 263 ++-- .../workspace/[workspaceId]/files/page.tsx | 9 +- .../resource-content/resource-content.tsx | 73 +- apps/sim/hooks/queries/workspace-files.ts | 21 +- apps/sim/instrumentation.ts | 1 + apps/sim/lib/copilot/constants.ts | 25 - apps/sim/lib/core/security/csp.ts | 1 + .../execution/sandbox/bundles/pptxgenjs.cjs | 42 +- apps/sim/lib/uploads/utils/validation.ts | 1 + apps/sim/package.json | 5 + bun.lock | 33 +- 17 files changed, 1808 insertions(+), 915 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx 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 606978a927..6dc7255b5a 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 @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' 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 +16,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 +30,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 +59,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, @@ -89,9 +86,9 @@ export const PUT = withRouteHandler( 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 81398b7f17..1f8b513eb8 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 5e31edcfb5..9a4629a4e3 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,63 @@ -import { memo } from 'react' +'use client' + +import { memo, useCallback, 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 const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) { +type EditingCell = { row: number; col: number } | null + +export const DataTable = memo(function DataTable({ headers, rows, editConfig }: DataTableProps) { + const [editingCell, setEditingCell] = useState(null) + const [editValue, setEditValue] = useState('') + + const setInputRef = useCallback((node: HTMLInputElement | null) => { + if (node) { + node.focus() + node.select() + } + }, []) + + const startEdit = (row: number, col: number, currentValue: string) => { + if (!editConfig) return + setEditingCell({ row, col }) + setEditValue(currentValue) + } + + const commitEdit = () => { + if (!editingCell || !editConfig) return + 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 +66,24 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro {headers.map((header, i) => ( ))} @@ -25,8 +92,26 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro {rows.map((row, ri) => ( {headers.map((_, ci) => ( - ))} 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 02e3c683ae..1f4cc1085b 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,33 +1,11 @@ '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, useReducer, useRef, useState } from 'react' +import type { OnMount } from '@monaco-editor/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 dynamic from 'next/dynamic' +import { Button, Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' @@ -39,8 +17,54 @@ import { } from '@/hooks/queries/workspace-files' import { useAutosave } from '@/hooks/use-autosave' import { DataTable } from './data-table' +import type { PdfDocumentSource } from './pdf-viewer' import { PreviewPanel, resolvePreviewType } from './preview-panel' +const MonacoEditor = dynamic( + async () => { + const [{ default: Editor, loader }, monaco] = await Promise.all([ + import('@monaco-editor/react'), + import('monaco-editor'), + ]) + + if (typeof window !== 'undefined' && !window.MonacoEnvironment) { + window.MonacoEnvironment = { + getWorker(_: string, label: string) { + if (label === 'json') { + return new Worker( + new URL('monaco-editor/esm/vs/language/json/json.worker', import.meta.url) + ) + } + if (label === 'css' || label === 'scss' || label === 'less') { + return new Worker( + new URL('monaco-editor/esm/vs/language/css/css.worker', import.meta.url) + ) + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return new Worker( + new URL('monaco-editor/esm/vs/language/html/html.worker', import.meta.url) + ) + } + if (label === 'typescript' || label === 'javascript') { + return new Worker( + new URL('monaco-editor/esm/vs/language/typescript/ts.worker', import.meta.url) + ) + } + return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker', import.meta.url)) + }, + } + } + + loader.config({ monaco }) + return Editor + }, + { ssr: false } +) + +const PdfViewerCore = dynamic(() => import('./pdf-viewer').then((m) => m.PdfViewerCore), { + ssr: false, +}) + const logger = createLogger('FileViewer') const SPLIT_MIN_PCT = 20 @@ -65,6 +89,7 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([ 'text/x-sh', 'text/x-sql', 'image/svg+xml', + 'text/x-mermaid', ]) const TEXT_EDITABLE_EXTENSIONS = new Set([ @@ -77,6 +102,7 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([ 'html', 'htm', 'svg', + 'mmd', ...SUPPORTED_CODE_EXTENSIONS, ]) @@ -86,6 +112,28 @@ 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']) + const PPTX_PREVIEWABLE_MIME_TYPES = new Set([ 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/x-pptxgenjs', @@ -107,68 +155,95 @@ type FileCategory = | 'text-editable' | 'iframe-previewable' | 'image-previewable' + | 'audio-previewable' + | 'video-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> = { +/** Maps file extensions to Monaco editor language IDs. */ +const MONACO_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', + rb: 'ruby', + go: 'go', + rs: 'rust', + java: 'java', + kt: 'kotlin', + swift: 'swift', + c: 'c', + cpp: 'cpp', + h: 'cpp', + hpp: 'cpp', + cs: 'csharp', + php: 'php', + sh: 'shell', + bash: 'shell', + zsh: 'shell', + fish: 'shell', sql: 'sql', + graphql: 'graphql', + gql: 'graphql', + json: 'json', + jsonl: 'json', yaml: 'yaml', yml: 'yaml', + toml: 'toml', + html: 'html', + htm: 'html', + xml: 'xml', + svg: 'xml', + css: 'css', + scss: 'scss', + less: 'less', + md: 'markdown', + mdx: 'markdown', + mmd: 'markdown', + dockerfile: 'dockerfile', + ini: 'ini', + conf: 'ini', + cfg: 'ini', + env: 'shell', + diff: 'diff', + patch: 'diff', } -const CODE_EDITOR_LANGUAGE_BY_MIME: Partial> = { +const MONACO_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/x-shellscript': 'shell', + 'text/x-sh': 'shell', 'text/css': 'css', - 'text/html': 'markup', - 'text/xml': 'markup', - 'application/xml': 'markup', - 'image/svg+xml': 'markup', + 'text/html': 'html', + 'text/xml': 'xml', + 'application/xml': 'xml', + 'image/svg+xml': 'xml', 'text/x-sql': 'sql', 'application/x-yaml': 'yaml', + 'text/markdown': 'markdown', + 'text/x-mermaid': 'markdown', + 'text/plain': 'plaintext', } -const CODE_EDITOR_LINE_HEIGHT_PX = CODE_LINE_HEIGHT_PX +function resolveMonacoLanguage(file: { type: string; name: string }): string { + const ext = getFileExtension(file.name) + return MONACO_LANGUAGE_BY_EXTENSION[ext] ?? MONACO_LANGUAGE_BY_MIME[file.type] ?? 'plaintext' +} 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' @@ -178,6 +253,8 @@ function resolveFileCategory(mimeType: string | null, filename: string): FileCat 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' @@ -200,7 +277,6 @@ interface FileViewerProps { file: WorkspaceFileRecord workspaceId: string canEdit: boolean - showPreview?: boolean previewMode?: PreviewMode autoFocus?: boolean onDirtyChange?: (isDirty: boolean) => void @@ -209,41 +285,9 @@ 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 { @@ -489,11 +533,25 @@ function useTextEditorContentState(options: SyncTextEditorContentStateOptions) { } } +function useMonacoTheme(): string { + const [isDark, setIsDark] = useState( + () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + ) + + useEffect(() => { + const update = () => setIsDark(document.documentElement.classList.contains('dark')) + const observer = new MutationObserver(update) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + + return isDark ? 'vs-dark' : 'vs' +} + export function FileViewer({ file, workspaceId, canEdit, - showPreview, previewMode, autoFocus, onDirtyChange, @@ -502,7 +560,6 @@ export function FileViewer({ streamingContent, streamingMode, disableStreamingAutoScroll = false, - useCodeRendererForCodeFiles = false, previewContextKey, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -513,7 +570,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 +578,6 @@ export function FileViewer({ streamingContent={streamingContent} streamingMode={streamingMode} disableStreamingAutoScroll={disableStreamingAutoScroll} - useCodeRendererForCodeFiles={useCodeRendererForCodeFiles} previewContextKey={previewContextKey} /> ) @@ -534,7 +590,15 @@ export function FileViewer({ } if (category === 'image-previewable') { - return + return + } + + if (category === 'audio-previewable') { + return + } + + if (category === 'video-previewable') { + return } if (category === 'docx-previewable') { @@ -546,7 +610,15 @@ export function FileViewer({ } if (category === 'xlsx-previewable') { - return + return ( + + ) } return @@ -564,7 +636,6 @@ interface TextEditorProps { streamingContent?: string streamingMode?: StreamingMode disableStreamingAutoScroll: boolean - useCodeRendererForCodeFiles?: boolean previewContextKey?: string } @@ -580,19 +651,15 @@ function TextEditor({ 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 monacoEditorRef = useRef[0] | null>(null) + const lastSyncedContentRef = useRef('') const hasAutoFocusedRef = useRef(false) const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT_PCT) const [isResizing, setIsResizing] = useState(false) - const [visualLineHeights, setVisualLineHeights] = useState([]) - const [activeLineNumber, setActiveLineNumber] = useState(1) const { data: fetchedContent, @@ -611,8 +678,9 @@ function TextEditor({ const updateContentRef = useRef(updateContent) updateContentRef.current = updateContent - const shouldUseCodeRenderer = useCodeRendererForCodeFiles && isCodeFile(file) - const codeLanguage = useMemo(() => resolveCodeEditorLanguage(file), [file.name, file.type]) + const monacoLanguage = resolveMonacoLanguage(file) + const monacoTheme = useMonacoTheme() + const onDirtyChangeRef = useRef(onDirtyChange) const onSaveStatusChangeRef = useRef(onSaveStatusChange) onDirtyChangeRef.current = onDirtyChange @@ -632,33 +700,61 @@ function TextEditor({ streamingMode, }) + // Sync external content (initial load + streaming) to Monaco model useEffect(() => { - if (!autoFocus || !isInitialized || hasAutoFocusedRef.current) { + const editor = monacoEditorRef.current + if (!editor) return + const model = editor.getModel() + if (!model) return + const monacoValue = model.getValue() + if (monacoValue === content) return + + // Only override Monaco when we're pushing external content, not user edits: + // - During streaming/reconciling: always push + // - On first init (monacoValue matches last synced value): push + if (isStreamInteractionLocked || monacoValue === lastSyncedContentRef.current) { + model.setValue(content) + lastSyncedContentRef.current = content + } + }, [content, isStreamInteractionLocked]) + + const textareaStuckRef = useRef(true) + useEffect(() => { + const editor = monacoEditorRef.current + if (!editor || !isStreamInteractionLocked || disableStreamingAutoScroll) { + textareaStuckRef.current = false return } - hasAutoFocusedRef.current = true - requestAnimationFrame(() => { - const editorTextarea = codeEditorRef.current?.querySelector('textarea') - if (editorTextarea instanceof HTMLTextAreaElement) { - editorTextarea.focus() - return - } - textareaRef.current?.focus() - }) - }, [autoFocus, isInitialized]) + textareaStuckRef.current = true + const domNode = editor.getDomNode() + if (!domNode) return - const handleContentChange = useCallback( - (value: string) => { - if (value === content) { - return - } - setDraftContent(value) - }, - [content, setDraftContent] - ) + const scrollable = domNode.querySelector('.monaco-scrollable-element') as HTMLElement | null + if (!scrollable) return - const onSave = useCallback(async () => { + const onWheel = (e: Event) => { + if ((e as WheelEvent).deltaY < 0) textareaStuckRef.current = false + } + scrollable.addEventListener('wheel', onWheel, { passive: true }) + + return () => { + scrollable.removeEventListener('wheel', onWheel) + } + }, [isStreamInteractionLocked, disableStreamingAutoScroll]) + + useEffect(() => { + if (!isStreamInteractionLocked || !textareaStuckRef.current || disableStreamingAutoScroll) + return + const editor = monacoEditorRef.current + if (!editor) return + const lineCount = editor.getModel()?.getLineCount() ?? 0 + if (lineCount > 0) { + editor.revealLine(lineCount) + } + }, [content, isStreamInteractionLocked, disableStreamingAutoScroll]) + + async function onSave() { if (content === savedContent) return await updateContentRef.current.mutateAsync({ @@ -667,7 +763,7 @@ function TextEditor({ content, }) markSavedContent(content) - }, [content, file.id, markSavedContent, savedContent, workspaceId]) + } const { saveStatus, saveImmediately, isDirty } = useAutosave({ content, @@ -685,12 +781,8 @@ function TextEditor({ }, [saveStatus]) useEffect(() => { - if (!saveRef) { - return - } - + if (!saveRef) return saveRef.current = saveImmediately - return () => { if (saveRef.current === saveImmediately) { saveRef.current = null @@ -728,211 +820,46 @@ function TextEditor({ (checkboxIndex: number, checked: boolean) => { const toggled = toggleMarkdownCheckbox(content, checkboxIndex, checked) if (toggled !== content) { - handleContentChange(toggled) + setDraftContent(toggled) + // Also update Monaco synchronously so the user sees the change + const model = monacoEditorRef.current?.getModel() + if (model) { + model.setValue(toggled) + lastSyncedContentRef.current = toggled + } } }, - [content, handleContentChange] + [content, setDraftContent] + ) + + const handleEditorMount: OnMount = useCallback((editor, monaco) => { + monacoEditorRef.current = editor + + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + saveImmediately() + }) + + const model = editor.getModel() + if (model && content && model.getValue() !== content) { + model.setValue(content) + lastSyncedContentRef.current = content + } + + if (autoFocus && !hasAutoFocusedRef.current) { + hasAutoFocusedRef.current = true + editor.focus() + } + }, []) + + const handleEditorChange = useCallback( + (value: string | undefined) => { + setDraftContent(value ?? '') + }, + [setDraftContent] ) 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' @@ -954,54 +881,47 @@ function TextEditor({ 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] ?? '') + )}