Skip to content

Commit 6a7d5ae

Browse files
authored
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
1 parent dc20229 commit 6a7d5ae

25 files changed

Lines changed: 3862 additions & 2139 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
3+
import { toError } from '@sim/utils/errors'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { getSession } from '@/lib/auth'
5-
import { generateRequestId } from '@/lib/core/utils/request'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
88
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -17,7 +17,6 @@ const logger = createLogger('WorkspaceFileContentAPI')
1717
*/
1818
export const PUT = withRouteHandler(
1919
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
20-
const requestId = generateRequestId()
2120
const { id: workspaceId, fileId } = await params
2221

2322
try {
@@ -32,20 +31,19 @@ export const PUT = withRouteHandler(
3231
workspaceId
3332
)
3433
if (userPermission !== 'admin' && userPermission !== 'write') {
35-
logger.warn(
36-
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
37-
)
34+
logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
3835
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
3936
}
4037

4138
const body = await request.json()
42-
const { content } = body as { content: string }
39+
const { content, encoding } = body as { content: string; encoding?: 'base64' | 'utf-8' }
4340

4441
if (typeof content !== 'string') {
4542
return NextResponse.json({ error: 'Content must be a string' }, { status: 400 })
4643
}
4744

48-
const buffer = Buffer.from(content, 'utf-8')
45+
const buffer =
46+
encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8')
4947

5048
const maxFileSizeBytes = 50 * 1024 * 1024
5149
if (buffer.length > maxFileSizeBytes) {
@@ -62,7 +60,7 @@ export const PUT = withRouteHandler(
6260
buffer
6361
)
6462

65-
logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`)
63+
logger.info(`Updated content for workspace file: ${updatedFile.name}`)
6664

6765
recordAudit({
6866
workspaceId,
@@ -83,15 +81,15 @@ export const PUT = withRouteHandler(
8381
file: updatedFile,
8482
})
8583
} catch (error) {
86-
const errorMessage = error instanceof Error ? error.message : 'Failed to update file content'
84+
const errorMessage = toError(error).message || 'Failed to update file content'
8785
const isNotFound = errorMessage.includes('File not found')
8886
const isQuotaExceeded = errorMessage.includes('Storage limit exceeded')
8987
const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
9088

9189
if (status === 500) {
92-
logger.error(`[${requestId}] Error updating file content:`, error)
90+
logger.error('Error updating file content:', error)
9391
} else {
94-
logger.warn(`[${requestId}] ${errorMessage}`)
92+
logger.warn(errorMessage)
9593
}
9694

9795
return NextResponse.json({ success: false, error: errorMessage }, { status })
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { Files } from '../files'
34

@@ -6,4 +7,10 @@ export const metadata: Metadata = {
67
robots: { index: false },
78
}
89

9-
export default Files
10+
export default function FilesFilePage() {
11+
return (
12+
<Suspense fallback={null}>
13+
<Files />
14+
</Suspense>
15+
)
16+
}
Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,98 @@
1-
import { memo } from 'react'
1+
'use client'
2+
3+
import { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'
4+
import { cn } from '@/lib/core/utils/cn'
5+
6+
interface EditConfig {
7+
onCellChange: (row: number, col: number, value: string) => void
8+
onHeaderChange: (col: number, value: string) => void
9+
}
210

311
interface DataTableProps {
412
headers: string[]
513
rows: string[][]
14+
editConfig?: EditConfig
15+
}
16+
17+
export interface DataTableHandle {
18+
commitEdit: () => void
619
}
720

8-
export const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) {
21+
type EditingCell = { row: number; col: number } | null
22+
23+
const DataTableBase = forwardRef<DataTableHandle, DataTableProps>(function DataTable(
24+
{ headers, rows, editConfig },
25+
ref
26+
) {
27+
const [editingCell, setEditingCell] = useState<EditingCell>(null)
28+
const [editValue, setEditValue] = useState('')
29+
30+
const editStateRef = useRef({ editingCell, editValue, editConfig })
31+
editStateRef.current = { editingCell, editValue, editConfig }
32+
33+
// Prevents double-commit if onBlur and imperative commitEdit fire concurrently
34+
const isCommittedRef = useRef(false)
35+
36+
useImperativeHandle(
37+
ref,
38+
() => ({
39+
commitEdit: () => {
40+
if (isCommittedRef.current) return
41+
const { editingCell, editValue, editConfig } = editStateRef.current
42+
if (!editingCell || !editConfig) return
43+
isCommittedRef.current = true
44+
const { row, col } = editingCell
45+
if (row === -1) {
46+
editConfig.onHeaderChange(col, editValue)
47+
} else {
48+
editConfig.onCellChange(row, col, editValue)
49+
}
50+
setEditingCell(null)
51+
},
52+
}),
53+
[]
54+
)
55+
56+
const setInputRef = useCallback((node: HTMLInputElement | null) => {
57+
if (node) {
58+
node.focus()
59+
node.select()
60+
}
61+
}, [])
62+
63+
const startEdit = (row: number, col: number, currentValue: string) => {
64+
if (!editConfig) return
65+
isCommittedRef.current = false
66+
setEditingCell({ row, col })
67+
setEditValue(currentValue)
68+
}
69+
70+
const commitEdit = () => {
71+
if (isCommittedRef.current || !editingCell || !editConfig) return
72+
isCommittedRef.current = true
73+
const { row, col } = editingCell
74+
if (row === -1) {
75+
editConfig.onHeaderChange(col, editValue)
76+
} else {
77+
editConfig.onCellChange(row, col, editValue)
78+
}
79+
setEditingCell(null)
80+
}
81+
82+
const cancelEdit = () => setEditingCell(null)
83+
84+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
85+
if (e.key === 'Enter' || e.key === 'Tab') {
86+
e.preventDefault()
87+
commitEdit()
88+
} else if (e.key === 'Escape') {
89+
cancelEdit()
90+
}
91+
}
92+
93+
const isEditing = (row: number, col: number) =>
94+
editingCell?.row === row && editingCell?.col === col
95+
996
return (
1097
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
1198
<table className='w-full border-collapse text-[13px]'>
@@ -14,9 +101,24 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
14101
{headers.map((header, i) => (
15102
<th
16103
key={i}
17-
className='whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'
104+
className={cn(
105+
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
106+
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
107+
)}
108+
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
18109
>
19-
{String(header ?? '')}
110+
{isEditing(-1, i) ? (
111+
<input
112+
ref={setInputRef}
113+
value={editValue}
114+
onChange={(e) => setEditValue(e.target.value)}
115+
onBlur={commitEdit}
116+
onKeyDown={handleKeyDown}
117+
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'
118+
/>
119+
) : (
120+
String(header ?? '')
121+
)}
20122
</th>
21123
))}
22124
</tr>
@@ -25,8 +127,26 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
25127
{rows.map((row, ri) => (
26128
<tr key={ri} className='border-[var(--border)] border-t'>
27129
{headers.map((_, ci) => (
28-
<td key={ci} className='whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]'>
29-
{String(row[ci] ?? '')}
130+
<td
131+
key={ci}
132+
className={cn(
133+
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
134+
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
135+
)}
136+
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
137+
>
138+
{isEditing(ri, ci) ? (
139+
<input
140+
ref={setInputRef}
141+
value={editValue}
142+
onChange={(e) => setEditValue(e.target.value)}
143+
onBlur={commitEdit}
144+
onKeyDown={handleKeyDown}
145+
className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
146+
/>
147+
) : (
148+
String(row[ci] ?? '')
149+
)}
30150
</td>
31151
))}
32152
</tr>
@@ -36,3 +156,5 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
36156
</div>
37157
)
38158
})
159+
160+
export const DataTable = memo(DataTableBase)

0 commit comments

Comments
 (0)