|
| 1 | +import { S, $ } from "./store"; |
| 2 | +import { guideProgress } from "./guide"; |
| 3 | + |
| 4 | +// Persistent review-progress chrome: a full-width fill strip along the bottom edge of the |
| 5 | +// topbar plus a "% reviewed" label beside the actions, visible with or without a guide (the |
| 6 | +// guidebar used to be progress's only home, so guideless desks showed none). Imperative |
| 7 | +// rather than Alpine because the *moment* of progress is animated — the number counts up |
| 8 | +// odometer-style and the strip pulses when the bar advances — which is rAF work. |
| 9 | + |
| 10 | +// Tab title carries progress too ("(58%) Galley — repo"), so it reads from other tabs. |
| 11 | +// main.ts names the base title at init; updateProgress stamps the prefix. |
| 12 | +let baseTitle = document.title; |
| 13 | +export function setBaseTitle(title: string) { |
| 14 | + baseTitle = title; |
| 15 | +} |
| 16 | + |
| 17 | +let shownPct: number | null = null; // % the label currently shows; null until first paint |
| 18 | +let raf = 0; |
| 19 | + |
| 20 | +// Count the label from `from` to `to` over ~450ms (ease-out) instead of jumping. |
| 21 | +function countUp(label: HTMLElement, from: number, to: number) { |
| 22 | + cancelAnimationFrame(raf); |
| 23 | + const start = performance.now(); |
| 24 | + const tick = (now: number) => { |
| 25 | + const k = Math.min(1, (now - start) / 450); |
| 26 | + const eased = 1 - (1 - k) ** 3; |
| 27 | + label.textContent = `${Math.round(from + (to - from) * eased)}% reviewed`; |
| 28 | + if (k < 1) raf = requestAnimationFrame(tick); |
| 29 | + }; |
| 30 | + raf = requestAnimationFrame(tick); |
| 31 | +} |
| 32 | + |
| 33 | +// Restart the .pulse CSS animation even when the class is already on the element. |
| 34 | +function pulse(el: HTMLElement) { |
| 35 | + el.classList.remove("pulse"); |
| 36 | + void el.offsetWidth; |
| 37 | + el.classList.add("pulse"); |
| 38 | +} |
| 39 | + |
| 40 | +// Called from render(): every state mutation that can move progress ends in a render, so |
| 41 | +// this is the single repaint point (and it must stay cheap — guideProgress is one pass). |
| 42 | +export function updateProgress() { |
| 43 | + const strip = $("progressStrip"); |
| 44 | + const label = $("progressPct"); |
| 45 | + if (!strip || !label) return; |
| 46 | + const hasFiles = !!S.state?.files?.length; |
| 47 | + strip.style.display = hasFiles ? "" : "none"; |
| 48 | + label.style.display = hasFiles ? "" : "none"; |
| 49 | + if (!hasFiles) { |
| 50 | + document.title = baseTitle; |
| 51 | + return; |
| 52 | + } |
| 53 | + const pct = guideProgress().pct; |
| 54 | + document.title = pct >= 100 ? `✓ ${baseTitle}` : pct > 0 ? `(${pct}%) ${baseTitle}` : baseTitle; |
| 55 | + ($("progressFill") as HTMLElement).style.width = `${pct}%`; // CSS transition animates the fill |
| 56 | + if (shownPct === null || pct === shownPct) { |
| 57 | + // First paint, or no movement (a re-render that didn't change progress): no ceremony. |
| 58 | + label.textContent = `${pct}% reviewed`; |
| 59 | + shownPct = pct; |
| 60 | + return; |
| 61 | + } |
| 62 | + if (pct > shownPct) pulse(strip); |
| 63 | + countUp(label, shownPct, pct); |
| 64 | + shownPct = pct; |
| 65 | +} |
| 66 | + |
| 67 | +// Whole-review numbers for the completion prompt — a small receipt of the work done. |
| 68 | +export function reviewStats(): { |
| 69 | + files: number; |
| 70 | + lines: number; |
| 71 | + comments: number; |
| 72 | + rejections: number; |
| 73 | +} { |
| 74 | + let lines = 0; |
| 75 | + for (const f of S.state?.files ?? []) |
| 76 | + for (const h of f.hunks ?? []) for (const l of h.lines) if (l.kind !== "context") lines++; |
| 77 | + return { |
| 78 | + files: S.state?.files?.length ?? 0, |
| 79 | + lines, |
| 80 | + comments: (S.state?.comments ?? []).filter((c) => c.role === "user" && c.status === "open") |
| 81 | + .length, |
| 82 | + rejections: (S.state?.changes ?? []).filter((c) => c.status === "rejected").length, |
| 83 | + }; |
| 84 | +} |
0 commit comments