feat(files): extract PDF viewer behind SSR boundary and polish file preview

## 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.
This commit is contained in:
waleed
2026-04-27 19:50:21 -07:00
parent 154b9d0883
commit 3250264de6
17 changed files with 1808 additions and 915 deletions

View File

@@ -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 })

View File

@@ -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 (
<Suspense>
<Files />
</Suspense>
)
}

View File

@@ -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<EditingCell>(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<HTMLInputElement>) => {
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 (
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>
@@ -14,9 +66,24 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
{headers.map((header, i) => (
<th
key={i}
className='whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'
className={cn(
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
)}
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
>
{String(header ?? '')}
{isEditing(-1, i) ? (
<input
ref={setInputRef}
value={editValue}
onChange={(e) => 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 ?? '')
)}
</th>
))}
</tr>
@@ -25,8 +92,26 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
{rows.map((row, ri) => (
<tr key={ri} className='border-[var(--border)] border-t'>
{headers.map((_, ci) => (
<td key={ci} className='whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]'>
{String(row[ci] ?? '')}
<td
key={ci}
className={cn(
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
)}
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
>
{isEditing(ri, ci) ? (
<input
ref={setInputRef}
value={editValue}
onChange={(e) => 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] ?? '')
)}
</td>
))}
</tr>

View File

@@ -0,0 +1,308 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
import 'react-pdf/dist/Page/TextLayer.css'
import { Button, Skeleton } from '@/components/emcn'
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).href
const logger = createLogger('PdfViewer')
const PDF_ZOOM_MIN = 0.5
const PDF_ZOOM_MAX = 3
const PDF_ZOOM_DEFAULT = 1
const PDF_ZOOM_STEP = 1.25
const PDF_PAGE_MAX_WIDTH = 816
const PDF_VIEWER_PADDING = 24
export type PdfDocumentSource =
| { kind: 'url'; url: string }
| { kind: 'buffer'; buffer: ArrayBuffer }
interface PdfViewerCoreProps {
source: PdfDocumentSource
filename: string
}
const PDF_SKELETON = (
<div className='absolute inset-0 flex flex-col items-center gap-4 overflow-y-auto bg-[var(--surface-1)] p-6'>
{[0, 1].map((i) => (
<div
key={i}
className='w-full max-w-[640px] shrink-0 rounded-md bg-[var(--surface-2)] p-8 shadow-medium'
style={{ aspectRatio: '1 / 1.414' }}
>
<div className='flex flex-col gap-3'>
<Skeleton className='h-[14px] w-[60%]' />
<Skeleton className='h-[14px] w-[80%]' />
<Skeleton className='h-[14px] w-[55%]' />
<Skeleton className='mt-2 h-[14px] w-[75%]' />
<Skeleton className='h-[14px] w-[65%]' />
<Skeleton className='h-[14px] w-[85%]' />
<Skeleton className='h-[14px] w-[50%]' />
</div>
</div>
))}
</div>
)
function PdfError({ error }: { error: string }) {
return (
<div className='flex flex-1 flex-col items-center justify-center gap-[8px]'>
<p className='font-medium text-[14px] text-[var(--text-body)]'>Failed to preview PDF</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: PdfViewerCoreProps) {
const containerRef = useRef<HTMLDivElement>(null)
const paddingWrapperRef = useRef<HTMLDivElement>(null)
const pagesWrapperRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
const pageWidthRef = useRef<number | undefined>(undefined)
const zoomRef = useRef(PDF_ZOOM_DEFAULT)
const [containerWidth, setContainerWidth] = useState(0)
const [pageCount, setPageCount] = useState(0)
const [isDocumentReady, setIsDocumentReady] = useState(false)
const [displayZoom, setDisplayZoom] = useState(PDF_ZOOM_DEFAULT)
const [currentPage, setCurrentPage] = useState(1)
const [loadError, setLoadError] = useState<string | null>(null)
const sourceValue = source.kind === 'url' ? source.url : source.buffer
const file = useMemo(
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer) }),
[sourceValue]
)
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
observer.observe(container)
return () => observer.disconnect()
}, [])
const pageWidth =
containerWidth > 0
? Math.min(containerWidth - 2 * PDF_VIEWER_PADDING, PDF_PAGE_MAX_WIDTH)
: undefined
pageWidthRef.current = pageWidth
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
const container = containerRef.current
const wrapper = pagesWrapperRef.current
const padWrapper = paddingWrapperRef.current
const pw = pageWidthRef.current
if (!container || !wrapper) return
const ratio = next / zoomRef.current
wrapper.style.zoom = String(next)
if (padWrapper && pw !== undefined) {
padWrapper.style.minWidth = `${pw * next + 2 * PDF_VIEWER_PADDING}px`
}
// Padding is outside the zoom subtree, so offset the anchor by it before scaling.
container.scrollLeft =
(container.scrollLeft + anchorX - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorX
container.scrollTop =
(container.scrollTop + anchorY - PDF_VIEWER_PADDING) * ratio + PDF_VIEWER_PADDING - anchorY
zoomRef.current = next
setDisplayZoom(next)
}, [])
const scrollToPage = (page: number) => {
const wrapper = pageRefs.current[page - 1]
if (wrapper && containerRef.current) {
containerRef.current.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
}
}
useEffect(() => {
const container = containerRef.current
if (!container || pageCount === 0) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const pageNum = Number((entry.target as HTMLElement).dataset.page)
if (pageNum) setCurrentPage(pageNum)
}
}
},
{ root: container, threshold: 0.5 }
)
for (const wrapper of pageRefs.current) {
if (wrapper) observer.observe(wrapper)
}
return () => observer.disconnect()
}, [pageCount])
useEffect(() => {
const container = containerRef.current
if (!container) return
const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return
e.preventDefault()
const next = Math.min(
PDF_ZOOM_MAX,
Math.max(PDF_ZOOM_MIN, zoomRef.current * (1 - e.deltaY * 0.005))
)
const rect = container.getBoundingClientRect()
applyZoomAt(next, e.clientX - rect.left, e.clientY - rect.top)
}
container.addEventListener('wheel', onWheel, { passive: false })
return () => container.removeEventListener('wheel', onWheel)
}, [applyZoomAt])
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{pageCount > 0 && !loadError && (
<div className='flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-3 py-1.5'>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
onClick={() => {
const prev = Math.max(1, currentPage - 1)
setCurrentPage(prev)
scrollToPage(prev)
}}
disabled={currentPage <= 1}
className='h-6 w-6 p-0 text-[var(--text-icon)]'
aria-label='Previous page'
>
<ChevronLeft className='h-[14px] w-[14px]' />
</Button>
<span className='min-w-[5rem] text-center text-[12px] text-[var(--text-secondary)]'>
{currentPage} / {pageCount}
</span>
<Button
variant='ghost'
size='sm'
onClick={() => {
const next = Math.min(pageCount, currentPage + 1)
setCurrentPage(next)
scrollToPage(next)
}}
disabled={currentPage >= pageCount}
className='h-6 w-6 p-0 text-[var(--text-icon)]'
aria-label='Next page'
>
<ChevronRight className='h-[14px] w-[14px]' />
</Button>
</div>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
onClick={() => {
const c = containerRef.current
applyZoomAt(
Math.max(PDF_ZOOM_MIN, zoomRef.current / PDF_ZOOM_STEP),
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
}}
disabled={displayZoom <= PDF_ZOOM_MIN}
className='h-6 w-6 p-0 text-[var(--text-icon)]'
aria-label='Zoom out'
>
<ZoomOut className='h-[14px] w-[14px]' />
</Button>
<span className='min-w-[3rem] text-center text-[12px] text-[var(--text-secondary)]'>
{Math.round(displayZoom * 100)}%
</span>
<Button
variant='ghost'
size='sm'
onClick={() => {
const c = containerRef.current
applyZoomAt(
Math.min(PDF_ZOOM_MAX, zoomRef.current * PDF_ZOOM_STEP),
c ? c.clientWidth / 2 : 0,
c ? c.clientHeight / 2 : 0
)
}}
disabled={displayZoom >= PDF_ZOOM_MAX}
className='h-6 w-6 p-0 text-[var(--text-icon)]'
aria-label='Zoom in'
>
<ZoomIn className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
)}
<div
ref={containerRef}
className='relative flex flex-1 items-start overflow-auto bg-[var(--surface-1)]'
>
{!isDocumentReady && PDF_SKELETON}
<ReactPdfDocument
file={file}
onLoadSuccess={({ numPages }) => {
setPageCount(numPages)
setCurrentPage(1)
setIsDocumentReady(true)
}}
onLoadError={(err) => {
logger.error('PDF load failed', { error: err.message })
setLoadError(err.message)
setIsDocumentReady(true)
}}
error={<PdfError error={loadError ?? 'Failed to load PDF'} />}
className='mx-auto'
>
<div
ref={paddingWrapperRef}
style={{
padding: PDF_VIEWER_PADDING,
minWidth:
pageWidth !== undefined
? `${pageWidth * displayZoom + 2 * PDF_VIEWER_PADDING}px`
: undefined,
}}
>
<div ref={pagesWrapperRef} style={{ width: pageWidth }}>
{Array.from({ length: pageCount }, (_, i) => (
<div
key={i}
ref={(el) => {
pageRefs.current[i] = el
}}
data-page={i + 1}
className='mb-4 overflow-clip rounded-md shadow-medium'
>
<ReactPdfPage
pageNumber={i + 1}
width={pageWidth}
className='!overflow-clip [&_.textLayer]:!overflow-clip'
renderTextLayer
renderAnnotationLayer={false}
aria-label={`${filename} page ${i + 1}`}
/>
</div>
))}
</div>
</div>
</ReactPdfDocument>
</div>
</div>
)
})

