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

* 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.

* fix(files): replace instanceof Error checks with toError() and fix skeleton tokens

- Use toError() from @sim/utils/errors across all catch blocks in
  file-viewer.tsx, preview-panel.tsx, and route.ts instead of the
  prohibited `err instanceof Error ? err.message : fallback` pattern
- Fix loading skeleton in files.tsx: bg-white → bg-[var(--surface-2)]
  and shadow-[var(--shadow-medium)] → shadow-medium

* fix(files): address PR review findings

- csp.ts: revert bare https: from img-src — it defeats the existing
  domain allowlist and opens info-leakage vectors
- files/page.tsx + files/[fileId]/page.tsx: add explicit fallback={null}
  to <Suspense> to make intent clear (React defaults to null, but
  omitting it looks like an oversight)
- preview-panel.tsx: restore pre passthrough in STATIC_MARKDOWN_COMPONENTS
  so Streamdown's wrapping <pre> doesn't nest inside the custom code
  block <div>, which produced invalid HTML and broken styling
- file-viewer.tsx: add 'webm' to VIDEO_PREVIEWABLE_EXTENSIONS to match
  'video/webm' in VIDEO_PREVIEWABLE_MIME_TYPES

* chore(files): revert accidental pptxgenjs.cjs re-minification

The bundle was regenerated non-deterministically during development (same
pptxgenjs 4.0.1, different variable names in minifier output). No functional
change — restore the prior version to keep the diff clean.

* fix(files): fix Monaco stale closure, XLSX Ctrl+S data loss, and async workbook mutation

Three bugs from Cursor Bugbot follow-up review:

1. Stale closure in handleEditorMount (Medium): useCallback([], []) captured
   content='' at first render. When Monaco mounts after content loads (e.g.
   switching from preview to editor mode), lastSyncedContentRef was never
   initialized and external content changes stopped syncing. Fixed by keeping
   a contentRef updated on every render and reading it inside handleEditorMount.

2. XLSX Ctrl+S discards active cell edit (Medium): handleSave read from
   workbookRef.current before DataTable's in-progress editValue was committed.
   Fixed by exposing commitEdit() from DataTable via useImperativeHandle
   (using an always-current editStateRef so the handle stays stable) and
   calling it at the top of handleSave.

3. Async workbook mutation fragility (Low): handleCellChange / handleHeaderChange
   updated the workbook inside import('xlsx').then(), creating microtask-order
   coupling with handleSave. Fixed by caching the xlsx module in xlsxModuleRef
   on first parse and using it synchronously in both handlers.

* refactor(files): cleanup anti-patterns across file viewer components

Six-pass cleanup over the file-viewer directory:

Effects (you-might-not-need-an-effect):
- AudioPreview, VideoPreview: replace reset useEffect with key={file.id} so
  the component remounts on file change — React's canonical solution
- DocxPreview: same key-prop fix; removes a 5-setState reset effect that was
  also clearing containerRef.current.innerHTML unnecessarily

Callbacks (you-might-not-need-a-callback):
- handleEditorMount, handleEditorChange: remove useCallback — MonacoEditor is
  dynamic(), not React.memo, so reference stability has no observer
- markSavedContent: remove useCallback — called only through an onSaveRef,
  never directly observed
- DataTable.setInputRef: remove useCallback — callback refs on native elements
  are called regardless of reference identity

Design tokens (emcn-design-review):
- VideoPreview: bg-black → bg-[var(--surface-inverted)]
- HtmlPreview iframe: bg-white → bg-[var(--surface-2)]

useMemo, useState, and react-query passes found no issues.

* improvement(files): replace stock Monaco theme with Sim design system theme

Define sim-dark and sim-light Monaco themes using Sim's exact design tokens
instead of the default vs/vs-dark which looked identical to stock VSCode.

