// Pure state for the popup's task list — no DOM, so it is unit-testable without a // browser. Fed by download.list (initial) and event.task.{added,progress,state,removed} // relayed over the panel bridge (docs/05 §3: "live progress via event.task.progress"). import type { TaskAddedEvent, TaskProgressEvent, TaskRemovedEvent, TaskState, TaskStateEvent, TaskSummary, } from '../shared/protocol/index.js'; export interface TaskRow { taskId: string; filename: string; state: TaskState; downloadedBytes: number; sizeBytes: number | null; speedBps: number; etaSeconds: number | null; } const ACTIVE_STATES: readonly TaskState[] = ['connecting', 'downloading', 'assembling', 'verifying', 'probing', 'queued']; function fromSummary(s: TaskSummary): TaskRow { return { taskId: s.taskId, filename: s.filename, state: s.state, downloadedBytes: s.downloadedBytes, sizeBytes: s.sizeBytes ?? null, speedBps: s.speedBps, etaSeconds: s.etaSeconds ?? null, }; } export class PopupStore { private rowsById = new Map(); setInitial(items: TaskSummary[]): void { this.rowsById.clear(); for (const s of items) this.rowsById.set(s.taskId, fromSummary(s)); } onAdded(evt: TaskAddedEvent): void { this.rowsById.set(evt.taskId, fromSummary(evt.summary)); } /** event.task.progress is a patch, never a rebuild (docs/05 §3 / EVENTS contract). */ onProgress(evt: TaskProgressEvent): void { for (const t of evt.tasks) { const row = this.rowsById.get(t.taskId); if (!row) continue; // a progress tick for a task we haven't seen added yet — ignore row.downloadedBytes = t.downloadedBytes; row.speedBps = t.speedBps; row.etaSeconds = t.etaSeconds ?? null; } } onState(evt: TaskStateEvent): void { const row = this.rowsById.get(evt.taskId); if (evt.summary) { this.rowsById.set(evt.taskId, fromSummary(evt.summary)); } else if (row) { row.state = evt.state; } } onRemoved(evt: TaskRemovedEvent): void { this.rowsById.delete(evt.taskId); } /** Active tasks first (what the user opened the popup to watch), then by filename. */ rows(): TaskRow[] { const isActive = (r: TaskRow): boolean => ACTIVE_STATES.includes(r.state) || r.state === 'paused'; return [...this.rowsById.values()].sort((a, b) => { const activeDiff = Number(isActive(b)) - Number(isActive(a)); if (activeDiff !== 0) return activeDiff; return a.filename.localeCompare(b.filename); }); } get size(): number { return this.rowsById.size; } } export function formatBytes(n: number): string { if (n < 1024) return `${n} B`; const units = ['KB', 'MB', 'GB', 'TB']; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; } return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; } export function formatSpeed(bps: number): string { return bps > 0 ? `${formatBytes(bps)}/s` : ''; } export function formatEta(seconds: number | null): string { if (seconds === null || seconds < 0 || !Number.isFinite(seconds)) return ''; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return m > 0 ? `${m}m ${s}s` : `${s}s`; } export function progressPercent(row: TaskRow): number | null { if (!row.sizeBytes || row.sizeBytes <= 0) return null; return Math.min(100, Math.round((row.downloadedBytes / row.sizeBytes) * 100)); }