Something went wrong. Try again.
a proof of concept realtime collaborative todo list
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241import { Component } from "preact";import { html } from "htm/preact";import { signal, effect } from "@preact/signals";import { LIST_NSID, ITEM_NSID, TOMBSTONE_NSID, parseAtUri, devtoolsRecords, devtoolsDids, devtoolsSubscription } from "./state.js";import { resolveHandle } from "./atproto.js";
const TABS = [ { id: "list", nsid: LIST_NSID, label: "Lists" }, { id: "item", nsid: ITEM_NSID, label: "Items" }, { id: "tombstone", nsid: TOMBSTONE_NSID, label: "Tombstones" },];
// Field order in the underlying record isn't stable (it depends on CRDT merge// order), so sort keys alphabetically rather than display whatever order the// record happens to carry. $crdt carries per-field bookkeeping that's only// interesting after the data itself, so it always goes last.function formatRecord(value) { if (!value || typeof value !== "object") return JSON.stringify(value, null, 2); const { $crdt, ...rest } = value; const sorted = Object.fromEntries(Object.keys(rest).sort().map((k) => [k, rest[k]])); return JSON.stringify($crdt !== undefined ? { ...sorted, $crdt } : sorted, null, 2);}
export class DevToolsPanel extends Component { activeTab = signal("list"); selectedRepo = signal(null); selected = signal(null); handles = signal({}); paused = signal(false); resuming = signal(false);
#disposeEffect = null;
componentDidMount() { // Resolve handles for DIDs whenever devtoolsDids changes. Uses peek() so // the effect doesn't re-subscribe to handles and create a feedback loop. this.#disposeEffect = effect(() => { const dids = devtoolsDids.value; for (const d of dids) { if (!this.handles.peek()[d]) { resolveHandle(d) .catch(() => d) .then((h) => { this.handles.value = { ...this.handles.value, [d]: h }; }); } } }); }
componentDidUpdate(prev) { if (prev.did !== this.props.did || prev.listUri !== this.props.listUri) { this.selected.value = null; this.selectedRepo.value = null; this.paused.value = false; } }
#togglePause = async () => { const sub = devtoolsSubscription.value; if (!sub) return; if (this.paused.value) { this.resuming.value = true; await sub.resume(); this.paused.value = false; this.resuming.value = false; } else { sub.pause(); this.paused.value = true; } };
componentWillUnmount() { this.#disposeEffect?.(); }
// rkeys of items belonging to the active list, across all known repos. itemRkeysForList(listRkey) { const byDid = devtoolsRecords.value[ITEM_NSID] ?? {}; const rkeys = new Set(); for (const recs of Object.values(byDid)) { for (const rec of recs) { if (rec.value?.listId === listRkey) rkeys.add(rec.uri.split("/").pop()); } } return rkeys; }
getEntries() { const tab = TABS.find((t) => t.id === this.activeTab.value); if (!tab) return []; const byDid = devtoolsRecords.value[tab.nsid] ?? {}; const selRepo = this.selectedRepo.value; const listRkey = parseAtUri(this.props.listUri)?.rkey; const itemRkeys = tab.id === "tombstone" && listRkey ? this.itemRkeysForList(listRkey) : null; const out = []; for (const [did, recs] of Object.entries(byDid)) { if (selRepo && did !== selRepo) continue; for (const rec of recs) { if (tab.id === "list" && listRkey && rec.uri.split("/").pop() !== listRkey) continue; if (tab.id === "item" && listRkey && rec.value?.listId !== listRkey) continue; if (itemRkeys && !itemRkeys.has(rec.value?.item)) continue; out.push({ did, uri: rec.uri, value: rec.value }); } } out.sort((a, b) => (a.uri < b.uri ? 1 : -1)); return out; }
switchTab(id) { this.activeTab.value = id; this.selected.value = null; }
renderDetail(entry) { return html` <div class="devtools-detail"> <div class="devtools-detail-meta" style="word-break:break-all">${entry.uri}</div> <pre class="devtools-doc-json">${formatRecord(entry.value)}</pre> </div> `; }
render() { const tab = this.activeTab.value; const dids = devtoolsDids.value; const handles = this.handles.value; const selRepo = this.selectedRepo.value; const entries = this.getEntries(); const sel = this.selected.value; const selEntry = sel ? entries.find((e) => e.uri === sel) : null;
return html` <div class="devtools-panel" style=${this.props.style}> <div class="devtools-zoom"> <div class="devtools-header"> <div class="devtools-tabs"> ${TABS.map( (t) => html` <button key=${t.id} class=${tab === t.id ? "active" : ""} onClick=${() => this.switchTab(t.id)} > ${t.label} </button> ` )} </div> <button class="icon-button devtools-action" style="margin-left:auto" title=${this.paused.value ? "Resume live updates" : "Pause live updates"} disabled=${this.resuming.value || !devtoolsSubscription.value} onClick=${this.#togglePause} > ${this.paused.value ? html`<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,2 13,8 3,14"/></svg> Play` : html`<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="2" width="3.5" height="12" rx="1"/><rect x="9.5" y="2" width="3.5" height="12" rx="1"/></svg> Pause`} </button> <button class="icon-button devtools-action" title="Close" onClick=${this.props.onClose}> <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" > <line x1="4" y1="4" x2="12" y2="12" /> <line x1="12" y1="4" x2="4" y2="12" /> </svg> </button> </div> <div class="devtools-body"> <div class="devtools-tab-content"> ${html` <div class="devtools-subtabs"> <button class=${!selRepo ? "active" : ""} onClick=${() => { this.selectedRepo.value = null; this.selected.value = null; }} > All </button> ${dids.map( (did) => html` <button key=${did} class=${selRepo === did ? "active" : ""} onClick=${() => { this.selectedRepo.value = did; this.selected.value = null; }} > @${handles[did] || did.slice(8, 20) + "…"} </button> ` )} </div> `} <div class=${`devtools-body-inner${selEntry ? " has-detail" : ""}`}> <div class="devtools-timeline"> ${dids.length === 0 ? html`<p class="devtools-empty">Open a list to inspect records.</p>` : entries.length === 0 ? html`<p class="devtools-empty">No records.</p>` : entries.map((entry, i) => { const active = sel === entry.uri; const rkey = entry.uri.split("/").pop(); const author = "@" + (handles[entry.did] || entry.did.slice(8, 20) + "…"); return html` <div class=${`devtools-entry${active ? " active" : ""}`} key=${entry.uri} onClick=${() => (this.selected.value = active ? null : entry.uri)} > <div class="devtools-entry-meta"> <span class="devtools-entry-index">${i + 1}</span> <span class="devtools-entry-author">${author}</span> <span class="devtools-entry-time">${rkey}</span> </div> <div class="devtools-entry-uri">${entry.uri}</div> <pre class="devtools-entry-json">${formatRecord(entry.value)}</pre> </div> `; })} </div> ${selEntry ? this.renderDetail(selEntry) : null} </div> </div> </div> </div> </div> `; }}