From b546113b9fb535da822b6cc85562a841c87faae9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 8 Mar 2026 18:17:11 -0700 Subject: [PATCH 01/17] feat(files): add inline file viewer with text editing and create file modal Add file preview/edit functionality to the workspace files page. Text files (md, json, txt, yaml, etc.) open in an editable textarea with Cmd/Ctrl+S save. PDFs render in an iframe. New file button creates empty .md files via a modal. Uses ResourceHeader breadcrumbs and ResourceOptionsBar for save/download/delete. Co-Authored-By: Claude Opus 4.6 --- .../[id]/files/[fileId]/content/route.ts | 81 +++ .../resource-options-bar.tsx | 2 +- .../create-file-modal/create-file-modal.tsx | 118 ++++ .../components/create-file-modal/index.ts | 1 + .../files/components/file-list/file-list.tsx | 521 ------------------ .../files/components/file-list/index.ts | 1 - .../components/file-viewer/file-viewer.tsx | 202 +++++++ .../files/components/file-viewer/index.ts | 1 + .../[workspaceId]/files/components/index.ts | 1 - .../workspace/[workspaceId]/files/files.tsx | 163 +++++- apps/sim/hooks/queries/workspace-files.ts | 70 +++ apps/sim/lib/audit/log.ts | 1 + .../workspace/workspace-file-manager.ts | 73 +++ packages/testing/src/mocks/audit.mock.ts | 1 + 14 files changed, 703 insertions(+), 533 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/create-file-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-list/file-list.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-list/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/index.ts diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..1fd355b6a06 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('WorkspaceFileContentAPI') + +/** + * PUT /api/workspaces/[id]/files/[fileId]/content + * Update a workspace file's text content (requires write permission) + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string; fileId: string }> } +) { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] 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 } + + if (typeof content !== 'string') { + return NextResponse.json({ error: 'Content must be a string' }, { status: 400 }) + } + + const buffer = Buffer.from(content, 'utf-8') + const updatedFile = await updateWorkspaceFileContent( + workspaceId, + fileId, + session.user.id, + buffer + ) + + logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + description: `Updated content of file "${updatedFile.name}"`, + request, + }) + + return NextResponse.json({ + success: true, + file: updatedFile, + }) + } catch (error) { + logger.error(`[${requestId}] Error updating file content:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to update file content', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 7fa82cc6fb3..034147e6517 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -19,7 +19,7 @@ export function ResourceOptionsBar({ onFilter, toolbarActions, }: ResourceOptionsBarProps) { - const hasContent = search || onSort || onFilter + const hasContent = search || onSort || onFilter || toolbarActions if (!hasContent) return null return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/create-file-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/create-file-modal.tsx new file mode 100644 index 00000000000..9d75e922387 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/create-file-modal.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { createLogger } from '@sim/logger' +import { + Button, + Input, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@/components/emcn' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { useUploadWorkspaceFile } from '@/hooks/queries/workspace-files' + +const logger = createLogger('CreateFileModal') + +interface CreateFileModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (fileId: string) => void + workspaceId: string +} + +export function CreateFileModal({ + open, + onOpenChange, + onCreated, + workspaceId, +}: CreateFileModalProps) { + const uploadFile = useUploadWorkspaceFile() + + const [filename, setFilename] = useState('untitled.md') + const [error, setError] = useState('') + + const handleCreate = useCallback(async () => { + const trimmed = filename.trim() + if (!trimmed) { + setError('Filename is required') + return + } + + if (!trimmed.includes('.')) { + setError('Filename must have an extension') + return + } + + setError('') + + try { + const ext = getFileExtension(trimmed) + const mimeType = getMimeTypeFromExtension(ext) + const blob = new Blob([''], { type: mimeType }) + const file = new File([blob], trimmed, { type: mimeType }) + + const result = await uploadFile.mutateAsync({ workspaceId, file }) + const fileId = result.file?.id + + if (fileId) { + onOpenChange(false) + onCreated(fileId) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create file' + setError(message) + logger.error('Failed to create file:', err) + } + }, [filename, workspaceId, onOpenChange, onCreated]) + + useEffect(() => { + if (open) { + setFilename('untitled.md') + setError('') + } + }, [open]) + + return ( + + + New file + +
+ + { + setFilename(e.target.value) + setError('') + }} + placeholder='untitled.md' + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleCreate() + } + }} + /> +
+
+ +
+ {error &&

{error}

} +
+ + +
+
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/index.ts new file mode 100644 index 00000000000..307b91ed518 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/create-file-modal/index.ts @@ -0,0 +1 @@ +export { CreateFileModal } from './create-file-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-list/file-list.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-list/file-list.tsx deleted file mode 100644 index 75c284aa4ae..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-list/file-list.tsx +++ /dev/null @@ -1,521 +0,0 @@ -'use client' - -import { useMemo, useRef, useState } from 'react' -import { createLogger } from '@sim/logger' -import { ArrowDown, Loader2, Plus, Search, X } from 'lucide-react' -import { useParams } from 'next/navigation' -import { - Button, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, - Trash, -} from '@/components/emcn' -import { Input, Skeleton } from '@/components/ui' -import { getEnv, isTruthy } from '@/lib/core/config/env' -import { cn } from '@/lib/core/utils/cn' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { getFileExtension } from '@/lib/uploads/utils/file-utils' -import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' -import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { - useDeleteWorkspaceFile, - useStorageInfo, - useUploadWorkspaceFile, - useWorkspaceFiles, -} from '@/hooks/queries/workspace-files' - -const logger = createLogger('FileUploadsSettings') -const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) - -const SUPPORTED_EXTENSIONS = [ - // Documents - 'pdf', - 'csv', - 'doc', - 'docx', - 'txt', - 'md', - 'xlsx', - 'xls', - 'html', - 'htm', - 'pptx', - 'ppt', - 'json', - 'yaml', - 'yml', - // Audio formats - 'mp3', - 'm4a', - 'wav', - 'webm', - 'ogg', - 'flac', - 'aac', - 'opus', - // Video formats - 'mp4', - 'mov', - 'avi', - 'mkv', -] as const -const ACCEPT_ATTR = - '.pdf,.csv,.doc,.docx,.txt,.md,.xlsx,.xls,.html,.htm,.pptx,.ppt,.json,.yaml,.yml,.mp3,.m4a,.wav,.webm,.ogg,.flac,.aac,.opus,.mp4,.mov,.avi,.mkv' - -const PLAN_NAMES = { - enterprise: 'Enterprise', - team: 'Team', - pro: 'Pro', - free: 'Free', -} as const - -export function FileList() { - const params = useParams() - const workspaceId = params?.workspaceId as string - - // React Query hooks - with placeholderData to show cached data immediately - const { data: files = [] } = useWorkspaceFiles(workspaceId) - const { data: storageInfo } = useStorageInfo(isBillingEnabled) - const uploadFile = useUploadWorkspaceFile() - const deleteFile = useDeleteWorkspaceFile() - - // Local UI state - const [uploading, setUploading] = useState(false) - const [failedFiles, setFailedFiles] = useState([]) - const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }) - const [downloadingFileId, setDownloadingFileId] = useState(null) - const [search, setSearch] = useState('') - const fileInputRef = useRef(null) - const scrollContainerRef = useRef(null) - - const { userPermissions, permissionsLoading } = useWorkspacePermissionsContext() - - const handleUploadClick = () => { - fileInputRef.current?.click() - } - - const handleFileChange = async (e: React.ChangeEvent) => { - const list = e.target.files - if (!list || list.length === 0 || !workspaceId) return - - try { - setUploading(true) - setFailedFiles([]) - - 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 - }) - - setUploadProgress({ completed: 0, total: allowedFiles.length }) - const failed: string[] = [...unsupported] - - for (let i = 0; i < allowedFiles.length; i++) { - const selectedFile = allowedFiles[i] - try { - await uploadFile.mutateAsync({ workspaceId, file: selectedFile }) - setUploadProgress({ completed: i + 1, total: allowedFiles.length }) - } catch (err) { - logger.error('Error uploading file:', err) - failed.push(selectedFile.name) - } - } - - if (failed.length > 0) { - setFailedFiles(failed) - } - } catch (error) { - logger.error('Error uploading file:', error) - } finally { - setUploading(false) - setUploadProgress({ completed: 0, total: 0 }) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - } - - const handleDownload = async (file: WorkspaceFileRecord) => { - if (!workspaceId || downloadingFileId === file.id) return - - setDownloadingFileId(file.id) - try { - const response = await fetch(`/api/workspaces/${workspaceId}/files/${file.id}/download`, { - method: 'POST', - }) - - if (!response.ok) { - throw new Error('Failed to get download URL') - } - - const data = await response.json() - - if (!data.success || !data.downloadUrl) { - throw new Error('Invalid download response') - } - - const link = document.createElement('a') - link.href = data.downloadUrl - link.download = data.fileName || file.name - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } catch (error) { - logger.error('Error downloading file:', error) - } finally { - setDownloadingFileId(null) - } - } - - const handleDelete = async (file: WorkspaceFileRecord) => { - if (!workspaceId) return - - try { - await deleteFile.mutateAsync({ - workspaceId, - fileId: file.id, - fileSize: file.size, - }) - } catch (error) { - logger.error('Error deleting file:', error) - } - } - - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - - const formatDate = (date: Date | string): string => { - const d = new Date(date) - const mm = String(d.getMonth() + 1).padStart(2, '0') - const dd = String(d.getDate()).padStart(2, '0') - const yy = String(d.getFullYear()).slice(2) - return `${mm}/${dd}/${yy}` - } - - const filteredFiles = useMemo(() => { - if (!search) return files - const q = search.toLowerCase() - return files.filter((f) => f.name.toLowerCase().includes(q)) - }, [files, search]) - - const truncateMiddle = (text: string, start = 24, end = 12) => { - if (!text) return '' - if (text.length <= start + end + 3) return text - return `${text.slice(0, start)}...${text.slice(-end)}` - } - - const formatStorageSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` - } - - const planName = storageInfo?.plan || 'free' - const displayPlanName = PLAN_NAMES[planName as keyof typeof PLAN_NAMES] || 'Free' - - const renderTableSkeleton = () => ( - - - - - - - - - - - - - - - - - - - {Array.from({ length: 3 }, (_, i) => ( - - -
- - -
-
- - - - - - - -
- - -
-
-
- ))} -
-
- ) - - return ( -
- {/* Search and Actions */} -
-
- - setSearch(e.target.value)} - disabled={permissionsLoading} - className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100' - /> -
-
- {(permissionsLoading || userPermissions.canEdit) && ( - <> - - - - )} -
-
- - {/* Content */} -
- {permissionsLoading ? ( - renderTableSkeleton() - ) : files.length === 0 && failedFiles.length === 0 ? ( -
- No files uploaded yet -
- ) : filteredFiles.length === 0 && failedFiles.length === 0 ? ( -
- No files found matching "{search}" -
- ) : ( - - - - - Name - - - Size - - - Uploaded - - - Actions - - - - - {failedFiles.map((fileName, index) => { - const Icon = getDocumentIcon('', fileName) - return ( - - -
- - - {truncateMiddle(fileName)} - -
-
- - — - - - — - - - - -
- ) - })} - {filteredFiles.map((file) => { - const Icon = getDocumentIcon(file.type || '', file.name) - return ( - - -
- - -
-
- - {formatFileSize(file.size)} - - - {formatDate(file.uploadedAt)} - - -
- - - - - Download file - - {userPermissions.canEdit && ( - - - - - Delete file - - )} -
-
-
- ) - })} -
-
- )} -
- - {/* Storage Info - Fixed at bottom */} - {isBillingEnabled && - (permissionsLoading ? ( -
-
-
- -
-
- - / - -
-
-
-
- {Array.from({ length: 12 }).map((_, i) => ( - - ))} -
-
- ) : ( - storageInfo && ( -
-
-
- - {displayPlanName} - -
-
- - {formatStorageSize(storageInfo.usedBytes)} - - / - - {formatStorageSize(storageInfo.limitBytes)} - -
-
-
-
- {Array.from({ length: 12 }).map((_, i) => { - const filledCount = Math.ceil((Math.min(storageInfo.percentUsed, 100) / 100) * 12) - const isFilled = i < filledCount - return ( -
- ) - })} -
-
- ) - ))} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-list/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-list/index.ts deleted file mode 100644 index df456f410a6..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FileList } from './file-list' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx new file mode 100644 index 00000000000..98d6b0535b9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { Skeleton } from '@/components/emcn' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { getFileExtension } from '@/lib/uploads/utils/file-utils' +import { + useUpdateWorkspaceFileContent, + useWorkspaceFileContent, +} from '@/hooks/queries/workspace-files' + +const logger = createLogger('FileViewer') + +const TEXT_EDITABLE_EXTENSIONS = new Set(['md', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm']) + +const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf']) + +interface FileViewerProps { + file: WorkspaceFileRecord + workspaceId: string + canEdit: boolean + onDirtyChange?: (isDirty: boolean) => void + saveRef?: React.MutableRefObject<(() => Promise) | null> +} + +export function FileViewer({ + file, + workspaceId, + canEdit, + onDirtyChange, + saveRef, +}: FileViewerProps) { + const ext = getFileExtension(file.name) + const isTextEditable = TEXT_EDITABLE_EXTENSIONS.has(ext) + const isIframePreviewable = IFRAME_PREVIEWABLE_EXTENSIONS.has(ext) + + if (isTextEditable) { + return ( + + ) + } + + if (isIframePreviewable) { + return + } + + return +} + +interface TextEditorProps { + file: WorkspaceFileRecord + workspaceId: string + canEdit: boolean + onDirtyChange?: (isDirty: boolean) => void + saveRef?: React.MutableRefObject<(() => Promise) | null> +} + +function TextEditor({ file, workspaceId, canEdit, onDirtyChange, saveRef }: TextEditorProps) { + const textareaRef = useRef(null) + const initializedRef = useRef(false) + const contentRef = useRef('') + + const { + data: fetchedContent, + isLoading, + error, + } = useWorkspaceFileContent(workspaceId, file.id, file.key) + + const updateContent = useUpdateWorkspaceFileContent() + + const [content, setContent] = useState('') + const [savedContent, setSavedContent] = useState('') + + useEffect(() => { + if (fetchedContent !== undefined && !initializedRef.current) { + setContent(fetchedContent) + setSavedContent(fetchedContent) + contentRef.current = fetchedContent + initializedRef.current = true + } + }, [fetchedContent]) + + const handleContentChange = useCallback((value: string) => { + setContent(value) + contentRef.current = value + }, []) + + const isDirty = initializedRef.current && content !== savedContent + + useEffect(() => { + onDirtyChange?.(isDirty) + }, [isDirty, onDirtyChange]) + + const handleSave = useCallback(async () => { + const currentContent = contentRef.current + if (currentContent === savedContent) return + + try { + await updateContent.mutateAsync({ + workspaceId, + fileId: file.id, + content: currentContent, + }) + setSavedContent(currentContent) + } catch (err) { + logger.error('Failed to save file content:', err) + } + }, [savedContent, workspaceId, file.id]) + + useEffect(() => { + if (saveRef) { + saveRef.current = handleSave + } + return () => { + if (saveRef) { + saveRef.current = null + } + } + }, [saveRef, handleSave]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault() + handleSave() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleSave]) + + if (isLoading) { + return ( +
+ + + + +
+ ) + } + + if (error) { + return ( +
+

Failed to load file content

+
+ ) + } + + return ( +
+