/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * playground-files.ts — virtual file store for the playground. * * Tools that "write a file" (bcf_export, model_save, export_ifc / csv / * json) DON'T trigger a browser download — that would be a surprising * privacy issue and against the user's explicit "never auto-download" * rule. Instead they push the artifact into this store, which a * Downloads panel in the playground sidebar lists with a per-row * "Download" button. The actual `Blob` → `` click only * happens when the user presses that button. */ import { useEffect, useState } from 'react'; export interface PlaygroundFile { /** Stable id used by tools to refer back to a written artifact. */ id: string; /** Suggested filename used when the user clicks Download. */ filename: string; /** MIME type for the download Blob. */ mimeType: string; /** Bytes — read once, cheap. */ size: number; /** The data. */ blob: Blob; /** ms since epoch. */ createdAt: number; /** Tool that produced it (`bcf_export`, `model_save`, …). */ source: string; /** Free-form line shown under the filename in the UI. */ description?: string; } /** Keep at most this many staged artifacts; oldest are evicted on add(). */ const MAX_FILES = 20; /** Drop oldest entries once the retained Blobs exceed this cumulative size. */ const MAX_TOTAL_BYTES = 256 * 1024 * 1024; class FileStore { private files: PlaygroundFile[] = []; private listeners = new Set<() => void>(); private nextId = 1; /** Id of an entry exempt from eviction (e.g. the auto-staged BCF bundle). */ private pinnedId: string | null = null; add(input: Omit): PlaygroundFile { const file: PlaygroundFile = { ...input, id: `pg-file-${this.nextId++}`, createdAt: Date.now(), }; this.files = this.evict([file, ...this.files]); this.notify(); return file; } /** Mark an entry as exempt from eviction; pass null to clear the pin. */ pin(id: string | null): void { this.pinnedId = id; } /** * Bound the store by count and cumulative bytes, evicting oldest-first * (entries are newest-first, so trim from the tail). The pinned entry is * never evicted so its tracked id can't be orphaned. */ private evict(files: PlaygroundFile[]): PlaygroundFile[] { const kept: PlaygroundFile[] = []; let bytes = 0; for (const f of files) { const pinned = f.id === this.pinnedId; if (!pinned && kept.length >= MAX_FILES) continue; if (!pinned && bytes + f.size > MAX_TOTAL_BYTES && kept.length > 0) continue; kept.push(f); bytes += f.size; } return kept; } list(): PlaygroundFile[] { return this.files; } remove(id: string): void { this.files = this.files.filter((f) => f.id !== id); if (this.pinnedId === id) this.pinnedId = null; this.notify(); } clear(): void { this.files = []; this.pinnedId = null; this.notify(); } /** User-triggered. Synthesises an click — never called by * tool code, only by the explicit Download button. */ download(id: string): void { const file = this.files.find((f) => f.id === id); if (!file) return; const url = URL.createObjectURL(file.blob); const a = document.createElement('a'); a.href = url; a.download = file.filename; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); // Revoke after a tick so the browser actually fetched the blob. setTimeout(() => URL.revokeObjectURL(url), 1000); } subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private notify(): void { for (const l of this.listeners) l(); } } export const playgroundFiles = new FileStore(); /** React hook for components that want to render the file list reactively. */ export function usePlaygroundFiles(): PlaygroundFile[] { const [files, setFiles] = useState(() => playgroundFiles.list()); useEffect(() => playgroundFiles.subscribe(() => setFiles(playgroundFiles.list())), []); return files; } export function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; if (bytes >= 1024) return (bytes / 1024).toFixed(0) + ' KB'; return bytes + ' B'; }