Skip to content

Commit 3250264

Browse files
committed
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.
1 parent 154b9d0 commit 3250264

17 files changed

Lines changed: 1805 additions & 912 deletions

File tree

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

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { getSession } from '@/lib/auth'
5-
import { generateRequestId } from '@/lib/core/utils/request'
65
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
76
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace'
87
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -17,7 +16,6 @@ const logger = createLogger('WorkspaceFileContentAPI')
1716
*/
1817
export const PUT = withRouteHandler(
1918
async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => {
20-
const requestId = generateRequestId()
2119
const { id: workspaceId, fileId } = await params
2220

2321
try {
@@ -32,20 +30,19 @@ export const PUT = withRouteHandler(
3230
workspaceId
3331
)
3432
if (userPermission !== 'admin' && userPermission !== 'write') {
35-
logger.warn(
36-
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
37-
)
33+
logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`)
3834
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
3935
}
4036

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

4440
if (typeof content !== 'string') {
4541
return NextResponse.json({ error: 'Content must be a string' }, { status: 400 })
4642
}
4743

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

5047
const maxFileSizeBytes = 50 * 1024 * 1024
5148
if (buffer.length > maxFileSizeBytes) {
@@ -62,7 +59,7 @@ export const PUT = withRouteHandler(
6259
buffer
6360
)
6461

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

6764
recordAudit({
6865
workspaceId,
@@ -89,9 +86,9 @@ export const PUT = withRouteHandler(
8986
const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500
9087

9188
if (status === 500) {
92-
logger.error(`[${requestId}] Error updating file content:`, error)
89+
logger.error('Error updating file content:', error)
9390
} else {
94-
logger.warn(`[${requestId}] ${errorMessage}`)
91+
logger.warn(errorMessage)
9592
}
9693

9794
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>
13+
<Files />
14+
</Suspense>
15+
)
16+
}

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

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,63 @@
1-
import { memo } from 'react'
1+
'use client'
2+
3+
import { memo, useCallback, 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
615
}
716

8-
export const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) {
17+
type EditingCell = { row: number; col: number } | null
18+
19+
export const DataTable = memo(function DataTable({ headers, rows, editConfig }: DataTableProps) {
20+
const [editingCell, setEditingCell] = useState<EditingCell>(null)
21+
const [editValue, setEditValue] = useState('')
22+
23+
const setInputRef = useCallback((node: HTMLInputElement | null) => {
24+
if (node) {
25+
node.focus()
26+
node.select()
27+
}
28+
}, [])
29+
30+
const startEdit = (row: number, col: number, currentValue: string) => {
31+
if (!editConfig) return
32+
setEditingCell({ row, col })
33+
setEditValue(currentValue)
34+
}
35+
36+
const commitEdit = () => {
37+
if (!editingCell || !editConfig) return
38+
const { row, col } = editingCell
39+
if (row === -1) {
40+
editConfig.onHeaderChange(col, editValue)
41+
} else {
42+
editConfig.onCellChange(row, col, editValue)
43+
}
44+
setEditingCell(null)
45+
}
46+
47+
const cancelEdit = () => setEditingCell(null)
48+
49+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
50+
if (e.key === 'Enter' || e.key === 'Tab') {
51+
e.preventDefault()
52+
commitEdit()
53+
} else if (e.key === 'Escape') {
54+
cancelEdit()
55+
}
56+
}
57+
58+
const isEditing = (row: number, col: number) =>
59+
editingCell?.row === row && editingCell?.col === col
60+
961
return (
1062
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
1163
<table className='w-full border-collapse text-[13px]'>
@@ -14,9 +66,24 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
1466
{headers.map((header, i) => (
1567
<th
1668
key={i}
17-
className='whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'
69+
className={cn(
70+
'whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]',
71+
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-3)]'
72+
)}
73+
onClick={() => editConfig && startEdit(-1, i, String(header ?? ''))}
1874
>
19-
{String(header ?? '')}
75+
{isEditing(-1, i) ? (
76+
<input
77+
ref={setInputRef}
78+
value={editValue}
79+
onChange={(e) => setEditValue(e.target.value)}
80+
onBlur={commitEdit}
81+
onKeyDown={handleKeyDown}
82+
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'
83+
/>
84+
) : (
85+
String(header ?? '')
86+
)}
2087
</th>
2188
))}
2289
</tr>
@@ -25,8 +92,26 @@ export const DataTable = memo(function DataTable({ headers, rows }: DataTablePro
2592
{rows.map((row, ri) => (
2693
<tr key={ri} className='border-[var(--border)] border-t'>
2794
{headers.map((_, ci) => (
28-
<td key={ci} className='whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]'>
29-
{String(row[ci] ?? '')}
95+
<td
96+
key={ci}
97+
className={cn(
98+
'whitespace-nowrap px-3 py-2 text-[var(--text-secondary)]',
99+
editConfig && 'cursor-pointer select-none hover:bg-[var(--surface-2)]'
100+
)}
101+
onClick={() => editConfig && startEdit(ri, ci, String(row[ci] ?? ''))}
102+
>
103+
{isEditing(ri, ci) ? (
104+
<input
105+
ref={setInputRef}
106+
value={editValue}
107+
onChange={(e) => setEditValue(e.target.value)}
108+
onBlur={commitEdit}
109+
onKeyDown={handleKeyDown}
110+
className='w-full min-w-[60px] bg-transparent text-[13px] text-[var(--text-secondary)] outline-none ring-1 ring-[var(--brand-secondary)] ring-inset'
111+
/>
112+
) : (
113+
String(row[ci] ?? '')
114+
)}
30115
</td>
31116
))}
32117
</tr>

0 commit comments

Comments
 (0)