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 index 4fb3110f96b..273889fbd59 100644 --- 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 @@ -1,4 +1,5 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { normalizeMimeType } from '@/lib/uploads/utils/mime' import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation' const TEXT_EDITABLE_MIME_TYPES = new Set([ @@ -132,6 +133,7 @@ export type FileCategory = | 'unsupported' export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory { + mimeType = normalizeMimeType(mimeType) 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' @@ -140,9 +142,11 @@ export function resolveFileCategory(mimeType: string | null, filename: string): 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' + if (mimeType?.startsWith('text/')) return 'text-editable' const ext = getFileExtension(filename) - const nameKey = ext || filename.toLowerCase() + const sourceName = filename.replace(/\.(example|template|sample|dist|default)$/i, '') + const nameKey = getFileExtension(sourceName) || sourceName.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' @@ -151,6 +155,8 @@ export function resolveFileCategory(mimeType: string | null, filename: string): 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' + if (/^\.?env(?: \(\d+\))*(?:\.[\w.-]+)?$/i.test(filename)) return 'text-editable' + if (/^dockerfile\.[\w.-]+$/i.test(filename)) return 'text-editable' 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 52407bbf750..b1106cbf4cd 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 @@ -4,6 +4,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Music } from '@sim/emcn/icons' import dynamic from 'next/dynamic' import type { FileDownloadSource } from '@/lib/uploads/client/download' +import { + MAX_TEXT_PREVIEW_BYTES, + TEXT_PREVIEW_SIZE_MESSAGE, +} from '@/lib/uploads/client/text-content' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils' import { @@ -51,12 +55,19 @@ const RichMarkdownEditor = dynamic( */ const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024 -export function isTextEditable(file: { type: string; name: string }): boolean { - return resolveFileCategory(file.type, file.name) === 'text-editable' +export function isTextEditable(file: { + type: string + name: string + size?: number | null +}): boolean { + return ( + resolveFileCategory(file.type, file.name) === 'text-editable' && + (file.size ?? 0) <= MAX_TEXT_PREVIEW_BYTES + ) } -export function isPreviewable(file: { type: string; name: string }): boolean { - return resolvePreviewType(file.type, file.name) !== null +export function isPreviewable(file: { type: string; name: string; size?: number | null }): boolean { + return resolvePreviewType(file.type, file.name) !== null && isTextEditable(file) } /** @@ -180,13 +191,17 @@ function FileViewerContent({ const category = resolveFileCategory(file.type, file.name) if (category === 'text-editable') { + if (isCsvStreamOnly(file)) { + return readOnly ? ( + + ) : ( + + ) + } + if (!isTextEditable(file)) { + return + } if (readOnly) { - // ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the - // browser. CsvTablePreview's streamed fallback is workspace-only, so on the - // read-only public path a large CSV is download-only. - if (isCsvStreamOnly(file)) { - return - } // Markdown renders through the inline rich editor (non-editable) so the public share // surface matches the in-app reading experience; canEdit={false} disables autosave, // the bubble menu, and every other editing affordance. @@ -203,12 +218,6 @@ function FileViewerContent({ } return } - // A large CSV can't be loaded whole into the editor (the browser OOMs on the full text). - // Render a streamed, read-only preview of the first rows + an "Import as a table" path instead. - if (isCsvStreamOnly(file)) { - return - } - if (isMarkdownFile(file)) { return ( = { export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS)) export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType { + mimeType = normalizeMimeType(mimeType) if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType] const ext = getFileExtension(filename) return PREVIEWABLE_EXTENSIONS[ext] ?? null diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx index ed585e4c382..a979162c088 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx @@ -8,21 +8,29 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FilePreview') +interface UnsupportedPreviewProps { + name: string + reason?: string +} + /** * Terminal fallback for a file this app cannot render — either the format has no * viewer at all, or a viewer that was expected to work failed (e.g. a HEIC whose * server-side derivative could not be produced). */ -export const UnsupportedPreview = memo(function UnsupportedPreview({ name }: { name: string }) { +export const UnsupportedPreview = memo(function UnsupportedPreview({ + name, + reason, +}: UnsupportedPreviewProps) { const ext = getFileExtension(name) return (

- Preview not available{ext ? ` for .${ext} files` : ' for this file'} + Preview not available{reason ? '' : ext ? ` for .${ext} files` : ' for this file'}

- Use the download button to view this file + {reason ?? 'Use the download button to view this file'}

) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts index 12f42b48c8a..dba7cbd7250 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts @@ -3,8 +3,8 @@ */ import { Editor } from '@tiptap/core' import { afterEach, describe, expect, it } from 'vitest' -import { createMarkdownContentExtensions } from './extensions' -import { findHeadingPos } from './heading-anchors' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors' let editor: Editor | null = null afterEach(() => { @@ -12,21 +12,42 @@ afterEach(() => { editor = null }) -/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */ -function docOf(markdown: string) { +function editorOf(markdown: string) { editor = new Editor({ extensions: createMarkdownContentExtensions() }) editor.commands.setContent(markdown, { contentType: 'markdown' }) - return editor.state.doc + return editor } -describe('findHeadingPos', () => { - it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => { - const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc') - const first = findHeadingPos(doc, 'notes') - const second = findHeadingPos(doc, 'notes-1') - const third = findHeadingPos(doc, 'notes-2') - expect(first).toBeGreaterThanOrEqual(0) - expect(second).toBeGreaterThan(first) - expect(third).toBeGreaterThan(second) +describe('Markdown heading navigation', () => { + it.each([ + ['Unicode headings', '# Café 你好', 'café-你好', 0], + ['punctuation spacing', '# Network & data', 'network--data', 0], + ['existing suffixes', '# Notes\n\n# Notes-1\n\n# Notes', 'notes-2', 2], + ] as const)('scrolls to GitHub fragments for %s', (_name, markdown, slug, index) => { + const editor = editorOf(markdown) + const headings = editor.view.dom.querySelectorAll('h1, h2, h3, h4, h5, h6') + let scrolledTo: HTMLElement | null = null + for (const heading of headings) { + heading.scrollIntoView = () => { + scrolledTo = heading + } + } + expect(scrollToHeading(editor.view, `#${encodeURIComponent(slug)}`)).toBe(true) + expect(scrolledTo).toBe(headings[index]) + }) + + it('scrolls to duplicate headings in document order', () => { + const editor = editorOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc') + const headings = editor.view.dom.querySelectorAll('h1, h2, h3, h4, h5, h6') + let scrolledTo: HTMLElement | null = null + for (const heading of headings) { + heading.scrollIntoView = () => { + scrolledTo = heading + } + } + for (const [index, fragment] of ['#notes', '#notes-1', '#notes-2'].entries()) { + expect(scrollToHeading(editor.view, fragment)).toBe(true) + expect(scrolledTo).toBe(headings[index]) + } }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts index 677964d65e4..eef763f41d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts @@ -1,36 +1,32 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import type { EditorView } from '@tiptap/pm/view' +import GithubSlugger from 'github-slugger' -/** - * Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so - * that `[label](#slug)` fragment links — written against how GitHub renders the same markdown — - * resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview. - */ -export function slugifyHeading(text: string): string { - return text - .toLowerCase() - .trim() - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') -} - -/** - * The document position of the heading a `#slug` fragment link targets, or -1 if none matches. - * Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate - * slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, … - */ -export function findHeadingPos(doc: ProseMirrorNode, slug: string): number { - const seen = new Map() +/** Resolves GitHub heading fragments, including Unicode and collisions with existing suffixes. */ +function findHeadingPos(doc: ProseMirrorNode, slug: string): number { + const slugger = new GithubSlugger() let found = -1 doc.descendants((node, pos) => { if (found >= 0) return false if (node.type.name !== 'heading') return true - const base = slugifyHeading(node.textContent) - if (!base) return true - const n = seen.get(base) ?? 0 - seen.set(base, n + 1) - if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos - return found < 0 + if (slugger.slug(node.textContent) === slug) found = pos + return false }) return found } + +/** Scrolls within this editor without navigating the page or changing the document selection. */ +export function scrollToHeading(view: EditorView, fragment: string): boolean { + let slug: string + try { + slug = decodeURIComponent(fragment.slice(1)) + } catch { + return false + } + const pos = slug ? findHeadingPos(view.state.doc, slug) : 0 + if (pos < 0) return false + const target = slug ? view.nodeDOM(pos) : view.dom + if (!(target instanceof HTMLElement)) return false + target.scrollIntoView({ behavior: 'smooth', block: 'start' }) + return true +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx index 30c71b60dad..adc890eb2b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx @@ -5,6 +5,7 @@ import { Check, Duplicate, Pencil, Unlink } from '@sim/emcn/icons' import { getMarkRange } from '@tiptap/core' import { type Editor, useEditorState } from '@tiptap/react' import { createPortal } from 'react-dom' +import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors' import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { applyLink, @@ -38,8 +39,8 @@ function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null { /** * Floating card shown when hovering a link, so the destination is visible even when the link text - * differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and - * Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short + * differs from the URL. Heading links scroll within the editor; other URLs open in a new tab. + * Copy is always available, while Edit (inline) and Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short * close delay plus the card's own hover bridge let the pointer travel from the link into the card. */ export function LinkHoverCard({ editor }: LinkHoverCardProps) { @@ -208,7 +209,13 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) { {safeHref ? ( { + if (!safeHref.startsWith('#')) return + event.preventDefault() + if (!editor.isDestroyed) scrollToHeading(editor.view, safeHref) + dismiss() + }} rel='noopener noreferrer' title={rawHref} className='max-w-[260px] truncate px-2 text-[var(--text-body)] text-small hover:underline' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 4c3dc3564d0..cfed0501286 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -44,7 +44,7 @@ import { isCollabReady } from '@/app/workspace/[workspaceId]/files/components/fi import { useFileDocCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration' import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' import { useMarkdownFind } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find' -import { findHeadingPos } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors' +import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors' import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' import { extractImageFiles, @@ -180,6 +180,19 @@ function ReadOnlyPlaceholder({ content, file, workspaceId }: ReadOnlyPlaceholder 'aria-multiline': 'true', 'aria-readonly': 'true', }, + handleClick: (_view, _pos, event) => + Boolean( + (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href')?.startsWith('#') + ), + handleDOMEvents: { + click: (view, event) => { + const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') + if (!href?.startsWith('#')) return false + event.preventDefault() + scrollToHeading(view, href) + return true + }, + }, }, }) const buildSelectionContext = useCallback( @@ -187,7 +200,12 @@ function ReadOnlyPlaceholder({ content, file, workspaceId }: ReadOnlyPlaceholder [editor, file.id, file.name] ) useSelectionCopyBridge(containerRef, buildSelectionContext, workspaceId) - return + return ( + <> + {editor && } + + + ) } interface RichMarkdownEditorProps { @@ -304,6 +322,7 @@ function RichMarkdownSurface({ isStreamInteractionLocked, isContentLoading, hasContentError, + contentError, saveImmediately, hasConflict, isReloading, @@ -333,7 +352,9 @@ function RichMarkdownSurface({ if (hasContentError) { return (
-

Failed to load file content

+

+ {contentError ?? 'Failed to load file content'} +

) } @@ -715,31 +736,34 @@ export function LoadedRichMarkdownEditor({ * read-only follows directly. A same-page anchor (`[x](#slug)`) scrolls to the matching heading; a * same-origin in-app path navigates within the SPA (same tab); everything else opens a new tab. */ - handleClick: (view, _pos, event) => { - const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') - if (!href) return false - if (view.editable && !(event.metaKey || event.ctrlKey)) return false - if (href.startsWith('#')) { - const pos = findHeadingPos(view.state.doc, href.slice(1)) - if (pos < 0) return false - ;(view.nodeDOM(pos) as HTMLElement | null)?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - return true - } - const normalized = normalizeLinkHref(href) - if (!normalized) return false - if ( - !(event.metaKey || event.ctrlKey) && - normalized.startsWith('/') && - !normalized.startsWith('//') - ) { - routerRef.current.push(normalized) + // Preserve the selection on modifier-click; navigation runs on the native click below. + handleClick: (view, _pos, event) => + (!view.editable || event.metaKey || event.ctrlKey) && + Boolean((event.target as HTMLElement | null)?.closest('a')?.getAttribute('href')), + handleDOMEvents: { + click: (view, event) => { + const href = (event.target as HTMLElement | null)?.closest('a')?.getAttribute('href') + if (!href) return false + if (view.editable && !(event.metaKey || event.ctrlKey)) return false + if (href.startsWith('#')) { + event.preventDefault() + scrollToHeading(view, href) + return true + } + const normalized = normalizeLinkHref(href) + if (!normalized) return false + event.preventDefault() + if ( + !(event.metaKey || event.ctrlKey) && + normalized.startsWith('/') && + !normalized.startsWith('//') + ) { + routerRef.current.push(normalized) + return true + } + window.open(normalized, '_blank', 'noopener,noreferrer') return true - } - window.open(normalized, '_blank', 'noopener,noreferrer') - return true + }, }, transformPasted: (slice, view) => normalizePastedImageSources(slice, view.state.doc, resolveImageSrcRef.current), diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 5411c931030..0dec965bf56 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -489,6 +489,7 @@ export const TextEditor = memo(function TextEditor({ isStreamInteractionLocked, isContentLoading, hasContentError, + contentError, saveImmediately, hasConflict, isReloading, @@ -767,7 +768,9 @@ export const TextEditor = memo(function TextEditor({ if (hasContentError) { return (
-

Failed to load file content

+

+ {contentError ?? 'Failed to load file content'} +

) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts index 7e57f6834c5..06d3d241e89 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-editable-file-content.ts @@ -86,6 +86,7 @@ interface EditableFileContent { isContentLoading: boolean /** True when the initial content fetch failed before any content was shown. */ hasContentError: boolean + contentError: string | undefined saveStatus: SaveStatus saveImmediately: () => Promise isDirty: boolean @@ -469,6 +470,7 @@ export function useEditableFileContent({ // hand-off, scroll, and parsed doc) until the fetch lands. isContentLoading: streamingContent === undefined && isLoading && !isInitialized, hasContentError: streamingContent === undefined && Boolean(error) && !isInitialized, + contentError: error?.message, saveStatus, saveImmediately, isDirty: isDirtyForCaller, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 9cb16f9a489..e786ee23518 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -44,15 +44,7 @@ import { isVideoFileType, resolveEffectiveMimeType, } from '@/lib/uploads/utils/file-utils' -import { - isSupportedExtension, - SUPPORTED_ARCHIVE_EXTENSIONS, - SUPPORTED_AUDIO_EXTENSIONS, - SUPPORTED_CODE_EXTENSIONS, - SUPPORTED_DOCUMENT_EXTENSIONS, - SUPPORTED_IMAGE_EXTENSIONS, - SUPPORTED_VIDEO_EXTENSIONS, -} from '@/lib/uploads/utils/validation' +import { isSupportedExtension } from '@/lib/uploads/utils/validation' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { FindBar } from '@/app/workspace/[workspaceId]/components/find-bar/find-bar' import { useFindShortcut } from '@/app/workspace/[workspaceId]/components/find-bar/use-find-shortcut' @@ -202,17 +194,6 @@ const FOLDER_TYPE_LABEL = 'Folder' as const */ const FILES_SEARCH_DEBOUNCE_MS = 200 as const -const SUPPORTED_EXTENSIONS = [ - ...SUPPORTED_DOCUMENT_EXTENSIONS, - ...SUPPORTED_CODE_EXTENSIONS, - ...SUPPORTED_AUDIO_EXTENSIONS, - ...SUPPORTED_VIDEO_EXTENSIONS, - ...SUPPORTED_IMAGE_EXTENSIONS, - ...SUPPORTED_ARCHIVE_EXTENSIONS, -] as const - -const ACCEPT_ATTR = SUPPORTED_EXTENSIONS.map((ext) => `.${ext}`).join(',') - const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name', widthMultiplier: 1.15 }, { id: 'size', header: 'Size', widthMultiplier: 0.85 }, @@ -271,6 +252,24 @@ function formatFileType(storedType: string | null, filename: string): string { return storedType ?? 'File' } +function getDroppedFiles(dataTransfer: DataTransfer): File[] { + if (dataTransfer.items.length === 0) return Array.from(dataTransfer.files) + const files: File[] = [] + for (const item of Array.from(dataTransfer.items)) { + if (item.kind !== 'file') continue + const entry = item.webkitGetAsEntry?.() + if (entry?.isDirectory) { + toast.error(`Cannot upload the folder "${entry.name}"`, { + description: 'Create a folder in Files, then upload the files inside it.', + }) + continue + } + const file = item.getAsFile() + if (file) files.push(file) + } + return files +} + export function Files() { return ( @@ -281,6 +280,7 @@ export function Files() { function FilesContent() { const fileInputRef = useRef(null) + const uploadQueueRef = useRef>(Promise.resolve()) const saveRef = useRef<(() => Promise) | null>(null) const downloadSourceRef = useRef(null) const discardRef = useRef<(() => void) | null>(null) @@ -337,7 +337,7 @@ function FilesContent() { for (const member of members ?? []) map.set(member.userId, member) return map }, [members]) - const uploadFile = useUploadWorkspaceFile() + const { mutateAsync: uploadFile } = useUploadWorkspaceFile() const createWorkspaceFile = useCreateWorkspaceFile() const notifyLimit = useLimitUpgradeToast() const deleteFile = useDeleteWorkspaceFile() @@ -943,8 +943,8 @@ function FilesContent() { const descendantFolderIdsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) - const uploadFiles = useCallback( - async (filesToUpload: File[], targetFolderId = currentFolderId) => { + const uploadFiles = (filesToUpload: File[], targetFolderId = currentFolderId) => { + const uploadBatch = async () => { if (!workspaceId || filesToUpload.length === 0 || !canEdit) return /** @@ -956,7 +956,7 @@ function FilesContent() { setSearchTerm('') const oversized: string[] = [] - const sizeFiltered = filesToUpload.filter((f) => { + const allowedFiles = filesToUpload.filter((f) => { if (f.size > MAX_WORKSPACE_FILE_SIZE) { oversized.push(f.name) return false @@ -971,18 +971,6 @@ function FilesContent() { ) } - const unsupported: string[] = [] - const allowedFiles = sizeFiltered.filter((f) => { - const ext = getFileExtension(f.name) - const ok = SUPPORTED_EXTENSIONS.includes(ext as (typeof SUPPORTED_EXTENSIONS)[number]) - if (!ok) unsupported.push(f.name) - return ok - }) - - if (unsupported.length > 0) { - logger.warn('Unsupported file types skipped:', unsupported) - } - if (allowedFiles.length === 0) return try { @@ -990,7 +978,7 @@ function FilesContent() { for (let i = 0; i < allowedFiles.length; i++) { try { - await uploadFile.mutateAsync({ + await uploadFile({ workspaceId, file: allowedFiles[i], folderId: targetFolderId, @@ -998,29 +986,32 @@ function FilesContent() { setUploadProgress((prev) => ({ ...prev, currentPercent: percent })) }, }) - setUploadProgress({ - completed: i + 1, - total: allowedFiles.length, - currentPercent: 0, - }) } catch (err) { logger.error('Error uploading file:', err) const message = getErrorMessage(err) if (/storage limit/i.test(message)) { notifyLimit('storage', message) } else { - toast.error(`Failed to upload "${allowedFiles[i].name}"`) + toast.error(`Failed to upload "${allowedFiles[i].name}"`, { description: message }) } + } finally { + setUploadProgress({ + completed: i + 1, + total: allowedFiles.length, + currentPercent: 0, + }) } } - } catch (err) { - logger.error('Error uploading file:', err) } finally { setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } - }, - [workspaceId, canEdit, currentFolderId, notifyLimit, setSearchTerm] - ) + } + uploadQueueRef.current = uploadQueueRef.current.then(uploadBatch).catch((error) => { + logger.error('Error uploading files:', error) + toast.error(getErrorMessage(error, 'Failed to upload files')) + }) + return uploadQueueRef.current + } const rowDragDropConfig = useFolderRowDragDrop({ dragMime: FILE_ROW_DRAG_MIME, @@ -1066,7 +1057,7 @@ function FilesContent() { matches: hasExternalFiles, onDropIntoFolder: (dataTransfer, targetFolderId) => { dismissUploadOverlay() - const dropped = Array.from(dataTransfer.files ?? []) + const dropped = getDroppedFiles(dataTransfer) if (dropped.length > 0) void uploadFiles(dropped, targetFolderId) }, }, @@ -1108,7 +1099,7 @@ function FilesContent() { */ rowDragDropConfig.externalDropHandled() dismissUploadOverlay() - const dropped = Array.from(e.dataTransfer.files) + const dropped = getDroppedFiles(e.dataTransfer) if (dropped.length > 0) await uploadFiles(dropped) } @@ -2384,7 +2375,6 @@ function FilesContent() { className='hidden' onChange={handleFileChange} disabled={uploading || !canEdit} - accept={ACCEPT_ATTR} multiple /> diff --git a/apps/sim/hooks/queries/workspace-files.test.tsx b/apps/sim/hooks/queries/workspace-files.test.tsx index 24cd6eaa5de..c07fe6ecbba 100644 --- a/apps/sim/hooks/queries/workspace-files.test.tsx +++ b/apps/sim/hooks/queries/workspace-files.test.tsx @@ -290,7 +290,9 @@ describe('useWorkspaceFileContent while a superseded key is being re-resolved', return { success: true, files: [] } }) - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }) const root: Root = createRoot(document.createElement('div')) let result: WorkspaceFileContentResult | undefined diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 5714ca235d1..61eeb5c1f04 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -25,6 +25,7 @@ import { updateWorkspaceFileDimensionsContract, } from '@/lib/api/contracts/workspace-files' import { uploadWorkspaceFileSession } from '@/lib/uploads/client/session-upload' +import { FileTextSizeError, readFileText } from '@/lib/uploads/client/text-content' import type { UploadProgressEvent } from '@/lib/uploads/client/types' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { UserFile } from '@/executor/types' @@ -291,7 +292,7 @@ async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Pro throw new Error('Failed to fetch file content') } - return response.text() + return readFileText(response) } /** @@ -336,6 +337,7 @@ export function useWorkspaceFileContent( }, enabled: !!workspaceId && !!fileId && !!key, staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME, + retry: (failureCount, error) => failureCount < 1 && !(error instanceof FileTextSizeError), refetchOnWindowFocus: options?.refetchOnWindowFocus === false ? false : 'always', refetchInterval: options?.refetchInterval ?? false, }) diff --git a/apps/sim/lib/uploads/client/download.ts b/apps/sim/lib/uploads/client/download.ts index 7717a3ca0f7..0f528f75da3 100644 --- a/apps/sim/lib/uploads/client/download.ts +++ b/apps/sim/lib/uploads/client/download.ts @@ -3,6 +3,8 @@ import { requestRaw } from '@/lib/api/client/request' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' +import { normalizeMimeType } from '@/lib/uploads/utils/mime' /** Action-time content from the mounted viewer, scoped so another file cannot consume it. */ export interface FileDownloadSource { @@ -43,10 +45,7 @@ export async function triggerFileDownload( record: WorkspaceFileRecord, source?: FileDownloadSource | null ): Promise { - const isMarkdown = - record.type === 'text/markdown' || - record.type === 'text/x-markdown' || - /\.(?:md|markdown)$/i.test(record.name) + const isMarkdown = isMarkdownFile(record) || normalizeMimeType(record.type) === 'text/x-markdown' const content = isMarkdown && diff --git a/apps/sim/lib/uploads/client/text-content.test.ts b/apps/sim/lib/uploads/client/text-content.test.ts new file mode 100644 index 00000000000..0c4dbbcc4f8 --- /dev/null +++ b/apps/sim/lib/uploads/client/text-content.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_TEXT_PREVIEW_BYTES, + readFileText, + TEXT_PREVIEW_SIZE_MESSAGE, +} from '@/lib/uploads/client/text-content' + +describe('bounded file text decoding', () => { + it('preserves Unicode split between network chunks and original line endings', async () => { + const bytes = new TextEncoder().encode('Café 你好\r\nKEY=value\n') + const response = new Response( + new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)) + controller.close() + }, + }) + ) + await expect(readFileText(response)).resolves.toBe('Café 你好\r\nKEY=value\n') + }) + + it('cancels an oversized response before pulling any body bytes', async () => { + let pulled = false + let cancelled = false + const response = new Response( + new ReadableStream( + { + pull(controller) { + pulled = true + controller.enqueue(Uint8Array.of(65)) + controller.close() + }, + cancel() { + cancelled = true + }, + }, + { highWaterMark: 0 } + ), + { headers: { 'content-length': String(MAX_TEXT_PREVIEW_BYTES + 1) } } + ) + await expect(readFileText(response)).rejects.toThrow(TEXT_PREVIEW_SIZE_MESSAGE) + expect(pulled).toBe(false) + expect(cancelled).toBe(true) + }) + + it.each([undefined, '1'])( + 'bounds a stream with content-length %s and releases it', + async (length) => { + let chunks = 0 + let cancelled = false + const response = new Response( + new ReadableStream( + { + pull(controller) { + chunks += 1 + controller.enqueue(new Uint8Array(MAX_TEXT_PREVIEW_BYTES / 5).fill(65)) + if (chunks === 8) controller.close() + }, + cancel() { + cancelled = true + }, + }, + { highWaterMark: 0 } + ), + { headers: length ? { 'content-length': length } : {} } + ) + await expect(readFileText(response)).rejects.toThrow(TEXT_PREVIEW_SIZE_MESSAGE) + expect(chunks).toBe(6) + expect(cancelled).toBe(true) + expect(response.body?.locked).toBe(false) + } + ) + + it.each([undefined, 'gzip'])( + 'accepts the exact decoded limit with encoding %s', + async (encoding) => { + const headers = new Headers({ + 'content-length': String(MAX_TEXT_PREVIEW_BYTES + (encoding ? 100 : 0)), + }) + if (encoding) headers.set('content-encoding', encoding) + await expect( + readFileText(new Response('a'.repeat(MAX_TEXT_PREVIEW_BYTES), { headers })) + ).resolves.toHaveLength(MAX_TEXT_PREVIEW_BYTES) + } + ) + + it('accepts empty responses', async () => { + await expect(readFileText(new Response(null))).resolves.toBe('') + }) +}) diff --git a/apps/sim/lib/uploads/client/text-content.ts b/apps/sim/lib/uploads/client/text-content.ts new file mode 100644 index 00000000000..d12d6e0ca69 --- /dev/null +++ b/apps/sim/lib/uploads/client/text-content.ts @@ -0,0 +1,46 @@ +import { PASTE_LIMITS } from '@sim/utils/paste' + +export const MAX_TEXT_PREVIEW_BYTES = PASTE_LIMITS.TEXT_EDITOR_BYTES + +export const TEXT_PREVIEW_SIZE_MESSAGE = + 'This file is too large to preview. Download it to view its contents.' + +/** A deterministic preview limit failure that must not trigger a download retry. */ +export class FileTextSizeError extends Error { + constructor() { + super(TEXT_PREVIEW_SIZE_MESSAGE) + this.name = 'FileTextSizeError' + } +} + +/** Bounds decoded text; Content-Length measures wire bytes for encoded responses. */ +export async function readFileText(response: Response): Promise { + if ( + !response.headers.has('content-encoding') && + Number(response.headers.get('content-length')) > MAX_TEXT_PREVIEW_BYTES + ) { + await response.body?.cancel().catch(() => {}) + throw new FileTextSizeError() + } + if (!response.body) return '' + + const reader = response.body.getReader() + const decoder = new TextDecoder() + const chunks: string[] = [] + let bytes = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + bytes += value.byteLength + if (bytes > MAX_TEXT_PREVIEW_BYTES) throw new FileTextSizeError() + const text = decoder.decode(value, { stream: true }) + if (text) chunks.push(text) + } + chunks.push(decoder.decode()) + return chunks.join('') + } finally { + await reader.cancel().catch(() => {}) + reader.releaseLock() + } +} diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 81724d0d2bf..3582fb2079b 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -1,6 +1,7 @@ import type { Logger } from '@sim/logger' import { omit } from '@sim/utils/object' import type { StorageContext } from '@/lib/uploads' +import { normalizeMimeType } from '@/lib/uploads/utils/mime' import { ACCEPTED_FILE_TYPES, isAlphanumericExtension, @@ -199,7 +200,7 @@ export function getFileExtension(filename: string): string { * merge) to exactly the files that can be open in that editor. */ export function isMarkdownFile(file: { type?: string | null; name: string }): boolean { - if (file.type === 'text/markdown') return true + if (normalizeMimeType(file.type) === 'text/markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' } diff --git a/apps/sim/lib/uploads/utils/mime.ts b/apps/sim/lib/uploads/utils/mime.ts new file mode 100644 index 00000000000..50eb5488a7e --- /dev/null +++ b/apps/sim/lib/uploads/utils/mime.ts @@ -0,0 +1,4 @@ +/** Returns the lowercase media type without parameters for format matching. */ +export function normalizeMimeType(mimeType: string | null | undefined): string { + return mimeType?.split(';', 1)[0].trim().toLowerCase() ?? '' +} diff --git a/apps/sim/lib/uploads/utils/validation.ts b/apps/sim/lib/uploads/utils/validation.ts index fce28fa6ac1..a63bcf4efb2 100644 --- a/apps/sim/lib/uploads/utils/validation.ts +++ b/apps/sim/lib/uploads/utils/validation.ts @@ -102,7 +102,7 @@ export const SUPPORTED_VIDEO_EXTENSIONS = ['mp4', 'mov', 'avi', 'mkv', 'webm'] a */ export const SUPPORTED_ARCHIVE_EXTENSIONS = ['zip'] as const -export const SUPPORTED_IMAGE_EXTENSIONS = [ +const SUPPORTED_IMAGE_EXTENSIONS = [ 'png', 'jpg', 'jpeg', diff --git a/apps/sim/package.json b/apps/sim/package.json index 081a4e7507b..5e6e258817a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -188,6 +188,7 @@ "echarts": "6.1.0", "es-toolkit": "1.45.1", "framer-motion": "^12.5.0", + "github-slugger": "2.0.0", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", diff --git a/bun.lock b/bun.lock index 69ac6322289..4c32ccbb959 100644 --- a/bun.lock +++ b/bun.lock @@ -312,6 +312,7 @@ "echarts": "6.1.0", "es-toolkit": "1.45.1", "framer-motion": "^12.5.0", + "github-slugger": "2.0.0", "google-auth-library": "10.5.0", "gray-matter": "^4.0.3", "groq-sdk": "^0.15.0", diff --git a/packages/testing/src/mocks/file-utils.mock.ts b/packages/testing/src/mocks/file-utils.mock.ts index 2185fad7f22..ddb2f1de6c6 100644 --- a/packages/testing/src/mocks/file-utils.mock.ts +++ b/packages/testing/src/mocks/file-utils.mock.ts @@ -507,7 +507,7 @@ export const fileUtilsMockFns = { mockCreateFileContentFromBase64: vi.fn(createFileContentFromBase64), mockGetFileExtension: vi.fn(getFileExtension), mockIsMarkdownFile: vi.fn((file: { type?: string | null; name: string }) => { - if (file.type === 'text/markdown') return true + if (file.type?.split(';', 1)[0].trim().toLowerCase() === 'text/markdown') return true const ext = getFileExtension(file.name) return ext === 'md' || ext === 'markdown' }), diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index ff4ded5f495..9db52af6744 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -410,7 +410,6 @@ "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence.ts#activateCaretLabel", "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts#EditorCollaboration", "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts#MarkdownFindController", - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.ts#slugifyHeading", "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts#MarkdownMention", "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts#Mention", "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts#MentionChip",