View File

@@ -1,23 +1,26 @@
'use client'
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createContext, memo, useContext, useEffect, useMemo, useRef, useState } from 'react'
import matter from 'gray-matter'
import { useRouter } from 'next/navigation'
import rehypeSlug from 'rehype-slug'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { Streamdown } from 'streamdown'
import 'streamdown/styles.css'
import { Checkbox } from '@/components/emcn'
import { generateShortId } from '@sim/utils/id'
import { Checkbox, CopyCodeButton, highlight, languages } from '@/components/emcn'
import '@/components/emcn/components/code/code.css'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-markup'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-yaml'
import 'prismjs/components/prism-sql'
import 'prismjs/components/prism-python'
import { cn } from '@/lib/core/utils/cn'
import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import { DataTable } from './data-table'
@@ -26,13 +29,14 @@ interface HastNode {
position?: { start?: { offset?: number } }
}
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
const PREVIEWABLE_MIME_TYPES: Record<string, PreviewType> = {
'text/markdown': 'markdown',
'text/html': 'html',
'text/csv': 'csv',
'image/svg+xml': 'svg',
'text/x-mermaid': 'mermaid',
}
const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
@@ -41,6 +45,7 @@ const PREVIEWABLE_EXTENSIONS: Record<string, PreviewType> = {
htm: 'html',
csv: 'csv',
svg: 'svg',
mmd: 'mermaid',
}
/** All extensions that have a rich preview renderer. */
@@ -80,11 +85,58 @@ export const PreviewPanel = memo(function PreviewPanel({
if (previewType === 'html') return <HtmlPreview content={content} />
if (previewType === 'csv') return <CsvPreview content={content} />
if (previewType === 'svg') return <SvgPreview content={content} />
if (previewType === 'mermaid') return <MermaidFilePreview content={content} />
return null
})
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
const CALLOUT_TYPES = new Set(['NOTE', 'TIP', 'WARNING', 'IMPORTANT', 'CAUTION'])
function remarkCallouts() {
return (tree: { type: string; children?: unknown[] }) => {
function processNode(node: { type: string; children?: unknown[] }) {
if (!node.children) return
for (const child of node.children) {
processNode(child as { type: string; children?: unknown[] })
const c = child as {
type: string
children?: unknown[]
data?: { hName?: string; hProperties?: Record<string, string> }
}
if (c.type !== 'blockquote') continue
const first = (c.children?.[0] ?? null) as {
type: string
children?: unknown[]
} | null
if (!first || first.type !== 'paragraph') continue
const firstText = (first.children?.[0] ?? null) as {
type: string
value?: string
} | null
if (!firstText || firstText.type !== 'text' || !firstText.value) continue
const match = firstText.value.match(/^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\s?/i)
if (!match) continue
const calloutType = match[1].toUpperCase()
if (!CALLOUT_TYPES.has(calloutType)) continue
c.data ??= {}
c.data.hProperties = { ...(c.data.hProperties ?? {}), 'data-callout': calloutType }
const remainder = firstText.value.slice(match[0].length)
if (remainder) {
firstText.value = remainder
} else if (first.children && first.children.length === 1) {
c.children?.shift()
} else {
first.children?.shift()
}
}
}
processNode(tree)
}
}
const REMARK_PLUGINS = [remarkGfm, remarkBreaks, remarkCallouts]
const REHYPE_PLUGINS = [rehypeSlug]
/**
@@ -101,6 +153,153 @@ const CheckboxIndexCtx = createContext(-1)
const NavigateCtx = createContext<((path: string) => void) | null>(null)
const CALLOUT_CONFIG: Record<
string,
{ label: string; borderColor: string; bgColor: string; textColor: string; iconColor: string }
> = {
NOTE: {
label: 'Note',
borderColor: 'border-blue-400/60',
bgColor: 'bg-blue-400/10',
textColor: 'text-[var(--text-primary)]',
iconColor: 'text-blue-500',
},
TIP: {
label: 'Tip',
borderColor: 'border-emerald-400/60',
bgColor: 'bg-emerald-400/10',
textColor: 'text-[var(--text-primary)]',
iconColor: 'text-emerald-500',
},
WARNING: {
label: 'Warning',
borderColor: 'border-amber-400/60',
bgColor: 'bg-amber-400/10',
textColor: 'text-[var(--text-primary)]',
iconColor: 'text-amber-500',
},
IMPORTANT: {
label: 'Important',
borderColor: 'border-violet-400/60',
bgColor: 'bg-violet-400/10',
textColor: 'text-[var(--text-primary)]',
iconColor: 'text-violet-500',
},
CAUTION: {
label: 'Caution',
borderColor: 'border-red-400/60',
bgColor: 'bg-red-400/10',
textColor: 'text-[var(--text-primary)]',
iconColor: 'text-red-500',
},
}
const CALLOUT_ICONS: Record<string, string> = {
NOTE: '',
TIP: '💡',
WARNING: '⚠',
IMPORTANT: '❕',
CAUTION: '🛑',
}
const LANG_ALIASES: Record<string, string> = {
js: 'javascript',
ts: 'typescript',
tsx: 'typescript',
jsx: 'javascript',
sh: 'bash',
shell: 'bash',
html: 'markup',
xml: 'markup',
yml: 'yaml',
py: 'python',
}
function CalloutBlock({ type, children }: { type: string; children?: React.ReactNode }) {
const config = CALLOUT_CONFIG[type]
if (!config) {
return (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
)
}
return (
<div
className={cn(
'my-4 rounded-lg border-l-4 px-4 py-3 text-[14px]',
config.borderColor,
config.bgColor
)}
>
<div
className={cn('mb-1 flex items-center gap-1.5 font-semibold text-[13px]', config.iconColor)}
>
<span>{CALLOUT_ICONS[type]}</span>
<span>{config.label}</span>
</div>
<div className={cn('break-words leading-[1.6]', config.textColor)}>{children}</div>
</div>
)
}
const MermaidDiagram = memo(function MermaidDiagram({ definition }: { definition: string }) {
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const idRef = useRef(`mermaid-${generateShortId(8)}`)
useEffect(() => {
if (typeof window === 'undefined') return
let cancelled = false
async function render() {
try {
const { default: mermaid } = await import('mermaid')
if (cancelled) return
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'default',
})
const { svg: rendered } = await mermaid.render(idRef.current, definition.trim())
if (!cancelled) {
setSvg(rendered)
setError(null)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to render diagram')
setSvg(null)
}
}
}
setSvg(null)
setError(null)
render()
return () => {
cancelled = true
}
}, [definition])
if (error) {
return (
<div className='my-4 rounded-lg border border-[var(--border)] p-4 text-[13px] text-[var(--text-muted)]'>
<span className='font-medium text-[var(--text-body)]'>Diagram error: </span>
{error}
</div>
)
}
if (!svg) {
return <div className='my-4 h-[100px] animate-pulse rounded-lg bg-[var(--surface-2)]' />
}
return <div className='my-4 overflow-auto rounded-lg' dangerouslySetInnerHTML={{ __html: svg }} />
})
const STATIC_MARKDOWN_COMPONENTS = {
p: ({ children }: { children?: React.ReactNode }) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
@@ -139,32 +338,104 @@ const STATIC_MARKDOWN_COMPONENTS = {
{children}
</h4>
),
h5: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
<h5
id={id}
className='mt-3 mb-1 break-words font-semibold text-[13px] text-[var(--text-primary)] first:mt-0'
>
{children}
</h5>
),
h6: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
<h6
id={id}
className='mt-3 mb-1 break-words font-medium text-[12px] text-[var(--text-secondary)] first:mt-0'
>
{children}
</h6>
),
inlineCode: ({ children }: { children?: React.ReactNode }) => {
if (typeof children === 'string' && children.includes('\n')) {
return (
<code className='my-4 block overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--surface-5)] p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
<code className='my-4 block overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)] leading-[1.6]'>
{children}
</code>
)
}
return (
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[13px] text-[var(--caution)]'>
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
{children}
</code>
)
},
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
const langMatch = className?.match(/language-(\w+)/)
const langRaw = langMatch?.[1] ?? ''
const codeString = extractTextContent(children)
if (langRaw === 'mermaid') {
return <MermaidDiagram definition={codeString} />
}
if (!codeString) {
return (
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-mono text-[var(--caution)]'>
{children}
</code>
)
}
const resolved = LANG_ALIASES[langRaw] || langRaw || 'javascript'
const grammar = languages[resolved] || languages.javascript
const html = grammar ? highlight(codeString.trimEnd(), grammar, resolved) : null
return (
<div className='my-4 overflow-hidden rounded-lg border border-[var(--border)]'>
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-3)] px-3 py-1.5'>
<span className='text-[11px] text-[var(--text-tertiary)]'>{langRaw || 'code'}</span>
<CopyCodeButton
code={codeString}
className='-mr-1 text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
/>
</div>
<div className='code-editor-theme bg-[var(--surface-5)]'>
{html ? (
<pre
className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] leading-[1.6]'
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[13px] text-[var(--text-primary)] leading-[1.6]'>
<code>{codeString.trimEnd()}</code>
</pre>
)}
</div>
</div>
)
},
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
<strong className='break-words font-semibold'>{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
em: ({ children }: { children?: React.ReactNode }) => <em className='break-words'>{children}</em>,
del: ({ children }: { children?: React.ReactNode }) => (
<del className='line-through opacity-50'>{children}</del>
),
blockquote: ({
children,
'data-callout': calloutType,
}: {
children?: React.ReactNode
'data-callout'?: string
}) => {
if (calloutType && CALLOUT_TYPES.has(calloutType)) {
return <CalloutBlock type={calloutType}>{children}</CalloutBlock>
}
return (
<blockquote className='my-4 break-words border-[var(--border-1)] border-l-4 py-1 pl-4 text-[var(--text-tertiary)] italic'>
{children}
</blockquote>
)
},
hr: () => <hr className='my-6 border-[var(--border)]' />,
img: ({ src, alt }: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img
@@ -299,36 +570,33 @@ function isInternalHref(
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
const navigate = useContext(NavigateCtx)
const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href])
const parsed = href ? isInternalHref(href) : null
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
e.preventDefault()
if (parsed.pathname === '' && parsed.hash) {
const el = document.getElementById(parsed.hash.slice(1))
if (el) {
const container = el.closest('.overflow-auto') as HTMLElement | null
if (container) {
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
} else {
el.scrollIntoView({ behavior: 'smooth' })
}
if (parsed.pathname === '' && parsed.hash) {
const el = document.getElementById(parsed.hash.slice(1))
if (el) {
const container = el.closest('.overflow-auto') as HTMLElement | null
if (container) {
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
} else {
el.scrollIntoView({ behavior: 'smooth' })
}
return
}
return
}
const destination = parsed.pathname + parsed.hash
if (navigate) {
navigate(destination)
} else {
window.location.assign(destination)
}
},
[parsed, navigate]
)
const destination = parsed.pathname + parsed.hash
if (navigate) {
navigate(destination)
} else {
window.location.assign(destination)
}
}
return (
<a
@@ -352,6 +620,30 @@ const MARKDOWN_COMPONENTS = {
input: InputRenderer,
}
function FrontMatterCard({ data }: { data: Record<string, unknown> }) {
const entries = Object.entries(data)
if (entries.length === 0) return null
return (
<div className='mb-6 rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[13px]'>
<dl className='flex flex-col gap-1.5'>
{entries.map(([key, value]) => (
<div key={key} className='flex gap-2 break-words'>
<dt className='shrink-0 font-medium text-[var(--text-secondary)]'>{key}:</dt>
<dd className='text-[var(--text-primary)]'>
{Array.isArray(value)
? value.join(', ')
: value instanceof Date
? value.toISOString().split('T')[0]
: String(value ?? '')}
</dd>
</div>
))}
</dl>
</div>
)
}
const MarkdownPreview = memo(function MarkdownPreview({
content,
isStreaming = false,
@@ -363,22 +655,28 @@ const MarkdownPreview = memo(function MarkdownPreview({
}) {
const { push: navigate } = useRouter()
const { ref: autoScrollRef } = useAutoScroll(isStreaming)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef(content)
contentRef.current = content
const { frontMatterData, markdownContent } = useMemo(() => {
if (isStreaming) return { frontMatterData: null, markdownContent: content }
try {
const parsed = matter(content)
const hasFrontMatter = Object.keys(parsed.data).length > 0
return {
frontMatterData: hasFrontMatter ? parsed.data : null,
markdownContent: hasFrontMatter ? parsed.content : content,
}
} catch {
return { frontMatterData: null, markdownContent: content }
}
}, [content, isStreaming])
const ctxValue = useMemo(
() => (onCheckboxToggle ? { contentRef, onToggle: onCheckboxToggle } : null),
[onCheckboxToggle]
)
const setScrollRef = useCallback(
(node: HTMLDivElement | null) => {
scrollContainerRef.current = node
autoScrollRef(node)
},
[autoScrollRef]
)
const hasScrolledToHash = useRef(false)
useEffect(() => {
@@ -398,37 +696,27 @@ const MarkdownPreview = memo(function MarkdownPreview({
const streamdownMode = isStreaming ? undefined : 'static'
if (onCheckboxToggle) {
return (
<NavigateCtx.Provider value={navigate}>
<MarkdownCheckboxCtx.Provider value={ctxValue}>
<div ref={setScrollRef} className='h-full overflow-auto p-6'>
<Streamdown
mode={streamdownMode}
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{content}
</Streamdown>
</div>
</MarkdownCheckboxCtx.Provider>
</NavigateCtx.Provider>
)
}
const body = (
<div ref={autoScrollRef} className='h-full overflow-auto p-6'>
{frontMatterData && <FrontMatterCard data={frontMatterData} />}
<Streamdown
mode={streamdownMode}
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{markdownContent}
</Streamdown>
</div>
)
return (
<NavigateCtx.Provider value={navigate}>
<div ref={setScrollRef} className='h-full overflow-auto p-6'>
<Streamdown
mode={streamdownMode}
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{content}
</Streamdown>
</div>
{onCheckboxToggle ? (
<MarkdownCheckboxCtx.Provider value={ctxValue}>{body}</MarkdownCheckboxCtx.Provider>
) : (
body
)}
</NavigateCtx.Provider>
)
})
@@ -497,9 +785,7 @@ function buildHtmlPreviewDocument(content: string): string {
}
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
// Run inline HTML/JS in an isolated iframe while blocking any navigation
// that would replace the preview with another document.
const wrappedContent = useMemo(() => buildHtmlPreviewDocument(content), [content])
const wrappedContent = buildHtmlPreviewDocument(content)
const containerRef = useRef<HTMLDivElement>(null)
const [isRenderable, setIsRenderable] = useState(false)
const [resumeNonce, setResumeNonce] = useState(0)
@@ -570,12 +856,8 @@ const HtmlPreview = memo(function HtmlPreview({ content }: { content: string })
)
})
const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
const wrappedContent = useMemo(
() =>
`<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`,
[content]
)
function SvgPreview({ content }: { content: string }) {
const wrappedContent = `<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`
return (
<div className='h-full overflow-hidden'>
@@ -587,7 +869,15 @@ const SvgPreview = memo(function SvgPreview({ content }: { content: string }) {
/>
</div>
)
})
}
function MermaidFilePreview({ content }: { content: string }) {
return (
<div className='h-full overflow-auto p-6'>
<MermaidDiagram definition={content} />
</div>
)
}
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
const { headers, rows } = useMemo(() => parseCsv(content), [content])

View File

@@ -2,7 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
Button,
Columns2,
@@ -136,7 +136,10 @@ export function Files() {
const params = useParams()
const router = useRouter()
const searchParams = useSearchParams()
const isNewFile = searchParams.get('new') === '1'
const workspaceId = params?.workspaceId as string
const fileIdFromRoute =
typeof params?.fileId === 'string' && params.fileId.length > 0 ? params.fileId : null
const userPermissions = useUserPermissionsContext()
@@ -178,6 +181,8 @@ export function Files() {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const [isDraggingOver, setIsDraggingOver] = useState(false)
const dragCounterRef = useRef(0)
const [inputValue, setInputValue] = useState('')
const debouncedSearchTerm = useDebounce(inputValue, 200)
const [activeSort, setActiveSort] = useState<{
@@ -192,6 +197,7 @@ export function Files() {
const [isDirty, setIsDirty] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
if (isNewFile) return 'editor'
if (fileIdFromRoute) {
const file = files.find((f) => f.id === fileIdFromRoute)
if (file && isPreviewable(file)) return 'preview'
@@ -201,7 +207,7 @@ export function Files() {
})
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
const contextMenuFileRef = useRef<WorkspaceFileRecord | null>(null)
const [deleteTargetFile, setDeleteTargetFile] = useState<WorkspaceFileRecord | null>(null)
const listRename = useInlineRename({
@@ -365,27 +371,26 @@ export function Files() {
filteredFiles,
])
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
if (!list || list.length === 0 || !workspaceId) return
const uploadFiles = useCallback(
async (filesToUpload: File[]) => {
if (!workspaceId || filesToUpload.length === 0) return
const unsupported: string[] = []
const allowedFiles = filesToUpload.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 {
setUploading(true)
const filesToUpload = Array.from(list)
const unsupported: string[] = []
const allowedFiles = filesToUpload.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)
}
setUploadProgress({ completed: 0, total: allowedFiles.length })
for (let i = 0; i < allowedFiles.length; i++) {
@@ -401,14 +406,42 @@ export function Files() {
} finally {
setUploading(false)
setUploadProgress({ completed: 0, total: 0 })
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
},
[workspaceId, uploadFile]
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
if (!list || list.length === 0) return
await uploadFiles(Array.from(list))
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
dragCounterRef.current++
if (e.dataTransfer.types.includes('Files')) setIsDraggingOver(true)
}
const handleDragLeave = () => {
dragCounterRef.current--
if (dragCounterRef.current === 0) setIsDraggingOver(false)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
dragCounterRef.current = 0
setIsDraggingOver(false)
const dropped = Array.from(e.dataTransfer.files)
if (dropped.length > 0) await uploadFiles(dropped)
}
const handleDownload = useCallback(async (file: WorkspaceFileRecord) => {
try {
await downloadWorkspaceFile(file)
@@ -527,13 +560,13 @@ export function Files() {
]
)
const handleDiscardChanges = useCallback(() => {
const handleDiscardChanges = () => {
setShowUnsavedChangesAlert(false)
setIsDirty(false)
setSaveStatus('idle')
setPreviewMode('editor')
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
}
const creatingFileRef = useRef(creatingFile)
creatingFileRef.current = creatingFile
@@ -558,7 +591,7 @@ export function Files() {
const fileId = result.file?.id
if (fileId) {
justCreatedFileIdRef.current = fileId
router.push(`/workspace/${workspaceId}/files/${fileId}`)
router.push(`/workspace/${workspaceId}/files/${fileId}?new=1`)
}
} catch (err) {
logger.error('Failed to create file:', err)
@@ -571,16 +604,13 @@ export function Files() {
(e: React.MouseEvent, rowId: string) => {
const file = filesRef.current.find((f) => f.id === rowId)
if (file) {
setContextMenuFile(file)
contextMenuFileRef.current = file
openContextMenu(e)
}
},
[openContextMenu]
)
const contextMenuFileRef = useRef(contextMenuFile)
contextMenuFileRef.current = contextMenuFile
const handleContextMenuOpen = useCallback(() => {
const file = contextMenuFileRef.current
if (!file) return
@@ -632,7 +662,7 @@ export function Files() {
if (fileIdFromRoute !== prevFileIdRef.current) {
prevFileIdRef.current = fileIdFromRoute
const isJustCreated =
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
isNewFile || (fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute)
if (justCreatedFileIdRef.current && !isJustCreated) {
justCreatedFileIdRef.current = null
}
@@ -649,6 +679,12 @@ export function Files() {
}
}
useEffect(() => {
if (isNewFile && fileIdFromRoute) {
router.replace(`/workspace/${workspaceId}/files/${fileIdFromRoute}`)
}
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!fileIdFromRouteRef.current) return
@@ -774,34 +810,25 @@ export function Files() {
const canEdit = userPermissions.canEdit === true
const searchConfig: SearchConfig = useMemo(
() => ({
value: inputValue,
onChange: setInputValue,
onClearAll: () => setInputValue(''),
placeholder: 'Search files...',
}),
[inputValue]
)
const searchConfig: SearchConfig = {
value: inputValue,
onChange: setInputValue,
onClearAll: () => setInputValue(''),
placeholder: 'Search files...',
}
const createConfig = useMemo(
() => ({
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || !canEdit,
}),
[handleCreateFile, uploading, creatingFile, canEdit]
)
const createConfig = {
label: 'New file',
onClick: handleCreateFile,
disabled: uploading || creatingFile || !canEdit,
}
const uploadButtonLabel = useMemo(
() =>
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload',
[uploading, uploadProgress.completed, uploadProgress.total]
)
const uploadButtonLabel =
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Upload'
const headerActionsConfig = useMemo(
() => [
@@ -818,39 +845,7 @@ export function Files() {
router.push(`/workspace/${workspaceId}/files`)
}, [router, workspaceId])
const loadingBreadcrumbs = useMemo(
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
[handleNavigateToFiles]
)
const typeDisplayLabel = useMemo(() => {
if (typeFilter.length === 0) return 'All'
if (typeFilter.length === 1) {
const labels: Record<string, string> = {
document: 'Documents',
audio: 'Audio',
video: 'Video',
}
return labels[typeFilter[0]] ?? typeFilter[0]
}
return `${typeFilter.length} selected`
}, [typeFilter])
const sizeDisplayLabel = useMemo(() => {
if (sizeFilter.length === 0) return 'All'
if (sizeFilter.length === 1) {
const labels: Record<string, string> = { small: 'Small', medium: 'Medium', large: 'Large' }
return labels[sizeFilter[0]] ?? sizeFilter[0]
}
return `${sizeFilter.length} selected`
}, [sizeFilter])
const uploadedByDisplayLabel = useMemo(() => {
if (uploadedByFilter.length === 0) return 'All'
if (uploadedByFilter.length === 1)
return members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member'
return `${uploadedByFilter.length} members`
}, [uploadedByFilter, members])
const loadingBreadcrumbs = [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }]
const memberOptions: ComboboxOption[] = useMemo(
() =>
@@ -893,8 +888,33 @@ export function Files() {
const hasActiveFilters =
typeFilter.length > 0 || sizeFilter.length > 0 || uploadedByFilter.length > 0
const filterContent = useMemo(
() => (
const filterContent = useMemo(() => {
const typeDisplayLabel =
typeFilter.length === 0
? 'All'
: typeFilter.length === 1
? (({ document: 'Documents', audio: 'Audio', video: 'Video' } as Record<string, string>)[
typeFilter[0]
] ?? typeFilter[0])
: `${typeFilter.length} selected`
const sizeDisplayLabel =
sizeFilter.length === 0
? 'All'
: sizeFilter.length === 1
? (({ small: 'Small', medium: 'Medium', large: 'Large' } as Record<string, string>)[
sizeFilter[0]
] ?? sizeFilter[0])
: `${sizeFilter.length} selected`
const uploadedByDisplayLabel =
uploadedByFilter.length === 0
? 'All'
: uploadedByFilter.length === 1
? (members?.find((m) => m.userId === uploadedByFilter[0])?.name ?? '1 member')
: `${uploadedByFilter.length} members`
return (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>File Type</span>
@@ -974,18 +994,8 @@ export function Files() {
</button>
)}
</div>
),
[
typeFilter,
sizeFilter,
uploadedByFilter,
memberOptions,
typeDisplayLabel,
sizeDisplayLabel,
uploadedByDisplayLabel,
hasActiveFilters,
]
)
)
}, [typeFilter, sizeFilter, uploadedByFilter, memberOptions, members, hasActiveFilters])
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
@@ -1027,8 +1037,24 @@ export function Files() {
return (
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
<div className='flex flex-1 items-center justify-center'>
<Skeleton className='h-[16px] w-[200px]' />
<div className='flex flex-1 flex-col items-center gap-4 overflow-y-auto bg-[var(--surface-1)] p-6'>
{[0, 1].map((i) => (
<div
key={i}
className='w-full max-w-[640px] shrink-0 rounded-md bg-white p-8 shadow-[var(--shadow-medium)]'
style={{ aspectRatio: '1 / 1.414' }}
>
<div className='flex flex-col gap-3'>
<Skeleton className='h-[14px] w-[60%]' />
<Skeleton className='h-[14px] w-[80%]' />
<Skeleton className='h-[14px] w-[55%]' />
<Skeleton className='mt-2 h-[14px] w-[75%]' />
<Skeleton className='h-[14px] w-[65%]' />
<Skeleton className='h-[14px] w-[85%]' />
<Skeleton className='h-[14px] w-[50%]' />
</div>
</div>
))}
</div>
</div>
)
@@ -1049,7 +1075,7 @@ export function Files() {
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode}
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
autoFocus={isNewFile || justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
onSaveStatusChange={setSaveStatus}
saveRef={saveRef}
@@ -1087,7 +1113,13 @@ export function Files() {
}
return (
<>
<div
className='relative flex h-full flex-col overflow-hidden'
onDragEnter={canEdit ? handleDragEnter : undefined}
onDragLeave={canEdit ? handleDragLeave : undefined}
onDragOver={canEdit ? handleDragOver : undefined}
onDrop={canEdit ? handleDrop : undefined}
>
<Resource
icon={FilesIcon}
title='Files'
@@ -1103,6 +1135,19 @@ export function Files() {
onRowContextMenu={handleRowContextMenu}
isLoading={isLoading}
onContextMenu={handleContentContextMenu}
overlay={
isDraggingOver ? (
<div className='pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-2 border border-[var(--accent)] border-dashed bg-[var(--surface-4)] transition-colors'>
<Upload className='h-5 w-5 text-[var(--accent)]' />
<div className='flex flex-col gap-0.5 text-center'>
<p className='font-medium text-[14px] text-[var(--accent)]'>Drop to upload</p>
<p className='text-[11px] text-[var(--text-tertiary)]'>
Release files here to add them to this workspace
</p>
</div>
</div>
) : null
}
/>
<FilesListContextMenu
@@ -1143,7 +1188,7 @@ export function Files() {
accept={ACCEPT_ATTR}
multiple
/>
</>
</div>
)
}

View File

@@ -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 FilesPage() {
return (
<Suspense>
<Files />
</Suspense>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import { lazy, memo, Suspense, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { formatDuration } from '@sim/utils/formatting'
import { Square } from 'lucide-react'
@@ -128,7 +128,6 @@ export const ResourceContent = memo(function ResourceContent({
}
}, [workspaceId, streamFileName])
const streamingFileMode: 'append' | 'replace' = 'replace'
const disableStreamingAutoScroll = previewSession?.operation === 'patch'
const rawPreviewText = previewSession?.previewText
const streamingPreviewText =
@@ -144,10 +143,9 @@ export const ResourceContent = memo(function ResourceContent({
canEdit={false}
previewMode={previewMode ?? 'preview'}
streamingContent={streamingPreviewText}
streamingMode={streamingFileMode}
streamingMode='replace'
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
useCodeRendererForCodeFiles
/>
) : (
<div className='flex h-full items-center justify-center'>
@@ -172,7 +170,7 @@ export const ResourceContent = memo(function ResourceContent({
streamingContent={
previewSession?.fileId === resource.id ? streamingPreviewText : undefined
}
streamingMode={streamingFileMode}
streamingMode='replace'
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
/>
@@ -257,7 +255,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
const isRunButtonDisabled =
!isExecuting && !effectivePermissions.canRead && !effectivePermissions.isLoading
const handleRun = useCallback(async () => {
const handleRun = async () => {
setActiveWorkflow(workflowId)
if (isExecuting) {
@@ -274,19 +272,11 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
}
await handleRunWorkflow()
}, [
handleCancelExecution,
handleRunWorkflow,
isExecuting,
navigateToSettings,
setActiveWorkflow,
usageExceeded,
workflowId,
])
}
const handleOpenWorkflow = useCallback(() => {
const handleOpenWorkflow = () => {
window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank')
}, [workspaceId, workflowId])
}
return (
<>
@@ -340,9 +330,9 @@ export function EmbeddedKnowledgeBaseActions({
}: EmbeddedKnowledgeBaseActionsProps) {
const router = useRouter()
const handleOpenKnowledgeBase = useCallback(() => {
const handleOpenKnowledgeBase = () => {
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
}, [router, workspaceId, knowledgeBaseId])
}
return (
<Tooltip.Root>
@@ -375,18 +365,18 @@ function EmbeddedFileActions({ workspaceId, fileId }: EmbeddedFileActionsProps)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
const handleDownload = useCallback(async () => {
const handleDownload = async () => {
if (!file) return
try {
await downloadWorkspaceFile(file)
} catch (err) {
fileLogger.error('Failed to download file:', err)
}
}, [file])
}
const handleOpenInFiles = useCallback(() => {
const handleOpenInFiles = () => {
router.push(`/workspace/${workspaceId}/files/${encodeURIComponent(fileId)}`)
}, [router, workspaceId, fileId])
}
return (
<>
@@ -432,10 +422,7 @@ interface EmbeddedWorkflowProps {
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId)
const workflowExists = useMemo(
() => (workflowList ?? []).some((w) => w.id === workflowId),
[workflowList, workflowId]
)
const workflowExists = (workflowList ?? []).some((w) => w.id === workflowId)
const hasLoadError = useWorkflowRegistry(
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
)
@@ -514,7 +501,6 @@ function EmbeddedFile({
streamingContent={streamingContent}
disableStreamingAutoScroll={disableStreamingAutoScroll}
previewContextKey={previewContextKey}
useCodeRendererForCodeFiles
/>
</div>
)
@@ -529,15 +515,8 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
const { data: folderList, isPending: isFoldersPending } = useFolders(workspaceId)
const { data: workflowList = [] } = useWorkflows(workspaceId)
const folder = useMemo(
() => (folderList ?? []).find((f) => f.id === folderId),
[folderList, folderId]
)
const folderWorkflows = useMemo(
() => workflowList.filter((w) => w.folderId === folderId),
[workflowList, folderId]
)
const folder = (folderList ?? []).find((f) => f.id === folderId)
const folderWorkflows = workflowList.filter((w) => w.folderId === folderId)
if (isFoldersPending) return LOADING_SKELETON
@@ -604,20 +583,14 @@ function EmbeddedLog({ logId }: EmbeddedLogProps) {
return filterHiddenOutputKeys(executionData.finalOutput) as Record<string, unknown>
}, [log?.executionData])
const isWorkflowExecutionLog = useMemo(() => {
if (!log) return false
return (
(log.trigger === 'manual' && !!log.duration) ||
(log.executionData?.enhanced && log.executionData?.traceSpans)
)
}, [log])
const isWorkflowExecutionLog =
!!log &&
((log.trigger === 'manual' && !!log.duration) ||
!!(log.executionData?.enhanced && log.executionData?.traceSpans))
const hasCostInfo = isWorkflowExecutionLog && log?.cost
const formattedTimestamp = useMemo(
() => (log ? formatDate(log.createdAt) : null),
[log?.createdAt]
)
const formattedTimestamp = log ? formatDate(log.createdAt) : null
if (isLoading) return LOADING_SKELETON
@@ -874,10 +847,10 @@ export function EmbeddedLogActions({ workspaceId, logId }: EmbeddedLogActionsPro
const router = useRouter()
const { data: log } = useLogDetail(logId)
const handleOpenInLogs = useCallback(() => {
const handleOpenInLogs = () => {
const param = log?.executionId ? `?executionId=${log.executionId}` : ''
router.push(`/workspace/${workspaceId}/logs${param}`)
}, [router, workspaceId, log?.executionId])
}
return (
<Tooltip.Root>

View File

@@ -101,7 +101,9 @@ async function fetchWorkspaceFileContent(
}
/**
* Hook to fetch workspace file content as text
* Hook to fetch workspace file content as text.
* `key` (the storage object key) is included in the query key so that a new
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
*/
export function useWorkspaceFileContent(
workspaceId: string,
@@ -110,7 +112,7 @@ export function useWorkspaceFileContent(
raw?: boolean
) {
return useQuery({
queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text'),
queryKey: [...workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text'), key],
queryFn: ({ signal }) => fetchWorkspaceFileContent(key, signal, raw),
enabled: !!workspaceId && !!fileId && !!key,
staleTime: 30 * 1000,
@@ -127,12 +129,12 @@ async function fetchWorkspaceFileBinary(key: string, signal?: AbortSignal): Prom
/**
* Hook to fetch workspace file content as binary (ArrayBuffer).
* Shares the same query key as useWorkspaceFileContent so cache
* invalidation from file updates triggers a refetch automatically.
* `key` (the storage object key) is included in the query key so that a new
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
*/
export function useWorkspaceFileBinary(workspaceId: string, fileId: string, key: string) {
return useQuery({
queryKey: workspaceFilesKeys.content(workspaceId, fileId, 'binary'),
queryKey: [...workspaceFilesKeys.content(workspaceId, fileId, 'binary'), key],
queryFn: ({ signal }) => fetchWorkspaceFileBinary(key, signal),
enabled: !!workspaceId && !!fileId && !!key,
staleTime: 30 * 1000,
@@ -210,10 +212,8 @@ export function useUploadWorkspaceFile() {
return data
},
onSuccess: (_data, variables) => {
// Invalidate files list to refetch
onSettled: () => {
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
// Invalidate storage info to update usage
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
onError: (error) => {
@@ -229,17 +229,18 @@ interface UpdateFileContentParams {
workspaceId: string
fileId: string
content: string
encoding?: 'base64' | 'utf-8'
}
export function useUpdateWorkspaceFileContent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, fileId, content }: UpdateFileContentParams) => {
mutationFn: async ({ workspaceId, fileId, content, encoding }: UpdateFileContentParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}/files/${fileId}/content`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
body: JSON.stringify(encoding ? { content, encoding } : { content }),
})
const data = await response.json()

View File

@@ -4,6 +4,7 @@
* This is the main entry point for OpenTelemetry instrumentation.
* It delegates to runtime-specific instrumentation modules.
*/
export async function register() {
// Load Node.js-specific instrumentation
if (process.env.NEXT_RUNTIME === 'nodejs') {

View File

@@ -10,27 +10,15 @@ export const SIM_AGENT_API_URL =
? rawAgentUrl
: SIM_AGENT_API_URL_DEFAULT
// ---------------------------------------------------------------------------
// Timeouts
// ---------------------------------------------------------------------------
/** Default timeout for the copilot orchestration stream loop (60 min). */
export const ORCHESTRATION_TIMEOUT_MS = 3_600_000
/** Timeout for the client-side streaming response handler (60 min). */
export const STREAM_TIMEOUT_MS = 3_600_000
// ---------------------------------------------------------------------------
// Stream resume
// ---------------------------------------------------------------------------
/** SessionStorage key for persisting active stream metadata across page reloads. */
export const STREAM_STORAGE_KEY = 'copilot_active_stream'
// ---------------------------------------------------------------------------
// Copilot API paths (client-side fetch targets)
// ---------------------------------------------------------------------------
/** POST — send a chat message through the unified mothership chat surface. */
export const MOTHERSHIP_CHAT_API_PATH = '/api/mothership/chat'
@@ -39,18 +27,9 @@ export const COPILOT_CONFIRM_API_PATH = '/api/copilot/confirm'
/** POST — forward diff-accepted/rejected stats to the copilot backend. */
export const COPILOT_STATS_API_PATH = '/api/copilot/stats'
// ---------------------------------------------------------------------------
// Dedup limits
// ---------------------------------------------------------------------------
/** Maximum entries in the in-memory SSE tool-event dedup cache. */
export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000
// ---------------------------------------------------------------------------
// Tool result size limits
// ---------------------------------------------------------------------------
/** Approximate max inline tool-result budget before artifact/error handling takes over. */
export const TOOL_RESULT_MAX_INLINE_TOKENS = 50_000
@@ -61,10 +40,6 @@ export const TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN = 4
export const TOOL_RESULT_MAX_INLINE_CHARS =
TOOL_RESULT_MAX_INLINE_TOKENS * TOOL_RESULT_ESTIMATED_CHARS_PER_TOKEN
// ---------------------------------------------------------------------------
// Copilot modes
// ---------------------------------------------------------------------------
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const

View File

@@ -65,6 +65,7 @@ const STATIC_IMG_SRC = [
"'self'",
'data:',
'blob:',
'https:',
'https://*.googleusercontent.com',
'https://*.google.com',
'https://*.atlassian.com',

File diff suppressed because one or more lines are too long

View File

@@ -77,6 +77,7 @@ export const SUPPORTED_CODE_EXTENSIONS = [
'editorconfig',
'prettierrc',
'eslintrc',
'mmd',
] as const
export type SupportedCodeExtension = (typeof SUPPORTED_CODE_EXTENSIONS)[number]

View File

@@ -63,6 +63,7 @@
"@linear/sdk": "40.0.0",
"@marsidev/react-turnstile": "1.4.2",
"@modelcontextprotocol/sdk": "1.25.3",
"@monaco-editor/react": "4.7.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-jaeger": "2.1.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
@@ -153,7 +154,9 @@
"lucide-react": "^0.479.0",
"mammoth": "^1.9.0",
"marked": "17.0.4",
"mermaid": "11.14.0",
"micromatch": "4.0.8",
"monaco-editor": "0.55.1",
"mongodb": "6.19.0",
"mysql2": "3.14.3",
"neo4j-driver": "6.0.1",
@@ -166,6 +169,7 @@
"openai": "^4.91.1",
"papaparse": "5.5.3",
"pdf-lib": "1.17.1",
"pdfjs-dist": "5.4.296",
"postgres": "^3.4.5",
"posthog-js": "1.364.4",
"posthog-node": "5.28.9",
@@ -176,6 +180,7 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
"react-joyride": "2.9.3",
"react-pdf": "10.4.1",
"react-simple-code-editor": "^0.14.1",
"react-window": "2.2.3",
"reactflow": "^11.11.4",

View File

@@ -117,6 +117,7 @@
"@linear/sdk": "40.0.0",
"@marsidev/react-turnstile": "1.4.2",
"@modelcontextprotocol/sdk": "1.25.3",
"@monaco-editor/react": "4.7.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-jaeger": "2.1.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.200.0",
@@ -207,7 +208,9 @@
"lucide-react": "^0.479.0",
"mammoth": "^1.9.0",
"marked": "17.0.4",
"mermaid": "11.14.0",
"micromatch": "4.0.8",
"monaco-editor": "0.55.1",
"mongodb": "6.19.0",
"mysql2": "3.14.3",
"neo4j-driver": "6.0.1",
@@ -220,6 +223,7 @@
"openai": "^4.91.1",
"papaparse": "5.5.3",
"pdf-lib": "1.17.1",
"pdfjs-dist": "5.4.296",
"postgres": "^3.4.5",
"posthog-js": "1.364.4",
"posthog-node": "5.28.9",
@@ -230,6 +234,7 @@
"react-dom": "19.2.4",
"react-hook-form": "^7.54.2",
"react-joyride": "2.9.3",
"react-pdf": "10.4.1",
"react-simple-code-editor": "^0.14.1",
"react-window": "2.2.3",
"reactflow": "^11.11.4",
@@ -1017,6 +1022,10 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
@@ -3001,8 +3010,12 @@
"mailchecker": ["mailchecker@6.0.20", "", {}, "sha512-mZ3kmtfXzGj06prtNm6d8an7D++Kf1G4jEkPZ1QQyhknYNLkmGoMtfaNPNHJU6E8J+Bm3AcZlIIfq5D6L4MS2g=="],
"make-cancellable-promise": ["make-cancellable-promise@2.0.0", "", {}, "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"make-event-props": ["make-event-props@2.0.0", "", {}, "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="],
"mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
@@ -3057,6 +3070,8 @@
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-refs": ["merge-refs@2.0.0", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -3175,6 +3190,8 @@
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
"mongodb": ["mongodb@6.19.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.3.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-H3GtYujOJdeKIMLKBT9PwlDhGrQfplABNF1G904w6r5ZXKWyv77aB0X9B+rhmaAwjtllHzaEkvi9mkGVZxs2Bw=="],
"mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
@@ -3363,7 +3380,7 @@
"pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
"pdfjs-dist": ["pdfjs-dist@5.5.207", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.95", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw=="],
"pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
@@ -3513,6 +3530,8 @@
"react-medium-image-zoom": ["react-medium-image-zoom@5.4.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g=="],
"react-pdf": ["react-pdf@10.4.1", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "5.4.296", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA=="],
"react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -3771,6 +3790,8 @@
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
@@ -3873,6 +3894,8 @@
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
@@ -4037,6 +4060,8 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
@@ -5389,6 +5414,10 @@
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
"monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"neo4j-driver-bolt-connection/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -5403,6 +5432,8 @@
"oauth2-mock-server/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"officeparser/pdfjs-dist": ["pdfjs-dist@5.5.207", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.95", "node-readable-to-web-readable-stream": "^0.4.2" } }, "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw=="],
"ollama-ai-provider-v2/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],