import { useEffect, useMemo, useRef, useState } from 'react'; import { useT } from '../i18n'; import { deleteProjectFile, fetchProjectFileText, uploadProjectFile, writeProjectTextFile, } from '../providers/registry'; import type { OpenTabsState, ProjectFile } from '../types'; import { DesignFilesPanel } from './DesignFilesPanel'; import { FileViewer } from './FileViewer'; import { Icon } from './Icon'; import { PasteTextDialog } from './PasteTextDialog'; import { SketchEditor, type SketchDocument, type SketchItem } from './SketchEditor'; interface Props { projectId: string; files: ProjectFile[]; onRefreshFiles: () => Promise | void; isDeck: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; openRequest?: { name: string; nonce: number } | null; // Persisted set of open tabs + active tab. Owned by ProjectView so the // daemon's SQLite store can hold the source of truth and survive reloads. tabsState: OpenTabsState; onTabsStateChange: (next: OpenTabsState) => void; } interface SketchState { items: SketchItem[]; dirty: boolean; persisted: boolean; loaded: boolean; saving: boolean; } const DESIGN_FILES_TAB = '__design_files__'; export function FileWorkspace({ projectId, files, onRefreshFiles, isDeck, onExportAsPptx, streaming, openRequest, tabsState, onTabsStateChange, }: Props) { const t = useT(); // Persisted tabs come from the parent. Active tab can transiently point // at a pending sketch — pending sketches are not in tabsState.tabs. const persistedTabs = tabsState.tabs; const [activeTab, setActiveTab] = useState( tabsState.active ?? DESIGN_FILES_TAB, ); const [showPasteDialog, setShowPasteDialog] = useState(false); const [sketches, setSketches] = useState>({}); const fileInputRef = useRef(null); // Pull the persisted active tab in when the parent's hydration completes // (or on project switch). Fall back to the Design Files browser so a // fresh project lands in a useful place. useEffect(() => { setActiveTab(tabsState.active ?? DESIGN_FILES_TAB); }, [tabsState.active]); function setPersistedActive(name: string | null) { setActiveTab(name ?? DESIGN_FILES_TAB); onTabsStateChange({ tabs: persistedTabs, active: name }); } function activatePending(name: string) { // Pending sketches are not in tabsState.tabs — flip the local // activeTab without round-tripping through the parent. setActiveTab(name); } // When the persisted tab list changes and the active tab is gone, fall // back to the last remaining tab. Skip transient activeTab values // (DESIGN_FILES_TAB, pending sketches) since those aren't in persistedTabs. useEffect(() => { if (activeTab === DESIGN_FILES_TAB) return; if (sketches[activeTab] && !sketches[activeTab]!.persisted) return; if (!persistedTabs.includes(activeTab)) { setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [persistedTabs, activeTab]); // External open requests from chat (tool cards, produced-file chips, // deep-linked URL, or the parent's auto-open after an agent Write) — // add the file to the open-tabs set and focus it. useEffect(() => { if (!openRequest) return; const name = openRequest.name; if (!name) return; onTabsStateChange({ tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name], active: name, }); setActiveTab(name); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openRequest]); function openFile(name: string) { onTabsStateChange({ tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name], active: name, }); setActiveTab(name); } function closeTab(name: string) { const isPending = sketches[name] && !sketches[name]!.persisted; if (isPending) { setSketches((curr) => { const next = { ...curr }; delete next[name]; return next; }); if (activeTab === name) { setPersistedActive(persistedTabs[persistedTabs.length - 1] ?? null); } return; } const nextTabs = persistedTabs.filter((n) => n !== name); const nextActive = tabsState.active === name ? nextTabs[nextTabs.length - 1] ?? null : tabsState.active; onTabsStateChange({ tabs: nextTabs, active: nextActive }); setActiveTab(nextActive ?? DESIGN_FILES_TAB); setSketches((curr) => { const next = { ...curr }; const entry = next[name]; if (entry && !entry.persisted) delete next[name]; return next; }); } async function handleFilePicked(ev: React.ChangeEvent) { const f = ev.target.files?.[0]; if (!f) return; const result = await uploadProjectFile(projectId, f); ev.target.value = ''; if (result) { await onRefreshFiles(); openFile(result.name); } } async function handleDelete(name: string) { if (!confirm(t('workspace.deleteFileConfirm', { name }))) return; const ok = await deleteProjectFile(projectId, name); if (ok) { await onRefreshFiles(); const nextTabs = persistedTabs.filter((n) => n !== name); const nextActive = tabsState.active === name ? nextTabs[nextTabs.length - 1] ?? null : tabsState.active; onTabsStateChange({ tabs: nextTabs, active: nextActive }); setActiveTab(nextActive ?? DESIGN_FILES_TAB); setSketches((curr) => { const next = { ...curr }; delete next[name]; return next; }); } } function startNewSketch() { const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const name = `sketch-${stamp}.sketch.json`; setSketches((curr) => ({ ...curr, [name]: { items: [], dirty: false, persisted: false, loaded: true, saving: false }, })); activatePending(name); } // When the active tab is a sketch we don't have items for yet, load from // disk. Pending sketches start with loaded=true and skip this path. useEffect(() => { if (activeTab === DESIGN_FILES_TAB) return; if (!isSketchName(activeTab)) return; if (sketches[activeTab]?.loaded) return; let cancelled = false; void fetchProjectFileText(projectId, activeTab).then((text) => { if (cancelled) return; const items = parseSketchDocument(text); setSketches((curr) => ({ ...curr, [activeTab]: { items, dirty: false, persisted: true, loaded: true, saving: false, }, })); }); return () => { cancelled = true; }; }, [activeTab, projectId, sketches]); function setSketchItems(name: string, items: SketchItem[]) { setSketches((curr) => ({ ...curr, [name]: { ...(curr[name] ?? { persisted: false, loaded: true, saving: false }), items, dirty: true, } as SketchState, })); } async function saveSketch(name: string) { const entry = sketches[name]; if (!entry) return; setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: true } })); const doc: SketchDocument = { version: 1, items: entry.items }; const file = await writeProjectTextFile(projectId, name, JSON.stringify(doc, null, 2)); if (file) { setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, dirty: false, persisted: true, saving: false }, })); // Promote the previously-pending sketch into the persisted tab list. onTabsStateChange({ tabs: persistedTabs.includes(name) ? persistedTabs : [...persistedTabs, name], active: name, }); setActiveTab(name); await onRefreshFiles(); } else { setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: false } })); } } const activeFile = useMemo(() => { if (activeTab === DESIGN_FILES_TAB) return null; const onDisk = files.find((f) => f.name === activeTab); if (onDisk) return onDisk; if (isSketchName(activeTab) && sketches[activeTab]) { return { name: activeTab, size: 0, mtime: Date.now(), kind: 'sketch', mime: 'application/json', }; } return null; }, [activeTab, files, sketches]); // Tabs rendered are persisted tabs plus any pending (un-saved) sketches. const tabNames = useMemo(() => { const seen = new Set(persistedTabs); const extras: string[] = []; for (const name of Object.keys(sketches)) { if (!sketches[name]?.persisted && !seen.has(name)) { extras.push(name); seen.add(name); } } return [...persistedTabs, ...extras]; }, [persistedTabs, sketches]); const isActiveSketch = activeFile?.kind === 'sketch' && isSketchName(activeFile.name); const activeSketch = activeFile && isActiveSketch ? sketches[activeFile.name] : null; return (
{tabNames.map((name) => { const sketchEntry = sketches[name]; const dirtyMark = sketchEntry && (sketchEntry.dirty || !sketchEntry.persisted) ? ' •' : ''; const isPending = sketchEntry && !sketchEntry.persisted; const onDisk = files.find((f) => f.name === name); const kind = onDisk?.kind ?? (isSketchName(name) ? 'sketch' : 'text'); return ( isPending ? activatePending(name) : setPersistedActive(name) } onClose={() => closeTab(name)} kind={kind} /> ); })}
{activeTab === DESIGN_FILES_TAB ? ( void handleDelete(name)} onUpload={() => fileInputRef.current?.click()} onPaste={() => setShowPasteDialog(true)} onNewSketch={startNewSketch} /> ) : isActiveSketch && activeSketch && activeFile ? ( activeSketch.loaded ? ( setSketchItems(activeFile.name, items)} onSave={() => saveSketch(activeFile.name)} saving={activeSketch.saving} dirty={activeSketch.dirty || !activeSketch.persisted} onCancel={() => closeTab(activeFile.name)} /> ) : (
{t('workspace.loadingSketch')}
) ) : activeFile ? ( ) : ( )}
{showPasteDialog ? ( setShowPasteDialog(false)} onSave={async (name, content) => { setShowPasteDialog(false); const file = await writeProjectTextFile(projectId, name, content); if (file) { await onRefreshFiles(); openFile(file.name); } }} /> ) : null}
); } function Tab({ label, active, onActivate, onClose, closable = true, kind, }: { label: string; active: boolean; onActivate: () => void; onClose?: () => void; closable?: boolean; kind?: 'html' | 'image' | 'sketch' | 'text' | 'code' | 'binary'; }) { const t = useT(); const iconName = kindIconName(kind); return ( ) : null} ); } function kindIconName( kind?: string, ): | 'file-code' | 'image' | 'pencil' | 'file' | null { if (kind === 'html') return 'file-code'; if (kind === 'image') return 'image'; if (kind === 'sketch') return 'pencil'; if (kind === 'code') return 'file-code'; if (kind === 'text') return 'file'; return 'file'; } function isSketchName(name: string): boolean { return name.endsWith('.sketch.json'); } function parseSketchDocument(text: string | null): SketchItem[] { if (!text) return []; try { const parsed = JSON.parse(text) as SketchDocument | { items?: SketchItem[] }; return Array.isArray(parsed.items) ? parsed.items : []; } catch { return []; } }