import { useEffect, useMemo, useRef, useState } from 'react'; import { useAnalytics } from '../analytics/provider'; import { trackFileManagerClick } from '../analytics/events'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { projectFileUrl, projectRawUrl } from '../providers/registry'; import { buildSrcdoc } from '../runtime/srcdoc'; import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind } from '../types'; import { createFileSystemReadError, FILE_SYSTEM_READ_ERROR_MESSAGE, isFileSystemReadError, } from '../utils/fileSystemErrors'; import type { PluginFolderAgentAction } from './design-files/pluginFolderActions'; import { getPluginFolderCandidates } from './design-files/pluginFolders'; import { Icon } from './Icon'; import { LiveArtifactBadges } from './LiveArtifactBadges'; import { isRenderableSketchJson, SketchPreview } from './SketchPreview'; type TranslateFn = (key: keyof Dict, vars?: Record) => string; interface Props { projectId: string; files: ProjectFile[]; liveArtifacts: LiveArtifactWorkspaceEntry[]; onRefreshFiles: () => Promise | void; onOpenFile: (name: string) => void; onOpenLiveArtifact: (tabId: LiveArtifactWorkspaceEntry['tabId']) => void; onRenameFile: (from: string, to: string) => Promise | ProjectFile | null; onDeleteFile: (name: string) => void; onDeleteFiles: (names: string[]) => Promise | void; onUpload: () => void; onUploadFiles: (files: File[]) => void; onPaste: () => void; onNewSketch: () => void; uploadError?: string | null; onClearUploadError?: () => void; onPluginFolderAgentAction?: ( relativePath: string, action: PluginFolderAgentAction, ) => Promise<{ message?: string; url?: string } | void> | { message?: string; url?: string } | void; activePluginActionPaths?: Set; hiddenPluginActionPaths?: Set; } interface ActionNotice { message: string; url?: string; } type DesignFilesGroupMode = 'kind' | 'modified'; type ModifiedSection = 'today' | 'yesterday' | 'previous7Days' | 'previous30Days' | 'older'; type SortKey = 'name' | 'kind' | 'mtime'; type SortDir = 'asc' | 'desc'; // Storage key for per-project view state. Bump the version suffix (v1 → v2) when // removing or renaming a persisted field — just adding an optional field is safe // without a version bump. No cleanup of old keys on project deletion; the keys // are small preference blobs and orphan gracefully. const VIEW_STATE_KEY_PREFIX = 'od:design-files:view-state:v1:'; const DEFAULT_SORT_KEY: SortKey = 'mtime'; const DEFAULT_SORT_DIR: SortDir = 'desc'; const DEFAULT_PAGE_SIZE: number | 'all' = 30; const PAGE_SIZE_OPTIONS = [15, 30, 45, 60, 'all'] as const; interface PersistedViewState { sortKey?: SortKey; sortDir?: SortDir; pageSize?: number | 'all'; kindFilter?: string[]; } function readViewState(projectId: string): PersistedViewState { try { if (typeof window === 'undefined') return {}; const raw = localStorage.getItem(VIEW_STATE_KEY_PREFIX + projectId); if (!raw) return {}; const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; return parsed as PersistedViewState; } catch { return {}; } } function writeViewState(projectId: string, state: PersistedViewState): void { try { localStorage.setItem(VIEW_STATE_KEY_PREFIX + projectId, JSON.stringify(state)); } catch { // localStorage unavailable (private mode, quota exceeded) — silently skip } } function isSortKey(v: unknown): v is SortKey { return v === 'name' || v === 'kind' || v === 'mtime'; } function isSortDir(v: unknown): v is SortDir { return v === 'asc' || v === 'desc'; } function isPageSize(v: unknown): v is number | 'all' { return (PAGE_SIZE_OPTIONS as ReadonlyArray).includes(v); } // Validate that a value is one of the known ProjectFileKind literals. This // guards against stored values that were valid under a previous schema but // are no longer part of the union — they are silently dropped rather than // poisoning the kindFilter state. const VALID_KIND_SET: ReadonlySet = new Set([ 'html', 'image', 'video', 'audio', 'sketch', 'text', 'code', 'pdf', 'document', 'presentation', 'spreadsheet', 'binary', ]); function isProjectFileKind(v: unknown): v is ProjectFileKind { return typeof v === 'string' && VALID_KIND_SET.has(v); } type FileSystemEntryWithReader = FileSystemEntry & { createReader?: () => FileSystemDirectoryReader; }; type FileSystemFileEntryWithFile = FileSystemFileEntry & { file: ( successCallback: (file: File) => void, errorCallback?: (error: DOMException) => void, ) => void; }; type DataTransferItemWithEntry = DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null; }; const MODIFIED_SECTION_ORDER: ModifiedSection[] = [ 'today', 'yesterday', 'previous7Days', 'previous30Days', 'older', ]; const MODIFIED_SECTION_LABEL_KEY: Record = { today: 'designFiles.modifiedToday', yesterday: 'designFiles.modifiedYesterday', previous7Days: 'designFiles.modifiedPrevious7Days', previous30Days: 'designFiles.modifiedPrevious30Days', older: 'designFiles.modifiedOlder', }; function buildActionNotice(message: string, url?: string): ActionNotice { const trimmedMessage = message.trim(); const trimmedUrl = url?.trim(); if (!trimmedUrl) return { message: trimmedMessage }; const normalizedMessage = trimmedMessage.replace(new RegExp(`\\s*${escapeRegExp(trimmedUrl)}\\s*$`), ''); return { message: normalizedMessage.trim() || trimmedUrl, url: trimmedUrl }; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function ActionNoticeView({ notice }: { notice: ActionNotice | null }) { if (!notice) return null; return ( <> {notice.message} {notice.url ? ( <> {' '} {notice.url} ) : null} ); } /** * Full-panel browser for a project's `.od/projects//` folder. Mirrors * Claude Design's "Design Files" surface: grouped sections, hover-revealed * row menu, drop-files footer, and (when a row is selected) a right-side * preview pane. Triggered as a sticky first tab in FileWorkspace. */ export function DesignFilesPanel({ projectId, files, liveArtifacts, onRefreshFiles, onOpenFile, onOpenLiveArtifact, onRenameFile, onDeleteFile, onDeleteFiles, onUpload, onUploadFiles, onPaste, onNewSketch, uploadError = null, onClearUploadError, onPluginFolderAgentAction, activePluginActionPaths = new Set(), hiddenPluginActionPaths = new Set(), }: Props) { const t = useT(); const analytics = useAnalytics(); const [refreshing, setRefreshing] = useState(false); const [draggingFiles, setDraggingFiles] = useState(false); const [dropReadError, setDropReadError] = useState(null); const dragDepthRef = useRef(0); const [hover, setHover] = useState(null); const [menuPos, setMenuPos] = useState<{ name: string; top: number; left: number } | null>(null); const MENU_ESTIMATED_HEIGHT = 145; const MENU_SAFE_PADDING = 8; const [preview, setPreview] = useState(null); const [selected, setSelected] = useState>(new Set()); // Read once at mount; projectId is stable for this component instance // (parent uses key={projectId} to remount on project switch). const savedViewState = useRef(readViewState(projectId)); // Guard for the persist useEffect: skip the initial write so we only // flush to localStorage when the user actually changes a preference. // Without this, every project the user opens gets a default-value entry // written on first render, making stale-key garbage grow unbounded. // Note: React 18 StrictMode (active in next dev) fires effects twice, // keeping refs intact across the simulated remount. This means the guard // fires on the first effect run, sets the ref true, and the second run // then writes the defaults. The result is a harmless default-value entry // for the project; subsequent user changes overwrite it correctly. The // invariant ("no write without a user action") only holds in production // builds where StrictMode is not active. const viewStateHasMounted = useRef(false); const [sortKey, setSortKey] = useState( () => isSortKey(savedViewState.current.sortKey) ? savedViewState.current.sortKey : DEFAULT_SORT_KEY, ); const [sortDir, setSortDir] = useState( () => isSortDir(savedViewState.current.sortDir) ? savedViewState.current.sortDir : DEFAULT_SORT_DIR, ); const lastKeyPress = useRef>(new Map()); const [deleting, setDeleting] = useState(false); const [installingFolder, setInstallingFolder] = useState(null); const [sharingFolder, setSharingFolder] = useState(null); const [installNotice, setInstallNotice] = useState(null); const [groupMode, setGroupMode] = useState('kind'); const [collapsedModifiedSections, setCollapsedModifiedSections] = useState< Set >(new Set()); const [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null); const [dayBoundary, setDayBoundary] = useState(() => Date.now()); const [kindFilter, setKindFilter] = useState>(() => { const { kindFilter: kf } = savedViewState.current; if (!Array.isArray(kf) || kf.length === 0) return new Set(); // Validate each stored value against the current ProjectFileKind union so // stale values from a prior schema (e.g. a renamed kind) are dropped silently. return new Set(kf.filter(isProjectFileKind)); }); const [filterMenuOpen, setFilterMenuOpen] = useState(false); const filterMenuRef = useRef(null); const [currentDir, setCurrentDir] = useState(''); // Derive immediate subdirectories and files at the current directory level // from the flat files list. Files with names like "a/b/c.html" contribute // "a" as a directory when currentDir is '' and "b" when currentDir is "a". const { dirsAtCurrentDir, filesAtCurrentDir } = useMemo(() => { const prefix = currentDir === '' ? '' : `${currentDir}/`; const dirs = new Set(); const localFiles: ProjectFile[] = []; for (const f of files) { if (!f.name.startsWith(prefix)) continue; const remainder = f.name.slice(prefix.length); const slashIdx = remainder.indexOf('/'); if (slashIdx === -1) { localFiles.push(f); } else { dirs.add(remainder.slice(0, slashIdx)); } } return { dirsAtCurrentDir: [...dirs].sort((a, b) => a.localeCompare(b)), filesAtCurrentDir: localFiles, }; }, [files, currentDir]); const kindCounts = useMemo(() => { const counts = new Map(); for (const f of filesAtCurrentDir) counts.set(f.kind, (counts.get(f.kind) ?? 0) + 1); return counts; }, [filesAtCurrentDir]); const availableKinds = useMemo( () => Array.from(kindCounts.keys()).sort( (a, b) => kindSortPriority(a) - kindSortPriority(b), ), [kindCounts], ); // Drop any selected-filter kinds that no longer appear in the file list // (e.g. after a delete leaves the kind empty). Keeps the filter UI honest // and prevents a stale filter from silently hiding everything. // Guard: skip when no kinds are available yet — availableKinds is empty only // when files haven't loaded. Running cleanup against an empty set would // clear a kindFilter that was correctly restored from localStorage before // the async file list arrived. useEffect(() => { if (availableKinds.length === 0) return; setKindFilter((prev) => { if (prev.size === 0) return prev; const present = new Set(availableKinds); const next = new Set(); let changed = false; for (const k of prev) { if (present.has(k)) next.add(k); else changed = true; } return changed ? next : prev; }); }, [availableKinds]); const filteredFiles = useMemo(() => { if (kindFilter.size === 0) return filesAtCurrentDir; return filesAtCurrentDir.filter((f) => kindFilter.has(f.kind)); }, [filesAtCurrentDir, kindFilter]); const sortedFiles = useMemo(() => { return [...filteredFiles].sort((a, b) => { let cmp: number; if (sortKey === 'name') cmp = a.name.localeCompare(b.name); else if (sortKey === 'kind') cmp = kindSortPriority(a.kind) - kindSortPriority(b.kind); else cmp = a.mtime - b.mtime; return sortDir === 'asc' ? cmp : -cmp; }); }, [filteredFiles, sortKey, sortDir]); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState( () => isPageSize(savedViewState.current.pageSize) ? savedViewState.current.pageSize : DEFAULT_PAGE_SIZE, ); const effectivePageSize = pageSize === 'all' ? Math.max(1, sortedFiles.length) : pageSize; const totalPages = Math.max(1, Math.ceil(sortedFiles.length / effectivePageSize)); const safePage = Math.min(page, totalPages - 1); const pageFiles = useMemo( () => sortedFiles.slice( safePage * effectivePageSize, (safePage + 1) * effectivePageSize, ), [effectivePageSize, safePage, sortedFiles], ); const modifiedGroups = useMemo(() => { const groups: Record = { today: [], yesterday: [], previous7Days: [], previous30Days: [], older: [], }; const thresholds = modifiedSectionThresholds(dayBoundary); for (const f of pageFiles) { groups[modifiedSectionFor(f.mtime, thresholds)].push(f); } return groups; }, [dayBoundary, pageFiles]); const visibleModifiedSections = MODIFIED_SECTION_ORDER.filter( (section) => modifiedGroups[section].length > 0, ); const rangeStart = safePage * effectivePageSize + 1; const rangeEnd = Math.min((safePage + 1) * effectivePageSize, sortedFiles.length); const allPageSelected = pageFiles.length > 0 && pageFiles.every((f) => selected.has(f.name)); const somePageSelected = !allPageSelected && pageFiles.some((f) => selected.has(f.name)); const hasMultiplePages = totalPages > 1; const showListControls = sortedFiles.length > 15 || selected.size > 0; useEffect(() => { setPage(0); }, [pageSize]); // Persist view state so it survives navigation (the panel remounts via // key={projectId} when the user tabs away and back). // Skip the initial render: we only want to write when the user actually // changes a preference, not on every project the user visits. useEffect(() => { if (!viewStateHasMounted.current) { viewStateHasMounted.current = true; return; } writeViewState(projectId, { sortKey, sortDir, pageSize, kindFilter: Array.from(kindFilter), }); }, [projectId, sortKey, sortDir, pageSize, kindFilter]); // Reset to the first page when the filter changes — the previous page // index may no longer exist (or may now sit past the new totalPages). useEffect(() => { setPage(0); }, [kindFilter]); // Drop any selected files that fall outside the active filter. Without // this, bulk delete / download would silently operate on rows the user // can no longer see — particularly dangerous for destructive deletes. // We keep the empty-filter branch a no-op so clearing the filter // doesn't disturb existing selections. useEffect(() => { if (kindFilter.size === 0) return; setSelected((prev) => { if (prev.size === 0) return prev; const visible = new Set(filteredFiles.map((f) => f.name)); const next = new Set(); let changed = false; for (const name of prev) { if (visible.has(name)) next.add(name); else changed = true; } return changed ? next : prev; }); }, [filteredFiles, kindFilter]); // Reset page, selection, and renaming state when the user navigates // into or out of a directory. useEffect(() => { setPage(0); setSelected(new Set()); setRenaming(null); }, [currentDir]); // Navigate up to the nearest ancestor that still exists when files under // currentDir disappear (e.g. after deleting the last file in a subfolder). useEffect(() => { if (currentDir === '') return; const prefix = `${currentDir}/`; if (files.some((f) => f.name.startsWith(prefix))) return; const parts = currentDir.split('/'); for (let i = parts.length - 1; i > 0; i--) { const ancestor = parts.slice(0, i).join('/'); if (files.some((f) => f.name.startsWith(`${ancestor}/`))) { setCurrentDir(ancestor); return; } } setCurrentDir(''); }, [files, currentDir]); // Outside-click + escape to close the filter popover. Stops short of a // full focus trap because the popover hosts only checkboxes plus a // small clear button; the existing tab order through them is fine. useEffect(() => { if (!filterMenuOpen) return; const onMouseDown = (event: MouseEvent) => { const root = filterMenuRef.current; if (root && event.target instanceof Node && !root.contains(event.target)) { setFilterMenuOpen(false); } }; const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setFilterMenuOpen(false); }; window.addEventListener('mousedown', onMouseDown); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('mousedown', onMouseDown); window.removeEventListener('keydown', onKey); }; }, [filterMenuOpen]); function toggleKindFilter(kind: ProjectFileKind): void { setKindFilter((prev) => { const next = new Set(prev); if (next.has(kind)) next.delete(kind); else next.add(kind); return next; }); } useEffect(() => { if (Number.isFinite(totalPages)) setPage((p) => Math.min(p, totalPages - 1)); }, [totalPages]); useEffect(() => { const now = Date.now(); const startOfTomorrow = new Date(now); startOfTomorrow.setHours(24, 0, 0, 0); const timer = window.setTimeout( () => setDayBoundary(Date.now()), Math.max(1, startOfTomorrow.getTime() - now), ); return () => window.clearTimeout(timer); }, [dayBoundary]); const pluginFolders = useMemo(() => getPluginFolderCandidates(files), [files]); // Prune selections that no longer exist in the current file list // (e.g. after a refresh or delete within the same project). // Cross-project leaks are handled by the parent remounting this // component via key={projectId}. useEffect(() => { setSelected((prev) => { if (prev.size === 0) return prev; const names = new Set(files.map((f) => f.name)); const next = new Set(prev); let changed = false; for (const n of next) { if (!names.has(n)) { next.delete(n); changed = true; } } return changed ? next : prev; }); }, [files]); const previewFile = useMemo( () => files.find((f) => f.name === preview) ?? null, [preview, files], ); useEffect(() => { if (!menuPos) return; const close = () => setMenuPos(null); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); }; window.addEventListener('mousedown', close); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('mousedown', close); window.removeEventListener('keydown', onKey); }; }, [menuPos]); async function handleRefresh() { setRefreshing(true); try { await onRefreshFiles(); } finally { setRefreshing(false); } } function toggleSort(key: SortKey) { return () => { if (sortKey === key) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setSortKey(key); setSortDir('asc'); } }; } function toggleSelect(name: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(name)) { next.delete(name); } else { next.add(name); } return next; }); } function toggleSelectPage() { setSelected((prev) => { const next = new Set(prev); if (allPageSelected) { for (const f of pageFiles) next.delete(f.name); } else { for (const f of pageFiles) next.add(f.name); } return next; }); } function selectAllFiles() { setSelected(new Set(sortedFiles.map((f) => f.name))); } function clearSelection() { setSelected(new Set()); } function openMenuFor(name: string, el: HTMLElement) { const rect = el.closest('.df-row-menu')?.getBoundingClientRect(); if (!rect) return; const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; let top: number; if (spaceBelow >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) { top = rect.bottom + 4; } else if (spaceAbove >= MENU_ESTIMATED_HEIGHT + MENU_SAFE_PADDING) { top = rect.top - MENU_ESTIMATED_HEIGHT - 4; } else { top = Math.max( MENU_SAFE_PADDING, viewportHeight - MENU_ESTIMATED_HEIGHT - MENU_SAFE_PADDING, ); } const left = Math.max(MENU_SAFE_PADDING, rect.right - 160); setMenuPos({ name, top, left }); } function startRename(name: string) { setMenuPos(null); setPreview(name); const draft = currentDir === '' ? name : name.slice(currentDir.length + 1); setRenaming({ name, draft, saving: false }); } async function commitRename(name: string, draft: string) { const nextBasename = draft.trim(); if (!nextBasename) { setRenaming(null); return; } const nextName = currentDir === '' ? nextBasename : `${currentDir}/${nextBasename}`; if (nextName === name) { setRenaming(null); return; } setRenaming({ name, draft, saving: true }); try { const renamed = await onRenameFile(name, nextName); if (!renamed) throw new Error('Rename failed'); setPreview((curr) => (curr === name ? renamed.name : curr)); setSelected((prev) => { if (!prev.has(name)) return prev; const next = new Set(prev); next.delete(name); next.add(renamed.name); return next; }); setRenaming(null); } catch (err) { alert(err instanceof Error ? err.message : String(err)); setRenaming({ name, draft, saving: false }); } } async function handleBatchDelete() { if (deleting) return; const fileList = [...selected]; if (fileList.length === 0) return; setDeleting(true); try { await onDeleteFiles(fileList); // Don't clear `selected` here: confirm-cancel and all-fail paths // should leave the user's selection intact for retry. The // `useEffect` above prunes successfully-deleted names automatically // once `files` refreshes. } finally { setDeleting(false); } } function toggleModifiedSection(section: ModifiedSection) { setCollapsedModifiedSections((prev) => { const next = new Set(prev); if (next.has(section)) { next.delete(section); } else { next.add(section); } return next; }); } function renderFileRow(f: ProjectFile) { const active = preview === f.name; const isHovered = hover === f.name; const renameState = renaming?.name === f.name ? renaming : null; return ( setHover(f.name)} onMouseLeave={() => setHover((c) => (c === f.name ? null : c))} > { e.stopPropagation(); toggleSelect(f.name); }} role="checkbox" aria-checked={selected.has(f.name)} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); toggleSelect(f.name); } }} > {selected.has(f.name) ? '\u2611' : '\u2610'} setPreview(f.name)} onDoubleClick={() => onOpenFile(f.name)} > {kindGlyph(f.kind)} { if (!renameState) setPreview(f.name); }} onDoubleClick={() => { if (!renameState) onOpenFile(f.name); }} > {renameState ? ( setRenaming({ ...renameState, draft: e.target.value })} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onBlur={(e) => { if (e.currentTarget.dataset.skipRenameCommit === '1') return; void commitRename(f.name, renameState.draft); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.dataset.skipRenameCommit = '1'; void commitRename(f.name, renameState.draft); } else if (e.key === 'Escape') { e.preventDefault(); e.currentTarget.dataset.skipRenameCommit = '1'; setRenaming(null); } }} /> ) : ( )} setPreview(f.name)} onDoubleClick={() => onOpenFile(f.name)} > {kindLabel(f.kind, t)} setPreview(f.name)} onDoubleClick={() => onOpenFile(f.name)} > {relativeTime(f.mtime, t)} { e.stopPropagation(); openMenuFor(f.name, e.target as HTMLElement); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); openMenuFor(f.name, e.currentTarget as HTMLElement); } }} > ⋯ ); } function renderDirRow(dirName: string) { const fullPath = currentDir === '' ? dirName : `${currentDir}/${dirName}`; const prefix = `${fullPath}/`; const count = files.filter((f) => f.name.startsWith(prefix)).length; return ( setCurrentDir(fullPath)}> setCurrentDir(fullPath)}> setCurrentDir(fullPath)}> {t('designFiles.kindFolder')} setCurrentDir(fullPath)} /> ); } function renderModifiedSections() { const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d)); const sectionRows = visibleModifiedSections.flatMap((section) => { const sectionFiles = modifiedGroups[section]; const collapsed = collapsedModifiedSections.has(section); const label = t(MODIFIED_SECTION_LABEL_KEY[section]); return [ , ...(collapsed ? [] : sectionFiles.map(renderFileRow)), ]; }); return [...dirRows, ...sectionRows]; } function renderKindSections() { const dirRows = dirsAtCurrentDir.map((d) => renderDirRow(d)); const grouped = new Map(); for (const file of pageFiles) { const next = grouped.get(file.kind) ?? []; next.push(file); grouped.set(file.kind, next); } const kindRows = [...grouped.entries()] .sort(([a], [b]) => kindSortPriority(a) - kindSortPriority(b)) .flatMap(([kind, kindFiles]) => [
{kindLabel(kind, t)} {kindFiles.length}
, ...kindFiles.map(renderFileRow), ]); return [...dirRows, ...kindRows]; } async function handleBatchDownload() { const fileList = [...selected]; if (fileList.length === 0) return; try { const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/archive/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: fileList }), }); if (!resp.ok) { const err = await resp.json().catch(() => null); throw new Error(err?.message || `request failed (${resp.status})`); } const blob = await resp.blob(); const header = resp.headers.get('content-disposition') || ''; const star = /filename\*=UTF-8''([^;]+)/i.exec(header); let filename = 'project.zip'; if (star && star[1]) { try { filename = decodeURIComponent(star[1]); } catch { filename = star[1]; } } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 60_000); } catch (err) { console.warn('[batchDownload] failed:', err); } } async function handleDrop(ev: React.DragEvent) { ev.preventDefault(); dragDepthRef.current = 0; setDraggingFiles(false); setDropReadError(null); try { const dropped = await filesFromDataTransfer(ev.dataTransfer); if (dropped.length > 0) onUploadFiles(dropped); } catch (error) { if (!isFileSystemReadError(error)) throw error; setDropReadError(FILE_SYSTEM_READ_ERROR_MESSAGE); } } async function handlePluginFolderAgentAction( relativePath: string, action: PluginFolderAgentAction, ) { if (!onPluginFolderAgentAction || installingFolder || sharingFolder) return; setInstallNotice(null); if (action === 'install') { setInstallingFolder(relativePath); } else { setSharingFolder(`${action}:${relativePath}`); } try { const outcome = await onPluginFolderAgentAction(relativePath, action); const url = outcome && typeof outcome === 'object' && typeof outcome.url === 'string' ? outcome.url : ''; const message = outcome && typeof outcome === 'object' && typeof outcome.message === 'string' ? outcome.message : ''; if (message || url) setInstallNotice(buildActionNotice(message || url, url)); } catch (err) { setInstallNotice({ message: err instanceof Error ? err.message : String(err) }); } finally { setInstallingFolder(null); setSharingFolder(null); } } const refreshControl = ( ); const fileActions = selected.size > 0 ? (
) : (
); const groupToggle = files.length > 0 ? (
{t('designFiles.groupBy')}
) : (