/** * usePendingOps — track async copy/move/delete operations. * * Backend POST /files/{copy,move,delete} returns `{op}` immediately * (status='pending') and the actual S3 work runs in a worker. The * client polls `/files/ops` every 2s while there's anything still * pending or running so the user gets live feedback ("3/5 copied"). * * Polling is reference-counted: callers `register(op)` after starting a * new op; an interval ticks while the registered set has any non- * terminal entries. Terminal ops (done/error) stay in the local cache * for `RETAIN_MS` so the UI can flash a final state before they fade * out, then a sweep removes them. */ import { computed, ref, onBeforeUnmount } from 'vue'; import type { ExplorerConfig } from '../types/ExplorerConfig'; import type { SurfaceOpenRequest } from '../types/Plugins'; import type { FileApi } from './useFileApi'; import { isOpenRequest } from '../lib/surfaceOpen'; /** * The queue kinds this package knows by name. A server may emit others (an * app-plugin job is `plugin-action` on the wire and `plugin` here); anything * unknown is carried through verbatim rather than being read as a delete. */ export type PendingOpType = 'copy' | 'move' | 'delete' | 'plugin' | 'trash-empty' | (string & {}); /** One produced file of a finished plugin job, as an adapter-qualified path. */ export interface PendingOpOutput { path: string; } export interface PendingOp { id: number; op_type: PendingOpType; /** `cancelled` — somebody stopped it (POST /ops/{id}/cancel). */ status: 'pending' | 'running' | 'cancelling' | 'done' | 'error' | 'cancelled'; progress_total: number; progress_done: number; /** Running cross-storage transfer's bytes (issue #27); absent otherwise. */ bytes_total?: number; bytes_done?: number; /** Objects a running same-storage copy/move/delete has found and finished * inside its sources (the storage driver's count); absent otherwise. */ objects_total?: number; objects_done?: number; target_path: string | null; source_dir: string | null; source_count: number; error_message: string | null; /** A failed APP job, classified by the server (lib/errorWords `jobFailure` * says it); absent on copy/move/delete rows and on an older server. */ error_code?: string; /** The engine a job needed and the server lacks (`error_code: engine_missing`). */ error_engine?: string; /** The server's sentence for the failure, in the reader's language * (backend ops/errcode.go): what lib/errorWords `opFailure` shows. */ error_text?: string; cancellable: boolean; started_at: string | null; finished_at: string | null; created_at: string | null; /* App-plugin jobs (`op_type: "plugin-action"` on the wire) — absent on * copy/move/delete rows. See docs/APP-PLUGINS-API.md → Ops rows. */ plugin?: string; action?: string; /** The action's label in the caller's locale — the tray's row title. */ label?: string; /** The last `job_progress` message. */ message?: string; /** Where a trash job (restore, permanent delete, "empty the trash") stands, * said by the server in the reader's language (0.54, findings A4/A15): * what the explorer says when the job ends. Absent on other rows. */ summary?: string; /** Files the job committed, once finished. */ outputs?: PendingOpOutput[]; /** * Where the FINISHED job sends the person who queued it: one of its outputs * and the app's own screen to start there (the job result's `surface.open`, * docs/APP-PLUGINS-API.md → Ops rows). Absent while it runs, after it * failed, and on every row whose app asked for nothing. * * ⚠ Followed only by the screen that queued the job (PluginPageView, the * explorer's own registered jobs): an administrator's list carries other * people's rows too, and their jobs must not move this person's screen. */ open?: SurfaceOpenRequest; } const POLL_MS = 2000; const RETAIN_MS = 8000; /** * normalizeOp maps the backend ops row (`{kind,total,done,failed,error,dest, * status:'ok'|'failed'|'partial'|…}`) onto the PendingOp contract the tray * renders (`{op_type,progress_total,progress_done,error_message,status:'done'| * 'error'|…}`). Without this the tray showed "undefined/undefined" for a * running op (progress_* were undefined) and never treated an 'ok'/'failed' * row as terminal. Tolerant of an already-normalized shape (idempotent). */ export function normalizeOp(raw: Record): PendingOp { const num = (...vals: unknown[]): number => { for (const v of vals) if (typeof v === 'number') return v; return 0; }; const str = (...vals: unknown[]): string | null => { for (const v of vals) if (typeof v === 'string' && v !== '') return v; return null; }; const rawStatus = String(raw.status ?? 'pending'); // ⚠ `cancelled` is an ending of its own. It used to fall through to // 'pending', so a job somebody cancelled sat in the operations centre as // "Queued" for as long as the list still carried the row. const status: PendingOp['status'] = rawStatus === 'ok' || rawStatus === 'done' ? 'done' : rawStatus === 'failed' || rawStatus === 'partial' || rawStatus === 'error' ? 'error' : rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'cancelling' : rawStatus === 'running' ? 'running' : 'pending'; const sources = Array.isArray(raw.sources) ? raw.sources : []; // ⚠ Unknown kinds pass through. This used to fold everything that was not // copy/move into `delete`, which drew a bin beside a plugin job and read // "Deleted (1)" when it finished. `plugin-action` (the wire name) becomes // `plugin`; a row with no kind at all is the one case still read as delete // (the legacy delete endpoint's rows carried none). const opType = str(raw.op_type, raw.kind) ?? 'delete'; const outputs = Array.isArray(raw.outputs) ? (raw.outputs as unknown[]) .map((o) => (o && typeof o === 'object' ? str((o as { path?: unknown }).path) : null)) .filter((p): p is string => p !== null) .map((path) => ({ path })) : undefined; // Only a request the client can act on, and only the fields the contract // has (path, action, view - lib/surfaceOpen), never whatever else rode along. let open: SurfaceOpenRequest | undefined; if (isOpenRequest(raw.open)) { const action = str(raw.open.action); const view = str(raw.open.view); open = { path: raw.open.path, ...(action ? { action } : {}), ...(view ? { view } : {}) }; } return { id: num(raw.id), op_type: opType === 'plugin-action' ? 'plugin' : opType, status, progress_total: num(raw.progress_total, raw.total, sources.length), progress_done: num(raw.progress_done, raw.done), bytes_total: num(raw.bytes_total), bytes_done: num(raw.bytes_done), objects_total: num(raw.objects_total), objects_done: num(raw.objects_done), target_path: str(raw.target_path, raw.dest), source_dir: str(raw.source_dir), source_count: num(raw.source_count, sources.length), error_message: str(raw.error_message, raw.error), error_code: str(raw.error_code) ?? undefined, error_engine: str(raw.error_engine) ?? undefined, error_text: str(raw.error_text) ?? undefined, cancellable: raw.cancellable === true, started_at: str(raw.started_at), finished_at: str(raw.finished_at), created_at: str(raw.created_at), plugin: str(raw.plugin) ?? undefined, action: str(raw.action) ?? undefined, label: str(raw.label) ?? undefined, message: str(raw.message) ?? undefined, summary: str(raw.summary) ?? undefined, outputs, ...(open ? { open } : {}), }; } export interface UsePendingOpsOptions { /** * Called once a job THIS instance registered (`register`) flips into a * terminal state (done|error|cancelled). * * ⚠ Only those. The list also carries other people's running jobs (an * administrator sees every account's, #58), and they end too: an admin's * explorer said "Renamed" and "3 items restored", and read its listing * again, for every colleague's rename and restore (#61). */ onSettled?: (op: PendingOp) => void; } export function usePendingOps( config: ExplorerConfig, api: FileApi, opts: UsePendingOpsOptions = {}, ) { void config; // kept for future per-instance config knobs const ops = ref([]); const announced = new Set(); /** The jobs registered here, still to be announced when they end. */ const mine = new Set(); const settledAt = new Map(); // First poll after mount returns the server's recent-history window // (last 5 min of done/error rows). They are marked announced silently — // none was registered here (`mine`), so no toast fires twice on F5 — and // kept out of the tray. let firstPollDone = false; const hasActive = computed(() => ops.value.some((o) => o.status === 'pending' || o.status === 'running' || o.status === 'cancelling'), ); let timer: ReturnType | undefined; function startPolling() { if (timer) return; if (!api.endpoints.opsList) return; timer = setInterval(() => { void poll(); }, POLL_MS); // Kick a poll right away. void poll(); } function stopPolling() { if (timer) { clearInterval(timer); timer = undefined; } } async function poll(): Promise { if (!api.endpoints.opsList) return; try { // ⚠ `lang=`: an app job's label and result are said in the reader's // language when the list is READ (backend wasmplugin/jobtext.go) — the // language on this screen, not the account's (an embed's config.locale). const res = await api.jsonFetch<{ ops: Record[] }>(api.withScreenLang(api.endpoints.opsList)); const incoming = (res.ops || []).map(normalizeOp); for (const op of incoming) { if ((op.status === 'done' || op.status === 'error' || op.status === 'cancelled') && !announced.has(op.id)) { announced.add(op.id); settledAt.set(op.id, Date.now()); // Only a job this tab queued is announced — on the first look too: // one that ended before it would otherwise go unsaid. (What the // first look finds from before a reload was never registered here.) if (mine.delete(op.id)) opts.onSettled?.(op); } } // Filter incoming: pending/running always; settled only if user // is currently seeing them in the local tray. const localIds = new Set(ops.value.map((o) => o.id)); const visibleIncoming = incoming.filter((op) => { if (op.status === 'pending' || op.status === 'running' || op.status === 'cancelling') return true; if (!firstPollDone) return false; return localIds.has(op.id); }); const visibleIds = new Set(visibleIncoming.map((o) => o.id)); const local = ops.value.filter((o) => !visibleIds.has(o.id)); const merged = [...visibleIncoming, ...local]; firstPollDone = true; // Sweep: drop terminal ops past RETAIN_MS. const now = Date.now(); const after = merged.filter((o) => { if (o.status === 'pending' || o.status === 'running' || o.status === 'cancelling') return true; const at = settledAt.get(o.id); return at !== undefined && now - at < RETAIN_MS; }); ops.value = after; if (!hasActive.value && after.length === 0) { stopPolling(); } } catch (err) { // eslint-disable-next-line no-console console.warn('[usePendingOps] poll failed:', err); } } function register(raw: PendingOp | Record): void { const op = normalizeOp(raw as Record); if (!announced.has(op.id)) mine.add(op.id); const exists = ops.value.some((o) => o.id === op.id); if (!exists) { ops.value = [op, ...ops.value]; } startPolling(); } function dismiss(id: number): void { ops.value = ops.value.filter((o) => o.id !== id); settledAt.delete(id); } onBeforeUnmount(() => { stopPolling(); }); return { ops, hasActive, register, dismiss, poll, startPolling, stopPolling, }; }