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 2cbc64a415..137754c5a1 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,9 +1,24 @@ 'use client' -import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { memo, type ReactElement, useCallback, useEffect, useMemo, 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 { createLogger } from '@sim/logger' import { ZoomIn, ZoomOut } from 'lucide-react' -import { Skeleton } from '@/components/emcn' +import { + CODE_LINE_HEIGHT_PX, + Code as CodeEditor, + calculateGutterWidth, + getCodeEditorProps, + highlight, + languages, + 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' @@ -89,6 +104,59 @@ type FileCategory = | '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 + 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' @@ -131,6 +199,21 @@ interface FileViewerProps { saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string streamingMode?: 'append' | 'replace' + useCodeRendererForCodeFiles?: boolean +} + +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]) +} + +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') + ) } export function FileViewer({ @@ -145,6 +228,7 @@ export function FileViewer({ saveRef, streamingContent, streamingMode, + useCodeRendererForCodeFiles = false, }: FileViewerProps) { const category = resolveFileCategory(file.type, file.name) @@ -153,7 +237,7 @@ export function FileViewer({ ) } @@ -199,6 +284,7 @@ interface TextEditorProps { saveRef?: React.MutableRefObject<(() => Promise) | null> streamingContent?: string streamingMode?: 'append' | 'replace' + useCodeRendererForCodeFiles?: boolean } function TextEditor({ @@ -212,14 +298,19 @@ function TextEditor({ saveRef, streamingContent, streamingMode = 'append', + useCodeRendererForCodeFiles = false, }: TextEditorProps) { const initializedRef = useRef(false) const contentRef = useRef('') const textareaRef = useRef(null) const containerRef = useRef(null) + const codeEditorRef = useRef(null) + const codeScrollRef = useRef(null) const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT_PCT) const [isResizing, setIsResizing] = useState(false) + const [visualLineHeights, setVisualLineHeights] = useState([]) + const [activeLineNumber, setActiveLineNumber] = useState(1) const { data: fetchedContent, @@ -242,13 +333,22 @@ function TextEditor({ const [content, setContent] = useState('') const [savedContent, setSavedContent] = useState('') const savedContentRef = useRef('') + const [isStreamInteractionLocked, setIsStreamInteractionLocked] = useState(false) const wasStreamingRef = useRef(false) const pendingStreamReconcileRef = useRef(false) const lastStreamedContentRef = useRef(null) + const shouldUseCodeRenderer = useCodeRendererForCodeFiles && isCodeFile(file) + const codeLanguage = useMemo(() => resolveCodeEditorLanguage(file), [file]) useEffect(() => { + const lastStreamed = lastStreamedContentRef.current + const hasPendingReconcile = wasStreamingRef.current && pendingStreamReconcileRef.current + const hasFetchedAdvanced = + fetchedContent !== undefined && fetchedContent !== savedContentRef.current + const fetchedMatchesLastStream = + fetchedContent !== undefined && lastStreamed !== null && fetchedContent === lastStreamed + if (streamingContent !== undefined) { - wasStreamingRef.current = true const nextContent = streamingMode === 'replace' || fetchedContent === undefined ? streamingContent @@ -256,6 +356,27 @@ function TextEditor({ fetchedContent.endsWith(`\n${streamingContent}`) ? fetchedContent : `${fetchedContent}\n${streamingContent}` + const fetchedMatchesNextStream = + fetchedContent !== undefined && fetchedContent === nextContent + + if ( + hasPendingReconcile && + (hasFetchedAdvanced || fetchedMatchesLastStream || fetchedMatchesNextStream) + ) { + pendingStreamReconcileRef.current = false + wasStreamingRef.current = false + lastStreamedContentRef.current = null + setIsStreamInteractionLocked(false) + setContent(fetchedContent) + contentRef.current = fetchedContent + setSavedContent(fetchedContent) + savedContentRef.current = fetchedContent + initializedRef.current = true + return + } + + wasStreamingRef.current = true + setIsStreamInteractionLocked(true) if (nextContent === contentRef.current) { pendingStreamReconcileRef.current = true lastStreamedContentRef.current = nextContent @@ -270,27 +391,24 @@ function TextEditor({ return } - if (wasStreamingRef.current && pendingStreamReconcileRef.current) { - const lastStreamed = lastStreamedContentRef.current - const hasFetchedAdvanced = - fetchedContent !== undefined && fetchedContent !== savedContentRef.current - const fetchedMatchesLastStream = - fetchedContent !== undefined && lastStreamed !== null && fetchedContent === lastStreamed - + if (hasPendingReconcile) { if (hasFetchedAdvanced || fetchedMatchesLastStream) { pendingStreamReconcileRef.current = false wasStreamingRef.current = false lastStreamedContentRef.current = null - if (lastStreamed !== null && contentRef.current === lastStreamed) { - setContent(fetchedContent) - contentRef.current = fetchedContent - } + setIsStreamInteractionLocked(false) + setContent(fetchedContent) + contentRef.current = fetchedContent setSavedContent(fetchedContent) savedContentRef.current = fetchedContent return } } + if (streamingContent === undefined) { + setIsStreamInteractionLocked(false) + } + if (fetchedContent === undefined) return if (!initializedRef.current) { @@ -301,7 +419,14 @@ function TextEditor({ initializedRef.current = true if (autoFocus) { - requestAnimationFrame(() => textareaRef.current?.focus()) + requestAnimationFrame(() => { + const editorTextarea = codeEditorRef.current?.querySelector('textarea') + if (editorTextarea instanceof HTMLTextAreaElement) { + editorTextarea.focus() + return + } + textareaRef.current?.focus() + }) } return } @@ -338,7 +463,7 @@ function TextEditor({ content, savedContent, onSave, - enabled: canEdit && initializedRef.current, + enabled: canEdit && initializedRef.current && streamingContent === undefined, }) useEffect(() => { @@ -393,16 +518,188 @@ function TextEditor({ [handleContentChange] ) - const isStreaming = streamingContent !== undefined + const isStreaming = isStreamInteractionLocked + const isEditorReadOnly = isStreamInteractionLocked || !canEdit const revealedContent = useStreamingText(content, false) + const renderedContent = isStreaming ? revealedContent : content + const gutterWidthPx = useMemo(() => { + const lineCount = renderedContent.split('\n').length + return calculateGutterWidth(lineCount) + }, [renderedContent]) + const sharedCodeEditorProps = useMemo( + () => + getCodeEditorProps({ + disabled: !canEdit, + }), + [canEdit] + ) + 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) + useEffect(() => { + if (!shouldUseCodeRenderer || !codeEditorRef.current) return + + const setCodeEditorReadOnly = () => { + const textarea = codeEditorRef.current?.querySelector('textarea') + if (!(textarea instanceof HTMLTextAreaElement)) return + textarea.readOnly = isEditorReadOnly + textarea.spellcheck = false + } + + setCodeEditorReadOnly() + + const timeoutId = setTimeout(setCodeEditorReadOnly, 0) + const observer = new MutationObserver(setCodeEditorReadOnly) + observer.observe(codeEditorRef.current, { + childList: true, + subtree: true, + }) + + return () => { + clearTimeout(timeoutId) + observer.disconnect() + } + }, [isEditorReadOnly, shouldUseCodeRenderer]) + + useEffect(() => { + if (!shouldUseCodeRenderer) return + const textarea = codeEditorRef.current?.querySelector('textarea') + if (!(textarea instanceof HTMLTextAreaElement)) return + + const updateActiveLineNumber = () => { + const pos = textarea.selectionStart + const textBeforeCursor = renderedContent.substring(0, pos) + setActiveLineNumber(textBeforeCursor.split('\n').length) + } + + updateActiveLineNumber() + 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) + } + }, [renderedContent, shouldUseCodeRenderer]) + + useEffect(() => { + if (!shouldUseCodeRenderer || !codeEditorRef.current) return + + const calculateVisualLines = () => { + const preElement = codeEditorRef.current?.querySelector('pre') + if (!(preElement instanceof HTMLElement)) return + + const lines = renderedContent.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(newVisualLineHeights) + } + + const timeoutId = setTimeout(calculateVisualLines, 50) + const resizeObserver = new ResizeObserver(calculateVisualLines) + resizeObserver.observe(codeEditorRef.current) + + return () => { + clearTimeout(timeoutId) + resizeObserver.disconnect() + } + }, [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 textareaStuckRef.current = true - const el = textareaRef.current + const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null if (!el) return const onWheel = (e: WheelEvent) => { @@ -421,14 +718,14 @@ function TextEditor({ el.removeEventListener('wheel', onWheel) el.removeEventListener('scroll', onScroll) } - }, [isStreaming]) + }, [isStreaming, shouldUseCodeRenderer]) useEffect(() => { if (!isStreaming || !textareaStuckRef.current) return - const el = textareaRef.current + const el = (shouldUseCodeRenderer ? codeScrollRef.current : textareaRef.current) ?? null if (!el) return el.scrollTop = el.scrollHeight - }, [isStreaming, revealedContent]) + }, [isStreaming, revealedContent, shouldUseCodeRenderer]) if (streamingContent === undefined) { if (isLoading) return DOCUMENT_SKELETON @@ -450,21 +747,49 @@ function TextEditor({ return (
- {showEditor && ( -