@dtudury/streamo 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -15
- package/package.json +3 -3
- package/public/apps/chat/main.js +18 -1
- package/public/apps/explorer/index.html +303 -0
- package/public/apps/explorer/main.js +1078 -0
- package/public/index.html +7 -0
- package/public/streamo/Addressifier.js +9 -0
- package/public/streamo/CodecRegistry.js +95 -0
- package/public/streamo/Repo.js +20 -2
- package/public/streamo/Signer.js +10 -0
- package/public/streamo/Streamo.js +64 -12
- package/public/streamo/chat-cli.js +19 -3
- package/public/streamo/codecs.js +49 -9
- package/public/streamo/registrySync.js +23 -0
- package/public/streamo/utils/Recaller.js +23 -8
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
// streamo explorer — read-only registry / address browser.
|
|
2
|
+
//
|
|
3
|
+
// Two view kinds, navigated by URL hash:
|
|
4
|
+
// #/ — registry list
|
|
5
|
+
// #/repo/<keyHex> — at HEAD, the most-recent sig
|
|
6
|
+
// (symbolic, like git's HEAD ref).
|
|
7
|
+
// Shorthand for /at/HEAD.
|
|
8
|
+
// #/repo/<keyHex>/at/HEAD — same thing, explicit form.
|
|
9
|
+
// #/repo/<keyHex>/at/<address> — pinned to a specific byte address.
|
|
10
|
+
//
|
|
11
|
+
// When the resolved chunk is a SIGNATURE, the page is the polished
|
|
12
|
+
// signed-commit view (selector dropdown at top, polished detail below,
|
|
13
|
+
// storage chunks tucked into a <details>). Otherwise it's storage
|
|
14
|
+
// drilling — value/storage tabs for that chunk, no selector.
|
|
15
|
+
//
|
|
16
|
+
// State lives in plain JS variables; reactivity is bridged from each Repo's
|
|
17
|
+
// internal Recaller into the app-level Recaller via the `signal` pattern
|
|
18
|
+
// (see chat/main.js for the same approach).
|
|
19
|
+
|
|
20
|
+
import { h } from '../../streamo/h.js'
|
|
21
|
+
import { mount } from '../../streamo/mount.js'
|
|
22
|
+
import { Recaller } from '../../streamo/utils/Recaller.js'
|
|
23
|
+
import { RepoRegistry } from '../../streamo/RepoRegistry.js'
|
|
24
|
+
import { registrySync } from '../../streamo/registrySync.js'
|
|
25
|
+
import { changedPaths } from '../../streamo/Streamo.js'
|
|
26
|
+
import { hexToBytes } from '../../streamo/utils.js'
|
|
27
|
+
|
|
28
|
+
// ── Connect ───────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const registry = new RepoRegistry()
|
|
31
|
+
const port = +location.port || 80
|
|
32
|
+
const connEl = document.getElementById('conn')
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await registrySync(registry, location.hostname, port)
|
|
36
|
+
connEl.textContent = `connected · ${location.hostname}:${port}`
|
|
37
|
+
connEl.classList.add('ok')
|
|
38
|
+
} catch (e) {
|
|
39
|
+
connEl.textContent = `connection failed: ${e.message}`
|
|
40
|
+
connEl.classList.add('err')
|
|
41
|
+
throw e
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── App-level reactivity ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const recaller = new Recaller('explorer')
|
|
47
|
+
const signal = {}
|
|
48
|
+
const dep = () => recaller.reportKeyAccess(signal, 'data')
|
|
49
|
+
|
|
50
|
+
const schedule = typeof requestAnimationFrame !== 'undefined'
|
|
51
|
+
? fn => requestAnimationFrame(fn)
|
|
52
|
+
: fn => queueMicrotask(fn)
|
|
53
|
+
let scheduled = false
|
|
54
|
+
function fire () {
|
|
55
|
+
if (scheduled) return
|
|
56
|
+
scheduled = true
|
|
57
|
+
schedule(() => {
|
|
58
|
+
scheduled = false
|
|
59
|
+
recaller.reportKeyMutation(signal, 'data')
|
|
60
|
+
// After mount has updated the DOM, sync byte-strip viewport indicators
|
|
61
|
+
// and (if appropriate) keep them pinned to HEAD on live updates.
|
|
62
|
+
syncByteStrips()
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const watched = new Set()
|
|
67
|
+
function watchRepo (key, repo) {
|
|
68
|
+
if (watched.has(key)) return
|
|
69
|
+
watched.add(key)
|
|
70
|
+
repo.watch(`explorer:${key}`, () => {
|
|
71
|
+
repo.byteLength
|
|
72
|
+
fire()
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
for (const [k, r] of registry) watchRepo(k, r)
|
|
76
|
+
registry.onOpen((k, r) => { watchRepo(k, r); fire() })
|
|
77
|
+
|
|
78
|
+
// ── Hash routing ──────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function viewFromHash () {
|
|
81
|
+
const m = (location.hash || '#/').match(/^#\/repo\/([0-9a-f]+)(?:\/at\/(HEAD|\d+))?\/?$/i)
|
|
82
|
+
if (!m) return { kind: 'registry' }
|
|
83
|
+
// Bare `/repo/<hex>` is shorthand for `/at/HEAD` — the symbolic pointer
|
|
84
|
+
// to the most recent signed commit (like git's HEAD).
|
|
85
|
+
const raw = m[2]
|
|
86
|
+
const address = raw == null || raw.toUpperCase() === 'HEAD' ? 'HEAD' : +raw
|
|
87
|
+
return { kind: 'at', keyHex: m[1], address }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hashFromView (v) {
|
|
91
|
+
if (v.kind !== 'at') return '#/'
|
|
92
|
+
// Canonical form for HEAD is the bare URL — concise and analogous to
|
|
93
|
+
// tools that imply HEAD when no ref is given.
|
|
94
|
+
if (v.address === 'HEAD') return `#/repo/${v.keyHex}`
|
|
95
|
+
return `#/repo/${v.keyHex}/at/${v.address}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let view = viewFromHash()
|
|
99
|
+
function go (next) {
|
|
100
|
+
view = next
|
|
101
|
+
const target = hashFromView(next)
|
|
102
|
+
if (location.hash !== target) location.hash = target
|
|
103
|
+
fire()
|
|
104
|
+
}
|
|
105
|
+
window.addEventListener('hashchange', () => {
|
|
106
|
+
const next = viewFromHash()
|
|
107
|
+
if (next.kind === view.kind && next.keyHex === view.keyHex && next.address === view.address) return
|
|
108
|
+
view = next
|
|
109
|
+
fire()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// At-view tab state — persists across at-view navigations so a user who
|
|
113
|
+
// wants to keep a "storage" lens on doesn't have to re-click after every
|
|
114
|
+
// drill-down. Reset to default on registry/repo views (set in go()).
|
|
115
|
+
let atTab = 'value'
|
|
116
|
+
|
|
117
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const truncKey = k => k.slice(0, 12) + '…'
|
|
120
|
+
const truncHex = (b, n = 16) => Array.from(b.subarray(0, n)).map(x => x.toString(16).padStart(2, '0')).join('') + (b.length > n ? '…' : '')
|
|
121
|
+
const fmtDate = d => d ? d.toLocaleString() : ''
|
|
122
|
+
|
|
123
|
+
function isCommitShape (v) {
|
|
124
|
+
return v && typeof v === 'object' && !Array.isArray(v) &&
|
|
125
|
+
typeof v.message === 'string' && v.date instanceof Date &&
|
|
126
|
+
typeof v.dataAddress === 'number'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function safeJSON (value) {
|
|
130
|
+
return JSON.stringify(value, (_, v) => {
|
|
131
|
+
if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
|
|
132
|
+
if (v instanceof Date) return v.toISOString()
|
|
133
|
+
return v
|
|
134
|
+
}, 2)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Walk every chunk newest-to-oldest, bundling each signature with the
|
|
138
|
+
// commit(s) it covers into one "signed commit" entry. A signed commit is
|
|
139
|
+
// the natural unit of authored history — the sig is what makes the bytes
|
|
140
|
+
// provably yours, and the commit gives those bytes meaning, so they read
|
|
141
|
+
// as one thing. Concurrent commits batched into one sign produce a single
|
|
142
|
+
// entry whose .commits is multi-element.
|
|
143
|
+
//
|
|
144
|
+
// Commits more recent than the latest sig (sign in flight, or none yet)
|
|
145
|
+
// surface as 'unsignedCommit'. Everything else — Duples, OBJECTs that
|
|
146
|
+
// aren't commits, ARRAYs, STRINGs, etc. — is 'other' and lives in the
|
|
147
|
+
// storage section.
|
|
148
|
+
function * signedCommits (repo) {
|
|
149
|
+
const len = repo.byteLength
|
|
150
|
+
if (len <= 0) return
|
|
151
|
+
let addr = len - 1
|
|
152
|
+
let pendingSig = null // most-recent sig still gathering its commits
|
|
153
|
+
let pendingCommits = [] // commits since the last yielded sig (newest first)
|
|
154
|
+
|
|
155
|
+
const flush = () => {
|
|
156
|
+
if (pendingSig) return [{ kind: 'signedCommit', ...pendingSig, commits: pendingCommits }]
|
|
157
|
+
return pendingCommits.map(c => ({ kind: 'unsignedCommit', ...c }))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
while (addr >= 0) {
|
|
161
|
+
const code = repo.resolve(addr)
|
|
162
|
+
if (!code || !code.length) break
|
|
163
|
+
const type = repo.footerToCodec[code.at(-1)]?.type
|
|
164
|
+
|
|
165
|
+
if (type === 'SIGNATURE') {
|
|
166
|
+
for (const e of flush()) yield e
|
|
167
|
+
let sig
|
|
168
|
+
try { sig = repo.decode(addr) } catch { sig = null }
|
|
169
|
+
pendingSig = sig
|
|
170
|
+
? {
|
|
171
|
+
sigAddress: addr,
|
|
172
|
+
signedFrom: sig.address,
|
|
173
|
+
signedTo: addr - code.length,
|
|
174
|
+
sigHex: truncHex(sig.compactRawBytes, 12)
|
|
175
|
+
}
|
|
176
|
+
: null
|
|
177
|
+
pendingCommits = []
|
|
178
|
+
} else if (type === 'OBJECT') {
|
|
179
|
+
let value
|
|
180
|
+
try { value = repo.decode(addr) } catch { value = null }
|
|
181
|
+
if (isCommitShape(value)) {
|
|
182
|
+
pendingCommits.push({
|
|
183
|
+
address: addr,
|
|
184
|
+
message: value.message,
|
|
185
|
+
date: value.date,
|
|
186
|
+
dataAddress: value.dataAddress,
|
|
187
|
+
parent: value.parent
|
|
188
|
+
})
|
|
189
|
+
} else {
|
|
190
|
+
yield { kind: 'other', address: addr, codecType: type }
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
yield { kind: 'other', address: addr, codecType: type }
|
|
194
|
+
}
|
|
195
|
+
addr -= code.length
|
|
196
|
+
}
|
|
197
|
+
for (const e of flush()) yield e
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The polished "signed commit" detail view — the verify banner, the
|
|
201
|
+
// covered message(s), and the rehydrated value at HEAD. Shared between
|
|
202
|
+
// the repo view (rendered for the currently-selected commit, below the
|
|
203
|
+
// commit selector) and the at-view's SIGNATURE branch (when you've
|
|
204
|
+
// drilled into a sig directly).
|
|
205
|
+
function signedCommitDetail (repo, keyHex, sigAddress) {
|
|
206
|
+
let decoded
|
|
207
|
+
try { decoded = repo.decode(sigAddress) } catch { return h`<div class="empty">decode error</div>` }
|
|
208
|
+
const chunk = repo.resolve(sigAddress)
|
|
209
|
+
const chunkLen = chunk.length
|
|
210
|
+
const signedTo = sigAddress - chunkLen
|
|
211
|
+
const sigChunkStart = sigAddress - chunkLen + 1
|
|
212
|
+
const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
|
|
213
|
+
const head = covered[0]
|
|
214
|
+
return h`
|
|
215
|
+
<div class="signed-commit-banner">
|
|
216
|
+
<span class="signed-label">signed commit</span>
|
|
217
|
+
${() => {
|
|
218
|
+
dep()
|
|
219
|
+
const status = verifyStatus(repo, keyHex, decoded, sigAddress)
|
|
220
|
+
const label = status === 'valid' ? 'verified — bytes match this repo’s public key'
|
|
221
|
+
: status === 'invalid' ? 'NOT VERIFIED — bytes do not match the repo key'
|
|
222
|
+
: status === 'pending' ? 'verifying…'
|
|
223
|
+
: `error: ${status?.error ?? 'unknown'}`
|
|
224
|
+
return h`${verifyBadge(status)} <span class="dim">${label}</span>`
|
|
225
|
+
}}
|
|
226
|
+
</div>
|
|
227
|
+
${covered.length === 0
|
|
228
|
+
? h`<div class="empty">this signature covers no commits (would only happen if a sign produced over a non-commit range)</div>`
|
|
229
|
+
: h`
|
|
230
|
+
<h3>${covered.length === 1 ? 'message' : `${covered.length} messages (batched into one signature)`}</h3>
|
|
231
|
+
${covered.map(c => h`
|
|
232
|
+
<div class="commit-card" data-key=${`cc${c.address}`}>
|
|
233
|
+
<div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
|
|
234
|
+
<div class="commit-meta dim">
|
|
235
|
+
<span>${fmtDate(c.date)}</span>
|
|
236
|
+
<span> · commit chunk <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${c.address}>@${c.address}</a></span>
|
|
237
|
+
<span> · value <a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${c.dataAddress}>@${c.dataAddress}</a></span>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
`)}
|
|
241
|
+
${head ? (() => {
|
|
242
|
+
let value
|
|
243
|
+
try { value = repo.decode(head.dataAddress) } catch { value = undefined }
|
|
244
|
+
return h`
|
|
245
|
+
<h3>value at this point</h3>
|
|
246
|
+
<pre class="value">${value === undefined ? '(decode error)' : safeJSON(value)}</pre>
|
|
247
|
+
`
|
|
248
|
+
})() : null}
|
|
249
|
+
`}
|
|
250
|
+
<h3>signature</h3>
|
|
251
|
+
<table class="kv">
|
|
252
|
+
<tbody>
|
|
253
|
+
<tr>
|
|
254
|
+
<td>covers</td>
|
|
255
|
+
<td><a class="addr-link" data-action="open-at"
|
|
256
|
+
data-keyhex=${keyHex} data-addr=${decoded.address}
|
|
257
|
+
>@${decoded.address}</a> through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
|
|
258
|
+
</tr>
|
|
259
|
+
<tr>
|
|
260
|
+
<td>sig chunk</td>
|
|
261
|
+
<td class="mono">@${sigChunkStart}…@${sigAddress} (${chunkLen} bytes)</td>
|
|
262
|
+
</tr>
|
|
263
|
+
<tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
|
|
264
|
+
</tbody>
|
|
265
|
+
</table>
|
|
266
|
+
`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Find the commits (newest-first) covered by a particular signature. Used
|
|
270
|
+
// by the at-view's SIGNATURE branch to assemble the "this is what you were
|
|
271
|
+
// looking for" polished view from a sig address alone.
|
|
272
|
+
function commitsCoveredBySignature (repo, signedFrom, signedTo) {
|
|
273
|
+
const commits = []
|
|
274
|
+
let addr = signedTo
|
|
275
|
+
while (addr >= signedFrom) {
|
|
276
|
+
const code = repo.resolve(addr)
|
|
277
|
+
if (!code || !code.length) break
|
|
278
|
+
const type = repo.footerToCodec[code.at(-1)]?.type
|
|
279
|
+
if (type === 'OBJECT') {
|
|
280
|
+
let value
|
|
281
|
+
try { value = repo.decode(addr) } catch { value = null }
|
|
282
|
+
if (isCommitShape(value)) {
|
|
283
|
+
commits.push({
|
|
284
|
+
address: addr,
|
|
285
|
+
message: value.message,
|
|
286
|
+
date: value.date,
|
|
287
|
+
dataAddress: value.dataAddress,
|
|
288
|
+
parent: value.parent
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
addr -= code.length
|
|
293
|
+
}
|
|
294
|
+
return commits
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Decode the value at an address but treat object/array as REFS (children
|
|
298
|
+
// are addresses, not decoded recursively). For primitives, returns the
|
|
299
|
+
// decoded value directly.
|
|
300
|
+
function valueAndChildren (repo, address) {
|
|
301
|
+
const code = repo.resolve(address)
|
|
302
|
+
const codecType = repo.footerToCodec[code.at(-1)]?.type
|
|
303
|
+
const refs = repo.asRefs(address)
|
|
304
|
+
// refs is either an object/array of addresses or just the address itself for primitives
|
|
305
|
+
return { codecType, refs, decoded: repo.decode(address) }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Views ─────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
function RegistryView () {
|
|
311
|
+
return h`
|
|
312
|
+
<h2>repos <span class="dim">${() => { dep(); return `(${[...registry].length})` }}</span></h2>
|
|
313
|
+
${() => {
|
|
314
|
+
dep()
|
|
315
|
+
const rows = []
|
|
316
|
+
for (const [keyHex, repo] of registry) {
|
|
317
|
+
const last = repo.lastCommit
|
|
318
|
+
rows.push(h`
|
|
319
|
+
<div class="row" data-key=${keyHex} data-action="open-repo">
|
|
320
|
+
<span class="mono">${truncKey(keyHex)}</span>
|
|
321
|
+
<span class="when">${last ? fmtDate(last.date) : '(no commits)'}</span>
|
|
322
|
+
<span class="msg dim">${last?.message || ''}</span>
|
|
323
|
+
</div>
|
|
324
|
+
`)
|
|
325
|
+
}
|
|
326
|
+
return rows.length ? rows : h`<div class="empty">waiting for repos…</div>`
|
|
327
|
+
}}
|
|
328
|
+
`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Resolve the symbolic HEAD address to the most-recent sig chunk's
|
|
332
|
+
// address. Returns undefined if the repo has no signatures yet.
|
|
333
|
+
function resolveHead (repo) {
|
|
334
|
+
let walk = repo.byteLength - 1
|
|
335
|
+
while (walk >= 0) {
|
|
336
|
+
const code = repo.resolve(walk)
|
|
337
|
+
if (!code || !code.length) break
|
|
338
|
+
if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') return walk
|
|
339
|
+
walk -= code.length
|
|
340
|
+
}
|
|
341
|
+
return undefined
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Commit selector dropdown — used at the top of any sig at-view. The
|
|
345
|
+
// summary is the currently-selected (= currently-viewed) signed commit
|
|
346
|
+
// styled as a head-card; the body lists every signed commit with the
|
|
347
|
+
// current one marked. Picking a different one (data-action=select-commit)
|
|
348
|
+
// navigates to /at/<sigAddress> via the URL.
|
|
349
|
+
function commitSelectorSection (repo, keyHex, currentSigAddr) {
|
|
350
|
+
const entries = [...signedCommits(repo)].filter(e => e.kind === 'signedCommit')
|
|
351
|
+
if (!entries.length) return null
|
|
352
|
+
const tagFor = i => i === 0 ? 'HEAD' : `HEAD-${i}`
|
|
353
|
+
const signedRow = (e, tag, { asSummary = false, isSelected = false } = {}) => {
|
|
354
|
+
const c0 = e.commits[0]
|
|
355
|
+
const headline = e.commits.length === 0
|
|
356
|
+
? h`<span class="dim">(sig with no covered commits)</span>`
|
|
357
|
+
: e.commits.length === 1
|
|
358
|
+
? (c0.message || h`<span class="dim">(no message)</span>`)
|
|
359
|
+
: h`<span class="dim">${e.commits.length}× </span>${c0?.message || ''}`
|
|
360
|
+
const cls = ['row', 'signed-commit',
|
|
361
|
+
asSummary ? 'head-card' : null,
|
|
362
|
+
isSelected ? 'selected' : null]
|
|
363
|
+
const action = asSummary ? null : 'select-commit'
|
|
364
|
+
return h`
|
|
365
|
+
<div class=${cls}
|
|
366
|
+
data-key=${`sc${e.sigAddress}`}
|
|
367
|
+
data-action=${action}
|
|
368
|
+
data-keyhex=${keyHex} data-addr=${e.sigAddress}>
|
|
369
|
+
<span class="kind">${tag} ${() => { dep(); return verifyBadge(verifyStatus(repo, keyHex, repo.decode(e.sigAddress), e.sigAddress)) }}</span>
|
|
370
|
+
<span class="msg">${headline}</span>
|
|
371
|
+
<span class="when">${c0 ? fmtDate(c0.date) : ''}</span>
|
|
372
|
+
<span class="mono dim">@${e.sigAddress}</span>
|
|
373
|
+
</div>`
|
|
374
|
+
}
|
|
375
|
+
const selectedIdx = entries.findIndex(e => e.sigAddress === currentSigAddr)
|
|
376
|
+
const selected = selectedIdx >= 0 ? entries[selectedIdx] : entries[0]
|
|
377
|
+
return h`
|
|
378
|
+
<details class="commit-selector" data-key=${`selector-${keyHex}`}>
|
|
379
|
+
<summary>${signedRow(selected, tagFor(selectedIdx >= 0 ? selectedIdx : 0), { asSummary: true })}</summary>
|
|
380
|
+
${entries.length > 1 ? h`
|
|
381
|
+
<div class="dropdown-body">
|
|
382
|
+
${entries.map((e, i) => signedRow(e, tagFor(i), { isSelected: e === selected }))}
|
|
383
|
+
</div>
|
|
384
|
+
` : null}
|
|
385
|
+
</details>
|
|
386
|
+
`
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Unsigned commits + the repo-wide "other storage chunks" list. Shown
|
|
390
|
+
// below the polished detail on sig at-views and on the no-HEAD page,
|
|
391
|
+
// so the repo's full inventory is always one click away.
|
|
392
|
+
function repoExtras (repo, keyHex) {
|
|
393
|
+
const entries = [...signedCommits(repo)]
|
|
394
|
+
const unsigned = entries.filter(e => e.kind === 'unsignedCommit')
|
|
395
|
+
const others = entries.filter(e => e.kind === 'other')
|
|
396
|
+
return h`
|
|
397
|
+
${unsigned.length ? h`
|
|
398
|
+
<h3>unsigned <span class="dim">(${unsigned.length} — sign in flight or pending)</span></h3>
|
|
399
|
+
${unsigned.map(e => h`
|
|
400
|
+
<div class="row unsigned-commit" data-key=${`u${e.address}`} data-action="open-at"
|
|
401
|
+
data-keyhex=${keyHex} data-addr=${e.address}>
|
|
402
|
+
<span class="kind">unsigned</span>
|
|
403
|
+
<span class="msg">${e.message || h`<span class="dim">(no message)</span>`}</span>
|
|
404
|
+
<span class="when">${fmtDate(e.date)}</span>
|
|
405
|
+
<span class="mono dim">@${e.address}</span>
|
|
406
|
+
</div>
|
|
407
|
+
`)}
|
|
408
|
+
` : null}
|
|
409
|
+
${others.length ? h`
|
|
410
|
+
<details class="other-storage">
|
|
411
|
+
<summary>storage chunks <span class="dim">(${others.length}) — the chunks underneath</span></summary>
|
|
412
|
+
<table class="kv clickable">
|
|
413
|
+
<tbody>
|
|
414
|
+
${others.map(e => h`
|
|
415
|
+
<tr data-key=${`o${e.address}`} data-action="open-at"
|
|
416
|
+
data-keyhex=${keyHex} data-addr=${e.address}>
|
|
417
|
+
<td class="mono dim">${e.codecType}</td>
|
|
418
|
+
<td>${(() => { try { return previewValue(repo.decode(e.address)) } catch { return '' } })()}</td>
|
|
419
|
+
<td class="mono dim">@${e.address}</td>
|
|
420
|
+
</tr>
|
|
421
|
+
`)}
|
|
422
|
+
</tbody>
|
|
423
|
+
</table>
|
|
424
|
+
</details>
|
|
425
|
+
` : null}
|
|
426
|
+
`
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function AtView ({ keyHex, address }) {
|
|
430
|
+
return h`
|
|
431
|
+
<a class="back" data-action="back-registry">← all repos</a>
|
|
432
|
+
<div class="keyfull">
|
|
433
|
+
<a class="repo-link" data-action="back-repo" data-keyhex=${keyHex}>${truncKey(keyHex)}</a>
|
|
434
|
+
<span class="dim"> @ ${address}</span>
|
|
435
|
+
</div>
|
|
436
|
+
${() => {
|
|
437
|
+
dep()
|
|
438
|
+
const repo = registry.get(keyHex)
|
|
439
|
+
if (!repo) return h`<div class="empty">opening…</div>`
|
|
440
|
+
|
|
441
|
+
// Resolve HEAD (symbolic) to the most-recent sig address. If the
|
|
442
|
+
// repo has no sigs yet, render a useful "no HEAD" page that still
|
|
443
|
+
// surfaces unsigned commits and storage chunks.
|
|
444
|
+
let resolvedAddr = address
|
|
445
|
+
if (address === 'HEAD') {
|
|
446
|
+
resolvedAddr = resolveHead(repo)
|
|
447
|
+
if (resolvedAddr === undefined) {
|
|
448
|
+
return h`
|
|
449
|
+
<h2>at HEAD <span class="dim">(no signed commits yet)</span></h2>
|
|
450
|
+
<div class="empty">this repo hasn't signed anything yet — once it does, HEAD will point to the most-recent signed commit and you'll land here automatically.</div>
|
|
451
|
+
${repoExtras(repo, keyHex)}
|
|
452
|
+
`
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (resolvedAddr >= repo.byteLength) return h`<div class="empty">loading…</div>`
|
|
456
|
+
|
|
457
|
+
let info
|
|
458
|
+
try { info = valueAndChildren(repo, resolvedAddr) }
|
|
459
|
+
catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
|
|
460
|
+
|
|
461
|
+
const { codecType, refs, decoded } = info
|
|
462
|
+
const isCommit = isCommitShape(decoded)
|
|
463
|
+
const isSig = codecType === 'SIGNATURE'
|
|
464
|
+
|
|
465
|
+
// Tabs are part of the page content (not the static header) so a
|
|
466
|
+
// sig at-view can render the commit selector ABOVE the tabs.
|
|
467
|
+
const tabs = h`
|
|
468
|
+
<nav class="tabs">
|
|
469
|
+
<a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
|
|
470
|
+
data-action="set-tab" data-tab="value">value</a>
|
|
471
|
+
<a class=${() => { dep(); return ['tab', atTab === 'storage' ? 'active' : null] }}
|
|
472
|
+
data-action="set-tab" data-tab="storage">storage</a>
|
|
473
|
+
</nav>
|
|
474
|
+
`
|
|
475
|
+
const selector = isSig ? commitSelectorSection(repo, keyHex, resolvedAddr) : null
|
|
476
|
+
|
|
477
|
+
// Storage tab: spatial view of where this chunk lives in the byte
|
|
478
|
+
// stream + outgoing references + this chunk's bytes + incoming
|
|
479
|
+
// referrers. The chunk graph from this chunk's perspective.
|
|
480
|
+
if (atTab === 'storage') {
|
|
481
|
+
return h`
|
|
482
|
+
${selector}
|
|
483
|
+
${tabs}
|
|
484
|
+
${byteStreamSection(repo, keyHex, resolvedAddr)}
|
|
485
|
+
${outgoingReferencesSection(repo, keyHex, resolvedAddr)}
|
|
486
|
+
${rawChunkSection(repo, resolvedAddr)}
|
|
487
|
+
${referrersSection(repo, keyHex, resolvedAddr)}
|
|
488
|
+
`
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Value tab — branches by codec.
|
|
492
|
+
if (isCommit) {
|
|
493
|
+
const parentDataAddr = decoded.parent !== undefined
|
|
494
|
+
? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
|
|
495
|
+
: undefined
|
|
496
|
+
const changes = parentDataAddr !== undefined
|
|
497
|
+
? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
|
|
498
|
+
: null
|
|
499
|
+
return h`
|
|
500
|
+
${tabs}
|
|
501
|
+
<div class="dim">codec: ${codecType} · this is a commit</div>
|
|
502
|
+
<table class="kv">
|
|
503
|
+
<tbody>
|
|
504
|
+
<tr><td>message</td><td>${decoded.message || h`<span class="dim">(empty)</span>`}</td></tr>
|
|
505
|
+
<tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
|
|
506
|
+
<tr>
|
|
507
|
+
<td>dataAddress</td>
|
|
508
|
+
<td><a class="addr-link" data-action="open-at"
|
|
509
|
+
data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
|
|
510
|
+
>@${decoded.dataAddress}</a></td>
|
|
511
|
+
</tr>
|
|
512
|
+
<tr>
|
|
513
|
+
<td>parent</td>
|
|
514
|
+
<td>${decoded.parent === undefined
|
|
515
|
+
? h`<span class="dim">(none — first commit)</span>`
|
|
516
|
+
: h`<a class="addr-link" data-action="open-at"
|
|
517
|
+
data-keyhex=${keyHex} data-addr=${decoded.parent}
|
|
518
|
+
>@${decoded.parent}</a>`}</td>
|
|
519
|
+
</tr>
|
|
520
|
+
</tbody>
|
|
521
|
+
</table>
|
|
522
|
+
${changes
|
|
523
|
+
? h`
|
|
524
|
+
<h3>changed paths <span class="dim">(${changes.length})</span></h3>
|
|
525
|
+
${changes.length
|
|
526
|
+
? h`<ul class="paths">${changes.map(p => h`<li class="mono">${p.length === 0 ? '/' : p.join('.')}</li>`)}</ul>`
|
|
527
|
+
: h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
|
|
528
|
+
`
|
|
529
|
+
: null}
|
|
530
|
+
<h3>rehydrated</h3>
|
|
531
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
532
|
+
`
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Duple: explain what this tree-node IS, then show its two children.
|
|
536
|
+
if (codecType === 'DUPLE') {
|
|
537
|
+
return h`
|
|
538
|
+
${tabs}
|
|
539
|
+
<div class="dim">codec: DUPLE</div>
|
|
540
|
+
<p class="explainer">
|
|
541
|
+
A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
|
|
542
|
+
to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
|
|
543
|
+
holds two slots; the slots are either values (a leaf) or other Duples
|
|
544
|
+
(an interior tree node). They're how content-addressing scales to
|
|
545
|
+
larger objects/arrays without rewriting the whole structure on every
|
|
546
|
+
small change — siblings keep their addresses, and dedup happens at
|
|
547
|
+
every level of the tree.
|
|
548
|
+
</p>
|
|
549
|
+
<table class="kv">
|
|
550
|
+
<tbody>
|
|
551
|
+
<tr><td class="mono">v[0]</td><td>${previewValue(decoded.v[0])}</td></tr>
|
|
552
|
+
<tr><td class="mono">v[1]</td><td>${previewValue(decoded.v[1])}</td></tr>
|
|
553
|
+
</tbody>
|
|
554
|
+
</table>
|
|
555
|
+
`
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Signature: the polished "signed commit" view + the unsigned/storage
|
|
559
|
+
// listing below. Shared with the storage tab's chunk-detail view.
|
|
560
|
+
if (isSig) {
|
|
561
|
+
return h`
|
|
562
|
+
${selector}
|
|
563
|
+
${tabs}
|
|
564
|
+
${signedCommitDetail(repo, keyHex, resolvedAddr)}
|
|
565
|
+
${repoExtras(repo, keyHex)}
|
|
566
|
+
<div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
|
|
567
|
+
`
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Object/array: clickable children with their addresses.
|
|
571
|
+
if (refs && typeof refs === 'object') {
|
|
572
|
+
const isArray = Array.isArray(refs)
|
|
573
|
+
const entries = isArray
|
|
574
|
+
? refs.map((addr, i) => [String(i), addr])
|
|
575
|
+
: Object.entries(refs)
|
|
576
|
+
if (entries.length === 0) {
|
|
577
|
+
return h`
|
|
578
|
+
${tabs}
|
|
579
|
+
<div class="dim">codec: ${codecType}</div>
|
|
580
|
+
<div class="empty">${isArray ? '[]' : '{}'}</div>
|
|
581
|
+
`
|
|
582
|
+
}
|
|
583
|
+
return h`
|
|
584
|
+
${tabs}
|
|
585
|
+
<div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
|
|
586
|
+
<table class="kv clickable">
|
|
587
|
+
<tbody>
|
|
588
|
+
${entries.map(([k, childAddr]) => {
|
|
589
|
+
// asRefs is mutation-impossible, so it returns undefined for
|
|
590
|
+
// inline children that don't have a separate chunk address.
|
|
591
|
+
// Show those non-clickably with the decoded value pulled from
|
|
592
|
+
// the parent.
|
|
593
|
+
if (childAddr === undefined) {
|
|
594
|
+
const inlineValue = isArray ? decoded[+k] : decoded[k]
|
|
595
|
+
return h`
|
|
596
|
+
<tr>
|
|
597
|
+
<td class="mono">${k}</td>
|
|
598
|
+
<td>${previewValue(inlineValue)}</td>
|
|
599
|
+
<td class="dim">(inline)</td>
|
|
600
|
+
</tr>
|
|
601
|
+
`
|
|
602
|
+
}
|
|
603
|
+
let preview = ''
|
|
604
|
+
try { preview = previewValue(repo.decode(childAddr)) }
|
|
605
|
+
catch { preview = '(error)' }
|
|
606
|
+
return h`
|
|
607
|
+
<tr data-key=${k} data-action="open-at"
|
|
608
|
+
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
609
|
+
<td class="mono">${k}</td>
|
|
610
|
+
<td>${preview}</td>
|
|
611
|
+
<td class="mono dim">@${childAddr}</td>
|
|
612
|
+
</tr>
|
|
613
|
+
`
|
|
614
|
+
})}
|
|
615
|
+
</tbody>
|
|
616
|
+
</table>
|
|
617
|
+
<h3>rehydrated</h3>
|
|
618
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
619
|
+
`
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Primitive: just show it.
|
|
623
|
+
return h`
|
|
624
|
+
${tabs}
|
|
625
|
+
<div class="dim">codec: ${codecType}</div>
|
|
626
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
627
|
+
`
|
|
628
|
+
}}
|
|
629
|
+
`
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Hex dump of the chunk at this address — the actual bytes that live in the
|
|
633
|
+
// streamo for this value. For commits we also include this so you can see
|
|
634
|
+
// the literal commit-record bytes.
|
|
635
|
+
function rawChunkSection (repo, address) {
|
|
636
|
+
let bytes
|
|
637
|
+
try { bytes = repo.resolve(address) }
|
|
638
|
+
catch { return null }
|
|
639
|
+
if (!bytes || !bytes.length) return null
|
|
640
|
+
return h`
|
|
641
|
+
<h3>chunk bytes <span class="dim">(${bytes.length} bytes ending @${address})</span></h3>
|
|
642
|
+
<pre class="value mono">${hexDump(bytes)}</pre>
|
|
643
|
+
`
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Map a codec type to a visual category. Many distinct codecs map to a
|
|
647
|
+
// shared category so the byte-stream stripe stays readable: commits (the
|
|
648
|
+
// narrative anchors), signatures (attestations), composite values, the
|
|
649
|
+
// Duple tree-scaffolding, strings, bytes, numbers, etc.
|
|
650
|
+
function codecCategory (type) {
|
|
651
|
+
switch (type) {
|
|
652
|
+
case 'SIGNATURE': return 'sig'
|
|
653
|
+
case 'OBJECT': case 'EMPTY_OBJECT': case 'ARRAY': case 'EMPTY_ARRAY': return 'composite'
|
|
654
|
+
case 'DUPLE': return 'duple'
|
|
655
|
+
case 'STRING': case 'EMPTY_STRING': return 'string'
|
|
656
|
+
case 'WORD': case 'UINT8ARRAY': case 'EMPTY_UINT8ARRAY': return 'bytes'
|
|
657
|
+
case 'DATE': case 'FLOAT64': case 'UINT7': return 'num'
|
|
658
|
+
case 'VARIABLE': return 'var'
|
|
659
|
+
default: return 'other'
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Byte stream as a color-coded SVG strip — every chunk is a rect, color
|
|
664
|
+
// coded by codec category. Modestly zoomed so even 1-byte chunks have a
|
|
665
|
+
// clickable width; horizontally scrollable, click-drag-to-pan inside the
|
|
666
|
+
// strip (cursor: grab/grabbing). First render auto-scrolls to HEAD (the
|
|
667
|
+
// newest content, at the right) and stays pinned there if you haven't
|
|
668
|
+
// dragged off it — so a live stream "follows" the newest activity. The
|
|
669
|
+
// signed-commits dropdown above is for jumping to a known commit; this
|
|
670
|
+
// strip is for poking around between them.
|
|
671
|
+
function byteStreamSection (repo, keyHex, currentAddress) {
|
|
672
|
+
const chunks = []
|
|
673
|
+
let addr = repo.byteLength - 1
|
|
674
|
+
while (addr >= 0) {
|
|
675
|
+
const code = repo.resolve(addr)
|
|
676
|
+
if (!code || !code.length) break
|
|
677
|
+
const codec = repo.footerToCodec[code.at(-1)]
|
|
678
|
+
chunks.unshift({
|
|
679
|
+
address: addr,
|
|
680
|
+
start: addr - code.length + 1,
|
|
681
|
+
length: code.length,
|
|
682
|
+
codecType: codec?.type || '?'
|
|
683
|
+
})
|
|
684
|
+
addr -= code.length
|
|
685
|
+
}
|
|
686
|
+
if (!chunks.length) return null
|
|
687
|
+
|
|
688
|
+
// Mark commit addresses by walking history once — cheap, lets commits
|
|
689
|
+
// appear as their own visual category instead of getting lumped in with
|
|
690
|
+
// generic OBJECTs.
|
|
691
|
+
const commitAddrs = new Set()
|
|
692
|
+
let walkAddr = repo.valueAddress
|
|
693
|
+
while (walkAddr !== undefined && walkAddr >= 0) {
|
|
694
|
+
let commit
|
|
695
|
+
try { commit = repo.decode(walkAddr) } catch { break }
|
|
696
|
+
if (!commit || typeof commit.message !== 'string' || !(commit.date instanceof Date)) break
|
|
697
|
+
commitAddrs.add(walkAddr)
|
|
698
|
+
walkAddr = commit.parent
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const total = repo.byteLength
|
|
702
|
+
// Each chunk gets max(MIN_PX, proportional zoomed width). At ZOOM=2 the
|
|
703
|
+
// strip is roughly 2x viewport-wide for typical repos — enough to
|
|
704
|
+
// scroll/drag through without losing spatial sense, and MIN_PX keeps
|
|
705
|
+
// even 1-byte chunks clickable.
|
|
706
|
+
const ZOOM = 2
|
|
707
|
+
const MIN_PX = 8
|
|
708
|
+
const H = 36
|
|
709
|
+
const zoomedW = 1200 * ZOOM
|
|
710
|
+
let cursorX = 0
|
|
711
|
+
const layout = chunks.map(c => {
|
|
712
|
+
const propW = (c.length / total) * zoomedW
|
|
713
|
+
const w = Math.max(MIN_PX, propW)
|
|
714
|
+
const item = { ...c, x: cursorX, w }
|
|
715
|
+
cursorX += w
|
|
716
|
+
return item
|
|
717
|
+
})
|
|
718
|
+
const stripW = cursorX
|
|
719
|
+
return h`
|
|
720
|
+
<h3>byte stream <span class="dim">(${total} bytes · ${chunks.length} chunks)</span></h3>
|
|
721
|
+
<div class="byte-map-legend">
|
|
722
|
+
<span class="cat-commit">commit</span>
|
|
723
|
+
<span class="cat-sig">sig</span>
|
|
724
|
+
<span class="cat-composite">object/array</span>
|
|
725
|
+
<span class="cat-duple">duple</span>
|
|
726
|
+
<span class="cat-string">string</span>
|
|
727
|
+
<span class="cat-bytes">bytes</span>
|
|
728
|
+
<span class="cat-num">num</span>
|
|
729
|
+
<span class="cat-var">var</span>
|
|
730
|
+
</div>
|
|
731
|
+
<div class="byte-strip-container" data-key=${`strip-${keyHex}`}>
|
|
732
|
+
<svg class="byte-map byte-strip" width=${stripW} height=${H} viewBox=${`0 0 ${stripW} ${H}`}>
|
|
733
|
+
${layout.map(c => {
|
|
734
|
+
const cat = commitAddrs.has(c.address) ? 'commit' : codecCategory(c.codecType)
|
|
735
|
+
const cls = ['chunk', `cat-${cat}`, c.address === currentAddress ? 'current' : null]
|
|
736
|
+
return h`<rect
|
|
737
|
+
class=${cls}
|
|
738
|
+
x=${c.x} y="0" width=${c.w} height=${H}
|
|
739
|
+
data-action="open-at"
|
|
740
|
+
data-keyhex=${keyHex}
|
|
741
|
+
data-addr=${c.address}
|
|
742
|
+
><title>${c.codecType} @${c.address} (${c.length} bytes)</title></rect>`
|
|
743
|
+
})}
|
|
744
|
+
</svg>
|
|
745
|
+
</div>
|
|
746
|
+
`
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Outgoing references — what THIS chunk points to in the chunk graph (as
|
|
750
|
+
// opposed to "referenced by", which is what points to this chunk). Walks
|
|
751
|
+
// the codec's parts via repo.directReferences. Codec-by-codec — exposes
|
|
752
|
+
// the storage chain so e.g. STRING → UINT8ARRAY → DUPLE → DUPLE → … → WORD
|
|
753
|
+
// is browsable one click at a time.
|
|
754
|
+
function outgoingReferencesSection (repo, keyHex, address) {
|
|
755
|
+
const refs = repo.directReferences(address)
|
|
756
|
+
if (!refs.length) return null
|
|
757
|
+
return h`
|
|
758
|
+
<h3>references <span class="dim">(${refs.length})</span></h3>
|
|
759
|
+
<table class="kv clickable">
|
|
760
|
+
<tbody>
|
|
761
|
+
${refs.map((childAddr, i) => {
|
|
762
|
+
let codecType = '?'
|
|
763
|
+
let preview = ''
|
|
764
|
+
try {
|
|
765
|
+
const childCode = repo.resolve(childAddr)
|
|
766
|
+
codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
|
|
767
|
+
preview = previewValue(repo.decode(childAddr))
|
|
768
|
+
} catch { preview = '(error)' }
|
|
769
|
+
return h`
|
|
770
|
+
<tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
|
|
771
|
+
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
772
|
+
<td class="mono dim">${codecType}</td>
|
|
773
|
+
<td>${preview}</td>
|
|
774
|
+
<td class="mono dim">@${childAddr}</td>
|
|
775
|
+
</tr>
|
|
776
|
+
`
|
|
777
|
+
})}
|
|
778
|
+
</tbody>
|
|
779
|
+
</table>
|
|
780
|
+
`
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// "Referenced by" — walks up the Duple tree-scaffolding to find the chunks
|
|
784
|
+
// that USE this address in a user-meaningful sense (OBJECT, ARRAY, VARIABLE,
|
|
785
|
+
// SIGNATURE, etc.). Internal Duples are skipped — they're how the codec
|
|
786
|
+
// builds balanced trees, not where the user thinks about the data living.
|
|
787
|
+
//
|
|
788
|
+
// Each row shows: codec, a one-line preview of the value, the address, and
|
|
789
|
+
// — if more than one Duple path leads to the same ancestor — a path count.
|
|
790
|
+
function referrersSection (repo, keyHex, address) {
|
|
791
|
+
const index = buildReferrerIndex(repo)
|
|
792
|
+
const refs = findUserReferrers(repo, address, index)
|
|
793
|
+
if (!refs.length) {
|
|
794
|
+
return h`
|
|
795
|
+
<h3>referenced by <span class="dim">(0)</span></h3>
|
|
796
|
+
<div class="dim">no chunks in this repo reference this value</div>
|
|
797
|
+
`
|
|
798
|
+
}
|
|
799
|
+
return h`
|
|
800
|
+
<h3>referenced by <span class="dim">(${refs.length} ${refs.length === 1 ? 'place' : 'places'})</span></h3>
|
|
801
|
+
<table class="kv clickable">
|
|
802
|
+
<tbody>
|
|
803
|
+
${refs.map(r => {
|
|
804
|
+
let preview = ''
|
|
805
|
+
try { preview = previewValue(repo.decode(r.address)) }
|
|
806
|
+
catch { preview = '(error)' }
|
|
807
|
+
return h`
|
|
808
|
+
<tr data-key=${`r${r.address}`} data-action="open-at"
|
|
809
|
+
data-keyhex=${keyHex} data-addr=${r.address}>
|
|
810
|
+
<td class="mono dim">${r.codecType || '?'}${r.count > 1 ? ` ×${r.count}` : ''}</td>
|
|
811
|
+
<td>${preview}</td>
|
|
812
|
+
<td class="mono dim">@${r.address}</td>
|
|
813
|
+
</tr>
|
|
814
|
+
`
|
|
815
|
+
})}
|
|
816
|
+
</tbody>
|
|
817
|
+
</table>
|
|
818
|
+
`
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Detect a Duple instance — codecs.js doesn't export the class so we have to
|
|
822
|
+
// duck-type. A Duple is an object whose only own property is `v`, a length-2
|
|
823
|
+
// array. (Used so we can render Duples as `[a, b]` rather than `{…} (1)`.)
|
|
824
|
+
function isDuple (v) {
|
|
825
|
+
return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function previewValue (v, depth = 0) {
|
|
829
|
+
if (v == null) return String(v)
|
|
830
|
+
if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
|
|
831
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
832
|
+
if (v instanceof Date) return v.toISOString()
|
|
833
|
+
if (v instanceof Uint8Array) return `Uint8Array(${v.length})`
|
|
834
|
+
if (isDuple(v)) {
|
|
835
|
+
if (depth > 2) return 'Duple(…)'
|
|
836
|
+
return `Duple(${previewValue(v.v[0], depth + 1)}, ${previewValue(v.v[1], depth + 1)})`
|
|
837
|
+
}
|
|
838
|
+
if (Array.isArray(v)) return `[…] (${v.length})`
|
|
839
|
+
if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
|
|
840
|
+
return String(v)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function safeGet (f) { try { return f() } catch { return undefined } }
|
|
844
|
+
|
|
845
|
+
// Build a child→parents index for the entire repo in one pass, so we can
|
|
846
|
+
// answer "who references address X?" in O(1) per query and walk up parent
|
|
847
|
+
// chains without re-scanning. Each entry maps a chunk's address to all the
|
|
848
|
+
// chunks that have it as a DIRECT child (via asRefs).
|
|
849
|
+
function buildReferrerIndex (repo) {
|
|
850
|
+
const index = new Map() // childAddr → [{ address, codecType }]
|
|
851
|
+
let addr = repo.byteLength - 1
|
|
852
|
+
while (addr >= 0) {
|
|
853
|
+
const code = repo.resolve(addr)
|
|
854
|
+
if (!code || !code.length) break
|
|
855
|
+
let refs
|
|
856
|
+
try { refs = repo.asRefs(addr) } catch { refs = null }
|
|
857
|
+
let childAddrs = []
|
|
858
|
+
if (Array.isArray(refs)) {
|
|
859
|
+
childAddrs = refs.filter(x => typeof x === 'number')
|
|
860
|
+
} else if (refs && typeof refs === 'object') {
|
|
861
|
+
if (Array.isArray(refs.v)) childAddrs = refs.v.filter(x => typeof x === 'number')
|
|
862
|
+
else childAddrs = Object.values(refs).filter(x => typeof x === 'number')
|
|
863
|
+
}
|
|
864
|
+
if (childAddrs.length) {
|
|
865
|
+
const codec = repo.footerToCodec[code.at(-1)]
|
|
866
|
+
const entry = { address: addr, codecType: codec?.type }
|
|
867
|
+
for (const child of childAddrs) {
|
|
868
|
+
if (!index.has(child)) index.set(child, [])
|
|
869
|
+
index.get(child).push(entry)
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
addr -= code.length
|
|
873
|
+
}
|
|
874
|
+
return index
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Walk up parent chains via the index. Internal Duple nodes are tree
|
|
878
|
+
// scaffolding — the user-meaningful containers are OBJECT / ARRAY /
|
|
879
|
+
// VARIABLE / SIGNATURE / etc. For each path that hits a non-Duple
|
|
880
|
+
// ancestor, accumulate that ancestor with a count of how many paths
|
|
881
|
+
// reach it. (Same value referenced from N different places yields N
|
|
882
|
+
// distinct user-level ancestors.)
|
|
883
|
+
function findUserReferrers (repo, targetAddr, index) {
|
|
884
|
+
const result = new Map() // ancestorAddr → { address, codecType, count }
|
|
885
|
+
function walkUp (from) {
|
|
886
|
+
const refs = index.get(from) ?? []
|
|
887
|
+
for (const r of refs) {
|
|
888
|
+
if (r.codecType === 'DUPLE') {
|
|
889
|
+
walkUp(r.address)
|
|
890
|
+
} else {
|
|
891
|
+
const existing = result.get(r.address)
|
|
892
|
+
if (existing) existing.count++
|
|
893
|
+
else result.set(r.address, { ...r, count: 1 })
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
walkUp(targetAddr)
|
|
898
|
+
return [...result.values()].sort((a, b) => b.address - a.address) // newest first
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Backwards-compat: if anyone wanted the raw direct referrers (Duples and
|
|
902
|
+
// all), this still works.
|
|
903
|
+
function findReferrers (repo, targetAddr) {
|
|
904
|
+
return buildReferrerIndex(repo).get(targetAddr) ?? []
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ── Signature verification cache ──────────────────────────────────────────
|
|
908
|
+
//
|
|
909
|
+
// repo.verify(sig, publicKey) is async. Slots render synchronously, so we
|
|
910
|
+
// cache results keyed by (keyHex, sigChunkAddress) and kick off the async
|
|
911
|
+
// verify on first encounter. When it resolves, fire() so the slot re-runs
|
|
912
|
+
// and the badge flips from "verifying…" to ✓ / ✗.
|
|
913
|
+
//
|
|
914
|
+
// One verify per signature per page load (~sub-ms each for secp256k1).
|
|
915
|
+
|
|
916
|
+
const verifyCache = new Map() // `${keyHex}:${addr}` → 'pending' | 'valid' | 'invalid' | { error }
|
|
917
|
+
|
|
918
|
+
function verifyStatus (repo, keyHex, sig, sigAddress) {
|
|
919
|
+
const cacheKey = `${keyHex}:${sigAddress}`
|
|
920
|
+
if (verifyCache.has(cacheKey)) return verifyCache.get(cacheKey)
|
|
921
|
+
verifyCache.set(cacheKey, 'pending')
|
|
922
|
+
repo.verify(sig, hexToBytes(keyHex))
|
|
923
|
+
.then(valid => { verifyCache.set(cacheKey, valid ? 'valid' : 'invalid'); fire() })
|
|
924
|
+
.catch(e => { verifyCache.set(cacheKey, { error: e.message }); fire() })
|
|
925
|
+
return 'pending'
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function verifyBadge (status) {
|
|
929
|
+
if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
|
|
930
|
+
if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
|
|
931
|
+
if (status === 'pending') return h`<span class="verify-badge pending" title="verifying…">…</span>`
|
|
932
|
+
return h`<span class="verify-badge error" title=${status?.error || 'verification error'}>⚠</span>`
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Hex dump of a chunk's raw bytes. Truncates at maxLen so a giant value
|
|
936
|
+
// chunk doesn't blow up the page.
|
|
937
|
+
function hexDump (bytes, maxLen = 256) {
|
|
938
|
+
const lines = []
|
|
939
|
+
const len = Math.min(bytes.length, maxLen)
|
|
940
|
+
for (let i = 0; i < len; i += 16) {
|
|
941
|
+
const offset = i.toString(16).padStart(4, '0')
|
|
942
|
+
const slice = bytes.subarray(i, Math.min(i + 16, len))
|
|
943
|
+
const hex = Array.from(slice).map(b => b.toString(16).padStart(2, '0')).join(' ')
|
|
944
|
+
const ascii = Array.from(slice).map(b => (b >= 0x20 && b < 0x7f) ? String.fromCharCode(b) : '·').join('')
|
|
945
|
+
lines.push(`${offset} ${hex.padEnd(48)} ${ascii}`)
|
|
946
|
+
}
|
|
947
|
+
if (bytes.length > maxLen) lines.push(`… (${bytes.length - maxLen} more bytes)`)
|
|
948
|
+
return lines.join('\n')
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ── Mount ─────────────────────────────────────────────────────────────────
|
|
952
|
+
|
|
953
|
+
const appEl = document.getElementById('app')
|
|
954
|
+
|
|
955
|
+
// Wrap each view in a data-keyed <section> so mount's tag-pool recycling
|
|
956
|
+
// doesn't pull stale elements from one view into another. Without this,
|
|
957
|
+
// switching from registry to an at-view would recycle the registry's
|
|
958
|
+
// <h2> and keep its old text children (patchElement only updates attrs).
|
|
959
|
+
// The data-key changes whenever the view's identity changes (kind + the
|
|
960
|
+
// params that affect rendering), forcing a fresh mount.
|
|
961
|
+
mount(h`${() => {
|
|
962
|
+
dep()
|
|
963
|
+
switch (view.kind) {
|
|
964
|
+
case 'registry': return h`<section class="view" data-key="view-registry">${RegistryView()}</section>`
|
|
965
|
+
case 'at': return h`<section class="view" data-key=${`view-at-${view.keyHex}-${view.address}`}>${AtView({ keyHex: view.keyHex, address: view.address })}</section>`
|
|
966
|
+
default: return h`<div class="empty">?</div>`
|
|
967
|
+
}
|
|
968
|
+
}}`, appEl, recaller)
|
|
969
|
+
|
|
970
|
+
// ── Click delegation ──────────────────────────────────────────────────────
|
|
971
|
+
|
|
972
|
+
let suppressClickUntil = 0
|
|
973
|
+
appEl.addEventListener('click', e => {
|
|
974
|
+
// Suppress the click that fires at the end of a drag-to-pan, so dragging
|
|
975
|
+
// doesn't accidentally navigate to a chunk under the pointer when the
|
|
976
|
+
// user releases.
|
|
977
|
+
if (Date.now() < suppressClickUntil) return
|
|
978
|
+
const el = e.target.closest('[data-action]')
|
|
979
|
+
if (!el) return
|
|
980
|
+
switch (el.dataset.action) {
|
|
981
|
+
case 'open-repo': return go({ kind: 'at', keyHex: el.dataset.key, address: 'HEAD' })
|
|
982
|
+
case 'open-at': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
|
|
983
|
+
case 'back-registry': return go({ kind: 'registry' })
|
|
984
|
+
case 'back-repo': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: 'HEAD' })
|
|
985
|
+
case 'set-tab': atTab = el.dataset.tab; return fire()
|
|
986
|
+
case 'select-commit': {
|
|
987
|
+
// Picking a commit is just navigation — go to /at/<sigAddress>.
|
|
988
|
+
// Close the dropdown imperatively so the new view renders with
|
|
989
|
+
// the selector collapsed (matches native <select> behavior).
|
|
990
|
+
el.closest('details.commit-selector')?.removeAttribute('open')
|
|
991
|
+
return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
// ── Byte-strip drag-to-pan + auto-scroll-to-HEAD ─────────────────────────
|
|
997
|
+
|
|
998
|
+
// On first render of a strip, scroll to the right edge (HEAD = newest
|
|
999
|
+
// content). On subsequent renders, only re-pin if the user is already at
|
|
1000
|
+
// or near the right edge — so a live stream "follows" without dragging
|
|
1001
|
+
// you back if you've scrolled into history.
|
|
1002
|
+
function syncByteStrips () {
|
|
1003
|
+
for (const container of appEl.querySelectorAll('.byte-strip-container')) {
|
|
1004
|
+
const visible = container.clientWidth || 1
|
|
1005
|
+
const atRight = container.scrollLeft + visible >= container.scrollWidth - 8
|
|
1006
|
+
if (!container.dataset.pinned || atRight) {
|
|
1007
|
+
container.dataset.pinned = '1'
|
|
1008
|
+
container.scrollLeft = container.scrollWidth
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Click-drag-to-pan inside the detail strip. Threshold of 4px before
|
|
1014
|
+
// treating a pointerdown as a drag — under that, fall through to the
|
|
1015
|
+
// regular click handler so chunk-clicks still navigate.
|
|
1016
|
+
let dragState = null
|
|
1017
|
+
appEl.addEventListener('pointerdown', e => {
|
|
1018
|
+
if (e.button !== undefined && e.button !== 0) return
|
|
1019
|
+
const container = e.target?.closest?.('.byte-strip-container')
|
|
1020
|
+
if (!container) return
|
|
1021
|
+
dragState = {
|
|
1022
|
+
container,
|
|
1023
|
+
pointerId: e.pointerId,
|
|
1024
|
+
startX: e.clientX,
|
|
1025
|
+
startScroll: container.scrollLeft,
|
|
1026
|
+
dragging: false
|
|
1027
|
+
}
|
|
1028
|
+
})
|
|
1029
|
+
appEl.addEventListener('pointermove', e => {
|
|
1030
|
+
if (!dragState || e.pointerId !== dragState.pointerId) return
|
|
1031
|
+
const dx = e.clientX - dragState.startX
|
|
1032
|
+
if (!dragState.dragging) {
|
|
1033
|
+
if (Math.abs(dx) < 4) return
|
|
1034
|
+
dragState.dragging = true
|
|
1035
|
+
dragState.container.classList.add('dragging')
|
|
1036
|
+
try { dragState.container.setPointerCapture(e.pointerId) } catch {}
|
|
1037
|
+
}
|
|
1038
|
+
dragState.container.scrollLeft = dragState.startScroll - dx
|
|
1039
|
+
e.preventDefault()
|
|
1040
|
+
})
|
|
1041
|
+
function endDrag () {
|
|
1042
|
+
if (!dragState) return
|
|
1043
|
+
if (dragState.dragging) {
|
|
1044
|
+
dragState.container.classList.remove('dragging')
|
|
1045
|
+
try { dragState.container.releasePointerCapture?.(dragState.pointerId) } catch {}
|
|
1046
|
+
suppressClickUntil = Date.now() + 100
|
|
1047
|
+
}
|
|
1048
|
+
dragState = null
|
|
1049
|
+
}
|
|
1050
|
+
appEl.addEventListener('pointerup', endDrag)
|
|
1051
|
+
appEl.addEventListener('pointercancel', endDrag)
|
|
1052
|
+
|
|
1053
|
+
// Cross-highlight: hovering any element with data-addr highlights the
|
|
1054
|
+
// matching chunk in the byte-map. References and referrers light up the
|
|
1055
|
+
// chunk's position in the stream so you can SEE where it lives. If the
|
|
1056
|
+
// hover came from somewhere other than the strip itself, smooth-scroll
|
|
1057
|
+
// the matching chunk into view inside any byte-strip-container —
|
|
1058
|
+
// otherwise hover-elsewhere can highlight chunks that are off-screen.
|
|
1059
|
+
appEl.addEventListener('mouseover', e => {
|
|
1060
|
+
const el = e.target.closest('[data-addr]')
|
|
1061
|
+
if (!el) return
|
|
1062
|
+
const addr = el.dataset.addr
|
|
1063
|
+
const matches = appEl.querySelectorAll(`.byte-map .chunk[data-addr="${addr}"]`)
|
|
1064
|
+
matches.forEach(c => c.classList.add('hovered'))
|
|
1065
|
+
if (!el.closest('.byte-strip-container')) {
|
|
1066
|
+
matches.forEach(c => {
|
|
1067
|
+
if (c.closest('.byte-strip-container')) {
|
|
1068
|
+
c.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
|
1069
|
+
}
|
|
1070
|
+
})
|
|
1071
|
+
}
|
|
1072
|
+
})
|
|
1073
|
+
appEl.addEventListener('mouseout', e => {
|
|
1074
|
+
const el = e.target.closest('[data-addr]')
|
|
1075
|
+
if (!el) return
|
|
1076
|
+
appEl.querySelectorAll('.byte-map .chunk.hovered')
|
|
1077
|
+
.forEach(c => c.classList.remove('hovered'))
|
|
1078
|
+
})
|