diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31b1aa6..782d5d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ schedules, and there is no read API to save those first. None of these four declares either. Background services are safe — they are declared in code, so they re-register on install. -## Two rules that are not obvious +## Three rules that are not obvious **Every runtime dependency belongs in the plugin's own `package.json`, under `dependencies`.** bb installs a single subdirectory out of this repository and @@ -66,6 +66,21 @@ governs the workspace; the nested ones are what a subdirectory install resolves against. After changing a plugin's dependencies, regenerate its lock from a copy of that directory alone, so workspace hoisting does not leak into it. +**A module two plugins share is copied.** A workspace package would not +install, because a plugin installs alone. A published npm package would, but it +runs the same way, because bb bundles each plugin's dependencies into that +plugin. Until something outside this repository needs it, copying one file is +cheaper than a publish pipeline. So `lib/complications.ts` lives in every plugin +that provides or draws a complication, byte for byte. Edit one copy, copy it +over the others, and `npm run check` fails first thing if you forget: the copies +are listed in `scripts/check-vendored.mjs`. + +Released copies can still disagree at runtime, because whichever bundle loads +first creates the registry every other plugin uses. Bump +`COMPLICATIONS_IMPLEMENTATION` with any change in its behaviour, fixes included, +so that a newer copy can tell, in the console, when an older one is in charge. +The module's header says what that freezes. + ## Releasing Each plugin releases under its own tag prefix, so a semver range tracks one diff --git a/README.md b/README.md index a569ecc..fd0a483 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Four plugins for [bb](https://github.com/get-bb/bb), the agent IDE. | Plugin | | | --- | --- | | **[Follow Up](plugins/follow-up)** | Keeps the work an agent noticed but skipped, so nothing is lost when a turn ends. Triage what a thread accumulated above the composer. | -| **[Thread Badges](plugins/thread-badges)** | Shows pull-request state, CI attention, follow-up progress and listening ports on sidebar rows, so you see what needs you without opening a thread. | +| **[Thread Badges](plugins/thread-badges)** | Shows pull-request state, CI attention, listening ports and badges other plugins publish, like Follow Up's progress, on sidebar rows, so you see what needs you without opening a thread. | | **[Top Tabs](plugins/top-tabs)** | Opens destinations as tabs across the top of the window, beside a permanent Threads tab, so you can keep several open and switch without losing your place. | | **[Workflow Stages](plugins/workflow-stages)** | Files every thread under a workflow stage you define, and moves it as the work progresses. Requires the Ribbon sidebar. | Each is independent. Three soft connections exist and none is required: Thread -Badges draws a follow-up ring when Follow Up is installed and a ports plug when -[Worktree Ports](https://github.com/to-infinity-labs/bb-plugin-worktree-ports) -is, and Workflow Stages needs +Badges draws Follow Up's progress ring once you turn it on, and a ports plug +when [Worktree Ports](https://github.com/to-infinity-labs/bb-plugin-worktree-ports) +is installed, and Workflow Stages needs [Ribbon sidebar](https://github.com/ariofrio/ribbon) to have anywhere to draw. ## Install diff --git a/package-lock.json b/package-lock.json index 295d4cf..eb758bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4580,7 +4580,7 @@ }, "plugins/follow-up": { "name": "bb-plugin-follow-up", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -4665,16 +4665,17 @@ }, "plugins/thread-badges": { "name": "bb-plugin-thread-badges", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", "@radix-ui/react-checkbox": "^1.3.7", - "@radix-ui/react-slot": "^1.3.0" + "@radix-ui/react-slot": "^1.3.0", + "zod": "^4.6.5" }, "devDependencies": { - "@get-bb/plugin-sdk": "0.4.47", + "@get-bb/plugin-sdk": "0.6.15", "@pierre/diffs": "^1.2.9", "@radix-ui/react-alert-dialog": "^1.1.19", "@radix-ui/react-context-menu": "^2.3.3", @@ -4686,6 +4687,7 @@ "@radix-ui/react-popover": "^1.1.19", "@radix-ui/react-select": "^2.3.3", "@radix-ui/react-tooltip": "^1.2.12", + "@testing-library/react": "^16.3.3", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.0.0", "@types/react": "^19.0.0", @@ -4695,14 +4697,64 @@ "clsx": "^2.1.1", "cron-parser": "^5.5.0", "hono": "^4.11.9", + "jsdom": "^30.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", "sonner": "^1.7.4", "tailwind-merge": "^3.4.0", "typescript": "^5.7.0", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "vitest": "^5.0.3" }, "engines": { - "bb": ">=0.36", - "bbPluginSdk": ">=0.4.47" + "bb": ">=0.45", + "bbPluginSdk": ">=0.6.15" + } + }, + "plugins/thread-badges/node_modules/@get-bb/plugin-sdk": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/@get-bb/plugin-sdk/-/plugin-sdk-0.6.15.tgz", + "integrity": "sha512-S7UUC+mfY1V9wTU1NiWw+VyP1STcWm4+Cgoi/rzHhj1kfCuxnVdTozJ7XedKMeDM9vVGYU4y3+r/CpEWIdsiRg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "zod": "^4.6.5" + }, + "peerDependencies": { + "@testing-library/react": "^16.3.2", + "@types/better-sqlite3": "^7.6.12", + "@types/react": "^19.0.0", + "better-sqlite3": ">=12", + "cron-parser": "^5.5.0", + "hono": "^4.11.9", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "peerDependenciesMeta": { + "@testing-library/react": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "cron-parser": { + "optional": true + }, + "hono": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, "plugins/top-tabs": { diff --git a/package.json b/package.json index 84cfc8f..67c753c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "typecheck": "npm run --workspaces --if-present typecheck", "test": "npm run --workspaces --if-present test", "build": "npm run --workspaces --if-present build", - "check": "npm run typecheck && npm run test && npm run build", + "check": "node scripts/check-vendored.mjs && npm run typecheck && npm run test && npm run build", "bb": "node scripts/bb-dev.mjs", "plugins": "node scripts/bb-dev.mjs status", "link": "node scripts/bb-dev.mjs link", diff --git a/plugins/follow-up/PLUGIN_OVERVIEW.md b/plugins/follow-up/PLUGIN_OVERVIEW.md index 83c0cce..face3ff 100644 --- a/plugins/follow-up/PLUGIN_OVERVIEW.md +++ b/plugins/follow-up/PLUGIN_OVERVIEW.md @@ -47,6 +47,11 @@ turn, and both are told that "nothing" is a real answer. ## For other plugins +Each thread's progress is published as the `follow-up/progress` complication, a +small value any plugin can draw that is published again the moment a follow-up +changes. Thread Badges draws it as a ring on sidebar rows, once you turn it on +there. + `getFollowUpCountsV1` is a stable, versioned contract: one request returns open and done counts for up to 500 threads, every requested thread comes back including ones with nothing recorded, and repeated ids are deduped in diff --git a/plugins/follow-up/README.md b/plugins/follow-up/README.md index fbe64ce..2d5a219 100644 --- a/plugins/follow-up/README.md +++ b/plugins/follow-up/README.md @@ -167,7 +167,36 @@ something and want agents to be able to raise it again. ## For other plugins -One method here is a contract rather than an internal call: +Two things here are contracts rather than internal calls: a live value for a +plugin drawing progress, and a batch call for one that only needs to ask. + +### The progress complication + +Each thread's progress is published as a *complication*: a small value any +plugin can draw, kept in a registry that every plugin bundle in a bb window +shares. [Thread Badges](../thread-badges) 0.4 draws it as a ring that moves the +moment a follow-up changes, once you turn it on under *Badges from other +plugins* in its settings. The counts call below cannot do that, because a plugin +hears only its own realtime signals; this plugin hears `followups-changed`, so +it publishes again on every one. + +The registry is protocol v1 of [`lib/complications.ts`](lib/complications.ts), +which a consumer copies into its own plugin; its header is the protocol. Want +`follow-up/progress` for `{ kind: "thread", id }` and the value is: + +```ts +{ icon: "TextWrap", label: "27 of 28 follow-ups done", tone: "default", + fraction: 27 / 28, text: "1" } // text: how many are still open +``` + +`null` for a thread that never recorded a follow-up. Once the list is clear, the +tone is `success` and there is no `text`. Values are published only for threads +a surface wants, and again whenever one of them changes or realtime reconnects. +The provider is registered as long as this plugin's frontend is loaded, so +`isProvided("follow-up/progress")` is how a consumer tells a live Follow Up from +an older one and falls back to the counts call. + +### The counts call ``` POST /api/v1/plugins/follow-up/rpc/getFollowUpCountsV1 diff --git a/plugins/follow-up/app.tsx b/plugins/follow-up/app.tsx index 6cdae68..d66bb0f 100644 --- a/plugins/follow-up/app.tsx +++ b/plugins/follow-up/app.tsx @@ -23,8 +23,17 @@ import { commands } from "./src/commands.ts"; import { FOLLOWUPS_PANEL_ACTION } from "./src/panel-ids.ts"; import { getRpc } from "./src/rpc.ts"; import { toast } from "sonner"; +import { ComplicationPublisher } from "./src/complication-publisher.tsx"; export default definePluginApp((app) => { + // Publishes each thread's follow-up progress for any surface drawing it — + // Thread Badges' ring today. An overlay because it must outlive any one + // thread view: it is the only listener for every thread's changes. + app.slots.experimental_appOverlay({ + id: "complications", + component: ComplicationPublisher, + }); + app.composer.customize({ id: "follow-up", // "Record the draft" is an alternative to sending it, so it is a row in diff --git a/plugins/follow-up/lib/complications.ts b/plugins/follow-up/lib/complications.ts new file mode 100644 index 0000000..fe1dbcf --- /dev/null +++ b/plugins/follow-up/lib/complications.ts @@ -0,0 +1,560 @@ +// Complications: small values one plugin provides and another draws. +// +// VENDORED. This file is copied byte for byte into every plugin in this +// repository that provides or draws a complication, and +// `scripts/check-vendored.mjs` fails the repository check when the copies +// drift. Copying is how it ships, not how it runs: bb bundles each plugin's +// dependencies into that plugin, so a published npm package would still put +// one copy in every bundle. A package would add versioning for plugins outside +// this repository; a workspace-only one would not install at all, because bb +// installs each plugin from its own subdirectory. +// +// The registry lives on `globalThis`, under a versioned symbol. bb imports +// every plugin's frontend bundle into the same document — a plain +// `import(url)`, with no iframe, worker or realm between them — so this is the +// one place two bundles meet without a server round trip. It is also the one +// place that is live across plugins: realtime delivers a plugin only its own +// signals, so a provider that hears its own change can tell everyone else here. +// +// Whichever bundle loads first creates the registry, and every later bundle +// uses that object, methods included. An older copy's code may therefore be +// the one answering a newer copy's calls, depending only on which plugin +// loaded first. Three rules follow, and they are what "protocol v1" means: +// +// - Behaviour is frozen. `provide`, `want`, `read`, `isProvided`, `subscribe`, +// `providers` and `subscribeProviders` keep exactly these semantics under +// this symbol. A new method or a changed meaning is a new symbol. +// - The schema is not. The registry validates the fields it names and carries +// every other JSON-safe field across untouched, so a field added later +// reaches surfaces even through an older copy. A surface must check any +// field this file does not name before it draws it. +// - Which copy is running is never a guess. Bump `COMPLICATIONS_IMPLEMENTATION` +// with any change to this file's behaviour, fixes included. The registry +// reports the number of the copy that created it, and a newer copy that +// finds an older one in charge says so, once, in the console: its fix does +// not apply until every plugin carrying the older copy updates. + +export const COMPLICATIONS_PROTOCOL = 1 as const; + +/** This copy's implementation. Bump it with any change in behaviour. */ +export const COMPLICATIONS_IMPLEMENTATION = 1; + +const REGISTRY_KEY = Symbol.for("bb-community.complications.v1"); + +/** The largest value the registry carries, in characters of its JSON. */ +export const MAX_VALUE_LENGTH = 4096; + +/** `/`, each lowercase letters, digits and single dashes. */ +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const MAX_KIND_LENGTH = 64; +const MAX_SUBJECT_ID_LENGTH = 2048; +const MAX_TONE_LENGTH = 32; + +/** + * What a complication is about. The registry checks only the shape: `kind` is + * open, and `id` is opaque. + * + * Kinds in use: `thread`, `project`, `environment`, `app`, `url`, `message`, + * `file`. A singleton has an empty id — `{ kind: "app", id: "" }`. Each kind's + * canonical id is a convention between its providers and surfaces, not the + * registry's business: a `url` is `new URL(…).href`, so a trailing slash or a + * capitalised host cannot split one link into two subjects. + */ +export interface ComplicationSubject { + kind: string; + id: string; +} + +/** + * The tones every surface understands. A tone is an open string: a surface + * draws one it does not know as `default`, so adding one costs nothing. + * `running` is the one with behaviour — bb animates it. + */ +export type ComplicationTone = "default" | "info" | "success" | "warning" | "error" | "running"; + +/** + * One value that can be read at a glance. Data, not a component: the surface + * draws it, in whichever size its slot has room for, and a surface outside bb + * can draw it too. Smallest to largest: a glyph (`icon`, `tone`), inline + * (`text`), a gauge (`fraction`). + * + * Fields not named here pass through the registry as JSON; read them with care. + * Two names are reserved for what no surface draws yet: `detail`, the large + * size a card or hover shows, and `open`, where clicking goes. Their shapes + * will be settled by the first surface that draws them, so the registry does + * not check them — a surface must, before it draws one, and an `href` above + * all. + */ +export interface ComplicationValue { + /** A bb icon name: a built-in glyph, or a namespaced `"/"`. */ + readonly icon: string; + /** Accessible label and tooltip. Required: a glyph alone is not a sentence. */ + readonly label: string; + readonly tone?: ComplicationTone | (string & {}); + /** A few characters beside the glyph — a count, a port, a percentage. */ + readonly text?: string; + /** Progress from 0 to 1. Present, it makes the value a gauge. */ + readonly fraction?: number; +} + +export interface ComplicationProviderRegistration { + /** `"/"`. One provider per id; a second replaces the first. */ + id: string; + /** What the settings list calls it: "Follow-up progress". */ + name: string; + description?: string; + /** The subject kinds it answers, so a slot offers it only where it can speak. */ + subjects?: readonly string[]; + /** A representative value, for previews in settings. */ + sample?: ComplicationValue; + /** + * Subjects that have just become wanted, batched per microtask — including, + * just after registering, every subject that was wanted before the provider + * loaded. Answer with the handle's `set`, now or later. + */ + onWanted?: (subjects: readonly ComplicationSubject[]) => void; +} + +/** A provider as `providers()` lists it: the registration without its callback. */ +export interface ComplicationProviderInfo { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly subjects?: readonly string[]; + readonly sample?: ComplicationValue; +} + +export interface ComplicationProviderHandle { + /** Publish a value, or `null` for "nothing to say". Ignored once withdrawn. */ + set(subject: ComplicationSubject, value: ComplicationValue | null): void; + /** Whether any surface currently wants this subject. */ + isWanted(subject: ComplicationSubject): boolean; + /** Every subject currently wanted. */ + wanted(): ComplicationSubject[]; + /** + * Withdraw, clearing every value this provider set. Idempotent, and a no-op + * once another registration has replaced this one, so a hot reload's late + * cleanup cannot wipe the generation that replaced it. + */ + dispose(): void; +} + +export interface ComplicationsRegistry { + readonly protocol: typeof COMPLICATIONS_PROTOCOL; + /** + * `COMPLICATIONS_IMPLEMENTATION` of the copy that created this registry — + * the copy whose code every plugin in the window is running. + */ + readonly implementation: number; + /** Throws on a malformed id or a blank name: those are bugs in the caller. */ + provide(registration: ComplicationProviderRegistration): ComplicationProviderHandle; + /** Say that a surface needs this value. Returns the release. */ + want(id: string, subject: ComplicationSubject): () => void; + /** + * `undefined` until a provider has answered for this subject, `null` once it + * has said there is nothing to show. The object is stable until it changes, + * so it is safe as a `useSyncExternalStore` snapshot. + */ + read(id: string, subject: ComplicationSubject): ComplicationValue | null | undefined; + isProvided(id: string): boolean; + /** A value's changes, or with a `null` subject, that provider coming and going. */ + subscribe(id: string, subject: ComplicationSubject | null, listener: () => void): () => void; + /** Every live provider. The array is stable until one comes, goes or is replaced. */ + providers(): readonly ComplicationProviderInfo[]; + subscribeProviders(listener: () => void): () => void; +} + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonBlank(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isSubject(value: unknown): value is ComplicationSubject { + return ( + isRecord(value) && + isNonBlank(value.kind) && + value.kind.length <= MAX_KIND_LENGTH && + typeof value.id === "string" && + value.id.length <= MAX_SUBJECT_ID_LENGTH + ); +} + +/** JSON, so no choice of separator can make two subjects collide. */ +function subjectKey(subject: ComplicationSubject): string { + return JSON.stringify([subject.kind, subject.id]); +} + +/** Own data properties only: `__proto__` must stay a key, not become a prototype. */ +function put(into: Record, key: string, value: unknown): void { + Object.defineProperty(into, key, { value, enumerable: true, writable: true, configurable: true }); +} + +/** A JSON-safe deep copy, or `undefined` when there is none: a function, a cycle, a BigInt. */ +function jsonCopy(value: unknown): Json | undefined { + try { + // `JSON.stringify` gives `undefined` for a function, a symbol or + // `undefined` itself, and throws for a cycle or a BigInt. + const text = JSON.stringify(value); + return text === undefined ? undefined : (JSON.parse(text) as Json); + } catch { + return undefined; + } +} + +/** + * Carry across every field `known` does not name, each only if it survives as + * JSON. This is the half of the protocol that lets the schema grow. + */ +function passThrough( + raw: Record, + known: ReadonlySet, + into: Record, +): void { + for (const key of Object.keys(raw)) { + if (known.has(key)) continue; + const copy = jsonCopy(raw[key]); + if (copy !== undefined) put(into, key, copy); + } +} + +function deepFreeze(value: T): T { + if (typeof value === "object" && value !== null) { + for (const key of Object.keys(value)) deepFreeze((value as Record)[key]); + Object.freeze(value); + } + return value; +} + +function normalizeTone(raw: unknown): string | undefined { + return isNonBlank(raw) && raw.length <= MAX_TONE_LENGTH ? raw : undefined; +} + +const VALUE_KNOWN: ReadonlySet = new Set(["icon", "label", "tone", "text", "fraction"]); +const REGISTRATION_KNOWN: ReadonlySet = new Set([ + "id", + "name", + "description", + "subjects", + "sample", + "onWanted", +]); + +/** + * The value as the registry stores it, with its JSON for comparison, or + * `undefined` when it is not a value at all. + * + * Named fields are checked: a bad required one rejects the value, a bad + * optional one is dropped. Other fields are copied as JSON. Copying rather + * than keeping the provider's object is the point — the provider is another + * plugin, and what it handed over must not change under a surface drawing it. + */ +function normalize(value: unknown): { value: ComplicationValue; json: string } | null | undefined { + if (value === null) return null; + if (!isRecord(value)) return undefined; + try { + if (!isNonBlank(value.icon) || !isNonBlank(value.label)) return undefined; + const next: Record = { icon: value.icon, label: value.label }; + const tone = normalizeTone(value.tone); + if (tone !== undefined) next.tone = tone; + if (isNonBlank(value.text)) next.text = value.text; + if (typeof value.fraction === "number" && Number.isFinite(value.fraction)) { + next.fraction = Math.min(1, Math.max(0, value.fraction)); + } + passThrough(value, VALUE_KNOWN, next); + const json = JSON.stringify(next); + if (json.length > MAX_VALUE_LENGTH) return undefined; + return { value: deepFreeze(next) as unknown as ComplicationValue, json }; + } catch { + // A getter that throws, a proxy that lies: not a value. + return undefined; + } +} + +/** Exported for tests: what `set` would store, or `undefined` for "refused". */ +export function normalizeValue(value: unknown): ComplicationValue | null | undefined { + const normalized = normalize(value); + return normalized === null ? null : normalized?.value; +} + +/** The registration as `providers()` lists it. Throws on what is a caller's bug. */ +function describe(registration: ComplicationProviderRegistration): ComplicationProviderInfo { + const raw = registration as unknown as Record; + if (typeof raw.id !== "string" || !ID_PATTERN.test(raw.id)) { + throw new TypeError( + `complications: provider id ${JSON.stringify(raw.id)} is not "/"`, + ); + } + if (!isNonBlank(raw.name)) { + throw new TypeError(`complications: provider ${raw.id} needs a name`); + } + const info: Record = { id: raw.id, name: raw.name }; + if (isNonBlank(raw.description)) info.description = raw.description; + if (Array.isArray(raw.subjects)) { + info.subjects = raw.subjects.filter( + (kind): kind is string => isNonBlank(kind) && kind.length <= MAX_KIND_LENGTH, + ); + } + const sample = normalizeValue(raw.sample); + if (sample != null) info.sample = sample; + passThrough(raw, REGISTRATION_KNOWN, info); + return deepFreeze(info) as unknown as ComplicationProviderInfo; +} + +/** + * Run another plugin's code without letting it take this one down. A listener + * belongs to whichever surface subscribed and a callback to whichever plugin + * provided; a throw in either would otherwise land in the caller's commit. + */ +function contain(run: () => void): void { + try { + run(); + } catch (error) { + console.error("[complications]", error); + } +} + +/** Exported for tests. Plugins use `getComplications`. */ +export function createRegistry(): ComplicationsRegistry { + const providers = new Map< + string, + { + token: object; + info: ComplicationProviderInfo; + onWanted: ComplicationProviderRegistration["onWanted"]; + } + >(); + const values = new Map>(); + const wants = new Map>(); + const listeners = new Map void>>(); + const providerListeners = new Set<() => void>(); + /** Newly wanted subjects not yet delivered, by complication id. */ + const fresh = new Map>(); + let flushQueued = false; + let providerList: readonly ComplicationProviderInfo[] | null = null; + + const valueKey = (id: string, key: string) => `${id}\n${key}`; + const presenceKey = (id: string) => `${id}\n`; + + function notify(key: string): void { + const set = listeners.get(key); + if (set === undefined) return; + for (const listener of [...set]) contain(listener); + } + + function providersChanged(id: string): void { + providerList = null; + notify(presenceKey(id)); + for (const listener of [...providerListeners]) contain(listener); + } + + function isWanted(id: string, subject: ComplicationSubject): boolean { + return wants.get(id)?.has(subjectKey(subject)) ?? false; + } + + /** Hand subjects to the provider, skipping any released since they were queued. */ + function deliver(id: string, subjects: Iterable): void { + const onWanted = providers.get(id)?.onWanted; + if (typeof onWanted !== "function") return; + // A row that mounted and unmounted within the batch asks for nothing. + const still = [...subjects].filter((subject) => isWanted(id, subject)); + if (still.length > 0) contain(() => onWanted(still)); + } + + function flush(): void { + flushQueued = false; + const batch = [...fresh]; + fresh.clear(); + for (const [id, subjects] of batch) deliver(id, subjects.values()); + } + + return { + protocol: COMPLICATIONS_PROTOCOL, + implementation: COMPLICATIONS_IMPLEMENTATION, + + provide(registration) { + const info = describe(registration); + const { id } = info; + const token = {}; + providers.set(id, { token, info, onWanted: registration.onWanted }); + providersChanged(id); + // Whatever was wanted before this provider loaded. A microtask rather + // than now: the provider's `onWanted` answers through the handle, which + // it does not have until this call returns. + if (wants.has(id)) { + queueMicrotask(() => { + if (providers.get(id)?.token !== token) return; + deliver(id, [...(wants.get(id)?.values() ?? [])].map((want) => want.subject)); + }); + } + + const current = () => providers.get(id)?.token === token; + return { + set(subject, value) { + if (!current() || !isSubject(subject)) return; + const next = normalize(value); + if (next === undefined) { + console.warn(`[complications] ${id}: ignored a value that is not a complication`); + return; + } + const stored = next ?? { value: null, json: "null" }; + let byId = values.get(id); + if (byId === undefined) { + byId = new Map(); + values.set(id, byId); + } + const key = subjectKey(subject); + // Unchanged keeps its identity, so a surface does not re-render. + if (byId.get(key)?.json === stored.json) return; + byId.set(key, stored); + notify(valueKey(id, key)); + }, + isWanted(subject) { + return isSubject(subject) && isWanted(id, subject); + }, + wanted() { + return [...(wants.get(id)?.values() ?? [])].map((want) => ({ ...want.subject })); + }, + dispose() { + if (!current()) return; + providers.delete(id); + const had = values.get(id); + values.delete(id); + for (const key of had?.keys() ?? []) notify(valueKey(id, key)); + providersChanged(id); + }, + }; + }, + + want(id, subject) { + if (!isSubject(subject)) return () => undefined; + const key = subjectKey(subject); + let byId = wants.get(id); + if (byId === undefined) { + byId = new Map(); + wants.set(id, byId); + } + let entry = byId.get(key); + if (entry === undefined) { + entry = { subject: { kind: subject.kind, id: subject.id }, count: 0 }; + byId.set(key, entry); + let queued = fresh.get(id); + if (queued === undefined) { + queued = new Map(); + fresh.set(id, queued); + } + queued.set(key, entry.subject); + if (!flushQueued) { + flushQueued = true; + queueMicrotask(flush); + } + } + entry.count += 1; + const held = entry; + let released = false; + return () => { + if (released) return; + released = true; + held.count -= 1; + if (held.count > 0) return; + // Released, but the value stays: sidebar rows unmount and remount + // constantly, and dropping it would blink every badge on each pass. + // Wanting it again asks the provider afresh. + const current = wants.get(id); + if (current?.get(key) === held) current.delete(key); + if (current?.size === 0) wants.delete(id); + }; + }, + + read(id, subject) { + if (!isSubject(subject)) return undefined; + return values.get(id)?.get(subjectKey(subject))?.value; + }, + + isProvided(id) { + return providers.has(id); + }, + + subscribe(id, subject, listener) { + if (subject !== null && !isSubject(subject)) return () => undefined; + const key = subject === null ? presenceKey(id) : valueKey(id, subjectKey(subject)); + let set = listeners.get(key); + if (set === undefined) { + set = new Set(); + listeners.set(key, set); + } + set.add(listener); + return () => { + const current = listeners.get(key); + if (current === undefined) return; + current.delete(listener); + if (current.size === 0) listeners.delete(key); + }; + }, + + providers() { + providerList ??= Object.freeze([...providers.values()].map((entry) => entry.info)); + return providerList; + }, + + subscribeProviders(listener) { + providerListeners.add(listener); + return () => { + providerListeners.delete(listener); + }; + }, + }; +} + +function isRegistry(value: unknown): value is ComplicationsRegistry { + if (!isRecord(value) || value.protocol !== COMPLICATIONS_PROTOCOL) return false; + return ["provide", "want", "read", "isProvided", "subscribe", "providers", "subscribeProviders"].every( + (method) => typeof value[method] === "function", + ); +} + +/** Registries this copy has already complained about, so it says so once. */ +const warnedAbout = new WeakSet(); + +/** + * Say, once, that an older copy is answering for everyone. Not an error: the + * older copy keeps working, by the frozen behaviour. But a fix in this copy + * does not apply until every plugin carrying the older one updates, and + * without this line that is indistinguishable from the fix not working. + */ +function warnIfOlder(registry: ComplicationsRegistry): void { + const running = typeof registry.implementation === "number" ? registry.implementation : 0; + if (running >= COMPLICATIONS_IMPLEMENTATION || warnedAbout.has(registry)) return; + warnedAbout.add(registry); + console.warn( + `[complications] An older copy (implementation ${running}) created this ` + + `window's registry, so its code is what runs; this copy is ` + + `implementation ${COMPLICATIONS_IMPLEMENTATION}. Update the plugin that ` + + `carries the older copy to get this one's behaviour.`, + ); +} + +/** + * The window's registry, created by whichever bundle asks first. `null` only + * if something other than a registry already holds the symbol — draw nothing + * and provide nothing, rather than guess at its shape. + */ +export function getComplications(scope: object = globalThis): ComplicationsRegistry | null { + const holder = scope as Record; + const existing = holder[REGISTRY_KEY]; + if (existing !== undefined) { + if (!isRegistry(existing)) return null; + warnIfOlder(existing); + return existing; + } + const created = createRegistry(); + // Not writable or configurable: a later bundle must find this object, not + // replace it and strand everyone who already subscribed to the old one. + Object.defineProperty(scope, REGISTRY_KEY, { value: created }); + return created; +} diff --git a/plugins/follow-up/lib/progress-complication.ts b/plugins/follow-up/lib/progress-complication.ts new file mode 100644 index 0000000..a464db2 --- /dev/null +++ b/plugins/follow-up/lib/progress-complication.ts @@ -0,0 +1,55 @@ +// The follow-up progress complication: how much of a thread's list is closed. +// +// Follow Up is the provider because Follow Up is the only plugin that hears +// its own `followups-changed` signal. Thread Badges used to draw this ring by +// polling `getFollowUpCountsV1` every 30 seconds, which is why recording a +// follow-up did not move the ring; publishing the value from here is what +// makes it move at once. See src/complication-publisher.tsx. +import type { + ComplicationProviderRegistration, + ComplicationSubject, + ComplicationValue, +} from "./complications.ts"; + +/** The complication's id in the registry: `/`. */ +export const FOLLOW_UP_PROGRESS = "follow-up/progress"; + +/** + * How Follow Up registers. Here rather than in the publisher so a test can + * prove the registry accepts it: a registration the registry refuses throws + * inside an effect, where all anyone sees is a ring that never goes live. + */ +export function progressRegistration( + ask: (threadIds: string[]) => void, +): ComplicationProviderRegistration { + return { + id: FOLLOW_UP_PROGRESS, + name: "Follow-up progress", + description: "How much of a thread's follow-up list is closed.", + subjects: ["thread"], + sample: progressValue({ open: 1, done: 3 }) ?? undefined, + onWanted: (subjects: readonly ComplicationSubject[]) => + ask(subjects.filter((subject) => subject.kind === "thread").map((subject) => subject.id)), + }; +} + +/** + * Counts to a gauge, or `null` for a thread that never recorded a follow-up — + * which has nothing to say, and must not draw an empty ring as if it did. + */ +export function progressValue(counts: { open: number; done: number }): ComplicationValue | null { + const total = counts.open + counts.done; + if (total <= 0) return null; + const complete = counts.open === 0; + return { + icon: "TextWrap", + label: complete + ? `All ${total} follow-up${total === 1 ? "" : "s"} done` + : `${counts.done} of ${total} follow-ups done`, + // Green only when the list is actually clear; otherwise quiet chrome. + tone: complete ? "success" : "default", + fraction: counts.done / total, + // What is still open, for a surface with room to say it. + ...(counts.open > 0 ? { text: String(counts.open) } : {}), + }; +} diff --git a/plugins/follow-up/package-lock.json b/plugins/follow-up/package-lock.json index 45ae0a6..2367250 100644 --- a/plugins/follow-up/package-lock.json +++ b/plugins/follow-up/package-lock.json @@ -1,12 +1,12 @@ { "name": "bb-plugin-follow-up", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-follow-up", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/plugins/follow-up/package.json b/plugins/follow-up/package.json index 93436e9..a2e32e4 100644 --- a/plugins/follow-up/package.json +++ b/plugins/follow-up/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-follow-up", - "version": "0.6.0", + "version": "0.7.0", "private": true, "license": "MIT", "type": "module", diff --git a/plugins/follow-up/src/complication-publisher.tsx b/plugins/follow-up/src/complication-publisher.tsx new file mode 100644 index 0000000..70e7787 --- /dev/null +++ b/plugins/follow-up/src/complication-publisher.tsx @@ -0,0 +1,100 @@ +// The provider half of the follow-up progress complication. +// +// Mounted once per window as an app overlay, so it lives exactly as long as +// this bundle does — and it hears `followups-changed` for every thread, which +// is the signal no other plugin can. Surfaces say which threads they are +// showing; this answers them through the batch counts call, then answers again +// whenever one of those threads changes. That is the whole liveness fix: a +// ring drawn by Thread Badges moves the moment a follow-up is recorded, +// instead of on its next 30-second poll. +// +// It renders nothing. A component, not a content script, because the realtime +// subscription and the RPC client are hooks. +import { useEffect, useRef } from "react"; +import { useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app"; +import type { rpcContract } from "../server"; +import { getComplications } from "../lib/complications.ts"; +import { progressRegistration, progressValue } from "../lib/progress-complication.ts"; +import { isChangeSignal } from "./use-follow-ups.ts"; + +/** Long enough that a sidebar mounting its rows lands in one request. */ +const BATCH_DELAY = 25; + +/** The counts call's own ceiling — `COUNTS_MAX_THREADS` in server.ts. */ +const MAX_THREADS = 500; + +interface Provider { + ask(threadIds: Iterable): void; + isWanted(threadId: string): boolean; + wanted(): string[]; +} + +export function ComplicationPublisher(): null { + const rpc = useRpc(); + const connection = useRealtimeConnectionState(); + const rpcRef = useRef(rpc); + rpcRef.current = rpc; + const provider = useRef(null); + + useEffect(() => { + const registry = getComplications(); + if (registry === null) return; + let live = true; + let timer = 0; + const pending = new Set(); + + const flush = () => { + timer = 0; + const threadIds = [...pending]; + pending.clear(); + for (let start = 0; start < threadIds.length; start += MAX_THREADS) { + const chunk = threadIds.slice(start, start + MAX_THREADS); + rpcRef.current + .call("getFollowUpCountsV1", { threadIds: chunk }) + .then(({ counts }) => { + if (!live) return; + for (const entry of counts) { + handle.set({ kind: "thread", id: entry.threadId }, progressValue(entry)); + } + }) + // Keep the last good value: a failed refresh is not "no follow-ups". + .catch(() => undefined); + } + }; + + const ask = (threadIds: Iterable) => { + for (const threadId of threadIds) pending.add(threadId); + if (pending.size > 0 && timer === 0) timer = window.setTimeout(flush, BATCH_DELAY); + }; + + const handle = registry.provide(progressRegistration(ask)); + provider.current = { + ask, + isWanted: (threadId) => handle.isWanted({ kind: "thread", id: threadId }), + wanted: () => handle.wanted().map((subject) => subject.id), + }; + + return () => { + live = false; + window.clearTimeout(timer); + provider.current = null; + handle.dispose(); + }; + }, []); + + // Only threads someone is drawing. One that scrolled away keeps its last + // value in the registry, and wanting it again asks afresh. + useRealtime("followups-changed", (payload) => { + if (isChangeSignal(payload) && provider.current?.isWanted(payload.threadId)) { + provider.current.ask([payload.threadId]); + } + }); + + // Realtime drops signals while it is down, so a reconnect re-asks for + // everything on screen rather than trusting what it may have missed. + useEffect(() => { + provider.current?.ask(provider.current.wanted()); + }, [connection]); + + return null; +} diff --git a/plugins/follow-up/tests/complications.test.ts b/plugins/follow-up/tests/complications.test.ts new file mode 100644 index 0000000..21fef78 --- /dev/null +++ b/plugins/follow-up/tests/complications.test.ts @@ -0,0 +1,483 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COMPLICATIONS_IMPLEMENTATION, + createRegistry, + getComplications, + MAX_VALUE_LENGTH, + normalizeValue, + type ComplicationProviderRegistration, + type ComplicationSubject, +} from "../lib/complications.ts"; +import { + FOLLOW_UP_PROGRESS, + progressRegistration, + progressValue, +} from "../lib/progress-complication.ts"; + +const ID = "test/progress"; +const KEY = Symbol.for("bb-community.complications.v1"); +const thread = (id: string): ComplicationSubject => ({ kind: "thread", id }); +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); +const value = (label: string) => ({ icon: "Circle", label }); +const provider = ( + extra: Partial = {}, +): ComplicationProviderRegistration => ({ id: ID, name: "Test progress", ...extra }); + +// The window + +test("every bundle in a window gets the same registry", () => { + const window = {}; + const first = getComplications(window); + const second = getComplications(window); + assert.ok(first !== null); + assert.equal(first, second); + assert.equal((window as Record)[KEY], first, "under the v1 symbol"); +}); + +test("a later bundle cannot replace the registry others subscribed to", () => { + const window = {}; + const first = getComplications(window); + assert.throws(() => { + (window as Record)[KEY] = {}; + }); + assert.equal(getComplications(window), first); +}); + +test("something that is not a registry under the symbol yields null, not a guess", () => { + assert.equal(getComplications({ [KEY]: { protocol: 2 } }), null); + const withoutDiscovery = { ...createRegistry() } as Record; + delete withoutDiscovery.providers; + assert.equal(getComplications({ [KEY]: withoutDiscovery }), null, "v1 includes discovery"); +}); + +test("a registry reports the implementation of the copy that created it", () => { + assert.equal(createRegistry().implementation, COMPLICATIONS_IMPLEMENTATION); + assert.equal(getComplications({})?.implementation, COMPLICATIONS_IMPLEMENTATION); +}); + +test("a newer copy that finds an older one in charge says so once, and still uses it", (t) => { + const warn = t.mock.method(console, "warn", () => undefined); + const older = { ...createRegistry(), implementation: COMPLICATIONS_IMPLEMENTATION - 1 }; + const window = { [KEY]: older }; + assert.equal(getComplications(window), older, "the older registry is the window's registry"); + assert.equal(getComplications(window), older); + assert.equal(warn.mock.callCount(), 1, "once per registry, not once per call"); + const message = String(warn.mock.calls[0]?.arguments[0]); + assert.match(message, new RegExp(`implementation ${COMPLICATIONS_IMPLEMENTATION - 1}\\b`)); + assert.match(message, new RegExp(`implementation ${COMPLICATIONS_IMPLEMENTATION}\\b`)); +}); + +test("a registry as new as this copy, or newer, raises nothing", (t) => { + const warn = t.mock.method(console, "warn", () => undefined); + for (const implementation of [COMPLICATIONS_IMPLEMENTATION, COMPLICATIONS_IMPLEMENTATION + 1]) { + const registry = { ...createRegistry(), implementation }; + assert.equal(getComplications({ [KEY]: registry }), registry); + } + assert.equal(warn.mock.callCount(), 0); +}); + +test("a registry that predates the stamp counts as older", (t) => { + const warn = t.mock.method(console, "warn", () => undefined); + const unstamped = { ...createRegistry() } as Record; + delete unstamped.implementation; + assert.equal(getComplications({ [KEY]: unstamped }), unstamped); + assert.equal(warn.mock.callCount(), 1); +}); + +// Wanting and providing + +test("subjects wanted before the provider loaded are handed to it once it does", async () => { + const registry = createRegistry(); + registry.want(ID, thread("a")); + registry.want(ID, thread("b")); + await settle(); + + const delivered: string[][] = []; + registry.provide(provider({ onWanted: (subjects) => delivered.push(subjects.map((s) => s.id)) })); + assert.deepEqual(delivered, [], "not before provide returns: the provider has no handle yet"); + await settle(); + assert.deepEqual(delivered, [["a", "b"]]); +}); + +test("subjects wanted in the same tick reach the provider as one batch", async () => { + const registry = createRegistry(); + const delivered: string[][] = []; + registry.provide(provider({ onWanted: (subjects) => delivered.push(subjects.map((s) => s.id)) })); + registry.want(ID, thread("a")); + registry.want(ID, thread("b")); + registry.want(ID, thread("a")); + await settle(); + assert.deepEqual(delivered, [["a", "b"]]); +}); + +test("a subject released before the batch flushes is never asked for", async () => { + const registry = createRegistry(); + const delivered: string[][] = []; + registry.provide(provider({ onWanted: (subjects) => delivered.push(subjects.map((s) => s.id)) })); + const release = registry.want(ID, thread("gone")); + registry.want(ID, thread("kept")); + release(); + await settle(); + assert.deepEqual(delivered, [["kept"]]); +}); + +test("a subject stays wanted until its last holder releases it", () => { + const registry = createRegistry(); + const handle = registry.provide(provider()); + const first = registry.want(ID, thread("a")); + const second = registry.want(ID, thread("a")); + first(); + first(); + assert.equal(handle.isWanted(thread("a")), true, "a double release must not free someone else's hold"); + second(); + assert.equal(handle.isWanted(thread("a")), false); +}); + +test("wanting a released subject again asks the provider afresh, and keeps the old value meanwhile", async () => { + const registry = createRegistry(); + const delivered: string[][] = []; + const handle = registry.provide( + provider({ onWanted: (subjects) => delivered.push(subjects.map((s) => s.id)) }), + ); + const release = registry.want(ID, thread("a")); + await settle(); + handle.set(thread("a"), value("one")); + release(); + assert.equal(registry.read(ID, thread("a"))?.label, "one"); + registry.want(ID, thread("a")); + await settle(); + assert.deepEqual(delivered, [["a"], ["a"]]); +}); + +// Subjects + +test("any subject kind is carried, and an empty id names a singleton", () => { + const registry = createRegistry(); + const handle = registry.provide(provider()); + handle.set({ kind: "app", id: "" }, value("app-wide")); + handle.set({ kind: "url", id: "https://github.com/o/r/pull/1" }, value("a link")); + assert.equal(registry.read(ID, { kind: "app", id: "" })?.label, "app-wide"); + assert.equal(registry.read(ID, { kind: "url", id: "https://github.com/o/r/pull/1" })?.label, "a link"); + assert.equal(registry.read(ID, { kind: "thread", id: "" }), undefined, "kind is part of identity"); +}); + +test("no kind and id can be spelled to collide with another pair", () => { + const registry = createRegistry(); + const handle = registry.provide(provider()); + handle.set({ kind: "a:b", id: "c" }, value("first")); + handle.set({ kind: "a", id: "b:c" }, value("second")); + assert.equal(registry.read(ID, { kind: "a:b", id: "c" })?.label, "first"); + assert.equal(registry.read(ID, { kind: "a", id: "b:c" })?.label, "second"); +}); + +test("a subject without a kind is refused everywhere", async () => { + const registry = createRegistry(); + const delivered: unknown[] = []; + const handle = registry.provide(provider({ onWanted: (subjects) => delivered.push(subjects) })); + const blank = { kind: " ", id: "x" }; + registry.want(ID, blank); + handle.set(blank, value("nope")); + await settle(); + assert.deepEqual(delivered, []); + assert.equal(registry.read(ID, blank), undefined); +}); + +// Values + +test("unknown, silent and answered are three different reads", () => { + const registry = createRegistry(); + const handle = registry.provide(provider()); + assert.equal(registry.read(ID, thread("a")), undefined); + handle.set(thread("a"), null); + assert.equal(registry.read(ID, thread("a")), null); + handle.set(thread("a"), value("yes")); + assert.equal(registry.read(ID, thread("a"))?.label, "yes"); +}); + +test("an unchanged value keeps its identity and wakes nobody, however deep the change", () => { + const registry = createRegistry(); + const handle = registry.provide(provider()); + let wakes = 0; + registry.subscribe(ID, thread("a"), () => wakes++); + const rich = (row: string) => ({ + icon: "Circle", + label: "x", + detail: { rows: [{ label: "Checks", value: row }] }, + }); + handle.set(thread("a"), rich("2 failing")); + const first = registry.read(ID, thread("a")); + handle.set(thread("a"), rich("2 failing")); + assert.equal(registry.read(ID, thread("a")), first); + assert.equal(wakes, 1); + handle.set(thread("a"), rich("all green")); + assert.equal(wakes, 2, "a change inside a detail row is a change"); + assert.notEqual(registry.read(ID, thread("a")), first); +}); + +test("named fields are checked: a bad required one refuses the value, a bad optional one is dropped", () => { + assert.equal(normalizeValue({ icon: "Circle", label: " " }), undefined); + assert.equal(normalizeValue({ label: "No icon" }), undefined); + assert.equal(normalizeValue("text"), undefined); + assert.equal(normalizeValue(null), null); + assert.deepEqual(normalizeValue({ icon: "Circle", label: "x", tone: 5, text: " ", fraction: 7 }), { + icon: "Circle", + label: "x", + fraction: 1, + }); + assert.equal(normalizeValue({ icon: "Circle", label: "x", fraction: -1 })?.fraction, 0); + assert.equal(normalizeValue({ icon: "Circle", label: "x", fraction: Number.NaN })?.fraction, undefined); +}); + +test("a tone is an open string, so a tone added later still arrives", () => { + assert.equal(normalizeValue({ icon: "Circle", label: "x", tone: "info" })?.tone, "info"); + assert.equal(normalizeValue({ icon: "Circle", label: "x", tone: "urgent" })?.tone, "urgent"); + assert.equal(normalizeValue({ icon: "Circle", label: "x", tone: "x".repeat(33) })?.tone, undefined); +}); + +test("fields this protocol does not name pass through as JSON", () => { + const stored = normalizeValue({ + icon: "Circle", + label: "x", + urgency: 2, + source: { host: "github", at: new Date(0) }, + callback: () => "smuggled", + }) as unknown as Record | null | undefined; + assert.equal(stored?.urgency, 2); + assert.deepEqual(stored?.source, { host: "github", at: "1970-01-01T00:00:00.000Z" }); + assert.equal("callback" in (stored ?? {}), false, "a function is not JSON"); +}); + +test("an unknown field that cannot be JSON is dropped, and the value kept", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + const stored = normalizeValue({ icon: "Circle", label: "x", cyclic, big: 1n }) as unknown as Record; + assert.equal(stored.label, "x"); + assert.equal("cyclic" in stored, false); + assert.equal("big" in stored, false); +}); + +test("a __proto__ field stays a field and never becomes a prototype", () => { + const stored = normalizeValue(JSON.parse('{"icon":"Circle","label":"x","__proto__":{"polluted":true}}')); + assert.equal(Object.getPrototypeOf(stored), Object.prototype); + assert.equal((stored as unknown as Record).polluted, undefined); + assert.deepEqual(Object.getOwnPropertyDescriptor(stored, "__proto__")?.value, { polluted: true }); +}); + +test("a stored value is frozen all the way down", () => { + const stored = normalizeValue({ + icon: "Circle", + label: "x", + detail: { rows: [{ label: "a", value: "b", open: { href: "/x" } }] }, + extra: { nested: [1, { deep: true }] }, + }) as Record; + assert.ok(Object.isFrozen(stored)); + assert.ok(Object.isFrozen(stored.detail.rows[0].open)); + assert.ok(Object.isFrozen(stored.extra.nested[1])); +}); + +test("a value over the size cap is refused", () => { + const near = { icon: "Circle", label: "x", note: "" }; + const overhead = JSON.stringify(near).length; + assert.ok(normalizeValue({ ...near, note: "y".repeat(MAX_VALUE_LENGTH - overhead) })); + assert.equal(normalizeValue({ ...near, note: "y".repeat(MAX_VALUE_LENGTH - overhead + 1) }), undefined); +}); + +test("a value that is not a complication is ignored, keeping the last good one", (t) => { + t.mock.method(console, "warn", () => undefined); + const registry = createRegistry(); + const handle = registry.provide(provider()); + handle.set(thread("a"), value("good")); + handle.set(thread("a"), { label: "no icon" } as never); + assert.equal(registry.read(ID, thread("a"))?.label, "good"); +}); + +// Reserved fields + +test("detail and open pass through untouched, for the first surface that draws them to define", () => { + const detail = { + title: "PR #12", + rows: [ + ...Array.from({ length: 12 }, (_, i) => ({ label: `row ${i}`, value: String(i) })), + { label: "a row with no value" }, + ], + }; + const open = { command: "github/rerun-checks" }; + const stored = normalizeValue({ icon: "Circle", label: "x", detail, open }) as unknown as Record; + assert.deepEqual(stored.detail, detail, "no row cap, no required value"); + assert.deepEqual(stored.open, open, "not limited to an href"); + assert.ok(Object.isFrozen((stored.detail as { rows: object[] }).rows[12])); +}); + +test("an href is not the registry's to vet: it arrives as sent, and a surface must check it", () => { + const stored = normalizeValue({ icon: "Circle", label: "x", open: { href: "javascript:alert(1)" } }) as unknown as Record; + assert.deepEqual(stored.open, { href: "javascript:alert(1)" }); +}); + +// Withdrawal and replacement + +test("withdrawing clears the provider's values and tells the surfaces drawing them", () => { + const registry = createRegistry(); + const handle = registry.provide(provider()); + handle.set(thread("a"), value("a")); + const heard: string[] = []; + registry.subscribe(ID, thread("a"), () => heard.push("value")); + registry.subscribe(ID, null, () => heard.push("presence")); + handle.dispose(); + assert.equal(registry.isProvided(ID), false); + assert.equal(registry.read(ID, thread("a")), undefined); + assert.deepEqual(heard, ["value", "presence"]); + handle.set(thread("a"), value("late")); + assert.equal(registry.read(ID, thread("a")), undefined, "a withdrawn handle publishes nothing"); +}); + +test("a replaced provider's late cleanup cannot wipe its replacement", () => { + // A hot reload: the new generation registers, then the old one unmounts. + const registry = createRegistry(); + const old = registry.provide(provider()); + const replacement = registry.provide(provider()); + replacement.set(thread("a"), value("new")); + old.set(thread("a"), value("stale")); + old.dispose(); + assert.equal(registry.isProvided(ID), true); + assert.equal(registry.read(ID, thread("a"))?.label, "new"); +}); + +// Discovery + +test("ids must be / and every provider needs a name", () => { + const registry = createRegistry(); + for (const id of ["progress", "Follow-Up/progress", "follow-up/", "/progress", "a/b/c", "a--b/c", "a/b "]) { + assert.throws(() => registry.provide(provider({ id })), TypeError, `refused: ${id}`); + } + assert.throws(() => registry.provide(provider({ name: " " })), TypeError); + assert.doesNotThrow(() => registry.provide(provider({ id: "worktree-ports/app-ports" }))); +}); + +test("providers() lists what is live, without callbacks, and is stable until it changes", () => { + const registry = createRegistry(); + const handle = registry.provide( + provider({ + description: "How much is closed.", + subjects: ["thread", " ", 3 as never], + sample: { icon: "Circle", label: "1 of 2" }, + onWanted: () => undefined, + families: ["gauge"], + } as Partial), + ); + const listed = registry.providers(); + assert.deepEqual(listed, [ + { + id: ID, + name: "Test progress", + description: "How much is closed.", + subjects: ["thread"], + sample: { icon: "Circle", label: "1 of 2" }, + families: ["gauge"], + }, + ]); + assert.equal(registry.providers(), listed, "same array until something changes"); + assert.ok(Object.isFrozen(listed[0])); + handle.dispose(); + assert.notEqual(registry.providers(), listed); + assert.deepEqual(registry.providers(), []); +}); + +test("subscribeProviders hears a provider arrive, get replaced and leave", () => { + const registry = createRegistry(); + let heard = 0; + const stop = registry.subscribeProviders(() => heard++); + const first = registry.provide(provider()); + registry.provide(provider({ name: "Renamed" })); + assert.equal(registry.providers()[0]?.name, "Renamed"); + first.dispose(); + assert.equal(heard, 2, "the replaced generation's cleanup is not a change"); + assert.equal(registry.providers().length, 1, "the replacement is still live"); + stop(); + registry.provide(provider({ id: "test/other" })); + assert.equal(heard, 2); +}); + +// Containment + +test("another plugin's throwing code is contained", async (t) => { + t.mock.method(console, "error", () => undefined); + const registry = createRegistry(); + const handle = registry.provide( + provider({ + onWanted: () => { + throw new Error("provider bug"); + }, + }), + ); + assert.doesNotThrow(() => registry.want(ID, thread("a"))); + await settle(); + + let heard = false; + registry.subscribe(ID, thread("a"), () => { + throw new Error("surface bug"); + }); + registry.subscribe(ID, thread("a"), () => { + heard = true; + }); + registry.subscribeProviders(() => { + throw new Error("settings bug"); + }); + assert.doesNotThrow(() => handle.set(thread("a"), value("a"))); + assert.equal(heard, true, "one surface's throw must not starve the next"); + assert.doesNotThrow(() => registry.provide(provider({ id: "test/other" }))); +}); + +// Follow Up's own complication + +test("progress: a thread that never recorded a follow-up says nothing", () => { + assert.equal(progressValue({ open: 0, done: 0 }), null); +}); + +test("progress: a partly closed list is a quiet gauge with the open count", () => { + assert.deepEqual(progressValue({ open: 1, done: 27 }), { + icon: "TextWrap", + label: "27 of 28 follow-ups done", + tone: "default", + fraction: 27 / 28, + text: "1", + }); +}); + +test("progress: a clear list is a full green gauge with nothing left to count", () => { + assert.deepEqual(progressValue({ open: 0, done: 3 }), { + icon: "TextWrap", + label: "All 3 follow-ups done", + tone: "success", + fraction: 1, + }); + assert.equal(progressValue({ open: 0, done: 1 })?.label, "All 1 follow-up done"); +}); + +test("progress: the value survives the registry unchanged", () => { + const registry = createRegistry(); + const handle = registry.provide({ id: FOLLOW_UP_PROGRESS, name: "Follow-up progress" }); + const published = progressValue({ open: 2, done: 2 }); + handle.set(thread("a"), published); + assert.deepEqual(registry.read(FOLLOW_UP_PROGRESS, thread("a")), published); +}); + +test("progress: the registry accepts Follow Up's registration and lists it with a preview", async () => { + const registry = createRegistry(); + const asked: string[][] = []; + registry.provide(progressRegistration((threadIds) => asked.push(threadIds))); + assert.deepEqual(registry.providers(), [ + { + id: FOLLOW_UP_PROGRESS, + name: "Follow-up progress", + description: "How much of a thread's follow-up list is closed.", + subjects: ["thread"], + sample: progressValue({ open: 1, done: 3 }), + }, + ]); + registry.want(FOLLOW_UP_PROGRESS, thread("a")); + registry.want(FOLLOW_UP_PROGRESS, { kind: "project", id: "p" }); + await settle(); + assert.deepEqual(asked, [["a"]], "only threads reach the counts call"); +}); diff --git a/plugins/follow-up/tests/ui/complication-publisher.test.tsx b/plugins/follow-up/tests/ui/complication-publisher.test.tsx new file mode 100644 index 0000000..4dc5451 --- /dev/null +++ b/plugins/follow-up/tests/ui/complication-publisher.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +// Loaded at the top, not inside a test, as in app.test.tsx: the first load pulls in +// every hugeicons icon, which can outlast a test's timeout on a busy machine. +import pluginApp from "../../app.tsx"; +import { ComplicationPublisher } from "../../src/complication-publisher.tsx"; +import { getComplications, type ComplicationSubject } from "../../lib/complications.ts"; +import { FOLLOW_UP_PROGRESS } from "../../lib/progress-complication.ts"; + +// The registry lives on globalThis and outlasts each render, as it does in a +// bb window, so every test asks about threads no other test has touched. +const registry = getComplications()!; +let threads = 0; +const freshThread = (): ComplicationSubject => ({ kind: "thread", id: `thr_pub${++threads}` }); +const label = (subject: ComplicationSubject) => registry.read(FOLLOW_UP_PROGRESS, subject)?.label; + +/** Wants outlive a render too, so each test gives its own back. */ +const releases: (() => void)[] = []; +const want = (subject: ComplicationSubject) => { + releases.push(registry.want(FOLLOW_UP_PROGRESS, subject)); +}; +afterEach(() => { + for (const release of releases.splice(0)) release(); +}); + +/** Render the publisher against a counts call that answers from `counts`. */ +function renderPublisher(counts: Record) { + const slot = renderSlot({ component: ComplicationPublisher }, {}, { + rpc: { + getFollowUpCountsV1: async ({ threadIds }: { threadIds: string[] }) => ({ + protocolVersion: 1, + counts: threadIds.map((threadId) => ({ threadId, ...(counts[threadId] ?? { open: 0, done: 0 }) })), + }), + } as never, + }); + const askedFor = () => + slot.inspection.rpcCalls + .filter((call) => call.method === "getFollowUpCountsV1") + .map((call) => (call.input as { threadIds: string[] }).threadIds); + return { slot, askedFor }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 80)); + +describe("ComplicationPublisher", () => { + it("is registered as an app overlay, so it outlives any one thread view", async () => { + const app = await loadPluginApp(pluginApp); + const overlay = app.appOverlays.find((entry) => entry.id === "complications"); + expect(overlay?.component).toBe(ComplicationPublisher); + }); + + it("provides follow-up progress while mounted, and withdraws it on unmount", async () => { + const a = freshThread(); + const { slot } = renderPublisher({ [a.id]: { open: 1, done: 1 } }); + expect(registry.isProvided(FOLLOW_UP_PROGRESS)).toBe(true); + expect(registry.providers().map((provider) => provider.id)).toContain(FOLLOW_UP_PROGRESS); + want(a); + await waitFor(() => expect(label(a)).toBe("1 of 2 follow-ups done")); + + slot.lifecycle.unmount(); + expect(registry.isProvided(FOLLOW_UP_PROGRESS)).toBe(false); + expect(registry.read(FOLLOW_UP_PROGRESS, a)).toBeUndefined(); + }); + + it("answers every wanted thread in one counts call", async () => { + const a = freshThread(); + const b = freshThread(); + const { askedFor } = renderPublisher({ + [a.id]: { open: 2, done: 1 }, + [b.id]: { open: 0, done: 4 }, + }); + want(a); + want(b); + await waitFor(() => expect(label(b)).toBe("All 4 follow-ups done")); + expect(label(a)).toBe("1 of 3 follow-ups done"); + expect(askedFor()).toEqual([[a.id, b.id]]); + }); + + it("publishes again when a wanted thread changes, and asks nothing for one nobody shows", async () => { + const shown = freshThread(); + const hidden = freshThread(); + const counts = { [shown.id]: { open: 1, done: 0 }, [hidden.id]: { open: 5, done: 0 } }; + const { slot, askedFor } = renderPublisher(counts); + want(shown); + await waitFor(() => expect(label(shown)).toBe("0 of 1 follow-ups done")); + + counts[shown.id] = { open: 2, done: 0 }; + await slot.behavior.emitRealtime("followups-changed", { threadId: shown.id }); + await waitFor(() => expect(label(shown)).toBe("0 of 2 follow-ups done")); + + await slot.behavior.emitRealtime("followups-changed", { threadId: hidden.id }); + await slot.behavior.emitRealtime("followups-changed", { nonsense: true }); + await settle(); + expect(askedFor().flat()).not.toContain(hidden.id); + expect(askedFor()).toEqual([[shown.id], [shown.id]]); + }); + + it("re-asks for everything on screen after realtime reconnects", async () => { + const a = freshThread(); + const b = freshThread(); + const counts = { [a.id]: { open: 1, done: 0 }, [b.id]: { open: 1, done: 0 } }; + const { slot, askedFor } = renderPublisher(counts); + want(a); + want(b); + await waitFor(() => expect(label(b)).toBe("0 of 1 follow-ups done")); + + // Changes made while the connection was down never arrive as signals. + counts[a.id] = { open: 0, done: 1 }; + counts[b.id] = { open: 0, done: 1 }; + await slot.behavior.setRealtimeConnectionState("reconnecting"); + await slot.behavior.setRealtimeConnectionState("connected"); + await waitFor(() => expect(label(a)).toBe("All 1 follow-up done")); + expect(label(b)).toBe("All 1 follow-up done"); + expect(askedFor().at(-1)?.slice().sort()).toEqual([a.id, b.id].sort()); + }); +}); diff --git a/plugins/thread-badges/PLUGIN_OVERVIEW.md b/plugins/thread-badges/PLUGIN_OVERVIEW.md index 6e1734b..18a8692 100644 --- a/plugins/thread-badges/PLUGIN_OVERVIEW.md +++ b/plugins/thread-badges/PLUGIN_OVERVIEW.md @@ -5,10 +5,11 @@ on :5173. These badges do, without opening anything. ## What you get -Four badge types, each independently switchable, drawn on whichever sidebar -you use — at most two per row by default, since a 260px row runs out of space -before the badges run out of things to say. You choose how many a row may draw -and which types win the slots when more than that have something to show. +Three built-in badge types, plus any badge another plugin publishes, each +independently switchable and drawn on whichever sidebar you use. A row draws at +most two by default, since a 260px row runs out of space before the badges run +out of things to say. You choose how many a row may draw and which types win +the slots when more than that have something to show. **Pull requests.** A glyph coloured by state — green for open, grey for draft, purple for merged, red for closed. Hovering names the number, state, attention, @@ -21,18 +22,21 @@ requested, merge blocked, or review requested. You can also show a tick when it is ready to merge, or narrow the badge to problems only, so it speaks solely when someone has to fix something. -**Follow-ups.** A ring showing how much of a thread's follow-up list is closed — -empty at none done, a visible notch at one left, full green once the list is -clear. You can show the open count beside the ring, or hide the ring once -everything is done. - **Ports.** A plug on threads whose worktree is serving something — green when it is the app you are developing, hovering to name it ("vite :5173 · 2 others"). By default it ignores backing services and the ephemeral loopback ports agent processes open, which would otherwise light up on every thread and -say nothing. This is the one badge that ships **off**: it sorts last, and at the -default cap of two it would draw nothing, so turning it on means also raising -the cap or lowering its priority. +say nothing. This is the one built-in badge that ships **off**: it sorts last, +and at the default cap of two it would draw nothing, so turning it on means also +raising the cap or lowering its priority. + +**Badges from other plugins.** Any plugin can publish a small value about a +thread, called a complication, and it appears in this plugin's settings with a +preview, off until you turn it on. A fraction draws as a ring, anything else as +the plugin's own icon in its tone, with its text beside it if you like. Values +arrive live, the moment the other plugin knows. Follow Up 0.7 publishes its +progress ring this way: how much of a thread's follow-up list is closed, green +once it is clear. ## How it works @@ -47,7 +51,8 @@ the staleness. Both pull-request badges observe one query per environment, so running them together costs no extra git-host requests. A thread with no branch, no environment, or a git-host hiccup shows nothing at all. -Adding a badge type takes two edits and no changes to the host: an entry in the +Another plugin adds a badge by publishing a complication, with no change here. +A built-in type takes two edits and no changes to the host: an entry in the catalog, and a component that renders nothing when it has nothing to say. ## Requirements @@ -55,8 +60,8 @@ catalog, and a component that renders nothing when it has nothing to say. The pull-request badges need a thread whose environment has a branch with a pull request on a git host BB can reach. -The follow-ups ring reads the Follow Up plugin, and the ports plug reads -Worktree Ports. Without one installed, that badge does not draw and the others -are unaffected. Nothing here scans for a port itself — Worktree Ports already -does the hard part, and its snapshot already says which threads a worktree -carries. +BB 0.45 or later. The ports plug reads Worktree Ports, and a complication needs +the plugin that publishes it; the follow-ups ring needs Follow Up 0.7 or later. +Without one installed, that badge does not draw and the others are unaffected. +Nothing here scans for a port itself — Worktree Ports already does the hard +part, and its snapshot already says which threads a worktree carries. diff --git a/plugins/thread-badges/README.md b/plugins/thread-badges/README.md index 2ff4dc7..d43967c 100644 --- a/plugins/thread-badges/README.md +++ b/plugins/thread-badges/README.md @@ -5,9 +5,11 @@ request has failing checks, that someone requested changes, that three follow-ups on it are still open, or that its worktree is serving a dev server on :5173. These badges do, on whichever sidebar you use, without opening anything. -Four badge types ship today. The plugin is built around the idea that there -will be more: the host owns mount points and ordering, and knows nothing about -what any badge means. +Three badge types ship built in: pull requests, their checks, and ports. Any +other plugin can add more by publishing a *complication*, a small value about a +thread, which this plugin draws once you turn it on. That's how Follow Up's +progress ring arrives. Either way, the host owns mount points and ordering, and +knows nothing about what any badge means. ## Install @@ -76,25 +78,6 @@ mergeability. Note the `open` in that condition: a draft pull request with checks running does not poll, so its clock clears on focus rather than on its own. -### Follow-ups - -A ring showing how much of a thread's follow-up list is closed: empty at none -done, a visible notch at "one left", and a full green ring once the list is -clear. A thread that never recorded a follow-up shows nothing. Hovering names -the count — "27 of 28 follow-ups done". - -The ring reads another plugin's state. -[Follow Up](../follow-up) publishes a versioned counts contract, and every -failure here is treated as "draw nothing", so an absent, disabled, or renamed -Follow Up makes the ring disappear and breaks nothing else. - -Liveness is the one thing that cannot be fixed from either side: a plugin -receives only its own realtime signals, so Follow Up publishing -`followups-changed` on every mutation is inaudible here. Verified — recording a -follow-up did not move the ring until the page reloaded. Until bb widens that, -this badge refreshes on mount and when the window regains focus (throttled to -once every ten seconds), which the host supplies to every badge as `revision`. - ### Ports A plug on threads whose worktree is serving something, hovering to name it — @@ -143,10 +126,47 @@ status API, this one portals into the row. Turn its *thread row icon* setting off and let the badge carry it, so ports participate in the same cap and priority ordering as everything else on the row. +### Complications from other plugins + +Any plugin can publish complications about a thread: small values like a +progress ring, a status icon or a count. Each one that answers threads appears +under **Badges from other plugins** in this plugin's settings, with its +description and a preview. **Each is off until you turn it on.** Installing a +plugin never changes your rows by itself. + +Turned on, a complication joins the built-in badges on the same priority scale. +It sorts after them by default, so it takes a slot only where they are silent, +until you give it a lower number. Two options apply to each one: show its text +beside it, and hide it once it is complete. The second applies only to gauges. + +How a value draws comes from the value itself: + +- **A fraction draws as a ring**, empty at nothing and full at done, in the + value's tone. +- **Anything else draws as the provider's own icon**, in its tone. +- **Tones use the built-in badges' palette:** success green, error red, warning + amber, info blue, and default muted. `running` is amber and pulses, unless + your system asks for reduced motion. +- **The value's label** is the badge's accessible name and its tooltip. + +The values arrive live. bb imports every plugin's bundle into one page, so +plugins share one JavaScript global, and a registry kept there carries each +value from the plugin that knows it to this one. A plugin hears only its own +realtime signals, so this is the only way another plugin's change reaches a +row without polling. Both sides carry the same copy of that registry, +[`lib/complications.ts`](lib/complications.ts), and its header is the protocol. + +**Follow-up progress** comes from [Follow Up](../follow-up) 0.7 or later. It +draws as a ring showing how much of a thread's follow-up list is closed: +"27 of 28 follow-ups done", and green once the list is clear. *Show its text* +adds how many are still open. Earlier versions of Follow Up publish nothing, so +there is no ring for them. + ## Settings -Each badge type can be switched off independently, and each owns a couple of -options. +Each built-in badge type can be switched off independently, and each owns a +couple of options. Complications from other plugins have their own section +below these, described above. | | Default | | --- | --- | @@ -159,12 +179,12 @@ options. | … priority | 2 | | … keep the tick on ready-to-merge pull requests | off | | … narrow to problems only | off | -| Follow-ups | on | -| … priority | 3 | -| … show how many are still open beside the ring | off | -| … hide the ring once everything is done | off | | Ports | **off** | +| … priority | 3 | +| Each complication from another plugin | **off** | | … priority | 4 | +| … show its text beside it | off | +| … hide it once complete | off | | … ignore backing services and internal listeners | on | | … show the port number beside the glyph | off | @@ -177,27 +197,31 @@ A sidebar row is around 260px wide and already carries a title, a preview line and the sidebar's own trailing controls. At roughly 14px a badge, three is busy and four starts truncating titles — so a row draws at most **two** badges by default, however many types are switched on. Raise *Badges per row* if you want -more; the ceiling is the number of types that exist, currently four. +more, up to six. The cap only bites on rows where more badges than that have something to say; a -thread with one open pull request and no follow-ups still shows one badge. When +thread with one open pull request and nothing else still shows one badge. When it does bite, the lowest *priority* numbers win the slots and the rest are dropped for that row. Priorities default to the order the types are listed -above, so out of the box a row with all of them in play shows the pull request -and its checks, and drops the follow-up ring. Give follow-ups priority 1 to flip -that. +above, with complications after the built-ins, so out of the box a row with all +of them in play shows the pull request and its checks. Give a complication +priority 1 to put it first. This is also why the ports badge ships off rather than on. At the default cap it would sort past the last slot and draw nothing, so it is switched off instead of switched on and silent — and because a badge that is off never subscribes, an untouched install never polls for ports at all. -Two types may share a priority number; ties fall back to catalog order, so a -half-configured set of priorities still draws a stable row. +Two types may share a priority number. Ties go to the built-ins first, then to +catalog order and the order providers appeared, so a half-configured set of +priorities still draws a stable row. ## Adding a badge type -Two edits, no changes to the host: +From another plugin, publish a complication; the protocol is the header of +[`lib/complications.ts`](lib/complications.ts), and Follow Up's +`src/complication-publisher.tsx` is a working provider. Nothing in this plugin +changes. A new built-in type is two edits here, with no changes to the host: 1. Add an entry to `BADGE_TYPES` in `badges/catalog.ts` — id, name, the description shown under its switch, whether it is on by default, and any @@ -266,16 +290,21 @@ Consequences worth knowing: ## Requirements The pull-request badges need a thread whose environment has a branch with a pull -request on a git host bb can reach. The follow-ups ring needs -[Follow Up](../follow-up) installed, and the ports plug needs +request on a git host bb can reach. The ports plug needs [Worktree Ports](https://github.com/to-infinity-labs/bb-plugin-worktree-ports); -without either one, that badge does not draw and the others are unaffected. +without it, that badge does not draw and the others are unaffected. A +complication needs the plugin that publishes it: the follow-up ring needs +[Follow Up](../follow-up) 0.7 or later. + +This version needs bb 0.45 or later, for the plugin SDK it is built on (0.6). +On an older bb, stay on Thread Badges 0.3. ## Development ```sh npm install npm run typecheck +npm test bb plugin install . bb plugin dev # rebuild + reload on save bb plugin logs thread-badges -f diff --git a/plugins/thread-badges/app.tsx b/plugins/thread-badges/app.tsx index 0a1f866..b1349ce 100644 --- a/plugins/thread-badges/app.tsx +++ b/plugins/thread-badges/app.tsx @@ -10,17 +10,19 @@ // changes. // // Nothing in this file knows what a badge means. It owns mount points and -// ordering; ./badges owns what is drawn. +// ordering; ./badges owns what is drawn. A row draws two kinds of badge on one +// priority scale: the built-in types this plugin computes, and complications +// other plugins publish, which you turn on in this plugin's settings. import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { definePluginApp, useSettings } from "@get-bb/plugin-sdk/app"; -import { - maxBadges, - orderedBadgeTypes, - type BadgeSettings, - type BadgeType, -} from "./badges/catalog"; +import { maxBadges, type BadgeSettings } from "./badges/catalog"; +import { ComplicationBadge, RUNNING_ATTRIBUTE } from "./badges/complication-badge"; +import { ComplicationSettings } from "./badges/complication-settings"; import { BADGE_COMPONENTS } from "./badges/components"; +import { orderedEntries, type RowEntry } from "./badges/order"; +import { useComplicationPrefs } from "./badges/use-complication-prefs"; +import { useThreadProviders } from "./badges/use-complication-providers"; /** * The column a sidebar pads on hover to clear room for the row's actions. @@ -186,6 +188,10 @@ const STYLE_ID = "thread-badges-cap"; * `!important` is not decoration: a badge sets `display` inline and the slot * sets its padding inline, and an inline style beats a stylesheet rule without * it. + * + * The third rule is a complication's `running` tone, which pulses. A keyframe + * cannot be an inline style, so it lives here, and only where the reader has + * not asked their system for less motion. */ function useBadgeStyles(max: number): void { useEffect(() => { @@ -198,6 +204,10 @@ function useBadgeStyles(max: number): void { style.textContent = [ `[${SLOT_ATTRIBUTE}] > *:nth-child(n + ${max + 1}) { display: none !important; }`, `[${SLOT_ATTRIBUTE}]:empty { padding: 0 !important; }`, + "@keyframes thread-badges-running { 50% { opacity: 0.4; } }", + "@media (prefers-reduced-motion: no-preference) {", + ` [${RUNNING_ATTRIBUTE}] { animation: thread-badges-running 1.6s ease-in-out infinite; }`, + "}", ].join("\n"); return () => { style?.remove(); @@ -235,25 +245,35 @@ function useFocusRevision(): number { } function RowBadges({ - badges, + entries, threadId, values, revision, }: { - /** Enabled types, already in priority order; the cap hides the tail. */ - badges: readonly BadgeType[]; + /** Enabled badges, already in priority order; the cap hides the tail. */ + entries: readonly RowEntry[]; threadId: string; values: BadgeSettings; revision: number; }) { return ( <> - {badges.map((badge) => { - const Badge = BADGE_COMPONENTS[badge.id]; + {entries.map((entry) => { + if (entry.kind === "complication") { + return ( + + ); + } + const Badge = BADGE_COMPONENTS[entry.type.id]; if (Badge === undefined) return null; return ( orderedBadgeTypes(values), [values]); + const providers = useThreadProviders(); + const { prefs } = useComplicationPrefs(); + // Sorted once per change rather than once per row: this list is the same for + // every row and there is one row per visible thread. + const entries = useMemo( + () => orderedEntries(values, providers.map((provider) => provider.id), prefs), + [values, providers, prefs], + ); useBadgeStyles(maxBadges(values)); return ( <> {slots.map(({ threadId, node }) => createPortal( { id: "thread-badges", component: SidebarBadges, }); + // Complications cannot be bb settings — their providers are only discovered + // here, in the app — so they get a section of their own, under the built-in + // badges' switches. + app.slots.settingsSection({ + id: "complications", + title: "Badges from other plugins", + description: + "Complications that other plugins publish about a thread. Each is off until you turn it on.", + component: ComplicationSettings, + }); }); diff --git a/plugins/thread-badges/badges/catalog.ts b/plugins/thread-badges/badges/catalog.ts index c5a5b5b..0c4004f 100644 --- a/plugins/thread-badges/badges/catalog.ts +++ b/plugins/thread-badges/badges/catalog.ts @@ -3,6 +3,11 @@ // Kept free of React so the backend can read it too: the settings a badge type // contributes are derived from this catalog rather than written out twice. // Adding a type is an entry here plus a component in ./components.tsx. +// +// These are the badges this plugin computes itself. Anything another plugin +// publishes as a complication — Follow Up's progress ring, for one — is not +// listed here: it is discovered in the app and drawn by ./complication-badge, +// with its settings in ./complication-prefs. export interface BadgeSetting { /** Unique across the plugin; prefix it with the badge id. */ @@ -71,25 +76,6 @@ export const BADGE_TYPES: readonly BadgeType[] = [ }, ], }, - { - id: "followUps", - name: "follow-ups", - description: - "A ring showing how much of this thread's follow-up list is done. Needs the Follow-ups plugin.", - defaultEnabled: true, - settings: [ - { - key: "followUps_showOpenCount", - label: "Follow-ups: show how many are still open", - default: false, - }, - { - key: "followUps_hideWhenComplete", - label: "Follow-ups: hide the ring once every follow-up is done", - default: false, - }, - ], - }, { id: "ports", name: "ports", @@ -133,8 +119,12 @@ export type BadgeSettings = Readonly>; */ export const MAX_BADGES_KEY = "max_badges"; export const DEFAULT_MAX_BADGES = 2; -/** Above this the cap stops meaning anything: every type could show at once. */ -export const MAX_BADGES_LIMIT = BADGE_TYPES.length; +/** + * The highest cap you can set. Not the number of badge types: complications + * from other plugins are badge types too, and how many exist is only known in + * the app. Six is already more than a row has room for. + */ +export const MAX_BADGES_LIMIT = 6; /** Its catalog position, so untouched settings keep the order written here. */ export function defaultPriority(id: string): number { @@ -150,26 +140,11 @@ export function maxBadges(values: BadgeSettings | undefined): number { return Math.min(Math.max(Math.round(value), 1), MAX_BADGES_LIMIT); } -function priorityOf(values: BadgeSettings | undefined, id: string): number { +export function priorityOf(values: BadgeSettings | undefined, id: string): number { const value = values?.[priorityKey(id)]; return typeof value === "number" && Number.isFinite(value) ? value : defaultPriority(id); } -/** - * The enabled badge types, most wanted first. - * - * This is the whole of "which badge wins": a row renders them in this order - * and the cap hides whatever falls past it, so the badge dropped from a busy - * row is the last one here. Ties keep catalog order, which is what makes a - * half-configured set of priorities stable rather than arbitrary. - */ -export function orderedBadgeTypes(values: BadgeSettings | undefined): readonly BadgeType[] { - return BADGE_TYPES.filter((type) => isEnabled(values, type.id)) - .map((type, index) => ({ index, priority: priorityOf(values, type.id), type })) - .sort((a, b) => a.priority - b.priority || a.index - b.index) - .map((entry) => entry.type); -} - export function isEnabled(values: BadgeSettings | undefined, id: string): boolean { const value = values?.[enabledKey(id)]; // Absent means the settings have not loaded yet; fall back to the catalog. diff --git a/plugins/thread-badges/badges/complication-badge.tsx b/plugins/thread-badges/badges/complication-badge.tsx new file mode 100644 index 0000000..d26e72d --- /dev/null +++ b/plugins/thread-badges/badges/complication-badge.tsx @@ -0,0 +1,65 @@ +// A badge for any complication another plugin provides. +// +// Nothing here knows what a value means — the provider does. This reads the +// value for one thread from the window's registry and draws it as +// ./complication-view decides: a ring for a gauge, otherwise the provider's +// icon in its tone, with text beside either when that was asked for. +// +// Style inline, like every badge: rows are portaled outside this plugin's +// stylesheet scope, so a utility class would do nothing. The one stylesheet +// rule is the running animation, which app.tsx injects with the cap. +import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app"; +import { useComplication } from "./complication"; +import { complicationView, type ComplicationView } from "./complication-view"; +import type { ComplicationPrefs } from "./complication-prefs"; +import { Ring } from "./ring"; + +/** Marks a badge that animates while its value says work is running. */ +export const RUNNING_ATTRIBUTE = "data-thread-badges-running"; + +const GLYPH_SIZE = 14; + +/** One view, drawn. Exactly one element, as the row's cap requires. */ +export function ComplicationGlyph({ view }: { view: ComplicationView }) { + return ( + + {view.kind === "ring" ? ( + + ) : ( + + )} + {view.text !== null ? {view.text} : null} + + ); +} + +export function ComplicationBadge({ + id, + threadId, + prefs, +}: { + id: string; + threadId: string; + prefs: ComplicationPrefs; +}) { + const { value } = useComplication(id, threadId); + const view = complicationView(value, prefs); + return view === null ? null : ; +} diff --git a/plugins/thread-badges/badges/complication-prefs.ts b/plugins/thread-badges/badges/complication-prefs.ts new file mode 100644 index 0000000..36f8671 --- /dev/null +++ b/plugins/thread-badges/badges/complication-prefs.ts @@ -0,0 +1,85 @@ +// How a complication from another plugin is drawn on a row: whether at all, +// where in the order, and its two options. +// +// Kept free of React because both halves read it: the backend stores these and +// the app draws with them. They live in the plugin's own storage behind an RPC +// rather than in bb's settings because bb's settings are declared when the +// backend loads, and a complication's provider is only discovered later, in +// the app, when that plugin's bundle registers it. +import { BADGE_TYPES } from "./catalog"; + +export interface ComplicationPrefs { + /** Off until you turn it on: installing a plugin does not change your rows. */ + enabled: boolean; + /** Same scale as the built-in badges' priorities; lower goes first. */ + priority: number; + /** Draw the value's `text` beside its glyph or ring. */ + showText: boolean; + /** Draw nothing once a gauge is full. Gauges only. */ + hideWhenComplete: boolean; +} + +export type ComplicationPrefsMap = Readonly>; + +/** The realtime signal the backend sends after a write, to every window. */ +export const PREFS_CHANGED = "complication-prefs-changed"; + +/** + * `/` — the registry's own rule, repeated because the vendored + * registry module does not export it, and this file must not touch that one. + */ +export const COMPLICATION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * How many complications may hold settings. Far above any real install; the + * point is that a misbehaving caller cannot grow the stored map without limit. + */ +export const MAX_STORED_PREFS = 200; + +/** + * Just after the built-in badges, so turning one on adds it to the end of the + * row rather than displacing a badge you already see. + */ +export const DEFAULT_COMPLICATION_PRIORITY = BADGE_TYPES.length + 1; + +export function defaultPrefs(): ComplicationPrefs { + return { + enabled: false, + priority: DEFAULT_COMPLICATION_PRIORITY, + showText: false, + hideWhenComplete: false, + }; +} + +/** A stored entry, field by field: anything missing or malformed takes its default. */ +export function normalizePrefs(raw: unknown): ComplicationPrefs { + const fallback = defaultPrefs(); + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return fallback; + const value = raw as Record; + const flag = (key: keyof ComplicationPrefs): boolean => + typeof value[key] === "boolean" ? (value[key] as boolean) : (fallback[key] as boolean); + return { + enabled: flag("enabled"), + priority: + typeof value.priority === "number" && Number.isFinite(value.priority) + ? value.priority + : fallback.priority, + showText: flag("showText"), + hideWhenComplete: flag("hideWhenComplete"), + }; +} + +/** The whole stored map, keeping only well-formed ids. */ +export function normalizePrefsMap(raw: unknown): Record { + const map: Record = {}; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return map; + for (const [id, entry] of Object.entries(raw)) { + if (COMPLICATION_ID.test(id)) map[id] = normalizePrefs(entry); + } + return map; +} + +/** One complication's settings, defaults included when it has none stored. */ +export function prefsFor(map: ComplicationPrefsMap, id: string): ComplicationPrefs { + return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : defaultPrefs(); +} diff --git a/plugins/thread-badges/badges/complication-settings.tsx b/plugins/thread-badges/badges/complication-settings.tsx new file mode 100644 index 0000000..4859075 --- /dev/null +++ b/plugins/thread-badges/badges/complication-settings.tsx @@ -0,0 +1,202 @@ +// Settings for complications other plugins provide: one row per provider that +// is running now, each off until you turn it on. +// +// A section rather than bb settings because providers are discovered here, in +// the app, after the backend has declared its settings. What this writes goes +// to the backend's storage, which tells every window, so a row elsewhere picks +// up the change without a reload. +import { useEffect, useId, useState, type KeyboardEvent } from "react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import type { ComplicationProviderInfo } from "../lib/complications"; +import { ComplicationGlyph } from "./complication-badge"; +import { prefsFor, type ComplicationPrefs } from "./complication-prefs"; +import { complicationView } from "./complication-view"; +import { useComplicationPrefs } from "./use-complication-prefs"; +import { useThreadProviders } from "./use-complication-providers"; + +export function ComplicationSettings() { + const providers = useThreadProviders(); + const { prefs, loaded, update } = useComplicationPrefs(); + const [error, setError] = useState(null); + + if (providers.length === 0) { + return ( +

