Rindle

API index and search · Build metadata

Source snapshot

packages/react-devtools/src/index.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
1// @rindle/react-devtools — a floating, dev-only panel over `@rindle/devtools`2// (DEBUG-TOOLS-BROWSER-DESIGN.md §6.1). It discovers a running client through the global hub (or an3// explicit `core` prop) and renders the §4 panes: the mutation TIMELINE (the fork/rebase loop, with4// the snap-back highlight), the QUERIES inspector, and the live DELTA stream. Pure React + inline5// styles — no CSS import. Like `@rindle/react` it uses `createElement` rather than JSX (Node's test6// runner strips TS types but not JSX). Mount it once near your app root in development:7//8//   {import.meta.env.DEV && createElement(RindleDevtools)}9//10// and attach a client in dev: `import("@rindle/devtools").then(d => d.attachDevtools(app))`.1112import { createElement as h, useCallback, useMemo, useState, useSyncExternalStore } from "react";13import type { CSSProperties, ReactNode } from "react";14import {15  getDevtoolsHub,16  type DeltaEntry,17  type DeltaKind,18  type DevtoolsCore,19  type DevtoolsState,20  type MutationState,21  type QueryEntry,22  type ResultType,23  type TimelineEntry,24} from "@rindle/devtools";2526export interface RindleDevtoolsProps {27  /** Bind to a specific core. Omit to auto-discover the most recently attached client via the28   *  global hub (the common case). */29  core?: DevtoolsCore;30  /** Open the panel on first mount. Default false (starts as the launcher button). */31  defaultOpen?: boolean;32}3334/** The floating Rindle devtools panel. Renders just a launcher until a client is attached via35 *  `attachDevtools(app)`. */36export function RindleDevtools({ core: explicit, defaultOpen = false }: RindleDevtoolsProps): ReactNode {37  const core = useDiscoveredCore(explicit);38  const [open, setOpen] = useState(defaultOpen);39  if (!open) {40    return h(41      "div",42      { style: S.launcher },43      h("button", { type: "button", style: S.launchBtn, onClick: () => setOpen(true), title: "Open Rindle devtools" }, "🌊 Rindle"),44    );45  }46  return h(Panel, { core, onClose: () => setOpen(false) });47}4849/** Discover the active core: an explicit prop wins; otherwise track the global hub's latest core,50 *  re-rendering when a client attaches/detaches (so the panel can be mounted before the app). */51function useDiscoveredCore(explicit?: DevtoolsCore): DevtoolsCore | undefined {52  const hub = useMemo(() => getDevtoolsHub(), []);53  const subscribe = useCallback((cb: () => void) => hub.subscribe(cb), [hub]);54  const latest = useCallback(() => hub.cores[hub.cores.length - 1], [hub]);55  const discovered = useSyncExternalStore(subscribe, latest, () => undefined);56  return explicit ?? discovered;57}5859type Tab = "timeline" | "queries" | "deltas";6061function Panel({ core, onClose }: { core: DevtoolsCore | undefined; onClose: () => void }): ReactNode {62  const [tab, setTab] = useState<Tab>("timeline");63  return h(64    "div",65    { style: S.panel },66    h(67      "div",68      { style: S.header },69      h("strong", { style: { fontSize: 12 } }, "🌊 Rindle devtools"),70      h("div", { style: { flex: 1 } }),71      h("button", { type: "button", style: S.iconBtn, onClick: onClose, title: "Close" }, "✕"),72    ),73    core74      ? h(CorePanel, { core, tab, setTab })75      : h(76          "div",77          { style: S.empty },78          "No Rindle client attached. ",79          h("br"),80          "Call ",81          h("code", { style: S.code }, "attachDevtools(app)"),82          " from ",83          h("code", { style: S.code }, "@rindle/devtools"),84          " in dev.",85        ),86  );87}8889function CorePanel({ core, tab, setTab }: { core: DevtoolsCore; tab: Tab; setTab: (t: Tab) => void }): ReactNode {90  const subscribe = useCallback((cb: () => void) => core.subscribe(cb), [core]);91  const getState = useCallback(() => core.getState(), [core]);92  const state = useSyncExternalStore(subscribe, getState, getState);93  const pendingCount = state.timeline.filter((t) => t.state === "pending").length;9495  return h(96    "div",97    { style: { display: "contents" } },98    h(99      "div",100      { style: S.tabs },101      h(TabButton, { active: tab === "timeline", onClick: () => setTab("timeline") }, "Timeline ", pendingCount > 0 ? h("span", { style: S.dot }, pendingCount) : null),102      h(TabButton, { active: tab === "queries", onClick: () => setTab("queries") }, "Queries ", h("span", { style: S.count }, state.queries.length)),103      h(TabButton, { active: tab === "deltas", onClick: () => setTab("deltas") }, "Deltas ", h("span", { style: S.count }, state.deltas.length)),104    ),105    h(106      "div",107      { style: S.body },108      tab === "timeline" ? h(TimelinePane, { state, core }) : null,109      tab === "queries" ? h(QueriesPane, { state }) : null,110      tab === "deltas" ? h(DeltasPane, { state, core }) : null,111    ),112  );113}114115// --- Timeline (§4.1) ---------------------------------------------------------116117function TimelinePane({ state, core }: { state: DevtoolsState; core: DevtoolsCore }): ReactNode {118  if (!state.capabilities.optimistic) {119    return h("div", { style: S.empty }, "This backend has no optimistic loop — the mutation timeline is empty.");120  }121  const rows = [...state.timeline].reverse(); // newest first122  const o = state.optimistic;123  return h(124    "div",125    null,126    h(127      "div",128      { style: S.toolbar },129      h(130        "span",131        { style: S.muted },132        `confirmed lmid ${o?.confirmedLmid ?? 0} · next mid ${o?.nextMid ?? 1}${o && o.bufferedFrames > 0 ? ` · ${o.bufferedFrames} buffered` : ""}`,133      ),134      h("div", { style: { flex: 1 } }),135      h("button", { type: "button", style: S.smallBtn, onClick: () => core.clearHistory() }, "clear settled"),136    ),137    rows.length === 0138      ? h("div", { style: S.empty }, "No mutations yet. Invoke a mutator to watch the fork/rebase loop.")139      : rows.map((t) => h(TimelineRow, { key: t.id, t })),140  );141}142143function TimelineRow({ t }: { t: TimelineEntry }): ReactNode {144  const [open, setOpen] = useState(false);145  const elapsed = (t.settledAt ?? Date.now()) - t.invokedAt;146  return h(147    "div",148    { style: S.row },149    h(150      "div",151      { style: S.rowHead, onClick: () => setOpen((v) => !v) },152      h(StateBadge, { state: t.state }),153      h("span", { style: S.mono }, t.name),154      h("span", { style: S.muted }, t.mid != null ? `mid ${t.mid}` : t.folded ? "folding…" : "—"),155      t.folded ? h("span", { style: S.tag }, "folded") : null,156      t.reconciledWithChurn157        ? h(158            "span",159            { style: S.warn, title: "View churn coincided with this confirmation — a possible snap-back (prediction diverged from the server)." },160            "⚡ snap-back?",161          )162        : null,163      h("div", { style: { flex: 1 } }),164      h("span", { style: S.muted }, fmtMs(elapsed)),165    ),166    open167      ? h(168          "div",169          { style: S.rowBody },170          h(KV, { k: "args", v: h("pre", { style: S.pre }, safeJson(t.args)) }),171          h(KV, { k: "tables", v: t.tables.join(", ") || "—" }),172          h(KV, { k: "affects queries", v: t.affectedQueries.length ? t.affectedQueries.map((q) => `#${q}`).join(", ") : "—" }),173          t.fold174            ? h(KV, {175                k: "fold",176                v: `${t.fold.foldKey} — debounce ${t.fold.debounceMs}ms${t.fold.maxWaitMs ? `, maxWait ${t.fold.maxWaitMs}ms` : ""}${177                  t.fold.deferAcrossWrites ? ", deferAcrossWrites" : ""178                }${t.fold.flushed ? " (flushed)" : ""}`,179              })180            : null,181        )182      : null,183  );184}185186// --- Queries (§4.2) ----------------------------------------------------------187188function QueriesPane({ state }: { state: DevtoolsState }): ReactNode {189  if (state.queries.length === 0) return h("div", { style: S.empty }, "No materialized views are mounted.");190  return h("div", null, state.queries.map((q) => h(QueryRow, { key: q.qid, q })));191}192193function QueryRow({ q }: { q: QueryEntry }): ReactNode {194  const [open, setOpen] = useState(false);195  return h(196    "div",197    { style: S.row },198    h(199      "div",200      { style: S.rowHead, onClick: () => setOpen((v) => !v) },201      h("span", { style: S.muted }, `#${q.qid}`),202      h(ResultBadge, { rt: q.resultType }),203      q.pending ? h("span", { style: S.tagPending }, "pending") : null,204      h("span", { style: S.mono, title: q.summary }, q.summary),205      h("div", { style: { flex: 1 } }),206      h("span", { style: S.muted }, `${q.rowCount} rows`),207    ),208    open209      ? h(210          "div",211          { style: S.rowBody },212          h(KV, { k: "ast", v: h("pre", { style: S.pre }, safeJson(q.ast)) }),213          h(KV, { k: "reads tables", v: q.tables.join(", ") }),214          h(KV, { k: `sample (${q.sample.length}/${q.rowCount})`, v: h("pre", { style: S.pre }, safeJson(q.sample)) }),215        )216      : null,217  );218}219220// --- Delta stream (§4.3) -----------------------------------------------------221222const DELTA_KINDS: DeltaKind[] = ["hello", "snapshot", "add", "remove", "edit"];223224function DeltasPane({ state, core }: { state: DevtoolsState; core: DevtoolsCore }): ReactNode {225  const [paused, setPaused] = useState(false);226  const [frozen, setFrozen] = useState<DeltaEntry[]>([]);227  const [hidden, setHidden] = useState<Set<DeltaKind>>(() => new Set());228229  const live = state.deltas;230  const shown = paused ? frozen : live;231  const rows = shown.filter((d) => !hidden.has(d.kind)).slice(-300).reverse();232233  const toggleKind = (k: DeltaKind) =>234    setHidden((prev) => {235      const next = new Set(prev);236      if (next.has(k)) next.delete(k);237      else next.add(k);238      return next;239    });240241  return h(242    "div",243    null,244    h(245      "div",246      { style: S.toolbar },247      ...DELTA_KINDS.map((k) =>248        h(249          "button",250          {251            key: k,252            type: "button",253            style: { ...S.chip, ...(hidden.has(k) ? S.chipOff : null) },254            onClick: () => toggleKind(k),255            title: hidden.has(k) ? `show ${k}` : `hide ${k}`,256          },257          k,258        ),259      ),260      h("div", { style: { flex: 1 } }),261      h(262        "button",263        {264          type: "button",265          style: S.smallBtn,266          onClick: () => {267            if (!paused) setFrozen(live.slice());268            setPaused((p) => !p);269          },270        },271        paused ? "▶ resume" : "⏸ pause",272      ),273      h("button", { type: "button", style: S.smallBtn, onClick: () => core.clearDeltas() }, "clear"),274    ),275    rows.length === 0276      ? h("div", { style: S.empty }, `No deltas${hidden.size ? " match the filter" : " yet"}.`)277      : rows.map((d) =>278          h(279            "div",280            { key: d.seq, style: S.deltaRow },281            h("span", { style: S.muted }, `#${d.qid}`),282            h(DeltaBadge, { kind: d.kind }),283            h("span", { style: S.mono }, d.label),284          ),285        ),286  );287}288289// --- small components --------------------------------------------------------290291function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children?: ReactNode }): ReactNode {292  return h("button", { type: "button", style: { ...S.tab, ...(active ? S.tabActive : null) }, onClick }, children);293}294295function KV({ k, v }: { k: string; v: ReactNode }): ReactNode {296  return h("div", { style: S.kv }, h("span", { style: S.kvKey }, k), h("span", { style: S.kvVal }, v));297}298299const STATE_COLOR: Record<MutationState, string> = { pending: "#d9a106", confirmed: "#2ea043", dropped: "#cf3b3b" };300function StateBadge({ state }: { state: MutationState }): ReactNode {301  return h("span", { style: { ...S.badge, background: STATE_COLOR[state] } }, state);302}303304function ResultBadge({ rt }: { rt: ResultType }): ReactNode {305  const color = rt === "complete" ? "#2ea043" : rt === "error" ? "#cf3b3b" : "#6b7785";306  return h("span", { style: { ...S.badge, background: color } }, rt);307}308309const DELTA_COLOR: Record<DeltaKind, string> = { hello: "#6b7785", snapshot: "#8957e5", add: "#2ea043", remove: "#cf3b3b", edit: "#1f6feb" };310function DeltaBadge({ kind }: { kind: DeltaKind }): ReactNode {311  return h("span", { style: { ...S.badge, background: DELTA_COLOR[kind] } }, kind);312}313314// --- formatting --------------------------------------------------------------315316function fmtMs(ms: number): string {317  if (ms < 1000) return `${Math.round(ms)}ms`;318  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;319  return `${Math.round(ms / 1000)}s`;320}321322function safeJson(v: unknown): string {323  try {324    return JSON.stringify(v, null, 2) ?? String(v);325  } catch {326    return String(v);327  }328}329330// --- styles (inline; no CSS dependency) --------------------------------------331332const mono = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";333const S: Record<string, CSSProperties> = {334  launcher: { position: "fixed", bottom: 12, right: 12, zIndex: 2147483000 },335  launchBtn: { font: `600 12px ${mono}`, color: "#e6edf3", background: "#161b22", border: "1px solid #30363d", borderRadius: 8, padding: "6px 10px", cursor: "pointer", boxShadow: "0 2px 8px rgba(0,0,0,.35)" },336  panel: { position: "fixed", bottom: 12, right: 12, width: 440, maxWidth: "calc(100vw - 24px)", height: "60vh", maxHeight: 640, display: "flex", flexDirection: "column", font: `12px ${mono}`, color: "#e6edf3", background: "#0d1117", border: "1px solid #30363d", borderRadius: 10, boxShadow: "0 8px 32px rgba(0,0,0,.5)", zIndex: 2147483000, overflow: "hidden" },337  header: { display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderBottom: "1px solid #21262d" },338  iconBtn: { background: "transparent", color: "#8b949e", border: "none", cursor: "pointer", fontSize: 13 },339  tabs: { display: "flex", gap: 2, padding: "6px 8px 0", borderBottom: "1px solid #21262d" },340  tab: { font: `600 11px ${mono}`, color: "#8b949e", background: "transparent", border: "none", borderBottom: "2px solid transparent", padding: "6px 10px", cursor: "pointer" },341  tabActive: { color: "#e6edf3", borderBottom: "2px solid #1f6feb" },342  body: { flex: 1, overflowY: "auto", padding: "6px 8px" },343  toolbar: { display: "flex", alignItems: "center", gap: 6, padding: "4px 2px 8px", flexWrap: "wrap" },344  row: { borderBottom: "1px solid #161b22" },345  rowHead: { display: "flex", alignItems: "center", gap: 6, padding: "5px 4px", cursor: "pointer" },346  rowBody: { padding: "2px 4px 8px 4px", display: "flex", flexDirection: "column", gap: 3 },347  deltaRow: { display: "flex", alignItems: "center", gap: 6, padding: "3px 4px", borderBottom: "1px solid #161b22" },348  badge: { font: `600 10px ${mono}`, color: "#fff", borderRadius: 4, padding: "1px 5px", textTransform: "uppercase" },349  tag: { font: `10px ${mono}`, color: "#c9a0ff", border: "1px solid #3b2d57", borderRadius: 4, padding: "0 4px" },350  tagPending: { font: `10px ${mono}`, color: "#f0c552", border: "1px solid #5a4a16", borderRadius: 4, padding: "0 4px" },351  warn: { font: `600 10px ${mono}`, color: "#f0883e" },352  mono: { fontFamily: mono, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },353  muted: { color: "#6e7681", fontSize: 11, whiteSpace: "nowrap" },354  count: { color: "#6e7681" },355  dot: { background: "#d9a106", color: "#0d1117", borderRadius: 8, padding: "0 5px", marginLeft: 2, fontWeight: 700 },356  empty: { color: "#6e7681", padding: 16, textAlign: "center", lineHeight: 1.6 },357  smallBtn: { font: `11px ${mono}`, color: "#c9d1d9", background: "#21262d", border: "1px solid #30363d", borderRadius: 5, padding: "2px 7px", cursor: "pointer" },358  chip: { font: `10px ${mono}`, color: "#c9d1d9", background: "#161b22", border: "1px solid #30363d", borderRadius: 10, padding: "1px 8px", cursor: "pointer" },359  chipOff: { color: "#484f58", textDecoration: "line-through" },360  kv: { display: "flex", gap: 6, alignItems: "baseline" },361  kvKey: { color: "#6e7681", minWidth: 96, flexShrink: 0 },362  kvVal: { color: "#c9d1d9", wordBreak: "break-word", flex: 1 },363  pre: { margin: 0, padding: "4px 6px", background: "#161b22", borderRadius: 5, maxHeight: 180, overflow: "auto", whiteSpace: "pre-wrap" },364  code: { background: "#161b22", borderRadius: 4, padding: "0 4px" },365};366367export { getDevtoolsCore, getDevtoolsHub, attachDevtools } from "@rindle/devtools";368export type { DevtoolsCore, DevtoolsState } from "@rindle/devtools";369