export const PAGE_SIZE = 10; /** * prev/next pagination over a deduplicated async item stream. fetched pages * are cached; a failed fetch rolls its items back and reopens the stream so * "retry" starts clean. exhaustion is detected lazily: "next" at the very * end no-ops once (instantly, the stream is already drained) then disables. */ export function paginate(opts: { /** items are appended here as they arrive */ list: HTMLElement; /** receives the prev/next buttons */ control: HTMLElement; /** fresh iterator; re-invoked to restart after an error */ open: () => AsyncIterator; /** stable id for cross-page dedup */ key: (item: T) => string; render: (item: T) => HTMLElement; /** pre-rendered page 0 (e.g. the static log) */ seed?: { items: HTMLElement[]; keys: string[] }; onShow?: (count: number) => void; onError?: (err: unknown) => void; }): void { const { list, control, open, key, render } = opts; list.classList.add("paginated"); const shown = new Set(opts.seed?.keys); const pages: HTMLElement[][] = opts.seed ? [opts.seed.items] : []; let current = pages.length - 1; let exhausted = false; let failed = false; let source = open(); const nextUnseen = async (): Promise => { while (true) { const next = await source.next(); if (next.done) return null; if (!shown.has(key(next.value))) return next.value; } }; const showPage = () => { if (current >= 0) list.replaceChildren(...pages[current]); previous.disabled = current <= 0; const atEnd = exhausted && current === pages.length - 1; next.disabled = atEnd; list.classList.toggle("exhausted", atEnd); next.textContent = "next →"; control.removeAttribute("title"); if (current >= 0) opts.onShow?.(pages[current].length); }; async function goNext() { failed = false; if (current + 1 < pages.length) { current++; showPage(); return; } previous.disabled = true; next.disabled = true; next.textContent = `(0 / ${PAGE_SIZE})`; list.replaceChildren(); const page: HTMLElement[] = []; const pageKeys: string[] = []; try { while (page.length < PAGE_SIZE) { const item = await nextUnseen(); if (!item) { exhausted = true; break; } shown.add(key(item)); pageKeys.push(key(item)); const element = render(item); page.push(element); list.append(element); next.textContent = `(${page.length} / ${PAGE_SIZE})`; } if (page.length > 0) { pages.push(page); current++; } showPage(); } catch (err) { for (const k of pageKeys) shown.delete(k); source = open(); failed = true; previous.disabled = current <= 0; next.disabled = false; next.textContent = "retry →"; if (opts.onError) opts.onError(err); else control.title = String(err); } } const previous = ( ) as HTMLButtonElement; const next = ( ) as HTMLButtonElement; control.replaceChildren(previous, next); if (opts.seed) showPage(); else void goNext(); }