import type { JsonValue } from "@berry/api"; import type { HeaderContext } from "@berry/api/web.ts"; import type { OpenFile, Save, Saved } from "./edit-types.ts"; export type Draft = { opened: OpenFile; content: string; pending?: Save }; export type Stored = { id: string; saved: boolean; closed: boolean; drafts: Record; /** Receipts of this session's saves, carried to the report when the session ends. */ saves: Saved[]; }; const PREFIX = "berry.diff.edits."; const recovering = new Map>(); const reporting = new Map>(); export function editId() { return Array.from( crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""); } export function draftKey(tid: string) { const storage = document.defaultView!.sessionStorage; let tab = storage.getItem("berry.diff.tab"); if (!tab) storage.setItem("berry.diff.tab", tab = editId()); return `${PREFIX}${tid}.${tab}`; } export function loadDrafts(key: string): Stored { try { const value = JSON.parse(localStorage.getItem(key) ?? "null"); if (value?.id && value.drafts) { return { ...value, drafts: Object.assign(Object.create(null), value.drafts), saves: Array.isArray(value.saves) ? value.saves : [], }; } } catch { /* a broken recovery record must not stop reviewing */ } return { id: editId(), saved: false, closed: false, drafts: Object.create(null), saves: [] }; } export function storeDrafts(key: string, stored: Stored) { localStorage.setItem(key, JSON.stringify(stored)); } /** Report a closed session's saves and retire it; concurrent callers share the one report. */ function reportStored(key: string, context: HeaderContext): Promise { const running = reporting.get(key); if (running) return running; const result = (async () => { const stored = loadDrafts(key); if (!stored.saved) return; await context.command( "diff-finish", JSON.stringify({ session: stored.id, saves: stored.saves }), ); const current = loadDrafts(key); if (Object.keys(current.drafts).length) { storeDrafts(key, { ...current, id: editId(), saved: false, closed: false, saves: [] }); } else localStorage.removeItem(key); })(); reporting.set(key, result); void result.finally(() => reporting.delete(key)).catch(() => {}); return result; } /** * Land a closed tab's unacknowledged saves. Reporting them is only kicked off: it may wait for the agent's * turn, which must not hold the recovering view open; `recoverClosed` awaits it instead. */ export function recover(key: string, context: HeaderContext): Promise { const running = recovering.get(key); if (running) return running; const result = (async () => { const stored = loadDrafts(key); if (!stored.closed) return; let failed: unknown; for (const draft of Object.values(stored.drafts)) { if (!draft.pending) continue; try { const saved = await context.request( "save", draft.pending as unknown as JsonValue, ) as unknown as Saved; draft.opened.content = draft.pending.content; draft.opened.version = saved.version; draft.opened.working = saved.working; delete draft.pending; if (!stored.saves.some((receipt) => receipt.id === saved.id)) stored.saves.push(saved); stored.saved = true; storeDrafts(key, stored); } catch (error) { failed ??= error; } } // a failed report stays saved in storage, to retry on a later visit if (stored.saved) void reportStored(key, context).catch(() => {}); if (failed) { throw new Error("Some saves could not be recovered. Their drafts are kept in the diff view."); } })(); recovering.set(key, result); void result.finally(() => recovering.delete(key)).catch(() => {}); return result; } export async function recoverClosed(context: HeaderContext) { const prefix = `${PREFIX}${context.tid}.`; const keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)!) .filter((key) => key.startsWith(prefix)); for (const key of keys) { await recover(key, context); if (loadDrafts(key).closed) await reportStored(key, context); } }