Skip to content

Commit db05297

Browse files
authored
feat(files): embed sim files and render mermaid diagrams in markdown preview (#4402)
* feat(files): resolve workspace file page URLs to raw content in markdown preview * refactor(files): replace regex URL matching with URL constructor in resolveSimFileUrl * fix(files): render mermaid diagrams in markdown preview using design tokens Streamdown intercepts fenced code blocks before components.code is called, so the previous mermaid branch in the code renderer was dead code. Add a remarkMermaid plugin that transforms mermaid code nodes at the MDAST stage via data.hName/hProperties so remark-rehype emits a <mermaid-diagram> element that our MermaidDiagram component handles. This avoids Streamdown's built-in mermaid renderer which uses mismatched Tailwind semantic classes. * fix(trace): reduce default tree pane width from 360 to 280 * fix(files): guard resolveSimFileUrl against cross-origin URL hijacking * refactor(files): use getBrowserOrigin() instead of window.location.origin * fix(trace): reduce default tree pane width to 240 * fix(files): narrow img src type before passing to resolveSimFileUrl * fix(files): use narrow props type for img renderer instead of runtime narrowing * fix(files): accept full ImgHTMLAttributes for img renderer to satisfy Streamdown Components type * fix(files): treat null getBrowserOrigin as same-origin to prevent SSR hydration mismatch * improvement(files): use centered aspect-ratio skeleton for mmd file preview * improvement(files): diagram-shaped skeleton card for mmd file preview * fix(files): only rewrite relative workspace file URLs to avoid hydration mismatch * fix(files): pass context not resolved params to parseRequest in view route * fix(files): simplify mmd file skeleton to match pptx card pattern * fix(files): match mmd skeleton to actual rendered layout — full-height zoomable area * fix(sidebar): left-align folder lock icon next to name
1 parent 5f3baa4 commit db05297

6 files changed

Lines changed: 156 additions & 28 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextRequest } from 'next/server'
3+
import { NextResponse } from 'next/server'
4+
import { fileViewContract } from '@/lib/api/contracts/storage-transfer'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
9+
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
10+
import { verifyFileAccess } from '@/app/api/files/authorization'
11+
12+
const logger = createLogger('FilesViewAPI')
13+
14+
export const GET = withRouteHandler(
15+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
16+
const parsed = await parseRequest(fileViewContract, request, context)
17+
if (!parsed.success) return parsed.response
18+
19+
const { id } = parsed.data.params
20+
21+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
22+
if (!authResult.success || !authResult.userId) {
23+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
24+
}
25+
26+
const record = await getFileMetadataById(id)
27+
if (!record) {
28+
logger.warn('File not found by ID', { id })
29+
return NextResponse.json({ error: 'Not found' }, { status: 404 })
30+
}
31+
32+
const hasAccess = await verifyFileAccess(record.key, authResult.userId)
33+
if (!hasAccess) {
34+
logger.warn('Unauthorized file view attempt', { id, userId: authResult.userId })
35+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
36+
}
37+
38+
const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
39+
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
40+
logger.info('Redirecting file view to serve path', { id, servePath })
41+
42+
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
43+
}
44+
)

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

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Streamdown } from 'streamdown'
1010
import 'streamdown/styles.css'
1111
import { toError } from '@sim/utils/errors'
1212
import { generateShortId } from '@sim/utils/id'
13-
import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn'
13+
import { Checkbox, CopyCodeButton, highlight, languages, Skeleton } from '@/components/emcn'
1414
import '@/components/emcn/components/code/code.css'
1515
import 'prismjs/components/prism-bash'
1616
import 'prismjs/components/prism-css'
@@ -98,6 +98,33 @@ export const PreviewPanel = memo(function PreviewPanel({
9898

9999
const CALLOUT_TYPES = new Set(['NOTE', 'TIP', 'WARNING', 'IMPORTANT', 'CAUTION'])
100100

101+
function remarkMermaid() {
102+
return (tree: { type: string; children?: unknown[] }) => {
103+
function processNode(node: {
104+
type: string
105+
children?: unknown[]
106+
lang?: string
107+
value?: string
108+
data?: Record<string, unknown>
109+
}) {
110+
if (!node.children) return
111+
for (const child of node.children) {
112+
const c = child as typeof node
113+
if (c.type === 'code' && c.lang === 'mermaid') {
114+
c.data = {
115+
hName: 'mermaid-diagram',
116+
hProperties: { definition: c.value ?? '' },
117+
hChildren: [],
118+
}
119+
} else {
120+
processNode(c)
121+
}
122+
}
123+
}
124+
processNode(tree)
125+
}
126+
}
127+
101128
function remarkCallouts() {
102129
return (tree: { type: string; children?: unknown[] }) => {
103130
function processNode(node: { type: string; children?: unknown[] }) {
@@ -142,7 +169,7 @@ function remarkCallouts() {
142169
}
143170
}
144171

145-
const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkCallouts]
172+
const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkMermaid, remarkCallouts]
146173
const REHYPE_PLUGINS = [rehypeSlug]
147174

148175
/**
@@ -418,13 +445,39 @@ const MermaidDiagram = memo(function MermaidDiagram({
418445
}
419446

420447
if (!trimmedDefinition || !svg || renderedDefinition !== trimmedDefinition) {
448+
if (zoomable) {
449+
return (
450+
<div className='h-full p-6'>
451+
<Skeleton className='h-full w-full rounded-lg' />
452+
</div>
453+
)
454+
}
421455
return <MermaidCodeBlockSkeleton />
422456
}
423457
return null
424458
})
425459

460+
function resolveSimFileUrl(src: string | undefined): string | undefined {
461+
if (!src) return src
462+
try {
463+
const parsed = new URL(src, 'http://placeholder')
464+
if (parsed.origin !== 'http://placeholder') return src
465+
const [, seg1, , seg3, fileId] = parsed.pathname.split('/')
466+
if (seg1 === 'workspace' && seg3 === 'files' && fileId) {
467+
return `/api/files/view/${fileId}`
468+
}
469+
} catch {
470+
// not a parseable URL
471+
}
472+
return src
473+
}
474+
426475
const STATIC_MARKDOWN_COMPONENTS = {
427476
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
477+
'mermaid-diagram': ({ definition }: { definition?: string }) => {
478+
const isStreaming = useContext(MermaidStreamingCtx)
479+
return <MermaidDiagram definition={definition ?? ''} isStreaming={isStreaming} />
480+
},
428481
p: ({ children }: { children?: React.ReactNode }) => (
429482
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
430483
{children}
@@ -493,15 +546,10 @@ const STATIC_MARKDOWN_COMPONENTS = {
493546
)
494547
},
495548
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
496-
const isMarkdownStreaming = useContext(MermaidStreamingCtx)
497549
const langMatch = className?.match(/language-(\w+)/)
498550
const langRaw = langMatch?.[1] ?? ''
499551
const codeString = extractTextContent(children)
500552

501-
if (langRaw === 'mermaid') {
502-
return <MermaidDiagram definition={codeString} isStreaming={isMarkdownStreaming} />
503-
}
504-
505553
if (!codeString) {
506554
return (
507555
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
@@ -562,14 +610,17 @@ const STATIC_MARKDOWN_COMPONENTS = {
562610
)
563611
},
564612
hr: () => <hr className='my-6 border-[var(--border)]' />,
565-
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => (
566-
<img
567-
src={src as string}
568-
alt={alt ?? ''}
569-
className='my-3 max-w-full rounded-md'
570-
loading='lazy'
571-
/>
572-
),
613+
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => {
614+
const resolvedSrc = resolveSimFileUrl(typeof src === 'string' ? src : undefined)
615+
return (
616+
<img
617+
src={resolvedSrc}
618+
alt={alt ?? ''}
619+
className='my-3 max-w-full rounded-md'
620+
loading='lazy'
621+
/>
622+
)
623+
},
573624
table: ({ children }: { children?: React.ReactNode }) => (
574625
<div className='my-4 max-w-full overflow-x-auto'>
575626
<table className='w-full border-collapse text-[13px]'>{children}</table>
@@ -832,6 +883,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
832883
remarkPlugins={REMARK_PLUGINS}
833884
rehypePlugins={REHYPE_PLUGINS}
834885
components={MARKDOWN_COMPONENTS}
886+
allowedTags={{ 'mermaid-diagram': ['definition'] }}
835887
>
836888
{markdownContent}
837889
</Streamdown>

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
4646
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
4747

48-
const DEFAULT_TREE_PANE_WIDTH = 360
48+
const DEFAULT_TREE_PANE_WIDTH = 240
4949
const MIN_TREE_PANE_WIDTH = 200
5050
const MAX_TREE_PANE_WIDTH = 600
5151
const INDENT_PX = 12

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -545,18 +545,20 @@ export function FolderItem({
545545
/>
546546
) : (
547547
<div className='flex min-w-0 flex-1 items-center gap-2'>
548-
<span
549-
className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'
550-
onDoubleClick={handleDoubleClick}
551-
>
552-
{folder.name}
553-
</span>
554-
{folder.locked && (
555-
<Lock
556-
className='h-[12px] w-[12px] flex-shrink-0 pointer-events-none text-[var(--text-icon)]'
557-
aria-label='Folder is locked'
558-
/>
559-
)}
548+
<div className='flex min-w-0 flex-1 items-center gap-1'>
549+
<span
550+
className='min-w-0 truncate font-base text-[var(--text-body)]'
551+
onDoubleClick={handleDoubleClick}
552+
>
553+
{folder.name}
554+
</span>
555+
{folder.locked && (
556+
<Lock
557+
className='h-[12px] w-[12px] flex-shrink-0 pointer-events-none text-[var(--text-icon)]'
558+
aria-label='Folder is locked'
559+
/>
560+
)}
561+
</div>
560562
<button
561563
type='button'
562564
aria-label='Folder options'

apps/sim/lib/api/contracts/storage-transfer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,10 @@ export const fileServeQuerySchema = z.object({
450450
raw: z.string().nullish(),
451451
})
452452

453+
export const fileViewParamsSchema = z.object({
454+
id: z.string().uuid('File ID must be a valid UUID'),
455+
})
456+
453457
export const boxUploadContract = defineRouteContract({
454458
method: 'POST',
455459
path: '/api/tools/box/upload',
@@ -696,6 +700,13 @@ export const fileServeContract = defineRouteContract({
696700
response: { mode: 'binary' },
697701
})
698702

703+
export const fileViewContract = defineRouteContract({
704+
method: 'GET',
705+
path: '/api/files/view/[id]',
706+
params: fileViewParamsSchema,
707+
response: { mode: 'binary' },
708+
})
709+
699710
export type BoxUploadBody = ContractBodyInput<typeof boxUploadContract>
700711
export type BoxUploadResponse = ContractJsonResponse<typeof boxUploadContract>
701712
export type DropboxUploadBody = ContractBodyInput<typeof dropboxUploadContract>
@@ -737,3 +748,4 @@ export type TokenBoundMultipartBody = z.output<typeof tokenBoundMultipartBodySch
737748
export type GetMultipartPartUrlsBody = z.output<typeof getMultipartPartUrlsBodySchema>
738749
export type FileServeParams = ContractParamsInput<typeof fileServeContract>
739750
export type FileServeQuery = ContractQueryInput<typeof fileServeContract>
751+
export type FileViewParams = ContractParamsInput<typeof fileViewContract>

apps/sim/lib/uploads/server/metadata.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,24 @@ export async function getFileMetadataByKey(
140140
return record ?? null
141141
}
142142

143+
/**
144+
* Get file metadata by ID
145+
*/
146+
export async function getFileMetadataById(
147+
id: string,
148+
options?: { includeDeleted?: boolean }
149+
): Promise<FileMetadataRecord | null> {
150+
const { includeDeleted = false } = options ?? {}
151+
const conditions = [eq(workspaceFiles.id, id)]
152+
if (!includeDeleted) conditions.push(isNull(workspaceFiles.deletedAt))
153+
const [record] = await db
154+
.select()
155+
.from(workspaceFiles)
156+
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
157+
.limit(1)
158+
return record ?? null
159+
}
160+
143161
/**
144162
* Get file metadata by context with optional workspaceId/userId filters
145163
*/

0 commit comments

Comments
 (0)