feat(files): expand file editor to support more formats, add docx/xlsx preview (#3971)

* feat(files): expand file editor to support more formats, add docx/xlsx preview

* lint

* fix(files): narrow fileData type for closure in docx/xlsx preview effects

* fix(files): address PR review — fix xlsx type, simplify error helper, tighten iframe sandbox

* add mothership read externsions

* fix(files): update upload test — js is now a supported extension

* fix(files): deduplicate code extensions, handle dotless filenames

* fix(files): lower xlsx preview row cap to 1k and type workbookRef properly

Reduces XLSX_MAX_ROWS from 10,000 to 1,000 to prevent browser sluggishness
on large spreadsheets. Types workbookRef with the proper xlsx.WorkBook
interface instead of unknown, removing the unsafe cast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(files): extract shared DataTable, isolate client-safe constants

- Move SUPPORTED_CODE_EXTENSIONS to validation-constants.ts so client
  components no longer transitively import Node's `path` module
- Extract shared DataTable component used by both CsvPreview and
  XlsxPreview, eliminating duplicated table markup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(validation): remove Node path import, use plain string extraction

Replace `import path from 'path'` with a simple `extractExtension` helper
that does `fileName.slice(fileName.lastIndexOf('.') + 1)`. This removes
the only Node module dependency from validation.ts, making it safe to
import from client components without pulling in a Node polyfill.

Deletes the unnecessary validation-constants.ts that was introduced as
a workaround — the constants now live back in validation.ts where they
belong.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-05 09:57:49 -07:00
committed by GitHub
parent ebc19484f2
commit 235f0748ca
7 changed files with 373 additions and 61 deletions

View File

@@ -458,10 +458,10 @@ describe('File Upload Security Tests', () => {
expect(response.status).toBe(200)
})
it('should reject JavaScript files', async () => {
it('should reject unsupported file types', async () => {
const formData = new FormData()
const maliciousJs = 'alert("XSS")'
const file = new File([maliciousJs], 'malicious.js', { type: 'application/javascript' })
const content = 'binary data'
const file = new File([content], 'archive.exe', { type: 'application/octet-stream' })
formData.append('file', file)
formData.append('context', 'workspace')
formData.append('workspaceId', 'test-workspace-id')
@@ -475,7 +475,7 @@ describe('File Upload Security Tests', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.message).toContain("File type 'js' is not allowed")
expect(data.message).toContain("File type 'exe' is not allowed")
})
it('should reject files without extensions', async () => {

View File

@@ -8,6 +8,7 @@ import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/works
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_CODE_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
validateFileType,
@@ -23,6 +24,7 @@ const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] as const
const ALLOWED_EXTENSIONS = new Set<string>([
...SUPPORTED_DOCUMENT_EXTENSIONS,
...SUPPORTED_CODE_EXTENSIONS,
...IMAGE_EXTENSIONS,
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,

View File

@@ -0,0 +1,38 @@
import { memo } from 'react'
interface DataTableProps {
headers: string[]
rows: string[][]
}
export const DataTable = memo(function DataTable({ headers, rows }: DataTableProps) {
return (
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>
<thead className='bg-[var(--surface-2)]'>
<tr>
{headers.map((header, i) => (
<th
key={i}
className='whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'
>
{String(header ?? '')}
</th>
))}
</tr>
</thead>
<tbody>
{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>
))}
</tr>
))}
</tbody>
</table>
</div>
)
})

View File

@@ -7,6 +7,7 @@ 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 { SUPPORTED_CODE_EXTENSIONS } from '@/lib/uploads/utils/validation'
import {
useUpdateWorkspaceFileContent,
useWorkspaceFileBinary,
@@ -14,6 +15,7 @@ import {
} from '@/hooks/queries/workspace-files'
import { useAutosave } from '@/hooks/use-autosave'
import { useStreamingText } from '@/hooks/use-streaming-text'
import { DataTable } from './data-table'
import { PreviewPanel, resolvePreviewType } from './preview-panel'
const logger = createLogger('FileViewer')
@@ -29,6 +31,16 @@ const TEXT_EDITABLE_MIME_TYPES = new Set([
'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',
])
@@ -42,6 +54,7 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([
'html',
'htm',
'svg',
...SUPPORTED_CODE_EXTENSIONS,
])
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf'])
@@ -55,11 +68,23 @@ const PPTX_PREVIEWABLE_MIME_TYPES = new Set([
])
const PPTX_PREVIEWABLE_EXTENSIONS = new Set(['pptx'])
const DOCX_PREVIEWABLE_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
])
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'])
type FileCategory =
| 'text-editable'
| 'iframe-previewable'
| 'image-previewable'
| 'pptx-previewable'
| 'docx-previewable'
| 'xlsx-previewable'
| 'unsupported'
function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
@@ -67,12 +92,17 @@ function resolveFileCategory(mimeType: string | null, filename: string): FileCat
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
if (mimeType && PPTX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'pptx-previewable'
if (mimeType && DOCX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'docx-previewable'
if (mimeType && XLSX_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'xlsx-previewable'
const ext = getFileExtension(filename)
if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable'
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 (PPTX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'pptx-previewable'
if (DOCX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'docx-previewable'
if (XLSX_PREVIEWABLE_EXTENSIONS.has(ext)) return 'xlsx-previewable'
return 'unsupported'
}
@@ -142,6 +172,14 @@ export function FileViewer({
return <PptxPreview file={file} workspaceId={workspaceId} streamingContent={streamingContent} />
}
if (category === 'docx-previewable') {
return <DocxPreview file={file} workspaceId={workspaceId} />
}
if (category === 'xlsx-previewable') {
return <XlsxPreview file={file} workspaceId={workspaceId} />
}
return <UnsupportedPreview file={file} />
}
@@ -339,16 +377,7 @@ function TextEditor({
}, [isStreaming, revealedContent])
if (streamingContent === undefined) {
if (isLoading) {
return (
<div className='flex flex-1 flex-col gap-[8px] p-[24px]'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
<Skeleton className='h-[16px] w-[70%]' />
</div>
)
}
if (isLoading) return DOCUMENT_SKELETON
if (error) {
return (
@@ -551,6 +580,29 @@ const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileR
)
})
function resolvePreviewError(fetchError: Error | null, renderError: string | null): string | null {
if (fetchError) return fetchError.message
return renderError
}
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>
)
}
const DOCUMENT_SKELETON = (
<div className='flex flex-1 flex-col gap-[8px] p-[24px]'>
<Skeleton className='h-[16px] w-[60%]' />
<Skeleton className='h-[16px] w-[80%]' />
<Skeleton className='h-[16px] w-[40%]' />
<Skeleton className='h-[16px] w-[70%]' />
</div>
)
const pptxSlideCache = new Map<string, string[]>()
function pptxCacheKey(fileId: string, dataUpdatedAt: number, byteLength: number): string {
@@ -769,23 +821,10 @@ function PptxPreview({
}
}, [fileData, dataUpdatedAt, streamingContent, cacheKey, workspaceId])
const error = fetchError
? fetchError instanceof Error
? fetchError.message
: 'Failed to load file'
: renderError
const error = resolvePreviewError(fetchError, renderError)
const loading = isFetching || rendering
if (error) {
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 presentation
</p>
<p className='text-[13px] text-[var(--text-muted)]'>{error}</p>
</div>
)
}
if (error) return <PreviewError label='presentation' error={error} />
if (loading && slides.length === 0) {
return (
@@ -826,6 +865,211 @@ function toggleMarkdownCheckbox(markdown: string, targetIndex: number, checked:
})
}
const DocxPreview = memo(function DocxPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const {
data: fileData,
isLoading,
error: fetchError,
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
const [html, setHtml] = useState<string | null>(null)
const [renderError, setRenderError] = useState<string | null>(null)
useEffect(() => {
if (!fileData) return
const data = fileData
let cancelled = false
async function convert() {
try {
setRenderError(null)
const mammoth = await import('mammoth')
const result = await mammoth.convertToHtml({ arrayBuffer: data })
if (!cancelled) setHtml(result.value)
} catch (err) {
if (!cancelled) {
const msg = err instanceof Error ? err.message : 'Failed to render document'
logger.error('DOCX render failed', { error: msg })
setRenderError(msg)
}
}
}
convert()
return () => {
cancelled = true
}
}, [fileData])
const error = resolvePreviewError(fetchError, renderError)
if (error) return <PreviewError label='document' error={error} />
if (isLoading || html === null) return DOCUMENT_SKELETON
return (
<div className='flex flex-1 overflow-hidden'>
<iframe
srcDoc={buildDocxPreviewHtml(html)}
sandbox=''
title={file.name}
className='h-full w-full border-0'
/>
</div>
)
})
/** Wraps mammoth HTML output with base styles. Uses raw hex colors because iframes cannot inherit CSS variables from the parent document. */
function buildDocxPreviewHtml(html: string): string {
return `<!DOCTYPE html><html><head><style>
body { margin: 0; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #e4e4e7; background: transparent; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
td, th { border: 1px solid #3f3f46; padding: 6px 10px; }
img { max-width: 100%; height: auto; }
p { margin: 0 0 8px; }
h1, h2, h3, h4, h5, h6 { margin: 16px 0 8px; }
</style></head><body>${html}</body></html>`
}
const XLSX_MAX_ROWS = 1_000
interface XlsxSheet {
name: string
headers: string[]
rows: string[][]
truncated: boolean
}
const XlsxPreview = memo(function XlsxPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
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 workbookRef = useRef<import('xlsx').WorkBook | null>(null)
useEffect(() => {
if (!fileData) return
const data = fileData
let cancelled = false
async function parse() {
try {
setRenderError(null)
const XLSX = await import('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 = err instanceof Error ? 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] ?? []
const dataRows = allRows.slice(1)
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 = err instanceof Error ? err.message : 'Failed to parse sheet'
logger.error('XLSX sheet parse failed', { error: msg })
setRenderError(msg)
}
}
}
parseSheet()
return () => {
cancelled = true
}
}, [sheetNames, activeSheet])
const error = resolvePreviewError(fetchError, renderError)
if (error) return <PreviewError label='spreadsheet' error={error} />
if (isLoading || currentSheet === null) return DOCUMENT_SKELETON
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{sheetNames.length > 1 && (
<div className='flex shrink-0 gap-0 border-[var(--border)] border-b bg-[var(--surface-1)]'>
{sheetNames.map((name, i) => (
<button
key={name}
type='button'
onClick={() => setActiveSheet(i)}
className={cn(
'px-3 py-1.5 text-[12px] transition-colors',
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>
)}
<div className='flex-1 overflow-auto p-6'>
<DataTable headers={currentSheet.headers} rows={currentSheet.rows} />
{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>
)
})
const UnsupportedPreview = memo(function UnsupportedPreview({
file,
}: {

View File

@@ -12,6 +12,7 @@ import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import { useStreamingReveal } from '@/hooks/use-streaming-reveal'
import { DataTable } from './data-table'
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | null
@@ -486,33 +487,7 @@ const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
return (
<div className='h-full overflow-auto p-6'>
<div className='overflow-x-auto rounded-md border border-[var(--border)]'>
<table className='w-full border-collapse text-[13px]'>
<thead className='bg-[var(--surface-2)]'>
<tr>
{headers.map((header, i) => (
<th
key={i}
className='whitespace-nowrap px-3 py-2 text-left font-semibold text-[12px] text-[var(--text-primary)]'
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{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)]'>
{row[ci] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<DataTable headers={headers} rows={rows} />
</div>
)
})

View File

@@ -39,6 +39,7 @@ import {
import {
isSupportedExtension,
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_CODE_EXTENSIONS,
SUPPORTED_DOCUMENT_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
@@ -83,6 +84,7 @@ const logger = createLogger('Files')
const SUPPORTED_EXTENSIONS = [
...SUPPORTED_DOCUMENT_EXTENSIONS,
...SUPPORTED_CODE_EXTENSIONS,
...SUPPORTED_AUDIO_EXTENSIONS,
...SUPPORTED_VIDEO_EXTENSIONS,
] as const

View File

@@ -1,5 +1,3 @@
import path from 'path'
/**
* Checks whether a string is a valid file extension (lowercase alphanumeric only).
* Rejects extensions containing spaces, punctuation, or other non-alphanumeric characters
@@ -9,6 +7,11 @@ export function isAlphanumericExtension(ext: string): boolean {
return /^[a-z0-9]+$/.test(ext)
}
function extractExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.')
return lastDot !== -1 ? fileName.slice(lastDot + 1).toLowerCase() : ''
}
export const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
export const SUPPORTED_DOCUMENT_EXTENSIONS = [
@@ -29,6 +32,54 @@ export const SUPPORTED_DOCUMENT_EXTENSIONS = [
'yml',
] as const
export const SUPPORTED_CODE_EXTENSIONS = [
'mdx',
'xml',
'css',
'scss',
'less',
'js',
'jsx',
'ts',
'tsx',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'swift',
'c',
'cpp',
'h',
'hpp',
'cs',
'php',
'sh',
'bash',
'zsh',
'fish',
'sql',
'graphql',
'gql',
'toml',
'ini',
'conf',
'cfg',
'env',
'log',
'diff',
'patch',
'dockerfile',
'makefile',
'gitignore',
'editorconfig',
'prettierrc',
'eslintrc',
] as const
export type SupportedCodeExtension = (typeof SUPPORTED_CODE_EXTENSIONS)[number]
export const SUPPORTED_AUDIO_EXTENSIONS = [
'mp3',
'm4a',
@@ -146,7 +197,7 @@ export interface FileValidationError {
* Validate if a file type is supported for document processing
*/
export function validateFileType(fileName: string, mimeType: string): FileValidationError | null {
const raw = path.extname(fileName).toLowerCase().substring(1)
const raw = extractExtension(fileName)
const extension = (isAlphanumericExtension(raw) ? raw : '') as SupportedDocumentExtension
if (!SUPPORTED_DOCUMENT_EXTENSIONS.includes(extension)) {
@@ -231,7 +282,7 @@ export function validateMediaFileType(
fileName: string,
mimeType: string
): FileValidationError | null {
const raw = path.extname(fileName).toLowerCase().substring(1)
const raw = extractExtension(fileName)
const extension = isAlphanumericExtension(raw) ? raw : ''
const isAudio = SUPPORTED_AUDIO_EXTENSIONS.includes(extension as SupportedAudioExtension)