+ No plugin is publishing thread complications right now. Follow Up 0.7 or later + publishes its progress here. +

+ ); + } + + const change = (id: string, patch: Partial): void => { + setError(null); + update(id, patch).catch((reason: unknown) => { + setError(reason instanceof Error ? reason.message : String(reason)); + }); + }; + + return ( +
+ {providers.map((provider) => ( + change(provider.id, patch)} + prefs={prefsFor(prefs, provider.id)} + provider={provider} + /> + ))} + {error !== null ? ( +

+ Could not save: {error} +

+ ) : null} +
+ ); +} + +function ProviderRow({ + provider, + prefs, + disabled, + onChange, +}: { + provider: ComplicationProviderInfo; + prefs: ComplicationPrefs; + disabled: boolean; + onChange: (patch: Partial) => void; +}) { + const id = useId(); + // The preview ignores "hide once complete": a sample that is complete would + // otherwise preview as nothing at all. + const preview = + provider.sample === undefined + ? null + : complicationView(provider.sample, { showText: prefs.showText, hideWhenComplete: false }); + + return ( +
+
+ onChange({ enabled: checked === true })} + /> +
+ + {provider.description !== undefined ? ( +

{provider.description}

+ ) : null} +
+ {preview !== null ? ( + + + + ) : null} +
+ {prefs.enabled ? ( +
+ onChange({ priority })} + value={prefs.priority} + /> +
+ ) : null} +
+ ); +} + +function Option({ + id, + label, + checked, + disabled, + onChange, +}: { + id: string; + label: string; + checked: boolean; + disabled: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + onChange(next === true)} + /> + + + ); +} + +/** + * Committed on blur or Enter, not per keystroke: typing "12" would otherwise + * save 1 and then 12, and reorder every row in between. Anything that is not a + * number puts the stored value back. + */ +function PriorityInput({ + id, + value, + disabled, + onCommit, +}: { + id: string; + value: number; + disabled: boolean; + onCommit: (priority: number) => void; +}) { + const [draft, setDraft] = useState(String(value)); + useEffect(() => setDraft(String(value)), [value]); + + const commit = (): void => { + const next = Number(draft); + if (draft.trim() === "" || !Number.isFinite(next)) { + setDraft(String(value)); + return; + } + if (next !== value) onCommit(next); + }; + + return ( + + + setDraft(event.target.value)} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === "Enter") commit(); + }} + type="number" + value={draft} + /> + + ); +} diff --git a/plugins/thread-badges/badges/complication-view.ts b/plugins/thread-badges/badges/complication-view.ts new file mode 100644 index 0000000..6a159e8 --- /dev/null +++ b/plugins/thread-badges/badges/complication-view.ts @@ -0,0 +1,60 @@ +// What a complication's value looks like on a row, decided without React. +// +// The value is data another plugin published; this turns it into the one +// thing a row draws, or nothing. A fraction is a gauge, drawn as the ring the +// follow-ups badge has always drawn. Anything else is the provider's own icon, +// coloured by its tone. Text rides beside either, when you asked for it. +import type { ComplicationValue } from "../lib/complications"; +import type { ComplicationPrefs } from "./complication-prefs"; + +/** + * The palette the built-in badges already use, so a provider's error looks + * like a failed check. Unknown tones — the vocabulary is open — draw as + * default rather than guessing. + */ +export const TONE_COLORS: Readonly> = { + default: "var(--muted-foreground)", + info: "light-dark(#0969da, #4493f8)", + success: "light-dark(#1a7f37, #3fb950)", + warning: "light-dark(#9a6700, #d29922)", + error: "var(--destructive)", + running: "light-dark(#9a6700, #d29922)", +}; + +export function toneColor(tone: string | undefined): string { + return tone !== undefined && Object.prototype.hasOwnProperty.call(TONE_COLORS, tone) + ? TONE_COLORS[tone] + : TONE_COLORS.default; +} + +interface ViewBase { + label: string; + color: string; + /** Animated where motion is allowed: bb's `running` tone is work in progress. */ + running: boolean; + /** Beside the glyph, or null. */ + text: string | null; +} + +export type ComplicationView = + | (ViewBase & { kind: "ring"; fraction: number }) + | (ViewBase & { kind: "glyph"; icon: string }); + +export function complicationView( + value: ComplicationValue | null | undefined, + prefs: Pick, +): ComplicationView | null { + // Unanswered and "nothing to say" both draw nothing, and take no slot. + if (value == null) return null; + const base: ViewBase = { + label: value.label, + color: toneColor(value.tone), + running: value.tone === "running", + text: prefs.showText && value.text !== undefined ? value.text : null, + }; + if (value.fraction !== undefined) { + if (prefs.hideWhenComplete && value.fraction >= 1) return null; + return { ...base, kind: "ring", fraction: value.fraction }; + } + return { ...base, kind: "glyph", icon: value.icon }; +} diff --git a/plugins/thread-badges/badges/complication.ts b/plugins/thread-badges/badges/complication.ts new file mode 100644 index 0000000..04a5281 --- /dev/null +++ b/plugins/thread-badges/badges/complication.ts @@ -0,0 +1,43 @@ +// Reading a complication another plugin provides. +// +// The consumer side of ../lib/complications: a row says it is showing a thread, +// and re-renders when the provider publishes for it. Only enabled complications +// mount a badge, so a provider is never asked about one you have switched off. +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { + getComplications, + type ComplicationSubject, + type ComplicationValue, +} from "../lib/complications"; + +const registry = getComplications(); + +export function useComplication( + id: string, + threadId: string, +): { provided: boolean; value: ComplicationValue | null | undefined } { + const subscribePresence = useCallback( + (listener: () => void) => registry?.subscribe(id, null, listener) ?? (() => undefined), + [id], + ); + const readPresence = useCallback(() => registry?.isProvided(id) ?? false, [id]); + const provided = useSyncExternalStore(subscribePresence, readPresence, readPresence); + + const subscribeValue = useCallback( + (listener: () => void) => + registry?.subscribe(id, thread(threadId), listener) ?? (() => undefined), + [id, threadId], + ); + const readValue = useCallback(() => registry?.read(id, thread(threadId)), [id, threadId]); + const value = useSyncExternalStore(subscribeValue, readValue, readValue); + + // Wanted whether or not a provider is there yet: one that loads later is + // handed everything already wanted, so rows mounted first are not skipped. + useEffect(() => registry?.want(id, thread(threadId)), [id, threadId]); + + return { provided, value }; +} + +function thread(id: string): ComplicationSubject { + return { kind: "thread", id }; +} diff --git a/plugins/thread-badges/badges/components.tsx b/plugins/thread-badges/badges/components.tsx index f095402..9eea057 100644 --- a/plugins/thread-badges/badges/components.tsx +++ b/plugins/thread-badges/badges/components.tsx @@ -11,7 +11,6 @@ // — or that it will be visible, since the cap may hide it. import type { ComponentType } from "react"; import type { BadgeSettings } from "./catalog"; -import { FollowUpsBadge } from "./follow-ups"; import { PortsBadge } from "./ports"; import { PrChecksBadge } from "./pr-checks"; import { PullRequestBadge } from "./pull-request"; @@ -30,7 +29,6 @@ export interface BadgeProps { } export const BADGE_COMPONENTS: Readonly>> = { - followUps: FollowUpsBadge, ports: PortsBadge, prChecks: PrChecksBadge, pullRequest: PullRequestBadge, diff --git a/plugins/thread-badges/badges/follow-up-counts.ts b/plugins/thread-badges/badges/follow-up-counts.ts deleted file mode 100644 index 893347b..0000000 --- a/plugins/thread-badges/badges/follow-up-counts.ts +++ /dev/null @@ -1,206 +0,0 @@ -// One counts request per sidebar, not one per row. -// -// Follow-ups exposes `getFollowUpCountsV1` as a real contract — batch, -// versioned, and answering for every thread asked about. This module is the -// consumer side of it: rows announce which thread they need, a short timer -// collects everything announced in the same render, and one request answers -// all of them. -// -// The store lives at module scope rather than in a React context because the -// badges are portaled into sidebar rows one subtree at a time; there is no -// common ancestor to hang a provider on. It also owns the refresh cadence, -// rather than leaning on the host's shared `revision`: a plugin hears only its -// own realtime signals, so counts recorded elsewhere have to be discovered by -// asking, and this is the only badge with that problem. Waking the others on -// a follow-ups timer would be someone else's staleness paid for by them. - -/** Follow-ups' plugin id and its one cross-plugin method. */ -const FOLLOW_UP_PLUGIN = "follow-up"; -const COUNTS_METHOD = "getFollowUpCountsV1"; - -/** - * The version this consumer understands. Follow-ups promises to bump it when - * the shape changes, which is the whole reason to read it: a payload that - * changed shape and a thread with nothing recorded both leave us holding no - * counts, and only one of them should stop us asking again. - */ -const PROTOCOL_VERSION = 1; - -/** Follow-ups refuses more than this many threads in one call. */ -const MAX_THREADS = 500; - -/** - * How long to collect thread ids before asking. Long enough that a sidebar - * mounting its rows lands in one request, short enough to be invisible. - */ -const BATCH_DELAY = 25; - -/** - * How often the visible rows re-ask. Batching is what makes this affordable — - * a tick is one request for the whole sidebar, not one per row — and 30s - * matches the interval bb itself polls a pull request's checks at, so the - * sidebar keeps one rhythm rather than two. - */ -const POLL_INTERVAL = 30_000; - -export interface Counts { - open: number; - done: number; -} - -/** Answers, kept across the constant unmount/remount of sidebar rows. */ -const counts = new Map(); - -/** Which revision each thread was last asked at, so re-renders do not re-ask. */ -const asked = new Map(); - -const listeners = new Map void>>(); - -const pending = new Set(); -let timer = 0; -let poll = 0; - -/** - * Set once Follow-ups answers with a version this code does not understand. - * Not the same as a failed request: a failure is worth retrying and a version - * we cannot read is not, so this stops the asking entirely rather than - * spending a request per revision on an answer that will not improve. - */ -let unsupported = false; - -function notify(threadId: string): void { - for (const listener of listeners.get(threadId) ?? []) listener(); -} - -async function ask(threadIds: readonly string[]): Promise { - const response = await fetch( - `/api/v1/plugins/${FOLLOW_UP_PLUGIN}/rpc/${COUNTS_METHOD}`, - { - body: JSON.stringify({ threadIds }), - credentials: "same-origin", - headers: { "content-type": "application/json" }, - method: "POST", - }, - ); - const envelope = (await response.json()) as { - ok?: boolean; - result?: { - protocolVersion?: number; - counts?: readonly { threadId?: string; open?: number; done?: number }[]; - }; - }; - if (envelope.ok !== true || envelope.result === undefined) return; - if (envelope.result.protocolVersion !== PROTOCOL_VERSION) { - unsupported = true; - stopPolling(); - // The one thing worth saying out loud. Every other failure here is - // "Follow-ups is not installed", which is not a problem; this one means - // the two plugins disagree about a contract and a human has to look. - console.warn( - `[thread-badges] Follow-ups answered ${COUNTS_METHOD} with protocol ` + - `${String(envelope.result.protocolVersion)}; this build reads ` + - `${PROTOCOL_VERSION}. The follow-ups ring is off until they agree.`, - ); - return; - } - for (const entry of envelope.result.counts ?? []) { - if (typeof entry.threadId !== "string") continue; - const next: Counts = { done: entry.done ?? 0, open: entry.open ?? 0 }; - const previous = counts.get(entry.threadId); - // Identity is the subscription's snapshot, so only replace it on a real - // change: an unchanged object keeps `useSyncExternalStore` from - // re-rendering every row on every refresh. - if (previous?.open === next.open && previous.done === next.done) continue; - counts.set(entry.threadId, next); - notify(entry.threadId); - } -} - -function flush(): void { - timer = 0; - const threadIds = [...pending]; - pending.clear(); - for (let start = 0; start < threadIds.length; start += MAX_THREADS) { - const chunk = threadIds.slice(start, start + MAX_THREADS); - // Follow-ups is optional: no plugin, no route, no answer, no ring. The - // ids stay marked as asked, so a failure costs one revision rather than a - // request on every re-render. - void ask(chunk).catch(() => undefined); - } -} - -function schedule(): void { - if (timer === 0) timer = window.setTimeout(flush, BATCH_DELAY); -} - -/** - * Re-ask for every thread currently on screen. - * - * Not gated on `asked`: a tick is precisely "ask again at the same revision", - * which is the guard's whole purpose to prevent for re-renders. It shares the - * batch timer, so a tick landing next to a focus refresh is one request rather - * than two. - */ -function tick(): void { - // A hidden window is asking about a sidebar nobody is looking at. The host's - // focus revision already refreshes on the way back, so skipping here costs - // nothing and stops a backgrounded window reading storage forever. - if (unsupported || document.hidden || listeners.size === 0) return; - for (const threadId of listeners.keys()) pending.add(threadId); - schedule(); -} - -/** - * Idempotent, and deliberately never stopped while the plugin is loaded. - * - * Tying the interval's life to the subscriptions is the obvious design and it - * starves: sidebar rows re-render constantly, every re-subscribe would clear - * and recreate the timer, and a timer recreated more often than every 30s - * never fires at all. A tick with no listeners is one comparison, which is a - * far cheaper thing to spend than that failure mode. - */ -function startPolling(): void { - if (poll !== 0 || unsupported) return; - poll = window.setInterval(tick, POLL_INTERVAL); -} - -function stopPolling(): void { - if (poll === 0) return; - window.clearInterval(poll); - poll = 0; -} - -/** - * Say that a row needs this thread's counts as of `revision`. Repeated calls - * at the same revision are free, which matters because sidebar rows re-render - * constantly. - */ -export function requestCounts(threadId: string, revision: number): void { - if (unsupported) return; - if (asked.get(threadId) === revision) return; - asked.set(threadId, revision); - pending.add(threadId); - schedule(); -} - -/** - * The first subscription starts the polling. A badge switched off in settings - * never subscribes, so it never starts a timer at all. - */ -export function subscribeCounts(threadId: string, listener: () => void): () => void { - let set = listeners.get(threadId); - if (set === undefined) { - set = new Set(); - listeners.set(threadId, set); - } - set.add(listener); - startPolling(); - return () => { - set.delete(listener); - if (set.size === 0) listeners.delete(threadId); - }; -} - -export function getCounts(threadId: string): Counts | null { - return counts.get(threadId) ?? null; -} diff --git a/plugins/thread-badges/badges/follow-ups.tsx b/plugins/thread-badges/badges/follow-ups.tsx deleted file mode 100644 index c1bc425..0000000 --- a/plugins/thread-badges/badges/follow-ups.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// The follow-ups badge: a ring showing how much of a thread's follow-up list -// has been closed. -// -// Unlike the pull-request badge, this one reads another plugin's state — but -// through a declared contract rather than a borrowed call. Follow-ups' -// `getFollowUpCountsV1` is batch and versioned, so the whole sidebar costs one -// request; ./follow-up-counts owns that request, its 30s refresh, and the -// staleness this file used to worry about. Follow-ups absent, disabled, or -// answering a version this code does not read all end the same way: no ring, -// nothing else affected. -import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { - getCounts, - requestCounts, - subscribeCounts, - type Counts, -} from "./follow-up-counts"; -import type { BadgeProps } from "./components"; - -function useFollowUpCounts(threadId: string, revision: number): Counts | null { - // `revision` is the host's focus signal, shared by every row, so all of them - // ask again on the same tick and the batch collects them into one request. - // It is no longer the only refresh — the store polls the visible rows every - // 30s — but it is still the one that fires the instant you come back, ahead - // of the next tick. - useEffect(() => { - requestCounts(threadId, revision); - }, [threadId, revision]); - - // Memoised because React re-subscribes whenever this identity changes, and - // these rows re-render constantly. - const subscribe = useCallback( - (listener: () => void) => subscribeCounts(threadId, listener), - [threadId], - ); - const read = useCallback(() => getCounts(threadId), [threadId]); - return useSyncExternalStore(subscribe, read, read); -} - -const SIZE = 14; -const RADIUS = 5; -const CIRCUMFERENCE = 2 * Math.PI * RADIUS; - -export function FollowUpsBadge({ threadId, values, revision }: BadgeProps) { - const counts = useFollowUpCounts(threadId, revision); - if (counts === null) return null; - const total = counts.open + counts.done; - // A thread that never recorded one has nothing to say about follow-ups. - if (total === 0) return null; - const complete = counts.open === 0; - if (complete && values.followUps_hideWhenComplete === true) return null; - - const fraction = counts.done / total; - const label = complete - ? `All ${total} follow-up${total === 1 ? "" : "s"} done` - : `${counts.done} of ${total} follow-ups done`; - // Green only when the list is actually clear, matching the pull-request - // badge; otherwise the ring stays quiet chrome. - const arc = complete ? "light-dark(#1a7f37, #3fb950)" : "var(--muted-foreground)"; - - return ( - - - - - - {values.followUps_showOpenCount === true && counts.open > 0 ? ( - {counts.open} - ) : null} - - ); -} diff --git a/plugins/thread-badges/badges/order.ts b/plugins/thread-badges/badges/order.ts new file mode 100644 index 0000000..bb82b90 --- /dev/null +++ b/plugins/thread-badges/badges/order.ts @@ -0,0 +1,53 @@ +// Which badges a row draws, in what order: the built-in types and the +// complications other plugins provide, on one priority scale. +// +// This is the whole of "which badge wins". A row renders these in order and +// the cap hides whatever falls past it, so the entry dropped from a busy row is +// the last one here. +import { + BADGE_TYPES, + isEnabled, + priorityOf, + type BadgeSettings, + type BadgeType, +} from "./catalog"; +import { prefsFor, type ComplicationPrefs, type ComplicationPrefsMap } from "./complication-prefs"; + +export type RowEntry = + | { kind: "builtin"; key: string; type: BadgeType } + | { kind: "complication"; key: string; id: string; prefs: ComplicationPrefs }; + +/** + * Enabled built-ins and enabled complications whose provider is live, lowest + * priority first. A complication whose plugin is not running has nothing to + * draw, so it is left out rather than asked for. + * + * Ties go to the built-ins, then to catalog or discovery order, so a set of + * priorities nobody has touched still draws a stable row. + */ +export function orderedEntries( + values: BadgeSettings | undefined, + providerIds: readonly string[], + prefs: ComplicationPrefsMap, +): readonly RowEntry[] { + const builtins = BADGE_TYPES.filter((type) => isEnabled(values, type.id)).map( + (type, index) => ({ + entry: { kind: "builtin", key: `builtin:${type.id}`, type } as RowEntry, + priority: priorityOf(values, type.id), + group: 0, + index, + }), + ); + const complications = providerIds + .map((id, index) => ({ id, index, prefs: prefsFor(prefs, id) })) + .filter(({ prefs: entry }) => entry.enabled) + .map(({ id, index, prefs: entry }) => ({ + entry: { kind: "complication", key: `complication:${id}`, id, prefs: entry } as RowEntry, + priority: entry.priority, + group: 1, + index, + })); + return [...builtins, ...complications] + .sort((a, b) => a.priority - b.priority || a.group - b.group || a.index - b.index) + .map(({ entry }) => entry); +} diff --git a/plugins/thread-badges/badges/port-counts.ts b/plugins/thread-badges/badges/port-counts.ts index e4b55c8..b153c4d 100644 --- a/plugins/thread-badges/badges/port-counts.ts +++ b/plugins/thread-badges/badges/port-counts.ts @@ -5,23 +5,20 @@ // already carries the `environment → threads` mapping a badge needs. So this // module reads, and nothing here scans. // -// The store lives at module scope for the same reason ./follow-up-counts does: -// badges are portaled into sidebar rows one subtree at a time, so there is no -// common ancestor to hang a provider on. +// The store lives at module scope because badges are portaled into sidebar +// rows one subtree at a time, so there is no common ancestor to hang a provider +// on. // -// Two things differ from the follow-ups store, both because the shape of the -// source differs: +// Two things it deliberately does not do, both because of the source's shape: // -// - No batching. Follow-ups answers about the threads you name, so rows have -// to collect ids before asking. The ports snapshot is the whole picture in -// one document, so a single request already answers for every row and the -// ids only decide who gets notified. -// - No version latch. Follow-ups stamps `protocolVersion` and this code stops -// asking when it reads one it does not understand. The snapshot carries no -// such stamp, so there is no such signal to act on: every failure here is -// treated as transient and retried, because "Worktree Ports is not -// installed" and "Worktree Ports changed shape" are indistinguishable from -// the outside and only one of them is worth giving up over. +// - No batching. The snapshot is the whole picture in one document, so a +// single request already answers for every row and the ids only decide who +// gets notified. +// - No version latch. The snapshot carries no version stamp, so there is no +// signal that it changed shape: every failure here is treated as transient +// and retried, because "Worktree Ports is not installed" and "Worktree +// Ports changed shape" are indistinguishable from the outside and only one +// of them is worth giving up over. /** Worktree Ports' plugin id, and the route its own thread-row script reads. */ const PORTS_PLUGIN = "worktree-ports"; diff --git a/plugins/thread-badges/badges/ring.tsx b/plugins/thread-badges/badges/ring.tsx new file mode 100644 index 0000000..ce97f1a --- /dev/null +++ b/plugins/thread-badges/badges/ring.tsx @@ -0,0 +1,41 @@ +// A gauge, drawn as the ring the follow-ups badge always drew: empty at +// nothing, a visible notch at "one left", full at done. + +const SIZE = 14; +const RADIUS = 5; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +export function Ring({ fraction, color }: { fraction: number; color: string }) { + return ( + + + + + ); +} diff --git a/plugins/thread-badges/badges/use-complication-prefs.ts b/plugins/thread-badges/badges/use-complication-prefs.ts new file mode 100644 index 0000000..c65549b --- /dev/null +++ b/plugins/thread-badges/badges/use-complication-prefs.ts @@ -0,0 +1,77 @@ +// The stored settings for complications, shared by the rows and Settings. +// +// One store at module scope: the overlay that draws the rows and the settings +// section are separate React trees, and both must show the same thing the +// moment either changes it. The backend announces every write, so another +// window catches up too. +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { + useRealtime, + useRealtimeConnectionState, + useRpc, + type PluginRpcClient, +} from "@get-bb/plugin-sdk/app"; +import type { rpcContract } from "../server"; +import { PREFS_CHANGED, type ComplicationPrefs, type ComplicationPrefsMap } from "./complication-prefs"; + +type Rpc = PluginRpcClient; + +let prefs: ComplicationPrefsMap = {}; +let loaded = false; +const listeners = new Set<() => void>(); + +function publish(next: ComplicationPrefsMap): void { + prefs = next; + loaded = true; + for (const listener of [...listeners]) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const read = () => prefs; +const readLoaded = () => loaded; + +async function load(rpc: Rpc): Promise { + try { + publish((await rpc.call("complicationPrefs_list", {})).prefs); + } catch { + // Keep what was last read: a failed refresh is not "nothing turned on". + } +} + +export function useComplicationPrefs(): { + prefs: ComplicationPrefsMap; + loaded: boolean; + update: (id: string, patch: Partial) => Promise; +} { + const rpc = useRpc(); + const connection = useRealtimeConnectionState(); + + // Again on reconnect: a write announced while the connection was down was + // never heard here. + useEffect(() => { + void load(rpc); + }, [rpc, connection]); + + useRealtime(PREFS_CHANGED, () => { + void load(rpc); + }); + + const update = useCallback( + async (id: string, patch: Partial) => { + publish((await rpc.call("complicationPrefs_set", { id, prefs: patch })).prefs); + }, + [rpc], + ); + + return { + prefs: useSyncExternalStore(subscribe, read, read), + loaded: useSyncExternalStore(subscribe, readLoaded, readLoaded), + update, + }; +} diff --git a/plugins/thread-badges/badges/use-complication-providers.ts b/plugins/thread-badges/badges/use-complication-providers.ts new file mode 100644 index 0000000..c5117d7 --- /dev/null +++ b/plugins/thread-badges/badges/use-complication-providers.ts @@ -0,0 +1,27 @@ +// The complications other plugins provide that can be drawn on a thread row. +// +// Read live from the window's registry: a provider appears when its plugin's +// bundle registers it and disappears when that plugin stops, so the list in +// Settings and the badges on rows follow installs without a reload. +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { getComplications, type ComplicationProviderInfo } from "../lib/complications"; + +const registry = getComplications(); +const NONE: readonly ComplicationProviderInfo[] = []; + +/** A provider that answers threads, or does not say which subjects it answers. */ +export function answersThreads(provider: ComplicationProviderInfo): boolean { + return provider.subjects === undefined || provider.subjects.includes("thread"); +} + +export function useThreadProviders(): readonly ComplicationProviderInfo[] { + const subscribe = useCallback( + (listener: () => void) => registry?.subscribeProviders(listener) ?? (() => undefined), + [], + ); + // `providers()` keeps its identity until something changes, so it is a safe + // snapshot; the filter below is memoised on it for the same reason. + const read = useCallback(() => registry?.providers() ?? NONE, []); + const all = useSyncExternalStore(subscribe, read, read); + return useMemo(() => all.filter(answersThreads), [all]); +} diff --git a/plugins/thread-badges/lib/complications.ts b/plugins/thread-badges/lib/complications.ts new file mode 100644 index 0000000..fe1dbcf --- /dev/null +++ b/plugins/thread-badges/lib/complications.ts @@ -0,0 +1,560 @@ +// Complications: small values one plugin provides and another draws. +// +// VENDORED. This file is copied byte for byte into every plugin in this +// repository that provides or draws a complication, and +// `scripts/check-vendored.mjs` fails the repository check when the copies +// drift. Copying is how it ships, not how it runs: bb bundles each plugin's +// dependencies into that plugin, so a published npm package would still put +// one copy in every bundle. A package would add versioning for plugins outside +// this repository; a workspace-only one would not install at all, because bb +// installs each plugin from its own subdirectory. +// +// The registry lives on `globalThis`, under a versioned symbol. bb imports +// every plugin's frontend bundle into the same document — a plain +// `import(url)`, with no iframe, worker or realm between them — so this is the +// one place two bundles meet without a server round trip. It is also the one +// place that is live across plugins: realtime delivers a plugin only its own +// signals, so a provider that hears its own change can tell everyone else here. +// +// Whichever bundle loads first creates the registry, and every later bundle +// uses that object, methods included. An older copy's code may therefore be +// the one answering a newer copy's calls, depending only on which plugin +// loaded first. Three rules follow, and they are what "protocol v1" means: +// +// - Behaviour is frozen. `provide`, `want`, `read`, `isProvided`, `subscribe`, +// `providers` and `subscribeProviders` keep exactly these semantics under +// this symbol. A new method or a changed meaning is a new symbol. +// - The schema is not. The registry validates the fields it names and carries +// every other JSON-safe field across untouched, so a field added later +// reaches surfaces even through an older copy. A surface must check any +// field this file does not name before it draws it. +// - Which copy is running is never a guess. Bump `COMPLICATIONS_IMPLEMENTATION` +// with any change to this file's behaviour, fixes included. The registry +// reports the number of the copy that created it, and a newer copy that +// finds an older one in charge says so, once, in the console: its fix does +// not apply until every plugin carrying the older copy updates. + +export const COMPLICATIONS_PROTOCOL = 1 as const; + +/** This copy's implementation. Bump it with any change in behaviour. */ +export const COMPLICATIONS_IMPLEMENTATION = 1; + +const REGISTRY_KEY = Symbol.for("bb-community.complications.v1"); + +/** The largest value the registry carries, in characters of its JSON. */ +export const MAX_VALUE_LENGTH = 4096; + +/** `/`, each lowercase letters, digits and single dashes. */ +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +const MAX_KIND_LENGTH = 64; +const MAX_SUBJECT_ID_LENGTH = 2048; +const MAX_TONE_LENGTH = 32; + +/** + * What a complication is about. The registry checks only the shape: `kind` is + * open, and `id` is opaque. + * + * Kinds in use: `thread`, `project`, `environment`, `app`, `url`, `message`, + * `file`. A singleton has an empty id — `{ kind: "app", id: "" }`. Each kind's + * canonical id is a convention between its providers and surfaces, not the + * registry's business: a `url` is `new URL(…).href`, so a trailing slash or a + * capitalised host cannot split one link into two subjects. + */ +export interface ComplicationSubject { + kind: string; + id: string; +} + +/** + * The tones every surface understands. A tone is an open string: a surface + * draws one it does not know as `default`, so adding one costs nothing. + * `running` is the one with behaviour — bb animates it. + */ +export type ComplicationTone = "default" | "info" | "success" | "warning" | "error" | "running"; + +/** + * One value that can be read at a glance. Data, not a component: the surface + * draws it, in whichever size its slot has room for, and a surface outside bb + * can draw it too. Smallest to largest: a glyph (`icon`, `tone`), inline + * (`text`), a gauge (`fraction`). + * + * Fields not named here pass through the registry as JSON; read them with care. + * Two names are reserved for what no surface draws yet: `detail`, the large + * size a card or hover shows, and `open`, where clicking goes. Their shapes + * will be settled by the first surface that draws them, so the registry does + * not check them — a surface must, before it draws one, and an `href` above + * all. + */ +export interface ComplicationValue { + /** A bb icon name: a built-in glyph, or a namespaced `"/"`. */ + readonly icon: string; + /** Accessible label and tooltip. Required: a glyph alone is not a sentence. */ + readonly label: string; + readonly tone?: ComplicationTone | (string & {}); + /** A few characters beside the glyph — a count, a port, a percentage. */ + readonly text?: string; + /** Progress from 0 to 1. Present, it makes the value a gauge. */ + readonly fraction?: number; +} + +export interface ComplicationProviderRegistration { + /** `"/"`. One provider per id; a second replaces the first. */ + id: string; + /** What the settings list calls it: "Follow-up progress". */ + name: string; + description?: string; + /** The subject kinds it answers, so a slot offers it only where it can speak. */ + subjects?: readonly string[]; + /** A representative value, for previews in settings. */ + sample?: ComplicationValue; + /** + * Subjects that have just become wanted, batched per microtask — including, + * just after registering, every subject that was wanted before the provider + * loaded. Answer with the handle's `set`, now or later. + */ + onWanted?: (subjects: readonly ComplicationSubject[]) => void; +} + +/** A provider as `providers()` lists it: the registration without its callback. */ +export interface ComplicationProviderInfo { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly subjects?: readonly string[]; + readonly sample?: ComplicationValue; +} + +export interface ComplicationProviderHandle { + /** Publish a value, or `null` for "nothing to say". Ignored once withdrawn. */ + set(subject: ComplicationSubject, value: ComplicationValue | null): void; + /** Whether any surface currently wants this subject. */ + isWanted(subject: ComplicationSubject): boolean; + /** Every subject currently wanted. */ + wanted(): ComplicationSubject[]; + /** + * Withdraw, clearing every value this provider set. Idempotent, and a no-op + * once another registration has replaced this one, so a hot reload's late + * cleanup cannot wipe the generation that replaced it. + */ + dispose(): void; +} + +export interface ComplicationsRegistry { + readonly protocol: typeof COMPLICATIONS_PROTOCOL; + /** + * `COMPLICATIONS_IMPLEMENTATION` of the copy that created this registry — + * the copy whose code every plugin in the window is running. + */ + readonly implementation: number; + /** Throws on a malformed id or a blank name: those are bugs in the caller. */ + provide(registration: ComplicationProviderRegistration): ComplicationProviderHandle; + /** Say that a surface needs this value. Returns the release. */ + want(id: string, subject: ComplicationSubject): () => void; + /** + * `undefined` until a provider has answered for this subject, `null` once it + * has said there is nothing to show. The object is stable until it changes, + * so it is safe as a `useSyncExternalStore` snapshot. + */ + read(id: string, subject: ComplicationSubject): ComplicationValue | null | undefined; + isProvided(id: string): boolean; + /** A value's changes, or with a `null` subject, that provider coming and going. */ + subscribe(id: string, subject: ComplicationSubject | null, listener: () => void): () => void; + /** Every live provider. The array is stable until one comes, goes or is replaced. */ + providers(): readonly ComplicationProviderInfo[]; + subscribeProviders(listener: () => void): () => void; +} + +type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonBlank(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isSubject(value: unknown): value is ComplicationSubject { + return ( + isRecord(value) && + isNonBlank(value.kind) && + value.kind.length <= MAX_KIND_LENGTH && + typeof value.id === "string" && + value.id.length <= MAX_SUBJECT_ID_LENGTH + ); +} + +/** JSON, so no choice of separator can make two subjects collide. */ +function subjectKey(subject: ComplicationSubject): string { + return JSON.stringify([subject.kind, subject.id]); +} + +/** Own data properties only: `__proto__` must stay a key, not become a prototype. */ +function put(into: Record, key: string, value: unknown): void { + Object.defineProperty(into, key, { value, enumerable: true, writable: true, configurable: true }); +} + +/** A JSON-safe deep copy, or `undefined` when there is none: a function, a cycle, a BigInt. */ +function jsonCopy(value: unknown): Json | undefined { + try { + // `JSON.stringify` gives `undefined` for a function, a symbol or + // `undefined` itself, and throws for a cycle or a BigInt. + const text = JSON.stringify(value); + return text === undefined ? undefined : (JSON.parse(text) as Json); + } catch { + return undefined; + } +} + +/** + * Carry across every field `known` does not name, each only if it survives as + * JSON. This is the half of the protocol that lets the schema grow. + */ +function passThrough( + raw: Record, + known: ReadonlySet, + into: Record, +): void { + for (const key of Object.keys(raw)) { + if (known.has(key)) continue; + const copy = jsonCopy(raw[key]); + if (copy !== undefined) put(into, key, copy); + } +} + +function deepFreeze(value: T): T { + if (typeof value === "object" && value !== null) { + for (const key of Object.keys(value)) deepFreeze((value as Record)[key]); + Object.freeze(value); + } + return value; +} + +function normalizeTone(raw: unknown): string | undefined { + return isNonBlank(raw) && raw.length <= MAX_TONE_LENGTH ? raw : undefined; +} + +const VALUE_KNOWN: ReadonlySet = new Set(["icon", "label", "tone", "text", "fraction"]); +const REGISTRATION_KNOWN: ReadonlySet = new Set([ + "id", + "name", + "description", + "subjects", + "sample", + "onWanted", +]); + +/** + * The value as the registry stores it, with its JSON for comparison, or + * `undefined` when it is not a value at all. + * + * Named fields are checked: a bad required one rejects the value, a bad + * optional one is dropped. Other fields are copied as JSON. Copying rather + * than keeping the provider's object is the point — the provider is another + * plugin, and what it handed over must not change under a surface drawing it. + */ +function normalize(value: unknown): { value: ComplicationValue; json: string } | null | undefined { + if (value === null) return null; + if (!isRecord(value)) return undefined; + try { + if (!isNonBlank(value.icon) || !isNonBlank(value.label)) return undefined; + const next: Record = { icon: value.icon, label: value.label }; + const tone = normalizeTone(value.tone); + if (tone !== undefined) next.tone = tone; + if (isNonBlank(value.text)) next.text = value.text; + if (typeof value.fraction === "number" && Number.isFinite(value.fraction)) { + next.fraction = Math.min(1, Math.max(0, value.fraction)); + } + passThrough(value, VALUE_KNOWN, next); + const json = JSON.stringify(next); + if (json.length > MAX_VALUE_LENGTH) return undefined; + return { value: deepFreeze(next) as unknown as ComplicationValue, json }; + } catch { + // A getter that throws, a proxy that lies: not a value. + return undefined; + } +} + +/** Exported for tests: what `set` would store, or `undefined` for "refused". */ +export function normalizeValue(value: unknown): ComplicationValue | null | undefined { + const normalized = normalize(value); + return normalized === null ? null : normalized?.value; +} + +/** The registration as `providers()` lists it. Throws on what is a caller's bug. */ +function describe(registration: ComplicationProviderRegistration): ComplicationProviderInfo { + const raw = registration as unknown as Record; + if (typeof raw.id !== "string" || !ID_PATTERN.test(raw.id)) { + throw new TypeError( + `complications: provider id ${JSON.stringify(raw.id)} is not "/"`, + ); + } + if (!isNonBlank(raw.name)) { + throw new TypeError(`complications: provider ${raw.id} needs a name`); + } + const info: Record = { id: raw.id, name: raw.name }; + if (isNonBlank(raw.description)) info.description = raw.description; + if (Array.isArray(raw.subjects)) { + info.subjects = raw.subjects.filter( + (kind): kind is string => isNonBlank(kind) && kind.length <= MAX_KIND_LENGTH, + ); + } + const sample = normalizeValue(raw.sample); + if (sample != null) info.sample = sample; + passThrough(raw, REGISTRATION_KNOWN, info); + return deepFreeze(info) as unknown as ComplicationProviderInfo; +} + +/** + * Run another plugin's code without letting it take this one down. A listener + * belongs to whichever surface subscribed and a callback to whichever plugin + * provided; a throw in either would otherwise land in the caller's commit. + */ +function contain(run: () => void): void { + try { + run(); + } catch (error) { + console.error("[complications]", error); + } +} + +/** Exported for tests. Plugins use `getComplications`. */ +export function createRegistry(): ComplicationsRegistry { + const providers = new Map< + string, + { + token: object; + info: ComplicationProviderInfo; + onWanted: ComplicationProviderRegistration["onWanted"]; + } + >(); + const values = new Map>(); + const wants = new Map>(); + const listeners = new Map void>>(); + const providerListeners = new Set<() => void>(); + /** Newly wanted subjects not yet delivered, by complication id. */ + const fresh = new Map>(); + let flushQueued = false; + let providerList: readonly ComplicationProviderInfo[] | null = null; + + const valueKey = (id: string, key: string) => `${id}\n${key}`; + const presenceKey = (id: string) => `${id}\n`; + + function notify(key: string): void { + const set = listeners.get(key); + if (set === undefined) return; + for (const listener of [...set]) contain(listener); + } + + function providersChanged(id: string): void { + providerList = null; + notify(presenceKey(id)); + for (const listener of [...providerListeners]) contain(listener); + } + + function isWanted(id: string, subject: ComplicationSubject): boolean { + return wants.get(id)?.has(subjectKey(subject)) ?? false; + } + + /** Hand subjects to the provider, skipping any released since they were queued. */ + function deliver(id: string, subjects: Iterable): void { + const onWanted = providers.get(id)?.onWanted; + if (typeof onWanted !== "function") return; + // A row that mounted and unmounted within the batch asks for nothing. + const still = [...subjects].filter((subject) => isWanted(id, subject)); + if (still.length > 0) contain(() => onWanted(still)); + } + + function flush(): void { + flushQueued = false; + const batch = [...fresh]; + fresh.clear(); + for (const [id, subjects] of batch) deliver(id, subjects.values()); + } + + return { + protocol: COMPLICATIONS_PROTOCOL, + implementation: COMPLICATIONS_IMPLEMENTATION, + + provide(registration) { + const info = describe(registration); + const { id } = info; + const token = {}; + providers.set(id, { token, info, onWanted: registration.onWanted }); + providersChanged(id); + // Whatever was wanted before this provider loaded. A microtask rather + // than now: the provider's `onWanted` answers through the handle, which + // it does not have until this call returns. + if (wants.has(id)) { + queueMicrotask(() => { + if (providers.get(id)?.token !== token) return; + deliver(id, [...(wants.get(id)?.values() ?? [])].map((want) => want.subject)); + }); + } + + const current = () => providers.get(id)?.token === token; + return { + set(subject, value) { + if (!current() || !isSubject(subject)) return; + const next = normalize(value); + if (next === undefined) { + console.warn(`[complications] ${id}: ignored a value that is not a complication`); + return; + } + const stored = next ?? { value: null, json: "null" }; + let byId = values.get(id); + if (byId === undefined) { + byId = new Map(); + values.set(id, byId); + } + const key = subjectKey(subject); + // Unchanged keeps its identity, so a surface does not re-render. + if (byId.get(key)?.json === stored.json) return; + byId.set(key, stored); + notify(valueKey(id, key)); + }, + isWanted(subject) { + return isSubject(subject) && isWanted(id, subject); + }, + wanted() { + return [...(wants.get(id)?.values() ?? [])].map((want) => ({ ...want.subject })); + }, + dispose() { + if (!current()) return; + providers.delete(id); + const had = values.get(id); + values.delete(id); + for (const key of had?.keys() ?? []) notify(valueKey(id, key)); + providersChanged(id); + }, + }; + }, + + want(id, subject) { + if (!isSubject(subject)) return () => undefined; + const key = subjectKey(subject); + let byId = wants.get(id); + if (byId === undefined) { + byId = new Map(); + wants.set(id, byId); + } + let entry = byId.get(key); + if (entry === undefined) { + entry = { subject: { kind: subject.kind, id: subject.id }, count: 0 }; + byId.set(key, entry); + let queued = fresh.get(id); + if (queued === undefined) { + queued = new Map(); + fresh.set(id, queued); + } + queued.set(key, entry.subject); + if (!flushQueued) { + flushQueued = true; + queueMicrotask(flush); + } + } + entry.count += 1; + const held = entry; + let released = false; + return () => { + if (released) return; + released = true; + held.count -= 1; + if (held.count > 0) return; + // Released, but the value stays: sidebar rows unmount and remount + // constantly, and dropping it would blink every badge on each pass. + // Wanting it again asks the provider afresh. + const current = wants.get(id); + if (current?.get(key) === held) current.delete(key); + if (current?.size === 0) wants.delete(id); + }; + }, + + read(id, subject) { + if (!isSubject(subject)) return undefined; + return values.get(id)?.get(subjectKey(subject))?.value; + }, + + isProvided(id) { + return providers.has(id); + }, + + subscribe(id, subject, listener) { + if (subject !== null && !isSubject(subject)) return () => undefined; + const key = subject === null ? presenceKey(id) : valueKey(id, subjectKey(subject)); + let set = listeners.get(key); + if (set === undefined) { + set = new Set(); + listeners.set(key, set); + } + set.add(listener); + return () => { + const current = listeners.get(key); + if (current === undefined) return; + current.delete(listener); + if (current.size === 0) listeners.delete(key); + }; + }, + + providers() { + providerList ??= Object.freeze([...providers.values()].map((entry) => entry.info)); + return providerList; + }, + + subscribeProviders(listener) { + providerListeners.add(listener); + return () => { + providerListeners.delete(listener); + }; + }, + }; +} + +function isRegistry(value: unknown): value is ComplicationsRegistry { + if (!isRecord(value) || value.protocol !== COMPLICATIONS_PROTOCOL) return false; + return ["provide", "want", "read", "isProvided", "subscribe", "providers", "subscribeProviders"].every( + (method) => typeof value[method] === "function", + ); +} + +/** Registries this copy has already complained about, so it says so once. */ +const warnedAbout = new WeakSet(); + +/** + * Say, once, that an older copy is answering for everyone. Not an error: the + * older copy keeps working, by the frozen behaviour. But a fix in this copy + * does not apply until every plugin carrying the older one updates, and + * without this line that is indistinguishable from the fix not working. + */ +function warnIfOlder(registry: ComplicationsRegistry): void { + const running = typeof registry.implementation === "number" ? registry.implementation : 0; + if (running >= COMPLICATIONS_IMPLEMENTATION || warnedAbout.has(registry)) return; + warnedAbout.add(registry); + console.warn( + `[complications] An older copy (implementation ${running}) created this ` + + `window's registry, so its code is what runs; this copy is ` + + `implementation ${COMPLICATIONS_IMPLEMENTATION}. Update the plugin that ` + + `carries the older copy to get this one's behaviour.`, + ); +} + +/** + * The window's registry, created by whichever bundle asks first. `null` only + * if something other than a registry already holds the symbol — draw nothing + * and provide nothing, rather than guess at its shape. + */ +export function getComplications(scope: object = globalThis): ComplicationsRegistry | null { + const holder = scope as Record; + const existing = holder[REGISTRY_KEY]; + if (existing !== undefined) { + if (!isRegistry(existing)) return null; + warnIfOlder(existing); + return existing; + } + const created = createRegistry(); + // Not writable or configurable: a later bundle must find this object, not + // replace it and strand everyone who already subscribed to the old one. + Object.defineProperty(scope, REGISTRY_KEY, { value: created }); + return created; +} diff --git a/plugins/thread-badges/package-lock.json b/plugins/thread-badges/package-lock.json index f0548cb..802ad2d 100644 --- a/plugins/thread-badges/package-lock.json +++ b/plugins/thread-badges/package-lock.json @@ -1,21 +1,22 @@ { "name": "bb-plugin-thread-badges", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-thread-badges", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", "@radix-ui/react-checkbox": "^1.3.7", - "@radix-ui/react-slot": "^1.3.0" + "@radix-ui/react-slot": "^1.3.0", + "zod": "^4.6.5" }, "devDependencies": { - "@get-bb/plugin-sdk": "0.4.47", + "@get-bb/plugin-sdk": "0.6.15", "@pierre/diffs": "^1.2.9", "@radix-ui/react-alert-dialog": "^1.1.19", "@radix-ui/react-context-menu": "^2.3.3", @@ -27,6 +28,7 @@ "@radix-ui/react-popover": "^1.1.19", "@radix-ui/react-select": "^2.3.3", "@radix-ui/react-tooltip": "^1.2.12", + "@testing-library/react": "^16.3.3", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.0.0", "@types/react": "^19.0.0", @@ -36,14 +38,259 @@ "clsx": "^2.1.1", "cron-parser": "^5.5.0", "hono": "^4.11.9", + "jsdom": "^30.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", "sonner": "^1.7.4", "tailwind-merge": "^3.4.0", "typescript": "^5.7.0", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "vitest": "^5.0.3" }, "engines": { - "bb": ">=0.36", - "bbPluginSdk": ">=0.4.47" + "bb": ">=0.45", + "bbPluginSdk": ">=0.6.15" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz", + "integrity": "sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.2", + "@csstools/css-color-parser": "^4.2.5", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@floating-ui/core": { @@ -89,10 +336,14 @@ "license": "MIT" }, "node_modules/@get-bb/plugin-sdk": { - "version": "0.4.47", - "resolved": "https://registry.npmjs.org/@get-bb/plugin-sdk/-/plugin-sdk-0.4.47.tgz", - "integrity": "sha512-qGUaLAzAZM/BCAAK8iTetlSwzRplw/BtMXqamgjVD2nKFKucOPAWzQOp/CtufS3t/1p7cfQ09rZwUYtnXtKFIQ==", + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/@get-bb/plugin-sdk/-/plugin-sdk-0.6.15.tgz", + "integrity": "sha512-S7UUC+mfY1V9wTU1NiWw+VyP1STcWm4+Cgoi/rzHhj1kfCuxnVdTozJ7XedKMeDM9vVGYU4y3+r/CpEWIdsiRg==", "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "zod": "^4.6.5" + }, "peerDependencies": { "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.12", @@ -101,8 +352,7 @@ "cron-parser": "^5.5.0", "hono": "^4.11.9", "react": "^19.0.0", - "react-dom": "^19.0.0", - "zod": "^4.3.6" + "react-dom": "^19.0.0" }, "peerDependenciesMeta": { "@testing-library/react": { @@ -128,16 +378,13 @@ }, "react-dom": { "optional": true - }, - "zod": { - "optional": true } } }, "node_modules/@hugeicons/core-free-icons": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.3.2.tgz", - "integrity": "sha512-31uDdIq71YcVxeltjcS/tsmYFt4VjmgyNTZfG1bY6mbeFmsYL6Nwz9PdDZetGtGPZOAEVg0bHoQFOoPWDC51bg==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.3.5.tgz", + "integrity": "sha512-Sv+NjHRPnQk+yZsGCMcGznCHdTfJR9PMMOEKcJYAQU4gy90dmlc9PwdtvYOImBZIjiheMsTLL5eMn2DmHxUiUg==", "license": "MIT" }, "node_modules/@hugeicons/react": { @@ -149,10 +396,49 @@ "react": ">=16.0.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@pierre/diffs": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.4.1.tgz", - "integrity": "sha512-rzvY9FeeYdGtVcErjNsW6tnrjpMb0DvGtgCFNuMoYT8wufE+gO1ZXAzKAc2VPRBS3Rl5HELEQ38WDlis4qZHkQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.5.1.tgz", + "integrity": "sha512-+EXNfz4ZXI6FHr7P6ToWEORwyYiPVfAmzhA70xF+0y/oNvj7gbNTLJJ6jNzkBjuMbRlu564TAmgYZLc5OyHURA==", "dev": true, "license": "apache-2.0", "dependencies": { @@ -1157,146 +1443,524 @@ "dev": true, "license": "MIT" }, - "node_modules/@shikijs/core": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", - "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/primitive": "4.4.3", - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5", - "hast-util-to-html": "^9.0.5" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", - "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.6" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", - "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/langs": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", - "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/primitive": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", - "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/themes": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", - "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "4.4.3" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/transformers": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.4.3.tgz", - "integrity": "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@shikijs/core": "4.4.3", - "@shikijs/types": "4.4.3" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/types": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", - "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.5" - }, + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@types/node": "*" + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/hast": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", - "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@types/unist": "*" + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@shikijs/core": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/transformers": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.5.0.tgz", + "integrity": "sha512-LM7twi043qCC3gRyGQfjoC2/sowzbEltWuruyRtT3RlBlk1hKzWk9ETnU7RcDBoVWQBmC8Mebnr7p28W7kPoaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.5.0", + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" } }, "node_modules/@types/mdast": { @@ -1310,9 +1974,9 @@ } }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { @@ -1320,9 +1984,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1330,13 +1994,13 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/unist": { @@ -1353,6 +2017,69 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1366,6 +2093,27 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1402,6 +2150,16 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -1460,6 +2218,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -1524,9 +2292,9 @@ } }, "node_modules/cron-parser": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.0.tgz", - "integrity": "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.1.tgz", + "integrity": "sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==", "dev": true, "license": "MIT", "dependencies": { @@ -1536,6 +2304,35 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1543,6 +2340,27 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1620,6 +2438,14 @@ "node": ">=0.3.1" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1630,6 +2456,36 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1640,6 +2496,34 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1654,6 +2538,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -1710,15 +2610,28 @@ } }, "node_modules/hono": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", - "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -1765,6 +2678,353 @@ "dev": true, "license": "ISC" }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lru_map": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", @@ -1772,6 +3032,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -1782,6 +3052,27 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -1804,6 +3095,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -1882,9 +3180,9 @@ "license": "MIT" }, "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", "dev": true, "funding": [ { @@ -1928,6 +3226,26 @@ "dev": true, "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1948,6 +3266,20 @@ "node": ">=10" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1977,6 +3309,80 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2002,7 +3408,23 @@ "prebuild-install": "bin.js" }, "engines": { - "node": ">=10" + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, "node_modules/property-information": { @@ -2027,6 +3449,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2044,28 +3476,34 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", - "peer": true, "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -2180,6 +3618,51 @@ "dev": true, "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2201,12 +3684,24 @@ ], "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" }, "node_modules/semver": { "version": "7.8.5", @@ -2221,19 +3716,42 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", - "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/core": "4.4.3", - "@shikijs/engine-javascript": "4.4.3", - "@shikijs/engine-oniguruma": "4.4.3", - "@shikijs/langs": "4.4.3", - "@shikijs/themes": "4.4.3", - "@shikijs/types": "4.4.3", + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" }, @@ -2299,6 +3817,16 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -2310,6 +3838,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -2346,9 +3881,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "dev": true, "license": "MIT", "funding": { @@ -2386,6 +3921,89 @@ "node": ">=6" } }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -2431,6 +4049,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2607,6 +4235,245 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2614,6 +4481,32 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/plugins/thread-badges/package.json b/plugins/thread-badges/package.json index b7b8ffe..3f6f974 100644 --- a/plugins/thread-badges/package.json +++ b/plugins/thread-badges/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-thread-badges", - "version": "0.3.0", + "version": "0.4.0", "private": true, "license": "MIT", "type": "module", @@ -23,12 +23,12 @@ "url": "https://github.com/matthewdias/bb-plugins/issues" }, "engines": { - "bb": ">=0.36", - "bbPluginSdk": ">=0.4.47" + "bb": ">=0.45", + "bbPluginSdk": ">=0.6.15" }, "bb": { "name": "Thread Badges", - "description": "Shows pull-request state, CI attention and follow-up progress on sidebar rows, so you see what needs you without opening a thread.", + "description": "Shows pull-request state, CI attention, listening ports and badges other plugins publish, like Follow Up's progress, on sidebar rows, so you see what needs you without opening a thread.", "branding": { "icon": "Circle" }, @@ -39,10 +39,11 @@ "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", "@radix-ui/react-checkbox": "^1.3.7", - "@radix-ui/react-slot": "^1.3.0" + "@radix-ui/react-slot": "^1.3.0", + "zod": "^4.6.5" }, "devDependencies": { - "@get-bb/plugin-sdk": "0.4.47", + "@get-bb/plugin-sdk": "0.6.15", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.0.0", "@types/react": "^19.0.0", @@ -66,11 +67,17 @@ "clsx": "^2.1.1", "sonner": "^1.7.4", "tailwind-merge": "^3.4.0", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "@testing-library/react": "^16.3.3", + "jsdom": "^30.1.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "vitest": "^5.0.3" }, "scripts": { "build": "bb plugin build", "typecheck": "tsc --noEmit", + "test": "vitest run", "dev": "bb plugin dev ." } } diff --git a/plugins/thread-badges/server.ts b/plugins/thread-badges/server.ts index 6c991b4..3685a12 100644 --- a/plugins/thread-badges/server.ts +++ b/plugins/thread-badges/server.ts @@ -1,10 +1,17 @@ -// Thread Badges — a backend that exists only to hold settings. +// Thread Badges — a backend that holds settings. // // Everything visible happens in the app: an overlay finds the sidebar's thread -// rows and portals badges into them. The switches are derived from the badge -// catalog rather than written out here, so a new badge type contributes its -// settings by existing. -import type { BbPluginApi, PluginSettingDescriptors } from "@get-bb/plugin-sdk"; +// rows and portals badges into them. Two kinds of setting live here. +// +// The built-in badges' switches are bb settings, derived from the badge +// catalog, so a new built-in type contributes its settings by existing. +// +// A complication from another plugin cannot be a bb setting: settings are +// declared when this backend loads, and providers are discovered later, in the +// app. Their settings live in this plugin's own storage instead, read and +// written through the two methods below, and every write tells every window. +import { defineRpcContract, type BbPluginApi, type PluginSettingDescriptors } from "@get-bb/plugin-sdk"; +import { z } from "zod"; import { BADGE_TYPES, DEFAULT_MAX_BADGES, @@ -14,6 +21,45 @@ import { enabledKey, priorityKey, } from "./badges/catalog"; +import { + COMPLICATION_ID, + MAX_STORED_PREFS, + PREFS_CHANGED, + normalizePrefsMap, + prefsFor, +} from "./badges/complication-prefs"; + +/** The storage key for every complication's settings, one map. */ +const PREFS_KEY = "complication-prefs"; + +const prefsSchema = z + .object({ + enabled: z.boolean(), + priority: z.number(), + showText: z.boolean(), + hideWhenComplete: z.boolean(), + }) + .strict(); + +const prefsMapSchema = z.object({ prefs: z.record(z.string(), prefsSchema) }).strict(); + +export const rpcContract = defineRpcContract({ + /** Every stored complication's settings. Ones never touched are absent. */ + complicationPrefs_list: { + input: z.object({}).strict(), + output: prefsMapSchema, + }, + /** Change some of one complication's settings; the rest keep their values. */ + complicationPrefs_set: { + input: z + .object({ + id: z.string().regex(COMPLICATION_ID), + prefs: prefsSchema.partial(), + }) + .strict(), + output: prefsMapSchema, + }, +}); export default function plugin(bb: BbPluginApi) { const descriptors: PluginSettingDescriptors = { @@ -49,5 +95,24 @@ export default function plugin(bb: BbPluginApi) { } } bb.settings.define(descriptors); - bb.log.info(`loaded with ${BADGE_TYPES.length} badge type(s)`); + + const readPrefs = async () => normalizePrefsMap(await bb.storage.kv.get(PREFS_KEY)); + + bb.rpc.register(rpcContract, { + complicationPrefs_list: async () => ({ prefs: await readPrefs() }), + complicationPrefs_set: async ({ id, prefs: patch }) => { + const prefs = await readPrefs(); + if (!Object.prototype.hasOwnProperty.call(prefs, id) && Object.keys(prefs).length >= MAX_STORED_PREFS) { + throw new Error(`Settings are already stored for ${MAX_STORED_PREFS} complications.`); + } + prefs[id] = { ...prefsFor(prefs, id), ...patch }; + await bb.storage.kv.set(PREFS_KEY, prefs); + // Every window draws from these, and a window hears only this plugin's + // own signals — which this is. + bb.realtime.publish(PREFS_CHANGED, { id }); + return { prefs }; + }, + }); + + bb.log.info(`loaded with ${BADGE_TYPES.length} built-in badge type(s)`); } diff --git a/plugins/thread-badges/tests/complication-prefs.test.ts b/plugins/thread-badges/tests/complication-prefs.test.ts new file mode 100644 index 0000000..fb883c9 --- /dev/null +++ b/plugins/thread-badges/tests/complication-prefs.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_COMPLICATION_PRIORITY, + defaultPrefs, + normalizePrefs, + normalizePrefsMap, + prefsFor, +} from "../badges/complication-prefs"; +import { BADGE_TYPES } from "../badges/catalog"; + +describe("complication settings", () => { + it("default to off, after the built-in badges", () => { + expect(defaultPrefs()).toEqual({ + enabled: false, + priority: BADGE_TYPES.length + 1, + showText: false, + hideWhenComplete: false, + }); + expect(DEFAULT_COMPLICATION_PRIORITY).toBe(BADGE_TYPES.length + 1); + }); + + it("take each malformed or missing field from the defaults, keeping the good ones", () => { + expect(normalizePrefs({ enabled: true, priority: "1", showText: 1 })).toEqual({ + ...defaultPrefs(), + enabled: true, + }); + expect(normalizePrefs({ priority: Number.POSITIVE_INFINITY })).toEqual(defaultPrefs()); + expect(normalizePrefs({ priority: 2.5, hideWhenComplete: true })).toEqual({ + ...defaultPrefs(), + priority: 2.5, + hideWhenComplete: true, + }); + expect(normalizePrefs(null)).toEqual(defaultPrefs()); + expect(normalizePrefs([true])).toEqual(defaultPrefs()); + }); + + it("drop stored entries whose id is not /", () => { + expect( + Object.keys( + normalizePrefsMap({ "follow-up/progress": {}, progress: {}, "A/b": {}, "a/b/c": {} }), + ), + ).toEqual(["follow-up/progress"]); + expect(normalizePrefsMap("nonsense")).toEqual({}); + }); + + it("read the defaults for a complication with nothing stored, without inheriting from the prototype", () => { + expect(prefsFor({}, "x/y")).toEqual(defaultPrefs()); + expect(prefsFor({}, "constructor")).toEqual(defaultPrefs()); + }); +}); diff --git a/plugins/thread-badges/tests/complication-view.test.ts b/plugins/thread-badges/tests/complication-view.test.ts new file mode 100644 index 0000000..1ef61ea --- /dev/null +++ b/plugins/thread-badges/tests/complication-view.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { complicationView, toneColor, TONE_COLORS } from "../badges/complication-view"; + +const prefs = { showText: false, hideWhenComplete: false }; +const gauge = (fraction: number, extra: object = {}) => ({ + icon: "TextWrap", + label: "Progress", + fraction, + ...extra, +}); + +describe("complicationView", () => { + it("draws nothing for an unanswered or silent value", () => { + expect(complicationView(undefined, prefs)).toBeNull(); + expect(complicationView(null, prefs)).toBeNull(); + }); + + it("draws a fraction as a ring, coloured by tone", () => { + expect(complicationView(gauge(0.25, { tone: "default" }), prefs)).toEqual({ + kind: "ring", + fraction: 0.25, + label: "Progress", + color: TONE_COLORS.default, + running: false, + text: null, + }); + expect(complicationView(gauge(1, { tone: "success" }), prefs)?.color).toBe(TONE_COLORS.success); + }); + + it("draws anything else as the provider's icon", () => { + expect( + complicationView({ icon: "Plug", label: "vite :5173", tone: "error" }, prefs), + ).toEqual({ + kind: "glyph", + icon: "Plug", + label: "vite :5173", + color: TONE_COLORS.error, + running: false, + text: null, + }); + }); + + it("shows text only when asked, and only when the value has some", () => { + const value = gauge(0.5, { text: "3" }); + expect(complicationView(value, prefs)?.text).toBeNull(); + expect(complicationView(value, { ...prefs, showText: true })?.text).toBe("3"); + expect(complicationView(gauge(0.5), { ...prefs, showText: true })?.text).toBeNull(); + }); + + it("hides a full gauge only when asked, and never a glyph", () => { + const hide = { ...prefs, hideWhenComplete: true }; + expect(complicationView(gauge(1), prefs)).not.toBeNull(); + expect(complicationView(gauge(1), hide)).toBeNull(); + expect(complicationView(gauge(0.99), hide)).not.toBeNull(); + expect(complicationView({ icon: "Check", label: "Done", tone: "success" }, hide)).not.toBeNull(); + }); + + it("marks a running value so it can pulse", () => { + expect(complicationView({ icon: "Clock", label: "Building", tone: "running" }, prefs)?.running).toBe(true); + expect(complicationView(gauge(0.5, { tone: "running" }), prefs)?.running).toBe(true); + }); +}); + +describe("toneColor", () => { + it("covers the whole vocabulary, and draws an unknown tone as default", () => { + for (const tone of ["default", "info", "success", "warning", "error", "running"]) { + expect(toneColor(tone)).toBe(TONE_COLORS[tone]); + } + expect(toneColor("urgent")).toBe(TONE_COLORS.default); + expect(toneColor(undefined)).toBe(TONE_COLORS.default); + expect(toneColor("toString")).toBe(TONE_COLORS.default); + }); +}); diff --git a/plugins/thread-badges/tests/order.test.ts b/plugins/thread-badges/tests/order.test.ts new file mode 100644 index 0000000..48554e1 --- /dev/null +++ b/plugins/thread-badges/tests/order.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { BADGE_TYPES, enabledKey, priorityKey } from "../badges/catalog"; +import { DEFAULT_COMPLICATION_PRIORITY, defaultPrefs } from "../badges/complication-prefs"; +import { orderedEntries, type RowEntry } from "../badges/order"; + +const keys = (entries: readonly RowEntry[]) => entries.map((entry) => entry.key); +const on = (priority = DEFAULT_COMPLICATION_PRIORITY) => ({ ...defaultPrefs(), enabled: true, priority }); + +describe("orderedEntries", () => { + it("draws the enabled built-ins in catalog order when nothing has been touched", () => { + expect(keys(orderedEntries({}, [], {}))).toEqual([ + "builtin:pullRequest", + "builtin:prChecks", + ]); + }); + + it("leaves out a provider's complication until it is turned on", () => { + expect(keys(orderedEntries({}, ["follow-up/progress"], {}))).not.toContain( + "complication:follow-up/progress", + ); + expect( + keys(orderedEntries({}, ["follow-up/progress"], { "follow-up/progress": on() })), + ).toEqual(["builtin:pullRequest", "builtin:prChecks", "complication:follow-up/progress"]); + }); + + it("leaves out a complication whose plugin is not running, however it is set", () => { + expect(keys(orderedEntries({}, [], { "follow-up/progress": on(0) }))).not.toContain( + "complication:follow-up/progress", + ); + }); + + it("puts built-ins and complications on one priority scale", () => { + const entries = orderedEntries( + { [priorityKey("pullRequest")]: 5 }, + ["follow-up/progress"], + { "follow-up/progress": on(1) }, + ); + expect(keys(entries)).toEqual([ + "complication:follow-up/progress", + "builtin:prChecks", + "builtin:pullRequest", + ]); + }); + + it("breaks ties built-ins first, then catalog and discovery order", () => { + const entries = orderedEntries( + Object.fromEntries(BADGE_TYPES.map((type) => [priorityKey(type.id), 1])), + ["b/second", "a/first"], + { "a/first": on(1), "b/second": on(1) }, + ); + expect(keys(entries)).toEqual([ + "builtin:pullRequest", + "builtin:prChecks", + "complication:b/second", + "complication:a/first", + ]); + }); + + it("drops a built-in that is switched off", () => { + expect(keys(orderedEntries({ [enabledKey("prChecks")]: false }, [], {}))).toEqual([ + "builtin:pullRequest", + ]); + }); + + it("hands each complication entry its own settings", () => { + const prefs = { ...on(), showText: true }; + const [entry] = orderedEntries({ [enabledKey("pullRequest")]: false, [enabledKey("prChecks")]: false }, ["x/y"], { + "x/y": prefs, + }); + expect(entry).toEqual({ kind: "complication", key: "complication:x/y", id: "x/y", prefs }); + }); +}); diff --git a/plugins/thread-badges/tests/server.test.ts b/plugins/thread-badges/tests/server.test.ts new file mode 100644 index 0000000..bd61373 --- /dev/null +++ b/plugins/thread-badges/tests/server.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import plugin from "../server"; +import { DEFAULT_COMPLICATION_PRIORITY, MAX_STORED_PREFS, PREFS_CHANGED } from "../badges/complication-prefs"; + +async function host() { + const fake = createFakePluginHost(); + await plugin(fake.bb); + return fake; +} + +const list = async (harness: Awaited>["harness"]) => + ((await harness.callRpc("complicationPrefs_list", {})) as { prefs: Record }).prefs; + +describe("settings", () => { + it("declares the built-in badges' switches and nothing for follow-ups any more", async () => { + const { harness } = await host(); + const keys = Object.keys(harness.registrations.settingsDescriptors); + expect(keys).toContain("show_pullRequest"); + expect(keys).toContain("ports_priority"); + expect(keys.filter((key) => key.toLowerCase().includes("followups"))).toEqual([]); + }); +}); + +describe("complication settings", () => { + it("starts empty: nothing is turned on until you turn it on", async () => { + const { harness } = await host(); + expect(await list(harness)).toEqual({}); + }); + + it("fills in the rest from defaults when one field is set, and keeps earlier fields", async () => { + const { harness } = await host(); + await harness.callRpc("complicationPrefs_set", { id: "follow-up/progress", prefs: { enabled: true } }); + await harness.callRpc("complicationPrefs_set", { id: "follow-up/progress", prefs: { showText: true } }); + expect(await list(harness)).toEqual({ + "follow-up/progress": { + enabled: true, + priority: DEFAULT_COMPLICATION_PRIORITY, + showText: true, + hideWhenComplete: false, + }, + }); + }); + + it("tells every window after a write", async () => { + const { harness } = await host(); + await harness.callRpc("complicationPrefs_set", { id: "follow-up/progress", prefs: { enabled: true } }); + expect(harness.realtimeSignals).toEqual([ + { channel: PREFS_CHANGED, payload: { id: "follow-up/progress" } }, + ]); + }); + + it("keeps settings across a reload", async () => { + const { harness } = await host(); + await harness.callRpc("complicationPrefs_set", { id: "follow-up/progress", prefs: { priority: 1 } }); + const reloaded = await harness.reload(plugin); + expect(await list(reloaded.harness)).toMatchObject({ "follow-up/progress": { priority: 1 } }); + }); + + it("refuses an id that is not /, and a field it does not know", async () => { + const { harness } = await host(); + await expect( + harness.callRpc("complicationPrefs_set", { id: "progress", prefs: { enabled: true } }), + ).rejects.toThrow(); + await expect( + harness.callRpc("complicationPrefs_set", { id: "a/b", prefs: { enabled: "yes" } }), + ).rejects.toThrow(); + await expect( + harness.callRpc("complicationPrefs_set", { id: "a/b", prefs: { urgent: true } }), + ).rejects.toThrow(); + expect(await list(harness)).toEqual({}); + expect(harness.realtimeSignals).toEqual([]); + }); + + it("stops storing new ids at the cap, but still updates ones it has", async () => { + const { harness } = await host(); + for (let index = 0; index < MAX_STORED_PREFS; index++) { + await harness.callRpc("complicationPrefs_set", { id: `p${index}/c`, prefs: { enabled: true } }); + } + await expect( + harness.callRpc("complicationPrefs_set", { id: "one/more", prefs: { enabled: true } }), + ).rejects.toThrow(/already stored/); + await expect( + harness.callRpc("complicationPrefs_set", { id: "p0/c", prefs: { enabled: false } }), + ).resolves.toBeDefined(); + }); +}); diff --git a/plugins/thread-badges/tests/ui/complication-badge.test.tsx b/plugins/thread-badges/tests/ui/complication-badge.test.tsx new file mode 100644 index 0000000..547c38d --- /dev/null +++ b/plugins/thread-badges/tests/ui/complication-badge.test.tsx @@ -0,0 +1,92 @@ +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import { ComplicationBadge } from "../../badges/complication-badge"; +import { defaultPrefs, type ComplicationPrefs } from "../../badges/complication-prefs"; +import { getComplications, type ComplicationProviderHandle } from "../../lib/complications"; + +// The window's registry outlives each render, as it does in bb, so every test +// withdraws what it provided. +const registry = getComplications()!; +const handles: ComplicationProviderHandle[] = []; +afterEach(() => { + for (const handle of handles.splice(0)) handle.dispose(); +}); + +let providers = 0; +function provider() { + const id = `test/badge${++providers}`; + const handle = registry.provide({ id, name: "Test" }); + handles.push(handle); + return { id, handle }; +} + +const THREAD = { kind: "thread", id: "thr_badge" } as const; + +function renderBadge(id: string, prefs: Partial = {}) { + return renderSlot( + { component: ComplicationBadge }, + { id, threadId: THREAD.id, prefs: { ...defaultPrefs(), enabled: true, ...prefs } }, + ); +} + +describe("ComplicationBadge", () => { + it("draws a gauge as a ring named by its label, and redraws when the provider publishes", async () => { + const { id, handle } = provider(); + handle.set(THREAD, { icon: "TextWrap", label: "1 of 4 done", fraction: 0.25 }); + const slot = renderBadge(id); + const badge = slot.getByLabelText("1 of 4 done"); + expect(badge.querySelector("svg circle")).not.toBeNull(); + + await act(async () => { + handle.set(THREAD, { icon: "TextWrap", label: "2 of 4 done", fraction: 0.5 }); + }); + expect(slot.getByLabelText("2 of 4 done")).toBeTruthy(); + }); + + it("draws nothing at all when the provider has nothing to say", async () => { + const { id, handle } = provider(); + const slot = renderBadge(id); + expect(slot.container.childElementCount).toBe(0); + await act(async () => { + handle.set(THREAD, null); + }); + expect(slot.container.childElementCount).toBe(0); + }); + + it("draws a value without a fraction as the provider's icon, with text when asked", () => { + const { id, handle } = provider(); + handle.set(THREAD, { icon: "Plug", label: "vite :5173", text: ":5173", tone: "success" }); + const plain = renderBadge(id); + expect(plain.getByLabelText("vite :5173").querySelector("svg circle")).toBeNull(); + expect(plain.queryByText(":5173")).toBeNull(); + plain.unmount(); + + const withText = renderBadge(id, { showText: true }); + expect(withText.getByText(":5173")).toBeTruthy(); + }); + + it("marks a running value for the pulse, and hides a full gauge when asked", () => { + const { id, handle } = provider(); + handle.set(THREAD, { icon: "Clock", label: "Building", tone: "running" }); + const running = renderBadge(id); + expect(running.getByLabelText("Building").hasAttribute("data-thread-badges-running")).toBe(true); + running.unmount(); + + handle.set(THREAD, { icon: "TextWrap", label: "All done", fraction: 1 }); + expect(renderBadge(id, { hideWhenComplete: true }).container.childElementCount).toBe(0); + }); + + it("asks the provider for the thread it draws", async () => { + const asked: string[] = []; + const id = `test/badge${++providers}`; + handles.push( + registry.provide({ id, name: "Test", onWanted: (subjects) => asked.push(...subjects.map((s) => s.id)) }), + ); + renderBadge(id); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(asked).toEqual([THREAD.id]); + }); +}); diff --git a/plugins/thread-badges/tests/ui/complication-settings.test.tsx b/plugins/thread-badges/tests/ui/complication-settings.test.tsx new file mode 100644 index 0000000..62b28e6 --- /dev/null +++ b/plugins/thread-badges/tests/ui/complication-settings.test.tsx @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, waitFor } from "@testing-library/react"; +import { renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import { defaultPrefs, PREFS_CHANGED, type ComplicationPrefs } from "../../badges/complication-prefs"; +import { getComplications, type ComplicationProviderHandle } from "../../lib/complications"; +// Imported once here, at collection, though every test imports it afresh below. The +// first load pulls in every hugeicons icon through the checkbox, which can outlast a +// test's timeout on a busy machine; Node keeps that package cached, so the per-test +// imports after vi.resetModules re-run only this plugin's own modules. +import "../../badges/complication-settings"; + +type Prefs = Record; + +const registry = getComplications()!; +const handles: ComplicationProviderHandle[] = []; +afterEach(() => { + for (const handle of handles.splice(0)) handle.dispose(); +}); + +// The settings store is module scope, shared by every surface in a window. A +// fresh module per test keeps one test's loaded settings out of the next. +beforeEach(() => { + vi.resetModules(); +}); + +function provide(id = "follow-up/progress") { + handles.push( + registry.provide({ + id, + name: "Follow-up progress", + description: "How much of a thread's follow-up list is closed.", + subjects: ["thread"], + sample: { icon: "TextWrap", label: "3 of 4 follow-ups done", fraction: 0.75, text: "1" }, + }), + ); +} + +/** A backend that keeps what it is told, like the real one. */ +function backend(initial: Prefs = {}, options: { list?: () => Promise<{ prefs: Prefs }> } = {}) { + let stored: Prefs = { ...initial }; + return { + complicationPrefs_list: options.list ?? (async () => ({ prefs: stored })), + complicationPrefs_set: async ({ id, prefs }: { id: string; prefs: Partial }) => { + stored = { ...stored, [id]: { ...(stored[id] ?? defaultPrefs()), ...prefs } }; + return { prefs: stored }; + }, + }; +} + +async function renderSettings(rpc: ReturnType) { + const { ComplicationSettings } = await import("../../badges/complication-settings"); + const slot = renderSlot({ component: ComplicationSettings }, {}, { rpc: rpc as never }); + const sets = () => + slot.inspection.rpcCalls.filter((call) => call.method === "complicationPrefs_set").map((call) => call.input); + return { slot, sets }; +} + +describe("ComplicationSettings", () => { + it("says what would appear here when no plugin is providing anything", async () => { + const { slot } = await renderSettings(backend()); + expect(slot.getByText(/No plugin is publishing thread complications/)).toBeTruthy(); + }); + + it("lists a provider off by default, with its description and a preview", async () => { + provide(); + const { slot } = await renderSettings(backend()); + const toggle = await waitFor(() => { + const element = slot.getByRole("checkbox", { name: "Follow-up progress" }); + expect(element.hasAttribute("disabled")).toBe(false); + return element; + }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(slot.getByText("How much of a thread's follow-up list is closed.")).toBeTruthy(); + expect(slot.getByLabelText("3 of 4 follow-ups done")).toBeTruthy(); + expect(slot.queryByLabelText("Priority")).toBeNull(); + }); + + it("cannot be flipped before your stored settings arrive", async () => { + provide(); + const { slot, sets } = await renderSettings(backend({}, { list: () => new Promise(() => undefined) })); + const toggle = slot.getByRole("checkbox", { name: "Follow-up progress" }); + expect(toggle.hasAttribute("disabled")).toBe(true); + fireEvent.click(toggle); + expect(sets()).toEqual([]); + }); + + it("turns a provider on, then offers its priority and options", async () => { + provide(); + const { slot, sets } = await renderSettings(backend()); + const toggle = await waitFor(() => { + const element = slot.getByRole("checkbox", { name: "Follow-up progress" }); + expect(element.hasAttribute("disabled")).toBe(false); + return element; + }); + fireEvent.click(toggle); + await waitFor(() => expect(slot.getByLabelText("Priority")).toBeTruthy()); + expect(sets()).toEqual([{ id: "follow-up/progress", prefs: { enabled: true } }]); + + fireEvent.click(slot.getByRole("checkbox", { name: "Show its text beside it" })); + await waitFor(() => expect(sets()).toHaveLength(2)); + expect(sets()[1]).toEqual({ id: "follow-up/progress", prefs: { showText: true } }); + }); + + it("commits a priority on blur, and puts back anything that is not a number", async () => { + provide(); + const { slot, sets } = await renderSettings( + backend({ "follow-up/progress": { ...defaultPrefs(), enabled: true } }), + ); + const priority = (await waitFor(() => slot.getByLabelText("Priority"))) as HTMLInputElement; + + fireEvent.change(priority, { target: { value: "" } }); + fireEvent.blur(priority); + expect(priority.value).toBe(String(defaultPrefs().priority)); + expect(sets()).toEqual([]); + + fireEvent.change(priority, { target: { value: "1" } }); + expect(sets()).toEqual([]); + fireEvent.keyDown(priority, { key: "Enter" }); + await waitFor(() => expect(sets()).toEqual([{ id: "follow-up/progress", prefs: { priority: 1 } }])); + }); + + it("says so when a change cannot be saved", async () => { + provide(); + const rpc = backend(); + rpc.complicationPrefs_set = async () => { + throw new Error("disk full"); + }; + const { slot } = await renderSettings(rpc); + const toggle = await waitFor(() => { + const element = slot.getByRole("checkbox", { name: "Follow-up progress" }); + expect(element.hasAttribute("disabled")).toBe(false); + return element; + }); + fireEvent.click(toggle); + await waitFor(() => expect(slot.getByRole("alert").textContent).toMatch(/Could not save: .*disk full/)); + }); + + it("re-reads when another window changes a setting", async () => { + provide(); + const rpc = backend(); + const { slot } = await renderSettings(rpc); + await waitFor(() => expect(slot.getByRole("checkbox", { name: "Follow-up progress" }).hasAttribute("disabled")).toBe(false)); + await rpc.complicationPrefs_set({ id: "follow-up/progress", prefs: { enabled: true } }); + await slot.behavior.emitRealtime(PREFS_CHANGED, { id: "follow-up/progress" }); + await waitFor(() => + expect(slot.getByRole("checkbox", { name: "Follow-up progress" }).getAttribute("aria-checked")).toBe("true"), + ); + }); +}); diff --git a/plugins/thread-badges/tests/ui/setup.ts b/plugins/thread-badges/tests/ui/setup.ts new file mode 100644 index 0000000..90849e8 --- /dev/null +++ b/plugins/thread-badges/tests/ui/setup.ts @@ -0,0 +1,6 @@ +// Testing Library unmounts between tests only when vitest globals are on, and +// they are not, so a later query would otherwise find an earlier render too. +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); diff --git a/plugins/thread-badges/tsconfig.json b/plugins/thread-badges/tsconfig.json index 92fe185..018a04f 100644 --- a/plugins/thread-badges/tsconfig.json +++ b/plugins/thread-badges/tsconfig.json @@ -25,6 +25,9 @@ "app.tsx", "components", "lib", - "hooks" + "hooks", + "badges", + "tests", + "vitest.config.ts" ] } diff --git a/plugins/thread-badges/vitest.config.ts b/plugins/thread-badges/vitest.config.ts new file mode 100644 index 0000000..0dbb763 --- /dev/null +++ b/plugins/thread-badges/vitest.config.ts @@ -0,0 +1,17 @@ +// Every test runs here, not on `node --test`: this plugin's modules import one +// another without file extensions, which only a bundler resolves. jsdom +// throughout, because the badge and settings tests render through the SDK's +// testing/app harness; the server test asks for node in its own header. +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { "@": fileURLToPath(new URL(".", import.meta.url)) }, + }, + test: { + environment: "jsdom", + include: ["tests/**/*.test.{ts,tsx}"], + setupFiles: ["tests/ui/setup.ts"], + }, +}); diff --git a/scripts/check-vendored.mjs b/scripts/check-vendored.mjs new file mode 100644 index 0000000..6845680 --- /dev/null +++ b/scripts/check-vendored.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// Fail when a vendored module's copies have drifted apart. +// +// bb installs each plugin from its own subdirectory, so code two plugins share +// cannot live in a workspace package. A published one would work, but bb +// bundles it into each plugin all the same, so for now it is simply copied +// into each. Those copies are one contract, and the complications registry is +// the sharpest case: whichever bundle loads first creates the registry that +// every other bundle then calls, so two copies that disagree are two plugins +// that disagree about the protocol, at runtime, depending on load order. +// +// Each entry lists every copy. Edit one, then copy it over the rest. +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const VENDORED = [ + ["plugins/follow-up/lib/complications.ts", "plugins/thread-badges/lib/complications.ts"], +]; + +let drifted = false; +for (const [canonical, ...copies] of VENDORED) { + const expected = readFileSync(join(REPO, canonical), "utf8"); + for (const copy of copies) { + if (readFileSync(join(REPO, copy), "utf8") === expected) continue; + drifted = true; + console.error(`${copy} has drifted from ${canonical}. Copy one over the other.`); + } +} +if (drifted) process.exit(1);