/** * useOperations — unified long-running-operation store (wiring:c3). * * One presentation-level aggregation point for everything that used to be * scattered across corner UIs: * - uploads (useUploadChunked jobs / legacy multipart rows) * - ops-queue jobs (usePendingOps copy/move/delete worker ops) * - future kinds (convert / archive — same `sync()` contract, own group) * * The store does NOT talk to the network. Publishers (renderless * UploadProgress / PendingOpsTray adapters) reconcile their reactive source * lists into the store via `sync(group, items)`; OperationsCenter renders the * merged picture (badge + panel). Action callbacks (cancel/retry/dismiss) * are registered per row so the panel can route user intent back to the * owning surface without the store knowing any transport details. * * Lifecycle rules (per the Operations Center contract): * - running rows live in `active`; * - done/aborted rows linger in `active` for DONE_LINGER_MS ("done" * flash) then move to `history` (session-only, capped); * - error rows are STICKY: they stay in `active` until the user * dismisses or retries them — even after the source list swept them * (usePendingOps drops terminal ops after its RETAIN window). */ import { computed, getCurrentScope, onScopeDispose, ref } from 'vue'; /** `plugin` — an app-plugin job (docs/APP-PLUGINS-API.md); also what any * queue kind this package has no drawing for is shown as. */ export type OperationKind = | 'upload' | 'copy' | 'move' | 'delete' | 'rename' | 'restore' | 'purge' | 'convert' | 'archive' | 'archive-create' | 'archive-extract' | 'plugin' | 'trash' | 'thumbs'; export type OperationStatus = 'running' | 'done' | 'error' | 'aborted'; /** What a publisher hands to `sync()` for one row. */ export interface OperationInput { /** Stable id within the group (upload uuid / ops row id). */ id: string | number; kind: OperationKind; /** Primary label — file name or target path. '' → renderer falls back to the kind verb. */ name: string; /** 0..100, or null for indeterminate (renderer shows a spinner). */ percent: number | null; status: OperationStatus; /** The failure, SAID (lib/errorWords) — never a raw server string. */ error?: string | null; /** The raw words behind it — set only for a caller who administers the * instance; drawn as a second line under the sentence. */ errorDetail?: string | null; /** Queue op accepted but not started yet ("Queued"). */ queued?: boolean; /** Cancellation was accepted and the worker is stopping/cleaning up. */ cancelling?: boolean; /** Progress counters for queue ops (3/5 items). */ doneCount?: number; totalCount?: number; /** The items one source is made of, when the server counts them (a folder * job on one storage): "25 of 100 items" instead of a source count of 0/1. */ itemsDone?: number; itemsTotal?: number; /** Byte progress for uploads. */ uploadedBytes?: number; totalBytes?: number; cancellable?: boolean; retryable?: boolean; /** A plugin job's last progress message — shown under the name while it runs. */ message?: string | null; /** Files a finished job produced (adapter-qualified paths); the row offers "Open". */ outputs?: string[]; /** * An app-plugin job's output mode — `sibling` (a new file beside the * original) or `version` (the same file, a new version of it). * * ⚠ It is on the row because the two are indistinguishable once the job * lands: "Open" navigates to `outputs[0]`, which in `version` mode IS the * input the person started from, so without the chip the tray reads as if * nothing was produced. Absent for every other kind. */ outputMode?: string | null; } export interface OperationActions { cancel?: () => void; /** Clean the row out of the OWNING source list (called on retire/dismiss). */ dismiss?: () => void; retry?: () => void; /** Reveal one of the row's outputs in the listing. */ open?: (path: string) => void; } export interface Operation { /** `${group}:${id}` — unique across publishers. */ key: string; kind: OperationKind; name: string; percent: number | null; status: OperationStatus; error: string | null; errorDetail: string | null; queued: boolean; cancelling: boolean; doneCount: number | null; totalCount: number | null; itemsDone: number | null; itemsTotal: number | null; uploadedBytes: number | null; totalBytes: number | null; cancellable: boolean; retryable: boolean; message: string | null; outputs: string[]; outputMode: string | null; startedAt: number; settledAt: number | null; } const DONE_LINGER_MS = 4000; const HISTORY_CAP = 50; function toOperation(key: string, input: OperationInput, prev?: Operation): Operation { const terminal = input.status !== 'running'; return { key, kind: input.kind, name: input.name, percent: input.percent, status: input.status, error: input.error ?? null, errorDetail: input.errorDetail ?? null, queued: input.queued ?? false, cancelling: input.cancelling ?? false, doneCount: input.doneCount ?? null, totalCount: input.totalCount ?? null, itemsDone: input.itemsDone ?? null, itemsTotal: input.itemsTotal ?? null, uploadedBytes: input.uploadedBytes ?? null, totalBytes: input.totalBytes ?? null, cancellable: input.cancellable ?? false, retryable: input.retryable ?? false, message: input.message ?? null, outputs: input.outputs ?? [], outputMode: input.outputMode ?? null, startedAt: prev?.startedAt ?? Date.now(), settledAt: prev?.settledAt ?? (terminal ? Date.now() : null), }; } export function useOperations() { const active = ref([]); const history = ref([]); const actions = new Map(); /** Keys already moved to history — sync must not resurrect them. */ const retired = new Set(); const lingerTimers = new Map>(); function clearTimer(key: string) { const tm = lingerTimers.get(key); if (tm !== undefined) { clearTimeout(tm); lingerTimers.delete(key); } } /** Move a row from active to the session history (newest first). */ function retire(key: string, opts: { toHistory?: boolean } = {}) { clearTimer(key); const idx = active.value.findIndex((o) => o.key === key); if (idx === -1) return; const [op] = active.value.splice(idx, 1); active.value = [...active.value]; retired.add(key); if (opts.toHistory !== false) { history.value = [{ ...op, settledAt: op.settledAt ?? Date.now() }, ...history.value].slice( 0, HISTORY_CAP, ); } } function scheduleLinger(key: string) { if (lingerTimers.has(key)) return; lingerTimers.set( key, setTimeout(() => { lingerTimers.delete(key); // Retire + ask the owning source to drop its row so it doesn't // resurrect on the next sync (uploads keep done rows forever). const acts = actions.get(key); retire(key); acts?.dismiss?.(); }, DONE_LINGER_MS), ); } /** * Full-list reconcile for one publisher group. Upserts every present row, * removes vanished ones (sticky errors excepted), and manages the * done-linger → history hand-off. */ function sync( group: string, items: Array<{ input: OperationInput; actions?: OperationActions }>, ): void { const prefix = `${group}:`; const present = new Set(); let next = [...active.value]; let changed = false; for (const item of items) { const key = `${group}:${item.input.id}`; present.add(key); if (item.actions) actions.set(key, item.actions); if (retired.has(key)) continue; // already history — don't resurrect const idx = next.findIndex((o) => o.key === key); const op = toOperation(key, item.input, idx === -1 ? undefined : next[idx]); if (idx === -1) { next = [op, ...next]; changed = true; } else if (JSON.stringify(next[idx]) !== JSON.stringify(op)) { next[idx] = op; changed = true; } if (op.status === 'done' || op.status === 'aborted') scheduleLinger(key); } // Vanished from source: running → drop silently (superseded elsewhere), // done/aborted → straight to history, error → sticky (keep visible). for (const o of [...next]) { if (!o.key.startsWith(prefix) || present.has(o.key)) continue; if (o.status === 'error') continue; clearTimer(o.key); next = next.filter((x) => x.key !== o.key); changed = true; if (o.status !== 'running') { retired.add(o.key); history.value = [{ ...o, settledAt: o.settledAt ?? Date.now() }, ...history.value].slice( 0, HISTORY_CAP, ); } } // Bookkeeping: retired keys whose source rows are gone can be forgotten // (their ids won't reappear — uuids / monotonic op ids). for (const key of [...retired]) { if (key.startsWith(prefix) && !present.has(key)) retired.delete(key); } for (const key of [...actions.keys()]) { if (key.startsWith(prefix) && !present.has(key) && !active.value.some((o) => o.key === key)) { // keep actions for rows still on screen (sticky errors) and for // history rows that can still be opened (a finished job's outputs) if (next.some((o) => o.key === key)) continue; if (history.value.some((o) => o.key === key && o.outputs.length > 0)) continue; actions.delete(key); } } if (changed) active.value = next; } function cancel(key: string) { actions.get(key)?.cancel?.(); } function retry(key: string) { const acts = actions.get(key); retire(key); // failed attempt goes to history; retry publishes a fresh row acts?.dismiss?.(); acts?.retry?.(); } function dismiss(key: string) { const acts = actions.get(key); retire(key); acts?.dismiss?.(); } /** Reveal a finished row's output — the first one unless a path is named. */ function open(key: string, path?: string) { const row = active.value.find((o) => o.key === key) ?? history.value.find((o) => o.key === key); const target = path ?? row?.outputs[0]; if (target) actions.get(key)?.open?.(target); } function clearHistory() { history.value = []; } /** * Bumped by `reveal()`; OperationsCenter opens its panel on every bump. * * ⚠ For a job the person has JUST started and whose dialog has closed — the * collapsed badge alone is a small pill in a far corner, and a job with only * that on screen reads as a click that did nothing. Measured in production * (2026-09-25): an archive of 175 files ran for two minutes, and the person * who started it saw no sign of it at all. Not for work nobody is waiting on * (a background sync, somebody else's job). */ const revealRequests = ref(0); function reveal() { revealRequests.value += 1; } const runningCount = computed( () => active.value.filter((o) => o.status === 'running').length, ); const errorCount = computed(() => active.value.filter((o) => o.status === 'error').length); const hasError = computed(() => errorCount.value > 0); /** * Aggregate progress across determinate running rows: each row's own * `percent`, the number its publisher already decided is honest. null → * nothing determinate is running (badge ring spins instead of filling). * * ⚠ Not the rows' counters. This used to read done/total before `percent`, * and a queued move, copy or delete of ONE folder is "0 of 1" until it ends * (the queue counts what was selected; on an object store every object * inside is its own request): the row drew a moving indicator, because * lib/opProgress gives it no percent, while the badge beside it read "0%" * over an empty ring for minutes. An upload whose bytes are all in filex * has no percent either while the server writes it to the storage. */ const overallPercent = computed(() => { const running = active.value.filter((o) => o.status === 'running'); let sum = 0; let n = 0; for (const o of running) { if (o.percent === null) continue; sum += Math.min(1, Math.max(0, o.percent / 100)); n += 1; } if (n === 0) return null; return Math.round((sum / n) * 100); }); if (getCurrentScope()) { onScopeDispose(() => { for (const tm of lingerTimers.values()) clearTimeout(tm); lingerTimers.clear(); }); } return { active, history, runningCount, errorCount, hasError, overallPercent, sync, cancel, retry, dismiss, open, clearHistory, revealRequests, reveal, }; } export type OperationsStore = ReturnType;