Skip to content

Commit 96273a5

Browse files
authored
fix(files): support arbitrary uploads and in-document links (#8807)
* fix(files): support arbitrary uploads and in-document links * fix(files): normalize previews and avoid oversized retries * fix(files): bound decoded text for encoded responses
1 parent e3bf8be commit 96273a5

23 files changed

Lines changed: 366 additions & 153 deletions

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
2+
import { normalizeMimeType } from '@/lib/uploads/utils/mime'
23
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
34

45
const TEXT_EDITABLE_MIME_TYPES = new Set([
@@ -132,6 +133,7 @@ export type FileCategory =
132133
| 'unsupported'
133134

134135
export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
136+
mimeType = normalizeMimeType(mimeType)
135137
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
136138
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
137139
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
@@ -140,9 +142,11 @@ export function resolveFileCategory(mimeType: string | null, filename: string):
140142
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
141143
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
142144
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
145+
if (mimeType?.startsWith('text/')) return 'text-editable'
143146

144147
const ext = getFileExtension(filename)
145-
const nameKey = ext || filename.toLowerCase()
148+
const sourceName = filename.replace(/\.(example|template|sample|dist|default)$/i, '')
149+
const nameKey = getFileExtension(sourceName) || sourceName.toLowerCase()
146150
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
147151
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
148152
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
@@ -151,6 +155,8 @@ export function resolveFileCategory(mimeType: string | null, filename: string):
151155
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
152156
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
153157
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
158+
if (/^\.?env(?: \(\d+\))*(?:\.[\w.-]+)?$/i.test(filename)) return 'text-editable'
159+
if (/^dockerfile\.[\w.-]+$/i.test(filename)) return 'text-editable'
154160

155161
return 'unsupported'
156162
}

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { Music } from '@sim/emcn/icons'
55
import dynamic from 'next/dynamic'
66
import type { FileDownloadSource } from '@/lib/uploads/client/download'
7+
import {
8+
MAX_TEXT_PREVIEW_BYTES,
9+
TEXT_PREVIEW_SIZE_MESSAGE,
10+
} from '@/lib/uploads/client/text-content'
711
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
812
import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils'
913
import {
@@ -51,12 +55,19 @@ const RichMarkdownEditor = dynamic(
5155
*/
5256
const CSV_INLINE_EDIT_MAX_BYTES = 5 * 1024 * 1024
5357

54-
export function isTextEditable(file: { type: string; name: string }): boolean {
55-
return resolveFileCategory(file.type, file.name) === 'text-editable'
58+
export function isTextEditable(file: {
59+
type: string
60+
name: string
61+
size?: number | null
62+
}): boolean {
63+
return (
64+
resolveFileCategory(file.type, file.name) === 'text-editable' &&
65+
(file.size ?? 0) <= MAX_TEXT_PREVIEW_BYTES
66+
)
5667
}
5768

58-
export function isPreviewable(file: { type: string; name: string }): boolean {
59-
return resolvePreviewType(file.type, file.name) !== null
69+
export function isPreviewable(file: { type: string; name: string; size?: number | null }): boolean {
70+
return resolvePreviewType(file.type, file.name) !== null && isTextEditable(file)
6071
}
6172

6273
/**
@@ -180,13 +191,17 @@ function FileViewerContent({
180191
const category = resolveFileCategory(file.type, file.name)
181192

182193
if (category === 'text-editable') {
194+
if (isCsvStreamOnly(file)) {
195+
return readOnly ? (
196+
<UnsupportedPreview name={file.name} />
197+
) : (
198+
<CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
199+
)
200+
}
201+
if (!isTextEditable(file)) {
202+
return <UnsupportedPreview name={file.name} reason={TEXT_PREVIEW_SIZE_MESSAGE} />
203+
}
183204
if (readOnly) {
184-
// ReadOnlyTextPreview loads the whole file as text; a large CSV would OOM the
185-
// browser. CsvTablePreview's streamed fallback is workspace-only, so on the
186-
// read-only public path a large CSV is download-only.
187-
if (isCsvStreamOnly(file)) {
188-
return <UnsupportedPreview name={file.name} />
189-
}
190205
// Markdown renders through the inline rich editor (non-editable) so the public share
191206
// surface matches the in-app reading experience; canEdit={false} disables autosave,
192207
// the bubble menu, and every other editing affordance.
@@ -203,12 +218,6 @@ function FileViewerContent({
203218
}
204219
return <ReadOnlyTextPreview file={file} workspaceId={workspaceId} />
205220
}
206-
// A large CSV can't be loaded whole into the editor (the browser OOMs on the full text).
207-
// Render a streamed, read-only preview of the first rows + an "Import as a table" path instead.
208-
if (isCsvStreamOnly(file)) {
209-
return <CsvTablePreview key={file.id} file={file} workspaceId={workspaceId} />
210-
}
211-
212221
if (isMarkdownFile(file)) {
213222
return (
214223
<RichMarkdownEditor

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useTheme } from 'next-themes'
66
import '@sim/emcn/components/code/code.css'
77
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
88
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
9+
import { normalizeMimeType } from '@/lib/uploads/utils/mime'
910
import {
1011
SIM_ARTIFACT_SHELL,
1112
SIM_ARTIFACT_STYLESHEET,
@@ -50,6 +51,7 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
5051
export const RICH_PREVIEWABLE_EXTENSIONS = new Set(Object.keys(PREVIEWABLE_EXTENSIONS))
5152

5253
export function resolvePreviewType(mimeType: string | null, filename: string): PreviewType {
54+
mimeType = normalizeMimeType(mimeType)
5355
if (mimeType && PREVIEWABLE_MIME_TYPES[mimeType]) return PREVIEWABLE_MIME_TYPES[mimeType]
5456
const ext = getFileExtension(filename)
5557
return PREVIEWABLE_EXTENSIONS[ext] ?? null

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,29 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils'
88

99
const logger = createLogger('FilePreview')
1010

11+
interface UnsupportedPreviewProps {
12+
name: string
13+
reason?: string
14+
}
15+
1116
/**
1217
* Terminal fallback for a file this app cannot render — either the format has no
1318
* viewer at all, or a viewer that was expected to work failed (e.g. a HEIC whose
1419
* server-side derivative could not be produced).
1520
*/
16-
export const UnsupportedPreview = memo(function UnsupportedPreview({ name }: { name: string }) {
21+
export const UnsupportedPreview = memo(function UnsupportedPreview({
22+
name,
23+
reason,
24+
}: UnsupportedPreviewProps) {
1725
const ext = getFileExtension(name)
1826

1927
return (
2028
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
2129
<p className='text-[14px] text-[var(--text-primary)]'>
22-
Preview not available{ext ? ` for .${ext} files` : ' for this file'}
30+
Preview not available{reason ? '' : ext ? ` for .${ext} files` : ' for this file'}
2331
</p>
2432
<p className='text-[var(--text-muted)] text-small'>
25-
Use the download button to view this file
33+
{reason ?? 'Use the download button to view this file'}
2634
</p>
2735
</div>
2836
)

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors.test.ts‎

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,51 @@
33
*/
44
import { Editor } from '@tiptap/core'
55
import { afterEach, describe, expect, it } from 'vitest'
6-
import { createMarkdownContentExtensions } from './extensions'
7-
import { findHeadingPos } from './heading-anchors'
6+
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
7+
import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors'
88

99
let editor: Editor | null = null
1010
afterEach(() => {
1111
editor?.destroy()
1212
editor = null
1313
})
1414

15-
/** A ProseMirror doc parsed from markdown, for the position-resolution tests. */
16-
function docOf(markdown: string) {
15+
function editorOf(markdown: string) {
1716
editor = new Editor({ extensions: createMarkdownContentExtensions() })
1817
editor.commands.setContent(markdown, { contentType: 'markdown' })
19-
return editor.state.doc
18+
return editor
2019
}
2120

22-
describe('findHeadingPos', () => {
23-
it('disambiguates duplicate slugs GitHub-style (foo, foo-1, foo-2)', () => {
24-
const doc = docOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
25-
const first = findHeadingPos(doc, 'notes')
26-
const second = findHeadingPos(doc, 'notes-1')
27-
const third = findHeadingPos(doc, 'notes-2')
28-
expect(first).toBeGreaterThanOrEqual(0)
29-
expect(second).toBeGreaterThan(first)
30-
expect(third).toBeGreaterThan(second)
21+
describe('Markdown heading navigation', () => {
22+
it.each([
23+
['Unicode headings', '# Café 你好', 'café-你好', 0],
24+
['punctuation spacing', '# Network & data', 'network--data', 0],
25+
['existing suffixes', '# Notes\n\n# Notes-1\n\n# Notes', 'notes-2', 2],
26+
] as const)('scrolls to GitHub fragments for %s', (_name, markdown, slug, index) => {
27+
const editor = editorOf(markdown)
28+
const headings = editor.view.dom.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
29+
let scrolledTo: HTMLElement | null = null
30+
for (const heading of headings) {
31+
heading.scrollIntoView = () => {
32+
scrolledTo = heading
33+
}
34+
}
35+
expect(scrollToHeading(editor.view, `#${encodeURIComponent(slug)}`)).toBe(true)
36+
expect(scrolledTo).toBe(headings[index])
37+
})
38+
39+
it('scrolls to duplicate headings in document order', () => {
40+
const editor = editorOf('# Notes\n\na\n\n# Notes\n\nb\n\n# Notes\n\nc')
41+
const headings = editor.view.dom.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')
42+
let scrolledTo: HTMLElement | null = null
43+
for (const heading of headings) {
44+
heading.scrollIntoView = () => {
45+
scrolledTo = heading
46+
}
47+
}
48+
for (const [index, fragment] of ['#notes', '#notes-1', '#notes-2'].entries()) {
49+
expect(scrollToHeading(editor.view, fragment)).toBe(true)
50+
expect(scrolledTo).toBe(headings[index])
51+
}
3152
})
3253
})
Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,32 @@
11
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
2+
import type { EditorView } from '@tiptap/pm/view'
3+
import GithubSlugger from 'github-slugger'
24

3-
/**
4-
* Slugify heading text GitHub-style (lowercase, drop punctuation, collapse whitespace to hyphens) so
5-
* that `[label](#slug)` fragment links — written against how GitHub renders the same markdown —
6-
* resolve to the matching heading. Mirrors what `rehype-slug` produced in the old preview.
7-
*/
8-
export function slugifyHeading(text: string): string {
9-
return text
10-
.toLowerCase()
11-
.trim()
12-
.replace(/[^\w\s-]/g, '')
13-
.replace(/\s+/g, '-')
14-
.replace(/-+/g, '-')
15-
}
16-
17-
/**
18-
* The document position of the heading a `#slug` fragment link targets, or -1 if none matches.
19-
* Computed on demand (at click time) rather than maintained as per-keystroke decorations. Duplicate
20-
* slugs are disambiguated GitHub-style: `intro`, `intro-1`, `intro-2`, …
21-
*/
22-
export function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
23-
const seen = new Map<string, number>()
5+
/** Resolves GitHub heading fragments, including Unicode and collisions with existing suffixes. */
6+
function findHeadingPos(doc: ProseMirrorNode, slug: string): number {
7+
const slugger = new GithubSlugger()
248
let found = -1
259
doc.descendants((node, pos) => {
2610
if (found >= 0) return false
2711
if (node.type.name !== 'heading') return true
28-
const base = slugifyHeading(node.textContent)
29-
if (!base) return true
30-
const n = seen.get(base) ?? 0
31-
seen.set(base, n + 1)
32-
if ((n === 0 ? base : `${base}-${n}`) === slug) found = pos
33-
return found < 0
12+
if (slugger.slug(node.textContent) === slug) found = pos
13+
return false
3414
})
3515
return found
3616
}
17+
18+
/** Scrolls within this editor without navigating the page or changing the document selection. */
19+
export function scrollToHeading(view: EditorView, fragment: string): boolean {
20+
let slug: string
21+
try {
22+
slug = decodeURIComponent(fragment.slice(1))
23+
} catch {
24+
return false
25+
}
26+
const pos = slug ? findHeadingPos(view.state.doc, slug) : 0
27+
if (pos < 0) return false
28+
const target = slug ? view.nodeDOM(pos) : view.dom
29+
if (!(target instanceof HTMLElement)) return false
30+
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
31+
return true
32+
}

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Check, Duplicate, Pencil, Unlink } from '@sim/emcn/icons'
55
import { getMarkRange } from '@tiptap/core'
66
import { type Editor, useEditorState } from '@tiptap/react'
77
import { createPortal } from 'react-dom'
8+
import { scrollToHeading } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors'
89
import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity'
910
import {
1011
applyLink,
@@ -38,8 +39,8 @@ function resolveLinkRange(editor: Editor, el: HTMLElement): LinkRange | null {
3839

3940
/**
4041
* Floating card shown when hovering a link, so the destination is visible even when the link text
41-
* differs from the URL. The URL opens in a new tab; Copy is always available, while Edit (inline) and
42-
* Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short
42+
* differs from the URL. Heading links scroll within the editor; other URLs open in a new tab.
43+
* Copy is always available, while Edit (inline) and Remove require an editable document. Positioned with Floating UI against the hovered anchor; a short
4344
* close delay plus the card's own hover bridge let the pointer travel from the link into the card.
4445
*/
4546
export function LinkHoverCard({ editor }: LinkHoverCardProps) {
@@ -208,7 +209,13 @@ export function LinkHoverCard({ editor }: LinkHoverCardProps) {
208209
{safeHref ? (
209210
<a
210211
href={safeHref}
211-
target='_blank'
212+
target={safeHref.startsWith('#') ? undefined : '_blank'}
213+
onClick={(event) => {
214+
if (!safeHref.startsWith('#')) return
215+
event.preventDefault()
216+
if (!editor.isDestroyed) scrollToHeading(editor.view, safeHref)
217+
dismiss()
218+
}}
212219
rel='noopener noreferrer'
213220
title={rawHref}
214221
className='max-w-[260px] truncate px-2 text-[var(--text-body)] text-small hover:underline'

0 commit comments

Comments
 (0)