Chrome changes (both themes):
- Background, gutter use --bg (not VSCode's near-black / pure-white defaults)
- Line numbers use --text-muted instead of VSCode gray
- Cursor switches to --brand-secondary (#33b4ff)
- Selection highlight is brand blue at 15% opacity
- Scrollbar shadow removed, track uses surface tokens
- Bracket match, word highlight, find match all keyed to brand blue
- Suggestion/hover widgets use --surface-2 / --border tokens
- All hardcoded shadows removed (scrollbar.shadow = transparent)

Syntax token changes (inherit: true — base handles unlisted tokens):
- Comments: muted gray + italic (vs VSCode's bright green)
- Strings: #3ab872 dark / #16825d light (vs VSCode orange-red)
- Numbers: warm amber / warm orange (both readable on their backgrounds)
- Keywords: #33b4ff dark / #0078d4 light (brand blue family)
- Types: complementary blue-gray / purple

* fix(files): bump light theme comment color to #888888 for WCAG contrast

* fix(files): fix dark mode comment contrast #4a4a4a → #606060 (~1.9:1 → ~2.9:1)

* improvement(files): cursor to default color, video background to surface-1

- Monaco cursor: #33b4ff (brand blue) → #e6e6e6 dark / #1a1a1a light
  (text cursor should be neutral, not loud)
- VideoPreview background: var(--surface-inverted) → var(--surface-1)
  (consistent with PDF viewer, fits workspace context over cinema-black)

* fix(files): stabilize setInputRef callback and guard against double-commit in DataTable

Wrap setInputRef in useCallback([], []) so React doesn't tear down and
re-mount the input ref on every keystroke. Without stable identity, every
editValue state change caused node.focus()/node.select() to fire, resetting
the cursor selection to "select all" on each character typed.

Add isCommittedRef to guard both the imperative commitEdit handle and the
inline commitEdit (called by onBlur) against double-application. The ref is
cleared in startEdit and set to true on the first commit, so a concurrent
onBlur cannot re-apply the same edit.

* fix(files): preserve scroll position during Mothership streaming edits

Two fixes to the Monaco auto-scroll logic:

1. At streaming start, initialize textareaStuckRef from the editor's actual
   scroll position (isAtBottom check) instead of unconditionally setting true.
   Previously every streaming session jumped the viewport to the last line on
   the very first content update, even when the user was reading mid-file.

2. Replace the wheel-only DOM listener with editor.onDidScrollChange(), the
   proper Monaco API. This covers trackpad, scrollbar drag, and keyboard scroll
   — not just mouse wheel. As a bonus, scrolling back to the bottom during
   streaming now re-engages follow mode (matching iTerm2/xterm.js behavior).

3. Save and restore view state around model.setValue() during streaming when
   the user has scrolled away from the bottom. This prevents Monaco from
   resetting the viewport on each content replacement. When the user is at
   the bottom, view state is not saved so Effect 3 can scroll to the new bottom.

* fix(files): fix two scroll logic bugs introduced in previous streaming scroll fix

The prior fix introduced a regression for the "user was at bottom" case and
a false-disengagement bug from programmatic scroll events.

Bug 1 — Effect ordering: all three effects fire on the same render when
isStreamInteractionLocked flips true. Effect 2 called isAtBottom() AFTER
Effect 1 had already called model.setValue(), which grew scrollHeight. The
old "at bottom" scroll position was now 200px short of the new bottom, so
isAtBottom() returned false, textareaStuckRef was set false, and Effect 3
never called revealLine. Users at the bottom stopped following the stream.

Fix: measure isAtBottom() in Effect 1 BEFORE setValue, while scrollHeight
is still accurate. Set textareaStuckRef = true only (never false here).
Effect 2 no longer initializes the ref — only the listener disengages it.

Bug 2 — onDidScrollChange fires during model.setValue: Monaco fires
onDidScrollChange when scroll dimensions change, including when setValue
grows the document. This caused the listener to disengage auto-scroll on
every content update even with no user interaction.

Fix: add suppressScrollListenerRef, set true before setValue/restoreViewState
and false after. The listener exits early when suppressed, so only genuine
user scroll events (wheel, trackpad, keyboard, scrollbar) can disengage.

Both refs moved to the component's ref block for conventional placement.

* chore(files): remove extraneous comments from file viewer and data table

* refactor(files): split 2281-line file-viewer.tsx into focused modules

TextEditor, DocxPreview, PptxPreview, XlsxPreview, ImagePreview each moved
to their own files. Shared utilities (PreviewError, resolvePreviewError,
shouldSuppressStreamingDocumentError, PDF_PAGE_SKELETON) extracted to
preview-shared.tsx. file-viewer.tsx is now the orchestrator + MIME constants
+ small stateless previews (~495 lines).

* fix(files): remove unnecessary TextEditorProps export

* refactor(files): four stellar-quality improvements to file-viewer split

- Extract useBlobUrl hook shared by AudioPreview and VideoPreview,
  eliminating ~30 lines of duplicated state/effect logic
- Stabilize markSavedContent with useCallback (matches setDraftContent)
- Stabilize handleEditorChange with useCallback([setDraftContent])
- Fix pptx static render effect deps: drop redundant dataUpdatedAt
  (already encoded in cacheKey) and unused workspaceId

* test(files): extract pure modules and add 122-test suite for file viewer logic

Extract TextEditorContentState machine and file category resolution into
plain .ts modules (text-editor-state.ts, file-category.ts) so they can
be unit-tested without React or Next.js overhead. Update component files
to import from the extracted modules, eliminating code duplication.

Add two test files:
- text-editor-state.test.ts: 32 tests covering resolveStreamingEditorContent,
  the reducer (edit / save-success), and syncTextEditorContentState across
  all phases (uninitialized, ready, streaming, reconciling) including
  reference-equality short-circuit checks for zero-allocation paths
- file-category.test.ts: 90 tests covering MIME-type routing for all 8
  categories, extension fallback, MIME-priority-over-extension, and
  case-insensitive extension handling

* fix(files): add key to IframePreview and use monotonic seq for streaming PDF key

- Add key={file.id} to IframePreview so React remounts on file switch,
  preventing stale renderError from persisting across different files
- Replace key={streamingBuffer.byteLength} with a monotonic sequence
  counter so same-size successive PDF compilations still trigger a remount

* fix(files): restore getFileExtension import dropped during refactor

* fix(files): clear loadError on PDF success and fix streaming null-flash

- pdf-viewer: add setLoadError(null) in onLoadSuccess so the toolbar
  is not permanently hidden after a failed-then-successful PDF load
- file-viewer: consolidate streaming-mode rendering so the debounce
  period (before rendering=true) shows a skeleton instead of null

* refactor(files): cleanup pass — effect, callback, state, and design fixes

- text-editor: replace sync-external useEffect with "adjust during render"
  pattern so the state machine advances immediately instead of after a paint
- text-editor: remove unnecessary useCallback from markSavedContent (no observer)
- files: narrow deleteTargetFile state to {id, name} — only those fields are used
- files: remove uploadFile (mutation object) from useCallback deps — .mutateAsync is stable
- files: remove unnecessary useCallback from handleNavigateToFiles (no observer)
- files: replace raw <button> with emcn Button for "Clear all filters" action
This commit is contained in:
Waleed
2026-04-28 13:07:24 -07:00
committed by GitHub
parent dc20229954
commit 6a7d5ae828
25 changed files with 3802 additions and 2079 deletions
@@ -1,8 +1,8 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
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 +17,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 +31,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 +60,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,
@@ -83,15 +81,15 @@ export const PUT = withRouteHandler(
file: updatedFile,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to update file content'
const errorMessage = toError(error).message || 'Failed to update file content'
const isNotFound = errorMessage.includes('File not found')
const isQuotaExceeded = errorMessage.includes('Storage limit exceeded')
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 })
@@ -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 fallback={null}>
<Files />
</Suspense>
)
}
@@ -1,11 +1,98 @@
import { memo } from 'react'
'use client'
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, 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) {
export interface DataTableHandle {
commitEdit: () => void
}
type EditingCell = { row: number; col: number } | null
const DataTableBase = forwardRef<DataTableHandle, DataTableProps>(function DataTable(
{ headers, rows, editConfig },
ref
) {
const [editingCell, setEditingCell] = useState<EditingCell>(null)
const [editValue, setEditValue] = useState('')
const editStateRef = useRef({ editingCell, editValue, editConfig })
editStateRef.current = { editingCell, editValue, editConfig }
// Prevents double-commit if onBlur and imperative commitEdit fire concurrently
const isCommittedRef = useRef(false)
useImperativeHandle(
ref,
() => ({
commitEdit: () => {
if (isCommittedRef.current) return
const { editingCell, editValue, editConfig } = editStateRef.current
if (!editingCell || !editConfig) return
isCommittedRef.current = true
const { row, col } = editingCell
if (row === -1) {
editConfig.onHeaderChange(col, editValue)
} else {
editConfig.onCellChange(row, col, editValue)
}
setEditingCell(null)
},
}),
[]
)
const setInputRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
node.select()
}
}, [])
const startEdit = (row: number, col: number, currentValue: string) => {
if (!editConfig) return
isCommittedRef.current = false
setEditingCell({ row, col })
setEditValue(currentValue)
}
const commitEdit = () => {
if (isCommittedRef.current || !editingCell || !editConfig) return
isCommittedRef.current = true
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 +101,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 +127,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>
@@ -36,3 +156,5 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
</div>
)
})
export const DataTable = memo(DataTableBase)
@@ -0,0 +1,183 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
import {
PDF_PAGE_SKELETON,
PreviewError,
resolvePreviewError,
shouldSuppressStreamingDocumentError,
} from './preview-shared'
const logger = createLogger('DocxPreview')
export const DocxPreview = memo(function DocxPreview({
file,
workspaceId,
streamingContent,
}: {
file: WorkspaceFileRecord
workspaceId: string
streamingContent?: string
}) {
const containerRef = useRef<HTMLDivElement>(null)
const lastSuccessfulHtmlRef = useRef('')
const {
data: fileData,
isLoading,
error: fetchError,
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
const [renderError, setRenderError] = useState<string | null>(null)
const [rendering, setRendering] = useState(false)
const [hasRenderedPreview, setHasRenderedPreview] = useState(false)
useEffect(() => {
if (!containerRef.current || !fileData || streamingContent !== undefined) return
let cancelled = false
async function render() {
try {
setRendering(true)
const { renderAsync } = await import('docx-preview')
if (cancelled || !containerRef.current) return
setRenderError(null)
containerRef.current.innerHTML = ''
await renderAsync(fileData, containerRef.current, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
})
if (!cancelled && containerRef.current) {
const wrapper = containerRef.current.querySelector<HTMLElement>('.docx-wrapper')
if (wrapper) wrapper.style.background = 'transparent'
containerRef.current.querySelectorAll<HTMLElement>('section.docx').forEach((page) => {
page.style.boxShadow = 'var(--shadow-medium)'
})
lastSuccessfulHtmlRef.current = containerRef.current.innerHTML
setHasRenderedPreview(true)
}
} catch (err) {
if (!cancelled) {
const msg = toError(err).message || 'Failed to render document'
logger.error('DOCX render failed', { error: msg })
setRenderError(msg)
}
} finally {
if (!cancelled) {
setRendering(false)
}
}
}
render()
return () => {
cancelled = true
}
}, [fileData, streamingContent])
useEffect(() => {
if (streamingContent === undefined || !containerRef.current) return
let cancelled = false
const controller = new AbortController()
const debounceTimer = setTimeout(async () => {
const container = containerRef.current
if (!container || cancelled) return
const previousHtml = lastSuccessfulHtmlRef.current
try {
setRendering(true)
setRenderError(null)
const response = await fetch(`/api/workspaces/${workspaceId}/docx/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: streamingContent }),
signal: controller.signal,
})
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Preview failed' }))
throw new Error(err.error || 'Preview failed')
}
const arrayBuffer = await response.arrayBuffer()
if (cancelled || !containerRef.current) return
const { renderAsync } = await import('docx-preview')
if (cancelled || !containerRef.current) return
containerRef.current.innerHTML = ''
await renderAsync(new Uint8Array(arrayBuffer), containerRef.current, undefined, {
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
})
if (!cancelled && containerRef.current) {
const wrapper = containerRef.current.querySelector<HTMLElement>('.docx-wrapper')
if (wrapper) wrapper.style.background = 'transparent'
containerRef.current.querySelectorAll<HTMLElement>('section.docx').forEach((page) => {
page.style.boxShadow = 'var(--shadow-medium)'
})
lastSuccessfulHtmlRef.current = containerRef.current.innerHTML
setHasRenderedPreview(true)
}
} catch (err) {
if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
if (containerRef.current && previousHtml) {
containerRef.current.innerHTML = previousHtml
setHasRenderedPreview(true)
}
const msg = toError(err).message || 'Failed to render document'
if (previousHtml || shouldSuppressStreamingDocumentError(msg)) {
logger.info('Suppressing transient DOCX streaming preview error', { error: msg })
} else {
logger.error('DOCX render failed', { error: msg })
setRenderError(msg)
}
}
} finally {
if (!cancelled) {
setRendering(false)
}
}
}, 500)
return () => {
cancelled = true
clearTimeout(debounceTimer)
controller.abort()
}
}, [streamingContent, workspaceId])
const error =
hasRenderedPreview && streamingContent !== undefined
? null
: streamingContent !== undefined
? renderError
: resolvePreviewError(fetchError, renderError)
if (error) return <PreviewError label='document' error={error} />
const showSkeleton =
!hasRenderedPreview &&
((streamingContent !== undefined && rendering) || (streamingContent === undefined && isLoading))
return (
<div className='relative h-full w-full overflow-auto bg-[var(--surface-1)]'>
{showSkeleton && (
<div className='absolute inset-0 z-10 bg-[var(--surface-1)]'>{PDF_PAGE_SKELETON}</div>
)}
<div
ref={containerRef}
className={cn('h-full w-full overflow-auto', showSkeleton && 'opacity-0')}
/>
</div>
)
})
@@ -0,0 +1,233 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/uploads/utils/validation', () => ({
SUPPORTED_CODE_EXTENSIONS: ['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'],
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
getFileExtension: (filename: string): string => {
const lastDot = filename.lastIndexOf('.')
return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ''
},
}))
import { resolveFileCategory } from './file-category'
describe('resolveFileCategory — MIME type routing', () => {
describe('text-editable', () => {
it.each([
'text/plain',
'text/markdown',
'application/json',
'application/x-yaml',
'text/csv',
'text/html',
'text/xml',
'application/xml',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'application/toml',
'text/x-python',
'text/x-sh',
'text/x-sql',
'image/svg+xml',
'text/x-mermaid',
])('%s → text-editable', (mime) => {
expect(resolveFileCategory(mime, 'file.txt')).toBe('text-editable')
})
})
describe('iframe-previewable (PDF)', () => {
it('application/pdf → iframe-previewable', () => {
expect(resolveFileCategory('application/pdf', 'doc.pdf')).toBe('iframe-previewable')
})
it('text/x-pdflibjs → iframe-previewable', () => {
expect(resolveFileCategory('text/x-pdflibjs', 'generated.pdf')).toBe('iframe-previewable')
})
})
describe('image-previewable', () => {
it.each(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])(
'%s → image-previewable',
(mime) => {
expect(resolveFileCategory(mime, 'img.png')).toBe('image-previewable')
}
)
})
describe('audio-previewable', () => {
it.each([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/webm',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/opus',
'audio/x-m4a',
])('%s → audio-previewable', (mime) => {
expect(resolveFileCategory(mime, 'audio.mp3')).toBe('audio-previewable')
})
})
describe('video-previewable', () => {
it.each(['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska', 'video/webm'])(
'%s → video-previewable',
(mime) => {
expect(resolveFileCategory(mime, 'video.mp4')).toBe('video-previewable')
}
)
})
describe('docx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.wordprocessingml.document → docx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc.docx'
)
).toBe('docx-previewable')
})
it('text/x-docxjs → docx-previewable', () => {
expect(resolveFileCategory('text/x-docxjs', 'doc.docx')).toBe('docx-previewable')
})
})
describe('pptx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.presentationml.presentation → pptx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'deck.pptx'
)
).toBe('pptx-previewable')
})
it('text/x-pptxgenjs → pptx-previewable', () => {
expect(resolveFileCategory('text/x-pptxgenjs', 'deck.pptx')).toBe('pptx-previewable')
})
})
describe('xlsx-previewable', () => {
it('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet → xlsx-previewable', () => {
expect(
resolveFileCategory(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'data.xlsx'
)
).toBe('xlsx-previewable')
})
})
})
describe('resolveFileCategory — extension fallback', () => {
describe('text-editable extensions', () => {
it.each(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm', 'svg', 'mmd'])(
'.%s → text-editable',
(ext) => {
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
}
)
})
describe('code extensions from SUPPORTED_CODE_EXTENSIONS', () => {
it.each(['js', 'ts', 'py', 'go', 'rs', 'sh', 'sql'])('.%s → text-editable', (ext) => {
expect(resolveFileCategory(null, `file.${ext}`)).toBe('text-editable')
})
})
describe('pdf extension', () => {
it('.pdf → iframe-previewable', () => {
expect(resolveFileCategory(null, 'document.pdf')).toBe('iframe-previewable')
})
})
describe('image extensions', () => {
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp'])('.%s → image-previewable', (ext) => {
expect(resolveFileCategory(null, `image.${ext}`)).toBe('image-previewable')
})
})
describe('audio extensions', () => {
it.each(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])(
'.%s → audio-previewable',
(ext) => {
expect(resolveFileCategory(null, `audio.${ext}`)).toBe('audio-previewable')
}
)
})
describe('video extensions', () => {
it.each(['mp4', 'mov', 'avi', 'mkv', 'webm'])('.%s → video-previewable', (ext) => {
expect(resolveFileCategory(null, `video.${ext}`)).toBe('video-previewable')
})
})
describe('docx extension', () => {
it('.docx → docx-previewable', () => {
expect(resolveFileCategory(null, 'doc.docx')).toBe('docx-previewable')
})
})
describe('pptx extension', () => {
it('.pptx → pptx-previewable', () => {
expect(resolveFileCategory(null, 'deck.pptx')).toBe('pptx-previewable')
})
})
describe('xlsx extension', () => {
it('.xlsx → xlsx-previewable', () => {
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
})
})
describe('unsupported', () => {
it('unknown extension → unsupported', () => {
expect(resolveFileCategory(null, 'file.xyz')).toBe('unsupported')
})
it('unknown mime with unknown extension → unsupported', () => {
expect(resolveFileCategory('application/octet-stream', 'file.bin')).toBe('unsupported')
})
it('no extension, no mime → unsupported', () => {
expect(resolveFileCategory(null, 'LICENSE')).toBe('unsupported')
})
})
})
describe('resolveFileCategory — MIME priority', () => {
it('text/plain MIME + .pdf extension → text-editable (MIME wins)', () => {
expect(resolveFileCategory('text/plain', 'notes.pdf')).toBe('text-editable')
})
it('application/pdf MIME + .txt extension → iframe-previewable (MIME wins)', () => {
expect(resolveFileCategory('application/pdf', 'disguised.txt')).toBe('iframe-previewable')
})
it('null MIME falls through to extension routing', () => {
expect(resolveFileCategory(null, 'data.xlsx')).toBe('xlsx-previewable')
})
it('unknown MIME falls through to extension routing', () => {
expect(resolveFileCategory('application/octet-stream', 'data.xlsx')).toBe('xlsx-previewable')
})
})
describe('resolveFileCategory — extension case', () => {
it('recognises uppercase extension via extension lookup (getFileExtension lowercases)', () => {
expect(resolveFileCategory(null, 'README.MD')).toBe('text-editable')
})
it('handles mixed-case correctly for json', () => {
expect(resolveFileCategory(null, 'config.JSON')).toBe('text-editable')
})
})
@@ -0,0 +1,117 @@
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
const TEXT_EDITABLE_MIME_TYPES = new Set([
'text/markdown',
'text/plain',
'application/json',
'application/x-yaml',
'text/csv',
'text/html',
'text/xml',
'application/xml',
'text/css',
'text/javascript',
'application/javascript',
'application/typescript',
'application/toml',
'text/x-python',
'text/x-sh',
'text/x-sql',
'image/svg+xml',
'text/x-mermaid',
])
const TEXT_EDITABLE_EXTENSIONS = new Set([
'md',
'txt',
'json',
'yaml',
'yml',
'csv',
'html',
'htm',
'svg',
'mmd',
...SUPPORTED_CODE_EXTENSIONS,
])
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf', 'text/x-pdflibjs'])
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/webm',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/opus',
'audio/x-m4a',
])
const AUDIO_PREVIEWABLE_EXTENSIONS = new Set(['mp3', 'm4a', 'wav', 'ogg', 'flac', 'aac', 'opus'])
const VIDEO_PREVIEWABLE_MIME_TYPES = new Set([
'video/mp4',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska',
'video/webm',
])
const VIDEO_PREVIEWABLE_EXTENSIONS = new Set(['mp4', 'mov', 'avi', 'mkv', 'webm'])
const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/x-pptxgenjs',
])
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/x-docxjs',
])
const DOCX_PREVIEWABLE_EXTENSIONS = new Set(['docx'])
const XLSX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
])
const XLSX_PREVIEWABLE_EXTENSIONS = new Set(['xlsx'])
export type FileCategory =
| 'text-editable'
| 'iframe-previewable'
| 'image-previewable'
| 'audio-previewable'
| 'video-previewable'
| 'pptx-previewable'
| 'docx-previewable'
| 'xlsx-previewable'
| 'unsupported'
export function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
if (mimeType && AUDIO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'audio-previewable'
if (mimeType && VIDEO_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'video-previewable'
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
const ext = getFileExtension(filename)
const nameKey = ext || filename.toLowerCase()
if (TEXT_EDITABLE_EXTENSIONS.has(nameKey)) return 'text-editable'
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
if (AUDIO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'audio-previewable'
if (VIDEO_PREVIEWABLE_EXTENSIONS.has(ext)) return 'video-previewable'
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
if (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
return 'unsupported'
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,121 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { Button } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
const ZOOM_WHEEL_SENSITIVITY = 0.005
const ZOOM_BUTTON_FACTOR = 1.2
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const offsetAtDragStart = useRef({ x: 0, y: 0 })
const offsetRef = useRef(offset)
offsetRef.current = offset
const containerRef = useRef<HTMLDivElement>(null)
const zoomIn = () => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR))
const zoomOut = () => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR))
useEffect(() => {
const el = containerRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
setZoom((z) => clampZoom(z * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
} else {
setOffset((o) => ({ x: o.x - e.deltaX, y: o.y - e.deltaY }))
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
offsetAtDragStart.current = offsetRef.current
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
e.preventDefault()
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging.current) return
setOffset({
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
})
}
const handleMouseUp = () => {
isDragging.current = false
if (containerRef.current) containerRef.current.style.cursor = 'grab'
}
return (
<div
ref={containerRef}
className='relative flex flex-1 cursor-grab overflow-hidden bg-[var(--surface-1)]'
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className='pointer-events-none absolute inset-0 flex items-center justify-center'
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
/>
</div>
<div
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-card'
onMouseDown={(e) => e.stopPropagation()}
>
<Button
variant='ghost'
size='sm'
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className='h-6 w-6 p-0'
aria-label='Zoom out'
>
<ZoomOut className='h-3.5 w-3.5' />
</Button>
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
{Math.round(zoom * 100)}%
</span>
<Button
variant='ghost'
size='sm'
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className='h-6 w-6 p-0'
aria-label='Zoom in'
>
<ZoomIn className='h-3.5 w-3.5' />
</Button>
</div>
</div>
)
})
@@ -0,0 +1,309 @@
'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)
setLoadError(null)
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>
)
})
@@ -0,0 +1,290 @@
'use client'
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { Skeleton } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
import {
PreviewError,
resolvePreviewError,
shouldSuppressStreamingDocumentError,
} from './preview-shared'
const logger = createLogger('PptxPreview')
const PPTX_SLIDE_SKELETON = (
<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-[720px] shrink-0 rounded-md bg-[var(--surface-2)] p-8 shadow-medium'
style={{ aspectRatio: '16 / 9' }}
>
<div className='flex h-full flex-col justify-between'>
<div className='flex flex-col gap-3'>
<Skeleton className='h-[18px] w-[50%]' />
<Skeleton className='h-[14px] w-[70%]' />
<Skeleton className='h-[14px] w-[60%]' />
</div>
<div className='flex flex-col gap-2'>
<Skeleton className='h-[14px] w-[80%]' />
<Skeleton className='h-[14px] w-[65%]' />
</div>
</div>
</div>
))}
</div>
)
const pptxSlideCache = new Map<string, string[]>()
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {
return `${fileId}:${dataUpdatedAt}:${byteLength}`
}
function shouldSuppressStreamingPptxError(message: string): boolean {
return (
shouldSuppressStreamingDocumentError(message) ||
message.includes('SyntaxError: Invalid or unexpected token') ||
message.includes('PPTX generation cancelled') ||
message.includes('SyntaxError: Unexpected end of input')
)
}
function pptxCacheSet(key: string, slides: string[]): void {
pptxSlideCache.set(key, slides)
if (pptxSlideCache.size > 5) {
const oldest = pptxSlideCache.keys().next().value
if (oldest !== undefined) pptxSlideCache.delete(oldest)
}
}
async function renderPptxSlides(
data: Uint8Array,
onSlide: (src: string, index: number) => void,
cancelled: () => boolean
): Promise<void> {
const { PPTXViewer } = await import('pptxviewjs')
if (cancelled()) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const { width, height } = await getPptxRenderSize(data, dpr)
const W = width
const H = height
const canvas = document.createElement('canvas')
canvas.width = W
canvas.height = H
const viewer = new PPTXViewer({ canvas })
await viewer.loadFile(data)
const count = viewer.getSlideCount()
if (cancelled() || count === 0) return
for (let i = 0; i < count; i++) {
if (cancelled()) break
if (i === 0) await viewer.render()
else await viewer.goToSlide(i)
onSlide(canvas.toDataURL('image/jpeg', 0.85), i)
}
}
async function getPptxRenderSize(
data: Uint8Array,
dpr: number
): Promise<{ width: number; height: number }> {
const fallback = {
width: Math.round(1920 * dpr),
height: Math.round(1080 * dpr),
}
try {
const JSZip = (await import('jszip')).default
const zip = await JSZip.loadAsync(data)
const presentationXml = await zip.file('ppt/presentation.xml')?.async('text')
if (!presentationXml) return fallback
const tagMatch = presentationXml.match(/<p:sldSz\s[^>]+>/)
if (!tagMatch) return fallback
const tag = tagMatch[0]
const cxMatch = tag.match(/\bcx="(\d+)"/)
const cyMatch = tag.match(/\bcy="(\d+)"/)
if (!cxMatch || !cyMatch) return fallback
const cx = Number(cxMatch[1])
const cy = Number(cyMatch[1])
if (!Number.isFinite(cx) || !Number.isFinite(cy) || cx <= 0 || cy <= 0) return fallback
const aspectRatio = cx / cy
if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return fallback
const baseLongEdge = 1920 * dpr
if (aspectRatio >= 1) {
return {
width: Math.round(baseLongEdge),
height: Math.round(baseLongEdge / aspectRatio),
}
}
return {
width: Math.round(baseLongEdge * aspectRatio),
height: Math.round(baseLongEdge),
}
} catch {
return fallback
}
}
export function PptxPreview({
file,
workspaceId,
streamingContent,
}: {
file: WorkspaceFileRecord
workspaceId: string
streamingContent?: string
}) {
const {
data: fileData,
isLoading: isFetching,
error: fetchError,
dataUpdatedAt,
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
const cacheKey = pptxCacheKey(file.id, dataUpdatedAt, fileData?.byteLength ?? 0)
const cached = pptxSlideCache.get(cacheKey)
const [slides, setSlides] = useState<string[]>(cached ?? [])
const [rendering, setRendering] = useState(false)
const [renderError, setRenderError] = useState<string | null>(null)
useEffect(() => {
if (streamingContent === undefined) return
let cancelled = false
const controller = new AbortController()
const debounceTimer = setTimeout(async () => {
if (cancelled) return
try {
setRendering(true)
setRenderError(null)
const response = await fetch(`/api/workspaces/${workspaceId}/pptx/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: streamingContent }),
signal: controller.signal,
})
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Preview failed' }))
throw new Error(err.error || 'Preview failed')
}
if (cancelled) return
const arrayBuffer = await response.arrayBuffer()
if (cancelled) return
const data = new Uint8Array(arrayBuffer)
const images: string[] = []
await renderPptxSlides(
data,
(src) => {
images.push(src)
if (!cancelled) setSlides([...images])
},
() => cancelled
)
} catch (err) {
if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
const msg = toError(err).message || 'Failed to render presentation'
if (shouldSuppressStreamingPptxError(msg)) {
logger.info('Suppressing transient PPTX streaming preview error', { error: msg })
} else {
logger.error('PPTX render failed', { error: msg })
setRenderError(msg)
}
}
} finally {
if (!cancelled) setRendering(false)
}
}, 500)
return () => {
cancelled = true
clearTimeout(debounceTimer)
controller.abort()
}
}, [streamingContent, workspaceId])
useEffect(() => {
if (streamingContent !== undefined) return
let cancelled = false
async function render() {
if (cancelled) return
try {
if (cached) {
setSlides(cached)
return
}
if (!fileData) return
setRendering(true)
setRenderError(null)
setSlides([])
const data = new Uint8Array(fileData)
const images: string[] = []
await renderPptxSlides(
data,
(src) => {
images.push(src)
if (!cancelled) setSlides([...images])
},
() => cancelled
)
if (!cancelled && images.length > 0) {
pptxCacheSet(cacheKey, images)
}
} catch (err) {
if (!cancelled) {
const msg = toError(err).message || 'Failed to render presentation'
logger.error('PPTX render failed', { error: msg })
setRenderError(msg)
}
} finally {
if (!cancelled) setRendering(false)
}
}
render()
return () => {
cancelled = true
}
}, [fileData, streamingContent, cacheKey])
const error = resolvePreviewError(fetchError, renderError)
const loading = isFetching || rendering
if (error) return <PreviewError label='presentation' error={error} />
if (loading && slides.length === 0) {
return PPTX_SLIDE_SKELETON
}
return (
<div className='flex-1 overflow-y-auto bg-[var(--surface-1)] p-[24px]'>
<div className='mx-auto flex max-w-[960px] flex-col gap-[16px]'>
{slides.map((src, i) => (
<img
key={i}
src={src}
alt={`Slide ${i + 1}`}
className='w-full rounded-md shadow-medium'
/>
))}
</div>
</div>
)
}
@@ -1,23 +1,27 @@
'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 { toError } from '@sim/utils/errors'
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 +30,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 +46,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 +86,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,7 +154,155 @@ 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(toError(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 = {
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
p: ({ children }: { children?: React.ReactNode }) => (
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
{children}
@@ -139,32 +340,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 +572,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 +622,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 +657,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 +698,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 +787,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)
@@ -563,19 +851,15 @@ const HtmlPreview = memo(function HtmlPreview({ content }: { content: string })
sandbox='allow-scripts'
referrerPolicy='no-referrer'
title='HTML Preview'
className='h-full w-full border-0 bg-white'
className='h-full w-full border-0 bg-[var(--surface-2)]'
/>
)}
</div>
)
})
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 +871,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])
@@ -0,0 +1,56 @@
'use client'
import { Skeleton } from '@/components/emcn'
export function PreviewError({ label, error }: { label: string; 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 {label}</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
export function resolvePreviewError(
fetchError: Error | null,
renderError: string | null
): string | null {
if (fetchError) return fetchError.message
return renderError
}
export function shouldSuppressStreamingDocumentError(message: string): boolean {
const lower = message.toLowerCase()
return (
lower.includes('preview failed') ||
lower.includes('aborterror') ||
lower.includes('unexpected end') ||
lower.includes('unexpected eof') ||
lower.includes('invalid or unexpected token') ||
lower.includes('end of central directory') ||
lower.includes('corrupted zip') ||
lower.includes('end of data reached')
)
}
export const PDF_PAGE_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>
)
@@ -0,0 +1,339 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
INITIAL_TEXT_EDITOR_CONTENT_STATE,
resolveStreamingEditorContent,
syncTextEditorContentState,
type TextEditorContentState,
textEditorContentReducer,
} from './text-editor-state'
function ready(content: string, savedContent = content): TextEditorContentState {
return { phase: 'ready', content, savedContent, lastStreamedContent: null }
}
function streaming(
content: string,
lastStreamedContent: string,
savedContent = ''
): TextEditorContentState {
return { phase: 'streaming', content, savedContent, lastStreamedContent }
}
function reconciling(content: string, savedContent = ''): TextEditorContentState {
return { phase: 'reconciling', content, savedContent, lastStreamedContent: null }
}
describe('resolveStreamingEditorContent', () => {
it('returns streamingContent when mode is replace', () => {
expect(resolveStreamingEditorContent('existing', 'new chunk', 'replace')).toBe('new chunk')
})
it('returns streamingContent when fetchedContent is undefined', () => {
expect(resolveStreamingEditorContent(undefined, 'chunk', 'append')).toBe('chunk')
})
it('returns fetchedContent when it already ends with streamingContent', () => {
expect(resolveStreamingEditorContent('base\nchunk', 'chunk', 'append')).toBe('base\nchunk')
})
it('returns fetchedContent when it ends with newline + streamingContent', () => {
expect(resolveStreamingEditorContent('base\nchunk', 'chunk', 'append')).toBe('base\nchunk')
})
it('appends with newline separator when fetched does not end with chunk', () => {
expect(resolveStreamingEditorContent('base', 'new stuff', 'append')).toBe('base\nnew stuff')
})
it('handles empty streamingContent in append mode', () => {
expect(resolveStreamingEditorContent('base', '', 'append')).toBe('base')
})
it('handles empty fetchedContent in append mode (prepends newline separator)', () => {
expect(resolveStreamingEditorContent('', 'chunk', 'append')).toBe('\nchunk')
})
})
describe("reducer 'edit' action", () => {
it('updates content when phase is ready and content differs', () => {
const state = ready('old')
const next = textEditorContentReducer(state, { type: 'edit', content: 'new' })
expect(next.content).toBe('new')
expect(next.savedContent).toBe('old')
expect(next.phase).toBe('ready')
})
it('returns same reference when content is unchanged', () => {
const state = ready('same')
const next = textEditorContentReducer(state, { type: 'edit', content: 'same' })
expect(next).toBe(state)
})
it('ignores edit when phase is streaming', () => {
const state = streaming('streamed', 'streamed')
const next = textEditorContentReducer(state, { type: 'edit', content: 'edited' })
expect(next).toBe(state)
})
it('ignores edit when phase is reconciling', () => {
const state = reconciling('current')
const next = textEditorContentReducer(state, { type: 'edit', content: 'edited' })
expect(next).toBe(state)
})
it('ignores edit when phase is uninitialized', () => {
const next = textEditorContentReducer(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
type: 'edit',
content: 'anything',
})
expect(next).toBe(INITIAL_TEXT_EDITOR_CONTENT_STATE)
})
})
describe("reducer 'save-success' action", () => {
it('marks content as saved', () => {
const state = ready('new content', 'old saved')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'new content' })
expect(next.savedContent).toBe('new content')
expect(next.content).toBe('new content')
expect(next.phase).toBe('ready')
expect(next.lastStreamedContent).toBeNull()
})
it('returns same reference when already clean', () => {
const state = ready('x')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'x' })
expect(next).toBe(state)
})
it('clears lastStreamedContent after save', () => {
const state = streaming('content', 'content')
const next = textEditorContentReducer(state, { type: 'save-success', content: 'content' })
expect(next.lastStreamedContent).toBeNull()
expect(next.phase).toBe('ready')
})
})
describe('syncTextEditorContentState — initialization', () => {
it('transitions from uninitialized to ready when fetchedContent arrives', () => {
const next = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: true,
fetchedContent: 'loaded',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('loaded')
expect(next.savedContent).toBe('loaded')
})
it('stays uninitialized when fetchedContent is undefined', () => {
const next = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: undefined,
streamingMode: 'append',
})
expect(next).toBe(INITIAL_TEXT_EDITOR_CONTENT_STATE)
})
})
describe('syncTextEditorContentState — static fetch updates', () => {
it('does not update content if fetchedContent matches savedContent', () => {
const state = ready('v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v1',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next).toBe(state)
})
it('updates content when fetched advances and no local edits', () => {
const state = ready('v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.content).toBe('v2')
expect(next.savedContent).toBe('v2')
})
it('preserves local edits when fetchedContent advances but user has changes', () => {
// User edited to 'user edit', but savedContent was 'v1' and fetched is 'v2'
const state: TextEditorContentState = {
phase: 'ready',
content: 'user edit',
savedContent: 'v1',
lastStreamedContent: null,
}
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: undefined,
streamingMode: 'append',
})
// Local edits take precedence — content should remain 'user edit'
expect(next.content).toBe('user edit')
expect(next.phase).toBe('ready')
})
})
describe('syncTextEditorContentState — streaming', () => {
it('enters streaming phase when streamingContent arrives (replace mode)', () => {
const state = ready('existing')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'existing',
streamingContent: 'streamed chunk',
streamingMode: 'replace',
})
expect(next.phase).toBe('streaming')
expect(next.content).toBe('streamed chunk')
expect(next.lastStreamedContent).toBe('streamed chunk')
})
it('appends streaming content to fetched in append mode', () => {
const next = syncTextEditorContentState(INITIAL_TEXT_EDITOR_CONTENT_STATE, {
canReconcileToFetchedContent: false,
fetchedContent: 'base',
streamingContent: 'addition',
streamingMode: 'append',
})
expect(next.phase).toBe('streaming')
expect(next.content).toBe('base\naddition')
})
it('returns same reference when streaming state is already current', () => {
const state = streaming('base\naddition', 'base\naddition', 'base')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'base',
streamingContent: 'addition',
streamingMode: 'append',
})
expect(next).toBe(state)
})
it('finalizes to ready when fetched matches lastStreamedContent', () => {
const state = streaming('base\nchunk', 'base\nchunk', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'base\nchunk',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('base\nchunk')
expect(next.savedContent).toBe('base\nchunk')
expect(next.lastStreamedContent).toBeNull()
})
it('moves to reconciling when streaming ends but fetched has not caught up', () => {
const state = streaming('streamed', 'streamed', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('reconciling')
})
it('finalizes immediately when streaming ends and canReconcile is false', () => {
const state = streaming('streamed', 'streamed', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: undefined,
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('streamed')
})
})
describe('syncTextEditorContentState — reconciling', () => {
it('stays reconciling when fetchedContent has not advanced', () => {
const state = reconciling('streamed', '')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: '',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('reconciling')
})
it('returns same reconciling reference when already reconciling', () => {
const state = reconciling('x')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: undefined,
streamingContent: undefined,
streamingMode: 'append',
})
expect(next).toBe(state)
})
it('finalizes when fetchedContent has advanced during reconciling', () => {
const state: TextEditorContentState = {
phase: 'reconciling',
content: 'streamed',
savedContent: 'v1',
lastStreamedContent: null,
}
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
it('finalizes immediately when canReconcile is false', () => {
const state = reconciling('streamed')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'v99',
streamingContent: undefined,
streamingMode: 'append',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('streamed')
})
})
describe('syncTextEditorContentState — streaming finalize shortcuts', () => {
it('finalizes immediately when fetched already equals resolved streaming content', () => {
// ready + user hasn't edited + fetched === what streaming would produce
const state = ready('v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: false,
fetchedContent: 'v2',
streamingContent: 'v2',
streamingMode: 'replace',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
it('finalizes from streaming when fetched has advanced beyond saved', () => {
const state = streaming('v1 chunk', 'v1 chunk', 'v1')
const next = syncTextEditorContentState(state, {
canReconcileToFetchedContent: true,
fetchedContent: 'v2',
streamingContent: 'chunk',
streamingMode: 'append',
})
expect(next.phase).toBe('ready')
expect(next.content).toBe('v2')
})
})
@@ -0,0 +1,213 @@
export type StreamingMode = 'append' | 'replace'
export type TextEditorContentPhase = 'uninitialized' | 'ready' | 'streaming' | 'reconciling'
export interface TextEditorContentState {
phase: TextEditorContentPhase
content: string
savedContent: string
lastStreamedContent: string | null
}
export interface SyncTextEditorContentStateOptions {
canReconcileToFetchedContent: boolean
fetchedContent?: string
streamingContent?: string
streamingMode: StreamingMode
}
export type TextEditorContentAction =
| ({ type: 'sync-external' } & SyncTextEditorContentStateOptions)
| { type: 'edit'; content: string }
| { type: 'save-success'; content: string }
export const INITIAL_TEXT_EDITOR_CONTENT_STATE: TextEditorContentState = {
phase: 'uninitialized',
content: '',
savedContent: '',
lastStreamedContent: null,
}
export function resolveStreamingEditorContent(
fetchedContent: string | undefined,
streamingContent: string,
streamingMode: StreamingMode
): string {
if (streamingMode === 'replace' || fetchedContent === undefined) {
return streamingContent
}
if (
fetchedContent.endsWith(streamingContent) ||
fetchedContent.endsWith(`\n${streamingContent}`)
) {
return fetchedContent
}
return `${fetchedContent}\n${streamingContent}`
}
function finalizeTextEditorContentState(
state: TextEditorContentState,
nextContent: string
): TextEditorContentState {
if (
state.phase === 'ready' &&
state.content === nextContent &&
state.savedContent === nextContent &&
state.lastStreamedContent === null
) {
return state
}
return {
phase: 'ready',
content: nextContent,
savedContent: nextContent,
lastStreamedContent: null,
}
}
function moveTextEditorContentStateToStreaming(
state: TextEditorContentState,
nextContent: string
): TextEditorContentState {
if (
state.phase === 'streaming' &&
state.content === nextContent &&
state.lastStreamedContent === nextContent
) {
return state
}
return {
...state,
phase: 'streaming',
content: nextContent,
lastStreamedContent: nextContent,
}
}
function moveTextEditorContentStateToReconcile(
state: TextEditorContentState
): TextEditorContentState {
if (state.phase === 'reconciling') {
return state
}
return {
...state,
phase: 'reconciling',
}
}
export function syncTextEditorContentState(
state: TextEditorContentState,
options: SyncTextEditorContentStateOptions
): TextEditorContentState {
const { canReconcileToFetchedContent, fetchedContent, streamingContent, streamingMode } = options
if (streamingContent !== undefined) {
const nextContent = resolveStreamingEditorContent(
fetchedContent,
streamingContent,
streamingMode
)
const fetchedMatchesNextContent = fetchedContent !== undefined && fetchedContent === nextContent
const fetchedMatchesLastStreamedContent =
fetchedContent !== undefined &&
state.lastStreamedContent !== null &&
fetchedContent === state.lastStreamedContent
const hasFetchedAdvanced = fetchedContent !== undefined && fetchedContent !== state.savedContent
if (
(state.phase === 'streaming' || state.phase === 'reconciling') &&
(hasFetchedAdvanced || fetchedMatchesLastStreamedContent || fetchedMatchesNextContent)
) {
return finalizeTextEditorContentState(state, fetchedContent)
}
if (
state.phase === 'ready' &&
state.content === state.savedContent &&
fetchedMatchesNextContent &&
fetchedContent !== undefined
) {
return finalizeTextEditorContentState(state, fetchedContent)
}
return moveTextEditorContentStateToStreaming(state, nextContent)
}
if (state.phase === 'streaming' || state.phase === 'reconciling') {
if (!canReconcileToFetchedContent) {
return finalizeTextEditorContentState(state, state.content)
}
if (fetchedContent !== undefined) {
const hasFetchedAdvanced = fetchedContent !== state.savedContent
const fetchedMatchesLastStreamedContent =
state.lastStreamedContent !== null && fetchedContent === state.lastStreamedContent
if (hasFetchedAdvanced || fetchedMatchesLastStreamedContent) {
return finalizeTextEditorContentState(state, fetchedContent)
}
}
return moveTextEditorContentStateToReconcile(state)
}
if (fetchedContent === undefined) {
return state
}
if (state.phase === 'uninitialized') {
return finalizeTextEditorContentState(state, fetchedContent)
}
if (fetchedContent === state.savedContent) {
return state
}
if (state.content === state.savedContent) {
return finalizeTextEditorContentState(state, fetchedContent)
}
return state
}
export function textEditorContentReducer(
state: TextEditorContentState,
action: TextEditorContentAction
): TextEditorContentState {
switch (action.type) {
case 'sync-external':
return syncTextEditorContentState(state, action)
case 'edit':
if (state.phase !== 'ready' || action.content === state.content) {
return state
}
return {
...state,
content: action.content,
}
case 'save-success':
if (
state.phase === 'ready' &&
state.content === action.content &&
state.savedContent === action.content &&
state.lastStreamedContent === null
) {
return state
}
return {
...state,
phase: 'ready',
content: action.content,
savedContent: action.content,
lastStreamedContent: null,
}
default:
return state
}
}
@@ -0,0 +1,688 @@
'use client'
import { memo, useCallback, useEffect, useReducer, useRef, useState } from 'react'
import type { OnMount } from '@monaco-editor/react'
import dynamic from 'next/dynamic'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import {
useUpdateWorkspaceFileContent,
useWorkspaceFileContent,
} from '@/hooks/queries/workspace-files'
import { useAutosave } from '@/hooks/use-autosave'
import type { PreviewMode } from './file-viewer'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
import {
INITIAL_TEXT_EDITOR_CONTENT_STATE,
type StreamingMode,
type SyncTextEditorContentStateOptions,
textEditorContentReducer,
} from './text-editor-state'
const SIM_DARK_RULES: import('monaco-editor').editor.ITokenThemeRule[] = [
{ token: 'comment', foreground: '606060', fontStyle: 'italic' },
{ token: 'string', foreground: '3ab872' },
{ token: 'string.escape', foreground: '3ab872' },
{ token: 'number', foreground: 'e8a87c' },
{ token: 'number.float', foreground: 'e8a87c' },
{ token: 'number.hex', foreground: 'e8a87c' },
{ token: 'keyword', foreground: '33b4ff' },
{ token: 'keyword.control', foreground: '33b4ff' },
{ token: 'storage', foreground: '33b4ff' },
{ token: 'type', foreground: '8fc7f5' },
{ token: 'type.identifier', foreground: '8fc7f5' },
{ token: 'regexp', foreground: 'ff8a65' },
{ token: 'annotation', foreground: 'ffca28' },
]
const SIM_LIGHT_RULES: import('monaco-editor').editor.ITokenThemeRule[] = [
{ token: 'comment', foreground: '888888', fontStyle: 'italic' },
{ token: 'string', foreground: '16825d' },
{ token: 'string.escape', foreground: '16825d' },
{ token: 'number', foreground: 'c9660c' },
{ token: 'number.float', foreground: 'c9660c' },
{ token: 'number.hex', foreground: 'c9660c' },
{ token: 'keyword', foreground: '0078d4' },
{ token: 'keyword.control', foreground: '0078d4' },
{ token: 'storage', foreground: '0078d4' },
{ token: 'type', foreground: '7c4dcc' },
{ token: 'type.identifier', foreground: '7c4dcc' },
{ token: 'regexp', foreground: 'd7390c' },
{ token: 'annotation', foreground: 'e67700' },
]
const MonacoEditor = dynamic(
async () => {
const [{ default: Editor, loader }, monaco] = await Promise.all([
import('@monaco-editor/react'),
import('monaco-editor'),
])
if (typeof window !== 'undefined' && !window.MonacoEnvironment) {
window.MonacoEnvironment = {
getWorker(_: string, label: string) {
if (label === 'json') {
return new Worker(
new URL('monaco-editor/esm/vs/language/json/json.worker', import.meta.url)
)
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new Worker(
new URL('monaco-editor/esm/vs/language/css/css.worker', import.meta.url)
)
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new Worker(
new URL('monaco-editor/esm/vs/language/html/html.worker', import.meta.url)
)
}
if (label === 'typescript' || label === 'javascript') {
return new Worker(
new URL('monaco-editor/esm/vs/language/typescript/ts.worker', import.meta.url)
)
}
return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker', import.meta.url))
},
}
}
loader.config({ monaco })
monaco.editor.defineTheme('sim-dark', {
base: 'vs-dark',
inherit: true,
rules: SIM_DARK_RULES,
colors: {
'editor.background': '#1b1b1b',
'editor.foreground': '#e6e6e6',
'editorLineNumber.foreground': '#404040',
'editorLineNumber.activeForeground': '#787878',
'editor.selectionBackground': '#33b4ff28',
'editor.inactiveSelectionBackground': '#33b4ff14',
'editor.lineHighlightBackground': '#23232380',
'editor.lineHighlightBorder': '#00000000',
'editorGutter.background': '#1b1b1b',
'editorWidget.background': '#242424',
'editorWidget.border': '#333333',
'editorWidget.foreground': '#e6e6e6',
'editor.findMatchBackground': '#33b4ff40',
'editor.findMatchHighlightBackground': '#33b4ff1a',
'editor.findMatchBorder': '#33b4ff',
'scrollbar.shadow': '#00000000',
'scrollbarSlider.background': '#33333380',
'scrollbarSlider.hoverBackground': '#45454580',
'scrollbarSlider.activeBackground': '#505050',
'editorBracketMatch.background': '#33b4ff1a',
'editorBracketMatch.border': '#33b4ff80',
'editorIndentGuide.background1': '#2a2a2a',
'editorIndentGuide.activeBackground1': '#454545',
'editorCursor.foreground': '#e6e6e6',
'editor.wordHighlightBackground': '#33b4ff14',
'editor.wordHighlightBorder': '#33b4ff40',
'editorSuggestWidget.background': '#242424',
'editorSuggestWidget.border': '#333333',
'editorSuggestWidget.foreground': '#e6e6e6',
'editorSuggestWidget.selectedBackground': '#292929',
'editorSuggestWidget.selectedForeground': '#e6e6e6',
'editorHoverWidget.background': '#242424',
'editorHoverWidget.border': '#333333',
'editorHoverWidget.foreground': '#e6e6e6',
'minimap.background': '#1b1b1b',
'minimapSlider.background': '#33333380',
focusBorder: '#33b4ff80',
'input.background': '#242424',
'input.border': '#333333',
'input.foreground': '#e6e6e6',
'inputOption.activeBorder': '#33b4ff',
},
})
monaco.editor.defineTheme('sim-light', {
base: 'vs',
inherit: true,
rules: SIM_LIGHT_RULES,
colors: {
'editor.background': '#fefefe',
'editor.foreground': '#1a1a1a',
'editorLineNumber.foreground': '#cccccc',
'editorLineNumber.activeForeground': '#707070',
'editor.selectionBackground': '#33b4ff22',
'editor.inactiveSelectionBackground': '#33b4ff12',
'editor.lineHighlightBackground': '#f7f7f7',
'editor.lineHighlightBorder': '#00000000',
'editorGutter.background': '#fefefe',
'editorWidget.background': '#ffffff',
'editorWidget.border': '#dedede',
'editorWidget.foreground': '#1a1a1a',
'editor.findMatchBackground': '#33b4ff40',
'editor.findMatchHighlightBackground': '#33b4ff1a',
'editor.findMatchBorder': '#33b4ff',
'scrollbar.shadow': '#00000000',
'scrollbarSlider.background': '#dedede80',
'scrollbarSlider.hoverBackground': '#cccccc',
'scrollbarSlider.activeBackground': '#b0b0b0',
'editorBracketMatch.background': '#33b4ff1a',
'editorBracketMatch.border': '#33b4ff80',
'editorIndentGuide.background1': '#f0f0f0',
'editorIndentGuide.activeBackground1': '#d8d8d8',
'editorCursor.foreground': '#1a1a1a',
'editor.wordHighlightBackground': '#33b4ff14',
'editor.wordHighlightBorder': '#33b4ff40',
'editorSuggestWidget.background': '#ffffff',
'editorSuggestWidget.border': '#dedede',
'editorSuggestWidget.foreground': '#1a1a1a',
'editorSuggestWidget.selectedBackground': '#f5f5f5',
'editorSuggestWidget.selectedForeground': '#1a1a1a',
'editorHoverWidget.background': '#ffffff',
'editorHoverWidget.border': '#dedede',
'editorHoverWidget.foreground': '#1a1a1a',
'minimap.background': '#fefefe',
'minimapSlider.background': '#dedede80',
focusBorder: '#33b4ff80',
'input.background': '#ffffff',
'input.border': '#dedede',
'input.foreground': '#1a1a1a',
'inputOption.activeBorder': '#33b4ff',
},
})
return Editor
},
{ ssr: false }
)
const SPLIT_MIN_PCT = 20
const SPLIT_MAX_PCT = 80
const SPLIT_DEFAULT_PCT = 50
/** Maps file extensions to Monaco editor language IDs. */
const MONACO_LANGUAGE_BY_EXTENSION: Partial<Record<string, string>> = {
js: 'javascript',
jsx: 'javascript',
ts: 'typescript',
tsx: 'typescript',
py: 'python',
rb: 'ruby',
go: 'go',
rs: 'rust',
java: 'java',
kt: 'kotlin',
swift: 'swift',
c: 'c',
cpp: 'cpp',
h: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
fish: 'shell',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
json: 'json',
jsonl: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
html: 'html',
htm: 'html',
xml: 'xml',
svg: 'xml',
css: 'css',
scss: 'scss',
less: 'less',
md: 'markdown',
mdx: 'markdown',
mmd: 'markdown',
dockerfile: 'dockerfile',
ini: 'ini',
conf: 'ini',
cfg: 'ini',
env: 'shell',
diff: 'diff',
patch: 'diff',
}
const MONACO_LANGUAGE_BY_MIME: Partial<Record<string, string>> = {
'text/javascript': 'javascript',
'application/javascript': 'javascript',
'text/typescript': 'typescript',
'application/typescript': 'typescript',
'text/x-python': 'python',
'application/json': 'json',
'text/x-shellscript': 'shell',
'text/x-sh': 'shell',
'text/css': 'css',
'text/html': 'html',
'text/xml': 'xml',
'application/xml': 'xml',
'image/svg+xml': 'xml',
'text/x-sql': 'sql',
'application/x-yaml': 'yaml',
'text/markdown': 'markdown',
'text/x-mermaid': 'markdown',
'text/plain': 'plaintext',
}
function resolveMonacoLanguage(file: { type: string; name: string }): string {
const ext = getFileExtension(file.name)
return MONACO_LANGUAGE_BY_EXTENSION[ext] ?? MONACO_LANGUAGE_BY_MIME[file.type] ?? 'plaintext'
}
function useTextEditorContentState(options: SyncTextEditorContentStateOptions) {
const [state, dispatch] = useReducer(textEditorContentReducer, INITIAL_TEXT_EDITOR_CONTENT_STATE)
const prevOptionsRef = useRef<SyncTextEditorContentStateOptions | null>(null)
const prev = prevOptionsRef.current
if (
prev === null ||
prev.canReconcileToFetchedContent !== options.canReconcileToFetchedContent ||
prev.fetchedContent !== options.fetchedContent ||
prev.streamingContent !== options.streamingContent ||
prev.streamingMode !== options.streamingMode
) {
prevOptionsRef.current = options
dispatch({ type: 'sync-external', ...options })
}
const setDraftContent = useCallback((content: string) => {
dispatch({ type: 'edit', content })
}, [])
const markSavedContent = (content: string) => {
dispatch({ type: 'save-success', content })
}
return {
content: state.content,
savedContent: state.savedContent,
isInitialized: state.phase !== 'uninitialized',
isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling',
setDraftContent,
markSavedContent,
}
}
function useMonacoTheme(): string {
const [isDark, setIsDark] = useState(
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
useEffect(() => {
const update = () => setIsDark(document.documentElement.classList.contains('dark'))
const observer = new MutationObserver(update)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
return isDark ? 'sim-dark' : 'sim-light'
}
function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked: boolean): string {
let currentIndex = 0
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)]) +)\[([ xX])\]/gm, (match, prefix: string) => {
if (currentIndex++ !== targetIndex) return match
return `${prefix}[${checked ? 'x' : ' '}]`
})
}
const DOCUMENT_SKELETON = (
<div className='flex flex-1 flex-col gap-[6px] p-[24px]'>
<Skeleton className='h-[14px] w-[45%]' />
<Skeleton className='h-[14px] w-[70%]' />
<Skeleton className='h-[14px] w-[55%]' />
<Skeleton className='mt-2 h-[14px] w-[80%]' />
<Skeleton className='h-[14px] w-[60%]' />
<Skeleton className='h-[14px] w-[75%]' />
<Skeleton className='h-[14px] w-[50%]' />
<Skeleton className='mt-2 h-[14px] w-[65%]' />
<Skeleton className='h-[14px] w-[40%]' />
</div>
)
interface TextEditorProps {
file: WorkspaceFileRecord
workspaceId: string
canEdit: boolean
previewMode: PreviewMode
autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
streamingContent?: string
streamingMode?: StreamingMode
disableStreamingAutoScroll: boolean
previewContextKey?: string
}
export const TextEditor = memo(function TextEditor({
file,
workspaceId,
canEdit,
previewMode,
autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
streamingContent,
streamingMode = 'append',
disableStreamingAutoScroll,
previewContextKey,
}: TextEditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const monacoEditorRef = useRef<Parameters<OnMount>[0] | null>(null)
const lastSyncedContentRef = useRef('')
const hasAutoFocusedRef = useRef(false)
const contentRef = useRef('')
const textareaStuckRef = useRef(false)
const suppressScrollListenerRef = useRef(false)
const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT_PCT)
const [isResizing, setIsResizing] = useState(false)
const {
data: fetchedContent,
isLoading,
error,
} = useWorkspaceFileContent(
workspaceId,
file.id,
file.key,
file.type === 'text/x-pptxgenjs' ||
file.type === 'text/x-docxjs' ||
file.type === 'text/x-pdflibjs'
)
const updateContent = useUpdateWorkspaceFileContent()
const updateContentRef = useRef(updateContent)
updateContentRef.current = updateContent
const monacoLanguage = resolveMonacoLanguage(file)
const monacoTheme = useMonacoTheme()
const onDirtyChangeRef = useRef(onDirtyChange)
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
onDirtyChangeRef.current = onDirtyChange
onSaveStatusChangeRef.current = onSaveStatusChange
const {
content,
savedContent,
isInitialized,
isStreamInteractionLocked,
setDraftContent,
markSavedContent,
} = useTextEditorContentState({
canReconcileToFetchedContent: file.key.length > 0,
fetchedContent,
streamingContent,
streamingMode,
})
contentRef.current = content
useEffect(() => {
const editor = monacoEditorRef.current
if (!editor) return
const model = editor.getModel()
if (!model) return
const monacoValue = model.getValue()
if (monacoValue === content) return
if (isStreamInteractionLocked || monacoValue === lastSyncedContentRef.current) {
if (isStreamInteractionLocked) {
const scrollTop = editor.getScrollTop()
const scrollHeight = editor.getScrollHeight()
const { height } = editor.getLayoutInfo()
if (scrollHeight - scrollTop - height < 80) {
textareaStuckRef.current = true
}
}
const viewState =
isStreamInteractionLocked && !textareaStuckRef.current ? editor.saveViewState() : null
suppressScrollListenerRef.current = true
model.setValue(content)
if (viewState) editor.restoreViewState(viewState)
suppressScrollListenerRef.current = false
lastSyncedContentRef.current = content
}
}, [content, isStreamInteractionLocked])
useEffect(() => {
const editor = monacoEditorRef.current
if (!editor || !isStreamInteractionLocked || disableStreamingAutoScroll) {
textareaStuckRef.current = false
return
}
const disposable = editor.onDidScrollChange(() => {
if (suppressScrollListenerRef.current) return
const scrollTop = editor.getScrollTop()
const scrollHeight = editor.getScrollHeight()
const { height } = editor.getLayoutInfo()
if (scrollHeight - scrollTop - height >= 80) {
textareaStuckRef.current = false
}
})
return () => {
disposable.dispose()
}
}, [isStreamInteractionLocked, disableStreamingAutoScroll])
useEffect(() => {
if (!isStreamInteractionLocked || !textareaStuckRef.current || disableStreamingAutoScroll)
return
const editor = monacoEditorRef.current
if (!editor) return
const lineCount = editor.getModel()?.getLineCount() ?? 0
if (lineCount > 0) {
editor.revealLine(lineCount)
}
}, [content, isStreamInteractionLocked, disableStreamingAutoScroll])
async function onSave() {
if (content === savedContent) return
await updateContentRef.current.mutateAsync({
workspaceId,
fileId: file.id,
content,
})
markSavedContent(content)
}
const { saveStatus, saveImmediately, isDirty } = useAutosave({
content,
savedContent,
onSave,
enabled: canEdit && isInitialized && !isStreamInteractionLocked,
})
useEffect(() => {
onDirtyChangeRef.current?.(isDirty)
}, [isDirty])
useEffect(() => {
onSaveStatusChangeRef.current?.(saveStatus)
}, [saveStatus])
useEffect(() => {
if (!saveRef) return
saveRef.current = saveImmediately
return () => {
if (saveRef.current === saveImmediately) {
saveRef.current = null
}
}
}, [saveImmediately, saveRef])
useEffect(() => {
if (!isResizing) return
const handleMouseMove = (e: MouseEvent) => {
const container = containerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
const pct = ((e.clientX - rect.left) / rect.width) * 100
setSplitPct(Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, pct)))
}
const handleMouseUp = () => setIsResizing(false)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing])
const handleCheckboxToggle = useCallback(
(checkboxIndex: number, checked: boolean) => {
const toggled = toggleMarkdownCheckbox(content, checkboxIndex, checked)
if (toggled !== content) {
setDraftContent(toggled)
const model = monacoEditorRef.current?.getModel()
if (model) {
model.setValue(toggled)
lastSyncedContentRef.current = toggled
}
}
},
[content, setDraftContent]
)
const handleEditorMount: OnMount = (editor, monaco) => {
monacoEditorRef.current = editor
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
saveImmediately()
})
const model = editor.getModel()
const currentContent = contentRef.current
if (model && currentContent && model.getValue() !== currentContent) {
model.setValue(currentContent)
lastSyncedContentRef.current = currentContent
}
if (autoFocus && !hasAutoFocusedRef.current) {
hasAutoFocusedRef.current = true
editor.focus()
}
}
const handleEditorChange = useCallback(
(value: string | undefined) => {
setDraftContent(value ?? '')
},
[setDraftContent]
)
const isStreaming = isStreamInteractionLocked
const isEditorReadOnly = isStreamInteractionLocked || !canEdit
const previewType = resolvePreviewType(file.type, file.name)
const isIframeRendered = previewType === 'html' || previewType === 'svg'
const effectiveMode = isStreaming && isIframeRendered ? 'editor' : previewMode
const showEditor = effectiveMode !== 'preview'
const showPreviewPane = effectiveMode !== 'editor'
if (streamingContent === undefined) {
if (isLoading) return DOCUMENT_SKELETON
if (error && !isInitialized) {
return (
<div className='flex flex-1 items-center justify-center'>
<p className='text-[13px] text-[var(--text-muted)]'>Failed to load file content</p>
</div>
)
}
}
return (
<div ref={containerRef} className='relative flex flex-1 overflow-hidden'>
{showEditor && (
<div
style={showPreviewPane ? { width: `${splitPct}%`, flexShrink: 0 } : undefined}
className={cn(
'min-w-0',
!showPreviewPane && 'w-full',
isResizing && 'pointer-events-none'
)}
>
<MonacoEditor
key={file.id}
defaultValue={content}
language={monacoLanguage}
theme={monacoTheme}
options={{
readOnly: isEditorReadOnly,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
fontSize: 13,
lineNumbers: 'on',
padding: { top: 24, bottom: 24 },
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
tabSize: 2,
automaticLayout: true,
renderLineHighlight: 'line',
occurrencesHighlight: 'off',
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollbar: {
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
},
}}
onChange={handleEditorChange}
onMount={handleEditorMount}
className='h-full'
/>
</div>
)}
{showPreviewPane && (
<>
{showEditor && (
<div className='relative shrink-0'>
<div className='h-full w-px bg-[var(--border)]' />
<div
className='-left-[3px] absolute top-0 z-10 h-full w-[6px] cursor-col-resize'
onMouseDown={() => setIsResizing(true)}
role='separator'
aria-orientation='vertical'
aria-label='Resize split'
/>
{isResizing && (
<div className='-translate-x-[0.5px] pointer-events-none absolute top-0 z-20 h-full w-[2px] bg-[var(--selection)]' />
)}
</div>
)}
<div
className={cn('min-w-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}
>
<PreviewPanel
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
content={content}
mimeType={file.type}
filename={file.name}
isStreaming={isStreaming}
onCheckboxToggle={canEdit && !isStreaming ? handleCheckboxToggle : undefined}
/>
</div>
</>
)}
</div>
)
})
@@ -0,0 +1,324 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { Button, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import {
useUpdateWorkspaceFileContent,
useWorkspaceFileBinary,
} from '@/hooks/queries/workspace-files'
import type { DataTableHandle } from './data-table'
import { DataTable } from './data-table'
import { PreviewError, resolvePreviewError } from './preview-shared'
const logger = createLogger('XlsxPreview')
const XLSX_MAX_ROWS = 1_000
interface XlsxSheet {
name: string
headers: string[]
rows: string[][]
truncated: boolean
}
const XLSX_SKELETON = (
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex shrink-0 items-center gap-2 border-[var(--border)] border-b bg-[var(--surface-1)] px-3 py-2'>
<Skeleton className='h-[22px] w-[60px] rounded' />
<Skeleton className='h-[22px] w-[48px] rounded' />
</div>
<div className='flex-1 overflow-auto p-6'>
<div className='overflow-hidden rounded-md border border-[var(--border)]'>
<div className='flex gap-4 bg-[var(--surface-2)] px-3 py-2'>
{[1, 1, 1, 1].map((_, i) => (
<Skeleton key={i} className='h-[12px] flex-1' />
))}
</div>
{[...Array(7)].map((_, i) => (
<div key={i} className='flex gap-4 border-[var(--border)] border-t px-3 py-2'>
{[1, 1, 1, 1].map((_, j) => (
<Skeleton key={j} className='h-[12px] flex-1' />
))}
</div>
))}
</div>
</div>
</div>
)
export const XlsxPreview = memo(function XlsxPreview({
file,
workspaceId,
canEdit,
onSaveStatusChange,
saveRef,
}: {
file: WorkspaceFileRecord
workspaceId: string
canEdit: boolean
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
}) {
const {
data: fileData,
isLoading,
error: fetchError,
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
const [sheetNames, setSheetNames] = useState<string[]>([])
const [activeSheet, setActiveSheet] = useState(0)
const [currentSheet, setCurrentSheet] = useState<XlsxSheet | null>(null)
const [renderError, setRenderError] = useState<string | null>(null)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const isSavingRef = useRef(false)
const workbookRef = useRef<import('xlsx').WorkBook | null>(null)
const xlsxModuleRef = useRef<typeof import('xlsx') | null>(null)
const dataTableRef = useRef<DataTableHandle>(null)
const updateContent = useUpdateWorkspaceFileContent()
const updateContentRef = useRef(updateContent)
updateContentRef.current = updateContent
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
onSaveStatusChangeRef.current = onSaveStatusChange
useEffect(() => {
if (!fileData) return
const data = fileData
let cancelled = false
async function parse() {
try {
setRenderError(null)
setIsDirty(false)
const XLSX = await import('xlsx')
xlsxModuleRef.current = XLSX
const workbook = XLSX.read(new Uint8Array(data), { type: 'array' })
if (!cancelled) {
workbookRef.current = workbook
setSheetNames(workbook.SheetNames)
setActiveSheet(0)
}
} catch (err) {
if (!cancelled) {
const msg = toError(err).message || 'Failed to parse spreadsheet'
logger.error('XLSX parse failed', { error: msg })
setRenderError(msg)
}
}
}
parse()
return () => {
cancelled = true
}
}, [fileData])
useEffect(() => {
if (sheetNames.length === 0 || !workbookRef.current) return
let cancelled = false
async function parseSheet() {
try {
const XLSX = await import('xlsx')
const workbook = workbookRef.current!
const name = sheetNames[activeSheet]
const sheet = workbook.Sheets[name]
const allRows = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1 })
const headers = (allRows[0] ?? []) as string[]
const dataRows = allRows.slice(1) as string[][]
const truncated = dataRows.length > XLSX_MAX_ROWS
if (!cancelled) {
setCurrentSheet({
name,
headers,
rows: truncated ? dataRows.slice(0, XLSX_MAX_ROWS) : dataRows,
truncated,
})
}
} catch (err) {
if (!cancelled) {
const msg = toError(err).message || 'Failed to parse sheet'
logger.error('XLSX sheet parse failed', { error: msg })
setRenderError(msg)
}
}
}
parseSheet()
return () => {
cancelled = true
}
}, [sheetNames, activeSheet])
const handleCellChange = useCallback(
(row: number, col: number, value: string) => {
const wb = workbookRef.current
const XLSX = xlsxModuleRef.current
if (wb && XLSX) {
const sheetName = sheetNames[activeSheet]
const ws = wb.Sheets[sheetName]
if (ws) {
const cellAddr = XLSX.utils.encode_cell({ r: row + 1, c: col })
const numValue = Number(value)
ws[cellAddr] =
value !== '' && !Number.isNaN(numValue) ? { t: 'n', v: numValue } : { t: 's', v: value }
}
}
setCurrentSheet((prev) => {
if (!prev) return prev
const newRows = prev.rows.map((r, ri) =>
ri === row ? r.map((v, ci) => (ci === col ? value : v)) : r
)
return { ...prev, rows: newRows }
})
setIsDirty(true)
},
[activeSheet, sheetNames]
)
const handleHeaderChange = useCallback(
(col: number, value: string) => {
const wb = workbookRef.current
const XLSX = xlsxModuleRef.current
if (wb && XLSX) {
const sheetName = sheetNames[activeSheet]
const ws = wb.Sheets[sheetName]
if (ws) {
const cellAddr = XLSX.utils.encode_cell({ r: 0, c: col })
ws[cellAddr] = { t: 's', v: value }
}
}
setCurrentSheet((prev) => {
if (!prev) return prev
const newHeaders = prev.headers.map((h, i) => (i === col ? value : h))
return { ...prev, headers: newHeaders }
})
setIsDirty(true)
},
[activeSheet, sheetNames]
)
const handleSave = useCallback(async () => {
dataTableRef.current?.commitEdit()
const wb = workbookRef.current
if (!wb || isSavingRef.current) return
try {
isSavingRef.current = true
setIsSaving(true)
onSaveStatusChangeRef.current?.('saving')
const XLSX = await import('xlsx')
const binary: number[] = XLSX.write(wb, { type: 'array', bookType: 'xlsx' })
const bytes = new Uint8Array(binary)
const chunkSize = 8192
const parts: string[] = []
for (let i = 0; i < bytes.length; i += chunkSize) {
parts.push(String.fromCharCode(...bytes.slice(i, i + chunkSize)))
}
const base64 = btoa(parts.join(''))
await updateContentRef.current.mutateAsync({
workspaceId,
fileId: file.id,
content: base64,
encoding: 'base64',
})
setIsDirty(false)
onSaveStatusChangeRef.current?.('saved')
} catch (err) {
logger.error('XLSX save failed', { error: toError(err).message })
onSaveStatusChangeRef.current?.('error')
} finally {
isSavingRef.current = false
setIsSaving(false)
}
}, [workspaceId, file.id])
useEffect(() => {
if (!saveRef) return
saveRef.current = handleSave
return () => {
if (saveRef.current === handleSave) saveRef.current = null
}
}, [handleSave, saveRef])
useEffect(() => {
if (!canEdit) return
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [canEdit, handleSave])
const editConfig = useMemo(
() =>
canEdit ? { onCellChange: handleCellChange, onHeaderChange: handleHeaderChange } : undefined,
[canEdit, handleCellChange, handleHeaderChange]
)
const error = resolvePreviewError(fetchError, renderError)
if (error) return <PreviewError label='spreadsheet' error={error} />
if (isLoading || currentSheet === null) return XLSX_SKELETON
return (
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)]'>
<div className='flex gap-0'>
{sheetNames.map((name, i) => (
<Button
key={name}
variant='ghost'
size='sm'
onClick={() => setActiveSheet(i)}
className={cn(
'rounded-none px-3 py-1.5 text-[12px]',
i === activeSheet
? 'border-[var(--brand-secondary)] border-b-2 font-medium text-[var(--text-primary)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
)}
>
{name}
</Button>
))}
</div>
{canEdit && isDirty && (
<Button
variant='primary'
size='sm'
onClick={handleSave}
disabled={isSaving}
className='mr-3'
>
{isSaving ? 'Saving…' : 'Save'}
</Button>
)}
</div>
<div className='flex-1 overflow-auto p-6'>
<DataTable
ref={dataTableRef}
headers={currentSheet.headers}
rows={currentSheet.rows}
editConfig={editConfig}
/>
{currentSheet.truncated && (
<p className='mt-3 text-center text-[12px] text-[var(--text-muted)]'>
Showing first {XLSX_MAX_ROWS.toLocaleString()} rows. Download the file to view all data.
</p>
)}
</div>
</div>
)
})
@@ -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,8 +207,10 @@ export function Files() {
})
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
const [deleteTargetFile, setDeleteTargetFile] = useState<WorkspaceFileRecord | null>(null)
const contextMenuFileRef = useRef<WorkspaceFileRecord | null>(null)
const [deleteTargetFile, setDeleteTargetFile] = useState<{ id: string; name: string } | null>(
null
)
const listRename = useInlineRename({
onSave: (fileId, name) => renameFile.mutate({ workspaceId, fileId, name }),
@@ -365,27 +373,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 +408,42 @@ export function Files() {
} finally {
setUploading(false)
setUploadProgress({ completed: 0, total: 0 })
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
},
[workspaceId, uploadFile]
[workspaceId]
)
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)
@@ -475,7 +510,7 @@ export function Files() {
const handleDeleteSelected = useCallback(() => {
const file = selectedFileRef.current
if (file) {
setDeleteTargetFile(file)
setDeleteTargetFile({ id: file.id, name: file.name })
setShowDeleteConfirm(true)
}
}, [])
@@ -527,13 +562,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 +593,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 +606,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
@@ -604,7 +636,7 @@ export function Files() {
const handleContextMenuDelete = useCallback(() => {
const file = contextMenuFileRef.current
if (!file) return
setDeleteTargetFile(file)
setDeleteTargetFile({ id: file.id, name: file.name })
setShowDeleteConfirm(true)
closeContextMenu()
}, [closeContextMenu])
@@ -632,7 +664,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 +681,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 +812,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(
() => [
@@ -814,43 +843,11 @@ export function Files() {
[uploadButtonLabel, handleUploadClick]
)
const handleNavigateToFiles = useCallback(() => {
const handleNavigateToFiles = () => {
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 +890,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>
@@ -961,31 +983,22 @@ export function Files() {
</div>
)}
{hasActiveFilters && (
<button
type='button'
<Button
variant='ghost'
size='sm'
onClick={() => {
setTypeFilter([])
setSizeFilter([])
setUploadedByFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
className='h-[32px] w-full text-[var(--text-secondary)] text-caption hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
</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 +1040,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-[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>
</div>
)
@@ -1049,7 +1078,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 +1116,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 +1138,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 +1191,7 @@ export function Files() {
accept={ACCEPT_ATTR}
multiple
/>
</>
</div>
)
}
@@ -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 fallback={null}>
<Files />
</Suspense>
)
}
@@ -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>
+11 -10
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()
+1
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') {
-25
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
+1
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]
+5
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",
+32 -1
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=="],