mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
chore: fix rerenders on files (#3805)
* chore: fix rerenders on files * chore: fix review changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Skeleton } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -183,6 +183,8 @@ function TextEditor({
|
||||
} = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs')
|
||||
|
||||
const updateContent = useUpdateWorkspaceFileContent()
|
||||
const updateContentRef = useRef(updateContent)
|
||||
updateContentRef.current = updateContent
|
||||
|
||||
const [content, setContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
@@ -230,14 +232,14 @@ function TextEditor({
|
||||
const currentContent = contentRef.current
|
||||
if (currentContent === savedContentRef.current) return
|
||||
|
||||
await updateContent.mutateAsync({
|
||||
await updateContentRef.current.mutateAsync({
|
||||
workspaceId,
|
||||
fileId: file.id,
|
||||
content: currentContent,
|
||||
})
|
||||
setSavedContent(currentContent)
|
||||
savedContentRef.current = currentContent
|
||||
}, [workspaceId, file.id, updateContent])
|
||||
}, [workspaceId, file.id])
|
||||
|
||||
const { saveStatus, saveImmediately, isDirty } = useAutosave({
|
||||
content,
|
||||
@@ -402,7 +404,7 @@ function TextEditor({
|
||||
)
|
||||
}
|
||||
|
||||
function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
|
||||
return (
|
||||
@@ -417,9 +419,9 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||
|
||||
return (
|
||||
@@ -432,7 +434,7 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const pptxSlideCache = new Map<string, string[]>()
|
||||
|
||||
@@ -701,7 +703,11 @@ function PptxPreview({
|
||||
)
|
||||
}
|
||||
|
||||
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
const UnsupportedPreview = memo(function UnsupportedPreview({
|
||||
file,
|
||||
}: {
|
||||
file: WorkspaceFileRecord
|
||||
}) {
|
||||
const ext = getFileExtension(file.name)
|
||||
|
||||
return (
|
||||
@@ -714,4 +720,4 @@ function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -42,7 +42,12 @@ interface PreviewPanelProps {
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) {
|
||||
export const PreviewPanel = memo(function PreviewPanel({
|
||||
content,
|
||||
mimeType,
|
||||
filename,
|
||||
isStreaming,
|
||||
}: PreviewPanelProps) {
|
||||
const previewType = resolvePreviewType(mimeType, filename)
|
||||
|
||||
if (previewType === 'markdown')
|
||||
@@ -52,7 +57,7 @@ export function PreviewPanel({ content, mimeType, filename, isStreaming }: Previ
|
||||
if (previewType === 'svg') return <SvgPreview content={content} />
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
|
||||
@@ -197,7 +202,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
)
|
||||
})
|
||||
|
||||
function HtmlPreview({ content }: { content: string }) {
|
||||
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='h-full overflow-hidden'>
|
||||
<iframe
|
||||
@@ -208,9 +213,9 @@ function HtmlPreview({ content }: { content: string }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function SvgPreview({ content }: { content: string }) {
|
||||
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>`,
|
||||
@@ -227,9 +232,9 @@ function SvgPreview({ content }: { content: string }) {
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function CsvPreview({ content }: { content: string }) {
|
||||
const CsvPreview = memo(function CsvPreview({ content }: { content: string }) {
|
||||
const { headers, rows } = useMemo(() => parseCsv(content), [content])
|
||||
|
||||
if (headers.length === 0) {
|
||||
@@ -271,7 +276,7 @@ function CsvPreview({ content }: { content: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -18,7 +19,7 @@ interface FilesListContextMenuProps {
|
||||
disableUpload?: boolean
|
||||
}
|
||||
|
||||
export function FilesListContextMenu({
|
||||
export const FilesListContextMenu = memo(function FilesListContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
@@ -64,4 +65,4 @@ export function FilesListContextMenu({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
HeaderAction,
|
||||
ResourceColumn,
|
||||
ResourceRow,
|
||||
SearchConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
InlineRenameInput,
|
||||
@@ -159,11 +160,29 @@ export function Files() {
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setInputValue(value)
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current)
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(value)
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
const [creatingFile, setCreatingFile] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('preview')
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
|
||||
if (fileIdFromRoute) {
|
||||
const file = files.find((f) => f.id === fileIdFromRoute)
|
||||
if (file && isPreviewable(file)) return 'preview'
|
||||
return 'editor'
|
||||
}
|
||||
return 'preview'
|
||||
})
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [contextMenuFile, setContextMenuFile] = useState<WorkspaceFileRecord | null>(null)
|
||||
@@ -183,59 +202,105 @@ export function Files() {
|
||||
() => (fileIdFromRoute ? files.find((f) => f.id === fileIdFromRoute) : null),
|
||||
[fileIdFromRoute, files]
|
||||
)
|
||||
const selectedFileRef = useRef(selectedFile)
|
||||
selectedFileRef.current = selectedFile
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
if (!searchTerm) return files
|
||||
const q = searchTerm.toLowerCase()
|
||||
if (!debouncedSearchTerm) return files
|
||||
const q = debouncedSearchTerm.toLowerCase()
|
||||
return files.filter((f) => f.name.toLowerCase().includes(q))
|
||||
}, [files, searchTerm])
|
||||
}, [files, debouncedSearchTerm])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
filteredFiles.map((file) => {
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
return {
|
||||
id: file.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: file.name,
|
||||
content:
|
||||
listRename.editingId === file.id ? (
|
||||
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
</span>
|
||||
<InlineRenameInput
|
||||
value={listRename.editValue}
|
||||
onChange={listRename.setEditValue}
|
||||
onSubmit={listRename.submitRename}
|
||||
onCancel={listRename.cancelRename}
|
||||
/>
|
||||
</span>
|
||||
) : undefined,
|
||||
},
|
||||
size: {
|
||||
label: formatFileSize(file.size, { includeBytes: true }),
|
||||
},
|
||||
type: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: formatFileType(file.type, file.name),
|
||||
},
|
||||
created: timeCell(file.uploadedAt),
|
||||
owner: ownerCell(file.uploadedBy, members),
|
||||
updated: timeCell(file.uploadedAt),
|
||||
},
|
||||
sortValues: {
|
||||
size: file.size,
|
||||
created: -new Date(file.uploadedAt).getTime(),
|
||||
updated: -new Date(file.uploadedAt).getTime(),
|
||||
},
|
||||
}
|
||||
}),
|
||||
[filteredFiles, members, listRename.editingId, listRename.editValue]
|
||||
const rowCacheRef = useRef(
|
||||
new Map<string, { row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }>()
|
||||
)
|
||||
|
||||
const baseRows: ResourceRow[] = useMemo(() => {
|
||||
const prevCache = rowCacheRef.current
|
||||
const nextCache = new Map<
|
||||
string,
|
||||
{ row: ResourceRow; file: WorkspaceFileRecord; members: typeof members }
|
||||
>()
|
||||
|
||||
const result = filteredFiles.map((file) => {
|
||||
const cached = prevCache.get(file.id)
|
||||
if (cached && cached.file === file && cached.members === members) {
|
||||
nextCache.set(file.id, cached)
|
||||
return cached.row
|
||||
}
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
const row: ResourceRow = {
|
||||
id: file.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: file.name,
|
||||
},
|
||||
size: {
|
||||
label: formatFileSize(file.size, { includeBytes: true }),
|
||||
},
|
||||
type: {
|
||||
icon: <Icon className='h-[14px] w-[14px]' />,
|
||||
label: formatFileType(file.type, file.name),
|
||||
},
|
||||
created: timeCell(file.uploadedAt),
|
||||
owner: ownerCell(file.uploadedBy, members),
|
||||
updated: timeCell(file.uploadedAt),
|
||||
},
|
||||
sortValues: {
|
||||
size: file.size,
|
||||
created: -new Date(file.uploadedAt).getTime(),
|
||||
updated: -new Date(file.uploadedAt).getTime(),
|
||||
},
|
||||
}
|
||||
nextCache.set(file.id, { row, file, members })
|
||||
return row
|
||||
})
|
||||
|
||||
rowCacheRef.current = nextCache
|
||||
return result
|
||||
}, [filteredFiles, members])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(() => {
|
||||
if (!listRename.editingId) return baseRows
|
||||
return baseRows.map((row) => {
|
||||
if (row.id !== listRename.editingId) return row
|
||||
const file = filteredFiles.find((f) => f.id === row.id)
|
||||
if (!file) return row
|
||||
const Icon = getDocumentIcon(file.type || '', file.name)
|
||||
return {
|
||||
...row,
|
||||
cells: {
|
||||
...row.cells,
|
||||
name: {
|
||||
...row.cells.name,
|
||||
content: (
|
||||
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
|
||||
<span className='flex-shrink-0 text-[var(--text-icon)]'>
|
||||
<Icon className='h-[14px] w-[14px]' />
|
||||
</span>
|
||||
<InlineRenameInput
|
||||
value={listRename.editValue}
|
||||
onChange={listRename.setEditValue}
|
||||
onSubmit={listRename.submitRename}
|
||||
onCancel={listRename.cancelRename}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [
|
||||
baseRows,
|
||||
listRename.editingId,
|
||||
listRename.editValue,
|
||||
listRename.setEditValue,
|
||||
listRename.submitRename,
|
||||
listRename.cancelRename,
|
||||
filteredFiles,
|
||||
])
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const list = e.target.files
|
||||
@@ -288,8 +353,13 @@ export function Files() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteTargetFileRef = useRef(deleteTargetFile)
|
||||
deleteTargetFileRef.current = deleteTargetFile
|
||||
const fileIdFromRouteRef = useRef(fileIdFromRoute)
|
||||
fileIdFromRouteRef.current = fileIdFromRoute
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
const target = deleteTargetFile
|
||||
const target = deleteTargetFileRef.current
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
@@ -299,7 +369,7 @@ export function Files() {
|
||||
})
|
||||
setShowDeleteConfirm(false)
|
||||
setDeleteTargetFile(null)
|
||||
if (fileIdFromRoute === target.id) {
|
||||
if (fileIdFromRouteRef.current === target.id) {
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
@@ -307,36 +377,44 @@ export function Files() {
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete file:', err)
|
||||
}
|
||||
}, [deleteTargetFile, workspaceId, fileIdFromRoute, router])
|
||||
}, [workspaceId, router])
|
||||
|
||||
const isDirtyRef = useRef(isDirty)
|
||||
isDirtyRef.current = isDirty
|
||||
const saveStatusRef = useRef(saveStatus)
|
||||
saveStatusRef.current = saveStatus
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!saveRef.current || !isDirty || saveStatus === 'saving') return
|
||||
if (!saveRef.current || !isDirtyRef.current || saveStatusRef.current === 'saving') return
|
||||
await saveRef.current()
|
||||
}, [isDirty, saveStatus])
|
||||
}, [])
|
||||
|
||||
const handleBackAttempt = useCallback(() => {
|
||||
if (isDirty) {
|
||||
if (isDirtyRef.current) {
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
setPreviewMode('editor')
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}
|
||||
}, [isDirty, router, workspaceId])
|
||||
}, [router, workspaceId])
|
||||
|
||||
const handleStartHeaderRename = useCallback(() => {
|
||||
if (selectedFile) headerRename.startRename(selectedFile.id, selectedFile.name)
|
||||
}, [selectedFile, headerRename.startRename])
|
||||
const file = selectedFileRef.current
|
||||
if (file) headerRename.startRename(file.id, file.name)
|
||||
}, [headerRename.startRename])
|
||||
|
||||
const handleDownloadSelected = useCallback(() => {
|
||||
if (selectedFile) handleDownload(selectedFile)
|
||||
}, [selectedFile, handleDownload])
|
||||
const file = selectedFileRef.current
|
||||
if (file) handleDownload(file)
|
||||
}, [handleDownload])
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
if (selectedFile) {
|
||||
setDeleteTargetFile(selectedFile)
|
||||
const file = selectedFileRef.current
|
||||
if (file) {
|
||||
setDeleteTargetFile(file)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
}, [selectedFile])
|
||||
}, [])
|
||||
|
||||
const fileDetailBreadcrumbs = useMemo(
|
||||
() =>
|
||||
@@ -379,9 +457,6 @@ export function Files() {
|
||||
handleBackAttempt,
|
||||
headerRename.editingId,
|
||||
headerRename.editValue,
|
||||
headerRename.setEditValue,
|
||||
headerRename.submitRename,
|
||||
headerRename.cancelRename,
|
||||
handleStartHeaderRename,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
@@ -396,12 +471,15 @@ export function Files() {
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const creatingFileRef = useRef(creatingFile)
|
||||
creatingFileRef.current = creatingFile
|
||||
|
||||
const handleCreateFile = useCallback(async () => {
|
||||
if (creatingFile) return
|
||||
if (creatingFileRef.current) return
|
||||
setCreatingFile(true)
|
||||
|
||||
try {
|
||||
const existingNames = new Set(files.map((f) => f.name))
|
||||
const existingNames = new Set(filesRef.current.map((f) => f.name))
|
||||
let name = 'untitled.md'
|
||||
let counter = 1
|
||||
while (existingNames.has(name)) {
|
||||
@@ -423,42 +501,49 @@ export function Files() {
|
||||
} finally {
|
||||
setCreatingFile(false)
|
||||
}
|
||||
}, [creatingFile, files, workspaceId, router])
|
||||
}, [workspaceId, router])
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const file = files.find((f) => f.id === rowId)
|
||||
const file = filesRef.current.find((f) => f.id === rowId)
|
||||
if (file) {
|
||||
setContextMenuFile(file)
|
||||
openContextMenu(e)
|
||||
}
|
||||
},
|
||||
[files, openContextMenu]
|
||||
[openContextMenu]
|
||||
)
|
||||
|
||||
const contextMenuFileRef = useRef(contextMenuFile)
|
||||
contextMenuFileRef.current = contextMenuFile
|
||||
|
||||
const handleContextMenuOpen = useCallback(() => {
|
||||
if (!contextMenuFile) return
|
||||
router.push(`/workspace/${workspaceId}/files/${contextMenuFile.id}`)
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
router.push(`/workspace/${workspaceId}/files/${file.id}`)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, closeContextMenu, router, workspaceId])
|
||||
}, [closeContextMenu, router, workspaceId])
|
||||
|
||||
const handleContextMenuDownload = useCallback(() => {
|
||||
if (!contextMenuFile) return
|
||||
handleDownload(contextMenuFile)
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
handleDownload(file)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, handleDownload, closeContextMenu])
|
||||
}, [handleDownload, closeContextMenu])
|
||||
|
||||
const handleContextMenuRename = useCallback(() => {
|
||||
if (contextMenuFile) listRename.startRename(contextMenuFile.id, contextMenuFile.name)
|
||||
const file = contextMenuFileRef.current
|
||||
if (file) listRename.startRename(file.id, file.name)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, listRename.startRename, closeContextMenu])
|
||||
}, [listRename.startRename, closeContextMenu])
|
||||
|
||||
const handleContextMenuDelete = useCallback(() => {
|
||||
if (!contextMenuFile) return
|
||||
setDeleteTargetFile(contextMenuFile)
|
||||
const file = contextMenuFileRef.current
|
||||
if (!file) return
|
||||
setDeleteTargetFile(file)
|
||||
setShowDeleteConfirm(true)
|
||||
closeContextMenu()
|
||||
}, [contextMenuFile, closeContextMenu])
|
||||
}, [closeContextMenu])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -479,41 +564,46 @@ export function Files() {
|
||||
closeListContextMenu()
|
||||
}, [closeListContextMenu])
|
||||
|
||||
useEffect(() => {
|
||||
const prevFileIdRef = useRef(fileIdFromRoute)
|
||||
if (fileIdFromRoute !== prevFileIdRef.current) {
|
||||
prevFileIdRef.current = fileIdFromRoute
|
||||
const isJustCreated =
|
||||
fileIdFromRoute != null && justCreatedFileIdRef.current === fileIdFromRoute
|
||||
if (justCreatedFileIdRef.current && !isJustCreated) {
|
||||
justCreatedFileIdRef.current = null
|
||||
}
|
||||
if (isJustCreated) {
|
||||
setPreviewMode('editor')
|
||||
} else {
|
||||
const file = fileIdFromRoute ? filesRef.current.find((f) => f.id === fileIdFromRoute) : null
|
||||
const canPreview = file ? isPreviewable(file) : false
|
||||
setPreviewMode(canPreview ? 'preview' : 'editor')
|
||||
const nextMode: PreviewMode = isJustCreated
|
||||
? 'editor'
|
||||
: (() => {
|
||||
const file = fileIdFromRoute
|
||||
? filesRef.current.find((f) => f.id === fileIdFromRoute)
|
||||
: null
|
||||
return file && isPreviewable(file) ? 'preview' : 'editor'
|
||||
})()
|
||||
if (nextMode !== previewMode) {
|
||||
setPreviewMode(nextMode)
|
||||
}
|
||||
}, [fileIdFromRoute])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFile) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!fileIdFromRouteRef.current) return
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedFile, handleSave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (!isDirtyRef.current) return
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [handleSave])
|
||||
|
||||
const handleCyclePreviewMode = useCallback(() => {
|
||||
setPreviewMode((prev) => {
|
||||
@@ -592,27 +682,92 @@ export function Files() {
|
||||
selectedFile,
|
||||
saveStatus,
|
||||
previewMode,
|
||||
isDirty,
|
||||
handleCyclePreviewMode,
|
||||
handleTogglePreview,
|
||||
handleSave,
|
||||
isDirty,
|
||||
handleDownloadSelected,
|
||||
handleDeleteSelected,
|
||||
])
|
||||
|
||||
/** Stable refs for values used in callbacks to avoid dependency churn */
|
||||
const listRenameRef = useRef(listRename)
|
||||
listRenameRef.current = listRename
|
||||
const headerRenameRef = useRef(headerRename)
|
||||
headerRenameRef.current = headerRename
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(id: string) => {
|
||||
if (listRenameRef.current.editingId !== id && !headerRenameRef.current.editingId) {
|
||||
router.push(`/workspace/${workspaceId}/files/${id}`)
|
||||
}
|
||||
},
|
||||
[router, workspaceId]
|
||||
)
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
fileInputRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const canEdit = userPermissions.canEdit === true
|
||||
|
||||
const handleSearchClearAll = useCallback(() => {
|
||||
handleSearchChange('')
|
||||
}, [handleSearchChange])
|
||||
|
||||
const searchConfig: SearchConfig = useMemo(
|
||||
() => ({
|
||||
value: inputValue,
|
||||
onChange: handleSearchChange,
|
||||
onClearAll: handleSearchClearAll,
|
||||
placeholder: 'Search files...',
|
||||
}),
|
||||
[inputValue, handleSearchChange, handleSearchClearAll]
|
||||
)
|
||||
|
||||
const createConfig = useMemo(
|
||||
() => ({
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || !canEdit,
|
||||
}),
|
||||
[handleCreateFile, uploading, creatingFile, canEdit]
|
||||
)
|
||||
|
||||
const uploadButtonLabel = useMemo(
|
||||
() =>
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload',
|
||||
[uploading, uploadProgress.completed, uploadProgress.total]
|
||||
)
|
||||
|
||||
const headerActionsConfig = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: uploadButtonLabel,
|
||||
icon: Upload,
|
||||
onClick: handleUploadClick,
|
||||
},
|
||||
],
|
||||
[uploadButtonLabel, handleUploadClick]
|
||||
)
|
||||
|
||||
const handleNavigateToFiles = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/files`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
const loadingBreadcrumbs = useMemo(
|
||||
() => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }],
|
||||
[handleNavigateToFiles]
|
||||
)
|
||||
|
||||
if (fileIdFromRoute && !selectedFile) {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col overflow-hidden bg-[var(--bg)]'>
|
||||
<ResourceHeader
|
||||
icon={FilesIcon}
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Files',
|
||||
onClick: () => router.push(`/workspace/${workspaceId}/files`),
|
||||
},
|
||||
{ label: '...' },
|
||||
]}
|
||||
/>
|
||||
<ResourceHeader icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center'>
|
||||
<Skeleton className='h-[16px] w-[200px]' />
|
||||
</div>
|
||||
@@ -633,7 +788,7 @@ export function Files() {
|
||||
key={selectedFile.id}
|
||||
file={selectedFile}
|
||||
workspaceId={workspaceId}
|
||||
canEdit={userPermissions.canEdit === true}
|
||||
canEdit={canEdit}
|
||||
previewMode={previewMode}
|
||||
autoFocus={justCreatedFileIdRef.current === selectedFile.id}
|
||||
onDirtyChange={setIsDirty}
|
||||
@@ -672,43 +827,18 @@ export function Files() {
|
||||
)
|
||||
}
|
||||
|
||||
const uploadButtonLabel =
|
||||
uploading && uploadProgress.total > 0
|
||||
? `${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: uploading
|
||||
? 'Uploading...'
|
||||
: 'Upload'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Resource
|
||||
icon={FilesIcon}
|
||||
title='Files'
|
||||
create={{
|
||||
label: 'New file',
|
||||
onClick: handleCreateFile,
|
||||
disabled: uploading || creatingFile || userPermissions.canEdit !== true,
|
||||
}}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search files...',
|
||||
}}
|
||||
create={createConfig}
|
||||
search={searchConfig}
|
||||
defaultSort='created'
|
||||
headerActions={[
|
||||
{
|
||||
label: uploadButtonLabel,
|
||||
icon: Upload,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
},
|
||||
]}
|
||||
headerActions={headerActionsConfig}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={(id) => {
|
||||
if (listRename.editingId !== id && !headerRename.editingId) {
|
||||
router.push(`/workspace/${workspaceId}/files/${id}`)
|
||||
}
|
||||
}}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
isLoading={isLoading}
|
||||
onContextMenu={handleContentContextMenu}
|
||||
@@ -720,58 +850,20 @@ export function Files() {
|
||||
onClose={closeListContextMenu}
|
||||
onCreateFile={handleCreateFile}
|
||||
onUploadFile={handleListUploadFile}
|
||||
disableCreate={uploading || creatingFile || userPermissions.canEdit !== true}
|
||||
disableUpload={uploading || userPermissions.canEdit !== true}
|
||||
disableCreate={uploading || creatingFile || !canEdit}
|
||||
disableUpload={uploading || !canEdit}
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
open={isContextMenuOpen}
|
||||
onOpenChange={(open) => !open && closeContextMenu()}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenuPosition.x}px`,
|
||||
top: `${contextMenuPosition.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={handleContextMenuOpen}>
|
||||
<Eye />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleContextMenuDownload}>
|
||||
<Download />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{userPermissions.canEdit === true && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={handleContextMenuRename}>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleContextMenuDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FileRowContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
onClose={closeContextMenu}
|
||||
onOpen={handleContextMenuOpen}
|
||||
onDownload={handleContextMenuDownload}
|
||||
onRename={handleContextMenuRename}
|
||||
onDelete={handleContextMenuDelete}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
|
||||
<DeleteConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
@@ -794,6 +886,75 @@ export function Files() {
|
||||
)
|
||||
}
|
||||
|
||||
interface FileRowContextMenuProps {
|
||||
isOpen: boolean
|
||||
position: { x: number; y: number }
|
||||
onClose: () => void
|
||||
onOpen: () => void
|
||||
onDownload: () => void
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
const FileRowContextMenu = memo(function FileRowContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
onClose,
|
||||
onOpen,
|
||||
onDownload,
|
||||
onRename,
|
||||
onDelete,
|
||||
canEdit,
|
||||
}: FileRowContextMenuProps) {
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={onOpen}>
|
||||
<Eye />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onDownload}>
|
||||
<Download />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onRename}>
|
||||
<Pencil />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onDelete}>
|
||||
<Trash />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -802,7 +963,7 @@ interface DeleteConfirmModalProps {
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
function DeleteConfirmModal({
|
||||
const DeleteConfirmModal = memo(function DeleteConfirmModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileName,
|
||||
@@ -833,4 +994,4 @@ function DeleteConfirmModal({
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user