import { useEffect, useMemo, useRef, useState } from 'react'; import { useAnalytics } from '../analytics/provider'; import { trackFileManagerClick } from '../analytics/events'; import { useT } from '../i18n'; import { LIBRARY_UI_VISIBLE } from '../features/libraryUi'; import type { Dict } from '../i18n/types'; import { copyToClipboard } from '../lib/copy-to-clipboard'; import { projectFileUrl, projectRawUrl } from '../providers/registry'; import { buildSrcdoc } from '../runtime/srcdoc'; import type { LiveArtifactWorkspaceEntry, ProjectFile, ProjectFileKind, ProjectFolder } from '../types'; import { createFileSystemReadError, FILE_SYSTEM_READ_ERROR_MESSAGE, isFileSystemReadError, } from '../utils/fileSystemErrors'; import { isVisualStabilityMode } from '../utils/visualStability'; import { selectInitialDesignPreviewFile } from './design-files/designArtifacts'; 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; export interface DesignFilesNavState { kindFilter: Set; currentDir: string; page: number; pageSize: number | 'all'; } interface Props { projectId: string; // Basename of the project's working directory when the user has chosen a // real folder (e.g. "openclaw"). Shown as the breadcrumb root instead of // the generic "project" label. Undefined for default-storage projects. rootDirName?: string; // True while the host is reindexing a freshly replaced working dir. Drives // a loading overlay so the panel doesn't sit silently on the stale tree. reloading?: boolean; // True while the chat agent is generating. The footer swaps its idle // drop/upload hint for the typewriter "tip" line while a run is in flight. running?: boolean; files: ProjectFile[]; // Persisted folders from `/api/projects/:id/folders`, including empty ones // that no file lives under. Without these, a folder only appears once a file // with a matching path prefix exists, so empty (user-created or imported) // folders would vanish from the tree. folders?: ProjectFolder[]; 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; onOpenBrowser?: () => void; onCreateDesignSystem?: () => void; onCreateDesignSystemFromProject?: () => void; createDesignSystemFromProjectBusy?: boolean; onDuplicateProject?: () => void; duplicateProjectBusy?: boolean; /** Opens the "Select from library" picker to pull registry assets in. */ onSelectFromLibrary?: () => void; // Reports the folder the panel is currently viewing so the parent can create // new files (upload / paste / new sketch / dropped files) under it instead // of the project root. Fires whenever the user navigates folders. onCurrentDirChange?: (dir: string) => void; uploadError?: string | null; onClearUploadError?: () => void; preferredPreviewFile?: string | null; autoPreviewDesignArtifacts?: boolean; onPluginFolderAgentAction?: ( relativePath: string, action: PluginFolderAgentAction, ) => Promise<{ message?: string; url?: string } | void> | { message?: string; url?: string } | void; activePluginActionPaths?: Set; hiddenPluginActionPaths?: Set; navState?: DesignFilesNavState; onNavStateChange?: (state: DesignFilesNavState) => void; } interface ActionNotice { message: string; url?: string; } // Display-only refinement of ProjectFileKind. The contract `kind` lumps all // source under `code`; the Design Files surface splits CSS/SCSS/etc. into a // dedicated "Stylesheets" section to mirror Claude Design. Everything else // maps 1:1 to its kind. type FileCategory = ProjectFileKind | 'stylesheet'; // Section render order. Empty categories are skipped; the FOLDERS section is // pinned above all of these from the directory list. const SECTION_ORDER: FileCategory[] = [ 'html', 'stylesheet', 'code', 'document', 'text', 'image', 'sketch', 'pdf', 'presentation', 'spreadsheet', 'video', 'audio', 'binary', ]; const STYLESHEET_EXTENSIONS = new Set(['css', 'scss', 'sass', 'less']); const HTML_THUMBNAIL_INLINE_MAX_BYTES = 512 * 1024; function fileCategory(file: ProjectFile): FileCategory { const dot = file.name.lastIndexOf('.'); const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : ''; if (STYLESHEET_EXTENSIONS.has(ext)) return 'stylesheet'; return file.kind; } type FileSystemEntryWithReader = FileSystemEntry & { createReader?: () => FileSystemDirectoryReader; }; type FileSystemFileEntryWithFile = FileSystemFileEntry & { file: ( successCallback: (file: File) => void, errorCallback?: (error: DOMException) => void, ) => void; }; type DataTransferItemWithEntry = DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntry | null; }; 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} ); } // Useful-info tips that rotate one at a time in the panel footer, ordered as // a loose journey: file basics → feeding context → generating → iterating → // exporting/sharing → community. A tip with a `url` renders its typed line as // a link to that destination. const USEFUL_TIPS: ReadonlyArray<{ key: keyof Dict; url?: string }> = [ { key: 'designFiles.usefulInfoTip' }, { key: 'designFiles.usefulInfoTip2' }, { key: 'designFiles.usefulInfoTip9' }, { key: 'designFiles.usefulInfoTip10' }, { key: 'designFiles.usefulInfoTip4' }, { key: 'designFiles.usefulInfoTip11' }, { key: 'designFiles.usefulInfoTip12' }, { key: 'designFiles.usefulInfoTip13' }, { key: 'designFiles.usefulInfoTip14' }, { key: 'designFiles.usefulInfoTip15' }, { key: 'designFiles.usefulInfoTip5' }, { key: 'designFiles.usefulInfoTip6', url: 'https://discord.gg/mHAjSMV6gz' }, { key: 'designFiles.usefulInfoTip7', url: 'https://github.com/nexu-io/open-design' }, { key: 'designFiles.usefulInfoTip8', url: 'https://x.com/OpenDesignHQ' }, { key: 'designFiles.usefulInfoTip16', url: 'https://www.threads.com/@opendesign.ai' }, { key: 'designFiles.usefulInfoTip17', url: 'https://www.instagram.com/opendesign.ai/' }, { key: 'designFiles.usefulInfoTip18', url: 'https://www.youtube.com/@Open-Design-ai' }, { key: 'designFiles.usefulInfoTip19', url: 'https://www.linkedin.com/company/open-design-ai/' }, { key: 'designFiles.usefulInfoTip20', url: 'https://www.xiaohongshu.com/user/profile/691effad000000003002978f', }, ]; const TIP_TYPE_MS = 32; // per-character typing speed const TIP_HOLD_MS = 3800; // pause on a fully-typed tip before advancing function prefersReducedMotion(): boolean { return ( typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ); } // Footer "tip" line that types out one tip at a time (typewriter), holds, then // advances to the next — mirroring Claude Design's empty-state hint. Under // prefers-reduced-motion the full tip is shown immediately and just cycles. function RotatingTip() { const t = useT(); const [index, setIndex] = useState(0); const [typed, setTyped] = useState(''); // Resolve tips each render but read them through a ref so the typing effect // depends only on `index` — depending on the (re-created) array would reset // the typewriter on every render and never advance. const tipsRef = useRef([]); tipsRef.current = USEFUL_TIPS.map(({ key }) => t(key)); useEffect(() => { const tips = tipsRef.current; const full = tips[index] ?? ''; if (isVisualStabilityMode()) { setIndex(0); setTyped(tips[0] ?? ''); return; } if (prefersReducedMotion()) { setTyped(full); if (tips.length < 2) return; const hold = window.setTimeout( () => setIndex((i) => (i + 1) % tips.length), TIP_HOLD_MS, ); return () => window.clearTimeout(hold); } setTyped(''); let i = 0; let holdTimer = 0; const typeTimer = window.setInterval(() => { i += 1; setTyped(full.slice(0, i)); if (i >= full.length) { window.clearInterval(typeTimer); if (tips.length < 2) return; holdTimer = window.setTimeout( () => setIndex((p) => (p + 1) % tips.length), TIP_HOLD_MS, ); } }, TIP_TYPE_MS); return () => { window.clearInterval(typeTimer); window.clearTimeout(holdTimer); }; }, [index]); return (
{t('designFiles.usefulInfoLabel')}
{USEFUL_TIPS[index]?.url ? ( {typed} ) : ( typed )}
); } /** * Full-panel browser for a project's `.od/projects//` folder. Mirrors * Claude Design's "Design Files" surface: a single-line toolbar (up / refresh * / breadcrumbs + actions), semantic sections (Folders, Stylesheets, Scripts, * Documents, Images …), hover-revealed row checkbox + menu, a right-side * preview pane, and a static "useful info" footer. Triggered as a sticky * first tab in FileWorkspace. */ export function DesignFilesPanel({ projectId, rootDirName, reloading, running = false, files, folders, liveArtifacts, onOpenFile, onOpenLiveArtifact, onRenameFile, onDeleteFile, onDeleteFiles, onUpload, onUploadFiles, onPaste, onNewSketch, onOpenBrowser, onCreateDesignSystem, onCreateDesignSystemFromProject, createDesignSystemFromProjectBusy = false, onDuplicateProject, duplicateProjectBusy = false, onSelectFromLibrary, uploadError = null, onClearUploadError, preferredPreviewFile = null, autoPreviewDesignArtifacts = false, onCurrentDirChange, onPluginFolderAgentAction, activePluginActionPaths = new Set(), hiddenPluginActionPaths = new Set(), navState, onNavStateChange, }: Props) { const t = useT(); const analytics = useAnalytics(); 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 = 180; const MENU_SAFE_PADDING = 8; const [preview, setPreview] = useState(null); const autoPreviewAppliedRef = useRef(false); const [selected, setSelected] = useState>(new Set()); 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 [renaming, setRenaming] = useState<{ name: string; draft: string; saving: boolean } | null>(null); const [copiedLocalPath, setCopiedLocalPath] = useState(null); const [currentDir, setCurrentDir] = useState(() => navState?.currentDir ?? ''); const [projectMenuOpen, setProjectMenuOpen] = useState(false); const projectMenuRef = useRef(null); // Keep the parent's create-target in sync with the folder being viewed, so // uploads / pastes / new sketches / dropped files land in the open folder // rather than the project root. useEffect(() => { onCurrentDirChange?.(currentDir); }, [currentDir, onCurrentDirChange]); useEffect(() => { onNavStateChange?.({ kindFilter: navState?.kindFilter ?? new Set(), currentDir, page: 0, pageSize: 30, }); }, [currentDir, navState?.kindFilter, onNavStateChange]); // 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)); if (currentDir === '') localFiles.push(f); } } // Also surface persisted folders (including empty ones with no files under // them) as immediate children of the current directory. for (const folder of folders ?? []) { if (!folder.path.startsWith(prefix)) continue; const remainder = folder.path.slice(prefix.length); if (!remainder) continue; // the current directory itself const slashIdx = remainder.indexOf('/'); dirs.add(slashIdx === -1 ? remainder : remainder.slice(0, slashIdx)); } return { dirsAtCurrentDir: [...dirs].sort((a, b) => a.localeCompare(b)), filesAtCurrentDir: localFiles, }; }, [files, folders, currentDir]); // Group files at the current level into semantic sections, ordered by // SECTION_ORDER. Files within a section sort most-recently-modified first. const sections = useMemo(() => { const grouped = new Map(); for (const f of filesAtCurrentDir) { const category = fileCategory(f); const bucket = grouped.get(category) ?? []; bucket.push(f); grouped.set(category, bucket); } for (const bucket of grouped.values()) { bucket.sort((a, b) => b.mtime - a.mtime); } return SECTION_ORDER.filter((category) => grouped.has(category)).map( (category) => [category, grouped.get(category)!] as const, ); }, [filesAtCurrentDir]); // Reset selection and renaming state when the user navigates into or out of // a directory. useEffect(() => { setSelected(new Set()); setRenaming(null); }, [currentDir]); // Navigate up to the nearest ancestor that still exists when the current // directory disappears (e.g. after deleting the last file in a subfolder). // A directory "exists" if it has files under it OR is a persisted folder // (possibly empty) — otherwise navigating into an empty folder would bounce // straight back to the root. useEffect(() => { if (currentDir === '') return; const dirExists = (dir: string) => files.some((f) => f.name.startsWith(`${dir}/`)) || (folders ?? []).some((fo) => fo.path === dir || fo.path.startsWith(`${dir}/`)); if (dirExists(currentDir)) return; const parts = currentDir.split('/'); for (let i = parts.length - 1; i > 0; i--) { const ancestor = parts.slice(0, i).join('/'); if (dirExists(ancestor)) { setCurrentDir(ancestor); return; } } setCurrentDir(''); }, [files, folders, currentDir]); 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], ); const initialPreviewFile = useMemo( () => autoPreviewDesignArtifacts ? selectInitialDesignPreviewFile(files, preferredPreviewFile) : null, [autoPreviewDesignArtifacts, files, preferredPreviewFile], ); useEffect(() => { if (autoPreviewAppliedRef.current) return; if (!initialPreviewFile) return; autoPreviewAppliedRef.current = true; setPreview(initialPreviewFile.name); }, [initialPreviewFile]); useEffect(() => { if (!preview) return; if (files.some((f) => f.name === preview)) return; setPreview(null); }, [files, preview]); 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]); useEffect(() => { const onClipboardPaste = (event: ClipboardEvent) => { if (shouldIgnoreClipboardFilePaste(event.target)) return; const pastedFiles = filesFromClipboardData(event.clipboardData); if (pastedFiles.length === 0) return; event.preventDefault(); setDropReadError(null); onClearUploadError?.(); onUploadFiles(pastedFiles); }; window.addEventListener('paste', onClipboardPaste); return () => window.removeEventListener('paste', onClipboardPaste); }, [onClearUploadError, onUploadFiles]); useEffect(() => { if (!projectMenuOpen) return; function handlePointerDown(event: PointerEvent) { const target = event.target; if (target instanceof Node && projectMenuRef.current?.contains(target)) return; setProjectMenuOpen(false); } function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') setProjectMenuOpen(false); } document.addEventListener('pointerdown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; }, [projectMenuOpen]); 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 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 }); } async function copyLocalPath(fileName: string) { const localPath = files.find((file) => file.name === fileName)?.localPath; if (!localPath) return; const copied = await copyToClipboard(localPath); if (copied) { setCopiedLocalPath(fileName); window.setTimeout(() => { setCopiedLocalPath((current) => (current === fileName ? null : current)); }, 1600); } } 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 renderFileRow(f: ProjectFile, category: FileCategory) { const active = preview === f.name; const isSelected = selected.has(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={isSelected} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); toggleSelect(f.name); } }} > {isSelected ? : null} setPreview(f.name)} onDoubleClick={() => onOpenFile(f.name)} > {categoryGlyph(category)}
{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)} > {humanBytes(f.size)} 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)}>
); } 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 fileActions = (
{LIBRARY_UI_VISIBLE && onSelectFromLibrary ? ( ) : null} {onCreateDesignSystemFromProject || onDuplicateProject ? (
{projectMenuOpen ? (
{onCreateDesignSystemFromProject ? ( ) : null} {onDuplicateProject ? ( ) : null}
) : null}
) : null}
); const breadcrumbs = ( ); const visibleUploadError = uploadError ?? dropReadError; const hasSelection = selected.size > 0; return (
{reloading ? (
{t('common.loading')}
) : null}
{breadcrumbs}
{fileActions}
{ ev.preventDefault(); dragDepthRef.current += 1; setDraggingFiles(true); }} onDragOver={(ev) => { ev.preventDefault(); ev.dataTransfer.dropEffect = 'copy'; }} onDragLeave={(ev) => { if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) { dragDepthRef.current = 0; setDraggingFiles(false); return; } dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setDraggingFiles(false); }} onDrop={handleDrop} > {visibleUploadError && !preview ? (
{visibleUploadError} {onClearUploadError || dropReadError ? ( ) : null}
) : null} {hasSelection ? (
{t('designFiles.downloadSelected', { n: selected.size })}
) : null} {files.length === 0 && liveArtifacts.length === 0 && (folders?.length ?? 0) === 0 ? (
{t('designFiles.empty')}
{onOpenBrowser ? ( ) : null}
) : ( <> {liveArtifacts.length > 0 ? (
{t('designFiles.sectionLiveArtifacts')}
{liveArtifacts.map((artifact) => ( ))}
) : null} {pluginFolders.length > 0 ? (
Plugin folders {pluginFolders.length}
{installNotice ? (
) : null} {pluginFolders.filter((folder) => !hiddenPluginActionPaths.has(folder.path)).map((folder) => { const actionBusy = activePluginActionPaths.has(folder.path); return (
{relativeTime(folder.updatedAt, t)} {onPluginFolderAgentAction ? (
) : null}
)})}
) : null} {dirsAtCurrentDir.length > 0 ? (
{t('designFiles.sectionFolders')} {dirsAtCurrentDir.length}
{dirsAtCurrentDir.map((d) => renderDirRow(d))}
) : null} {sections.map(([category, sectionFiles]) => (
{sectionLabel(category, t)} {sectionFiles.length}
{sectionFiles.map((f) => renderFileRow(f, category))}
))} )}
{running ? ( ) : (
{t('designFiles.dropLabel')} {t('designFiles.dropDesc')}
)}
{draggingFiles ? (
{t('designFiles.dropTitle')} {t('designFiles.dropDesc')}
) : null}
{preview && previewFile ? ( // Key on the file name so React unmounts the previous DfPreview // (and its iframe / image element) when the user clicks a // different file. Without this, React diffing reuses the same // iframe DOM node and the browser keeps showing the first // file's contents — only the `src` prop changes but the iframe // never actually navigates. onOpenFile(previewFile.name)} onClose={() => setPreview(null)} /> ) : null} {menuPos ? (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} >
) : null}
); } function DfPreview({ projectId, file, onOpen, onClose, }: { projectId: string; file: ProjectFile; onOpen: () => void; onClose: () => void; }) { const t = useT(); const url = projectFileUrl(projectId, file.name); const rendersSketchJson = isRenderableSketchJson(file); const openPreviewLabel = `${t('designFiles.previewOpen')} ${file.name}`; const thumbCanOpen = file.kind !== 'audio' && file.kind !== 'video'; return ( ); } function HtmlPreviewThumbnail({ projectId, file, }: { projectId: string; file: ProjectFile; }) { const t = useT(); const tooLargeForThumbnail = file.size > HTML_THUMBNAIL_INLINE_MAX_BYTES; const url = projectFileUrl(projectId, file.name); const [srcDoc, setSrcDoc] = useState(null); useEffect(() => { setSrcDoc(null); if (tooLargeForThumbnail) return; const controller = new AbortController(); let cancelled = false; void fetch(`${url}?v=${Math.round(file.mtime)}`, { signal: controller.signal }) .then((response) => (response.ok ? response.text() : null)) .then((html) => { if (cancelled || html === null) return; const nextSrcDoc = buildSrcdoc(html, { baseHref: projectRawUrl(projectId, baseDirForFile(file.name)) }); if (!cancelled) setSrcDoc(nextSrcDoc); }) .catch((err) => { if (err instanceof DOMException && err.name === 'AbortError') return; if (!cancelled) setSrcDoc(null); }); return () => { cancelled = true; controller.abort(); }; }, [file.mtime, file.name, projectId, tooLargeForThumbnail, url]); if (tooLargeForThumbnail || srcDoc === null) { return ; } return (