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