@dtudury/streamo 3.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 +38 -23
- package/package.json +3 -3
- package/public/apps/chat/main.js +18 -1
- package/public/apps/explorer/index.html +191 -6
- package/public/apps/explorer/main.js +752 -108
- 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 +19 -3
- 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,42 +134,164 @@ function safeJSON (value) {
|
|
|
111
134
|
}, 2)
|
|
112
135
|
}
|
|
113
136
|
|
|
114
|
-
// Walk every chunk newest-to-oldest
|
|
115
|
-
//
|
|
116
|
-
|
|
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) {
|
|
117
149
|
const len = repo.byteLength
|
|
118
150
|
if (len <= 0) return
|
|
119
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
|
+
|
|
120
160
|
while (addr >= 0) {
|
|
121
161
|
const code = repo.resolve(addr)
|
|
122
|
-
if (!code || !code.length)
|
|
162
|
+
if (!code || !code.length) break
|
|
123
163
|
const type = repo.footerToCodec[code.at(-1)]?.type
|
|
164
|
+
|
|
124
165
|
if (type === 'SIGNATURE') {
|
|
166
|
+
for (const e of flush()) yield e
|
|
125
167
|
let sig
|
|
126
168
|
try { sig = repo.decode(addr) } catch { sig = null }
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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({
|
|
134
183
|
address: addr,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
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 }
|
|
140
191
|
}
|
|
141
|
-
} else
|
|
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') {
|
|
142
280
|
let value
|
|
143
281
|
try { value = repo.decode(addr) } catch { value = null }
|
|
144
282
|
if (isCommitShape(value)) {
|
|
145
|
-
|
|
283
|
+
commits.push({
|
|
284
|
+
address: addr,
|
|
285
|
+
message: value.message,
|
|
286
|
+
date: value.date,
|
|
287
|
+
dataAddress: value.dataAddress,
|
|
288
|
+
parent: value.parent
|
|
289
|
+
})
|
|
146
290
|
}
|
|
147
291
|
}
|
|
148
292
|
addr -= code.length
|
|
149
293
|
}
|
|
294
|
+
return commits
|
|
150
295
|
}
|
|
151
296
|
|
|
152
297
|
// Decode the value at an address but treat object/array as REFS (children
|
|
@@ -183,66 +328,167 @@ function RegistryView () {
|
|
|
183
328
|
`
|
|
184
329
|
}
|
|
185
330
|
|
|
186
|
-
|
|
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]
|
|
187
377
|
return h`
|
|
188
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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}
|
|
225
426
|
`
|
|
226
427
|
}
|
|
227
428
|
|
|
228
429
|
function AtView ({ keyHex, address }) {
|
|
229
430
|
return h`
|
|
230
|
-
<a class="back" data-action="back-
|
|
231
|
-
<div class="keyfull"
|
|
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>
|
|
232
436
|
${() => {
|
|
233
437
|
dep()
|
|
234
438
|
const repo = registry.get(keyHex)
|
|
235
439
|
if (!repo) return h`<div class="empty">opening…</div>`
|
|
236
|
-
|
|
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>`
|
|
237
456
|
|
|
238
457
|
let info
|
|
239
|
-
try { info = valueAndChildren(repo,
|
|
458
|
+
try { info = valueAndChildren(repo, resolvedAddr) }
|
|
240
459
|
catch (e) { return h`<pre class="value">decode error: ${e.message}</pre>` }
|
|
241
460
|
|
|
242
461
|
const { codecType, refs, decoded } = info
|
|
243
462
|
const isCommit = isCommitShape(decoded)
|
|
463
|
+
const isSig = codecType === 'SIGNATURE'
|
|
244
464
|
|
|
245
|
-
//
|
|
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.
|
|
246
492
|
if (isCommit) {
|
|
247
493
|
const parentDataAddr = decoded.parent !== undefined
|
|
248
494
|
? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
|
|
@@ -251,6 +497,7 @@ function AtView ({ keyHex, address }) {
|
|
|
251
497
|
? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
|
|
252
498
|
: null
|
|
253
499
|
return h`
|
|
500
|
+
${tabs}
|
|
254
501
|
<div class="dim">codec: ${codecType} · this is a commit</div>
|
|
255
502
|
<table class="kv">
|
|
256
503
|
<tbody>
|
|
@@ -282,34 +529,41 @@ function AtView ({ keyHex, address }) {
|
|
|
282
529
|
: null}
|
|
283
530
|
<h3>rehydrated</h3>
|
|
284
531
|
<pre class="value">${safeJSON(decoded)}</pre>
|
|
285
|
-
${rawChunkSection(repo, address)}
|
|
286
532
|
`
|
|
287
533
|
}
|
|
288
534
|
|
|
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
|
|
535
|
+
// Duple: explain what this tree-node IS, then show its two children.
|
|
536
|
+
if (codecType === 'DUPLE') {
|
|
295
537
|
return h`
|
|
296
|
-
|
|
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>
|
|
297
549
|
<table class="kv">
|
|
298
550
|
<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>
|
|
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>
|
|
310
553
|
</tbody>
|
|
311
554
|
</table>
|
|
312
|
-
|
|
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>
|
|
313
567
|
`
|
|
314
568
|
}
|
|
315
569
|
|
|
@@ -321,21 +575,34 @@ function AtView ({ keyHex, address }) {
|
|
|
321
575
|
: Object.entries(refs)
|
|
322
576
|
if (entries.length === 0) {
|
|
323
577
|
return h`
|
|
578
|
+
${tabs}
|
|
324
579
|
<div class="dim">codec: ${codecType}</div>
|
|
325
580
|
<div class="empty">${isArray ? '[]' : '{}'}</div>
|
|
326
|
-
${rawChunkSection(repo, address)}
|
|
327
581
|
`
|
|
328
582
|
}
|
|
329
583
|
return h`
|
|
584
|
+
${tabs}
|
|
330
585
|
<div class="dim">codec: ${codecType}${isArray ? ` · length ${entries.length}` : ''}</div>
|
|
331
586
|
<table class="kv clickable">
|
|
332
587
|
<tbody>
|
|
333
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
|
+
}
|
|
334
603
|
let preview = ''
|
|
335
|
-
try {
|
|
336
|
-
|
|
337
|
-
preview = previewValue(v)
|
|
338
|
-
} catch { preview = '(error)' }
|
|
604
|
+
try { preview = previewValue(repo.decode(childAddr)) }
|
|
605
|
+
catch { preview = '(error)' }
|
|
339
606
|
return h`
|
|
340
607
|
<tr data-key=${k} data-action="open-at"
|
|
341
608
|
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
@@ -349,15 +616,14 @@ function AtView ({ keyHex, address }) {
|
|
|
349
616
|
</table>
|
|
350
617
|
<h3>rehydrated</h3>
|
|
351
618
|
<pre class="value">${safeJSON(decoded)}</pre>
|
|
352
|
-
${rawChunkSection(repo, address)}
|
|
353
619
|
`
|
|
354
620
|
}
|
|
355
621
|
|
|
356
622
|
// Primitive: just show it.
|
|
357
623
|
return h`
|
|
624
|
+
${tabs}
|
|
358
625
|
<div class="dim">codec: ${codecType}</div>
|
|
359
626
|
<pre class="value">${safeJSON(decoded)}</pre>
|
|
360
|
-
${rawChunkSection(repo, address)}
|
|
361
627
|
`
|
|
362
628
|
}}
|
|
363
629
|
`
|
|
@@ -377,12 +643,198 @@ function rawChunkSection (repo, address) {
|
|
|
377
643
|
`
|
|
378
644
|
}
|
|
379
645
|
|
|
380
|
-
|
|
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) {
|
|
381
829
|
if (v == null) return String(v)
|
|
382
830
|
if (typeof v === 'string') return v.length > 60 ? JSON.stringify(v.slice(0, 60)) + '…' : JSON.stringify(v)
|
|
383
831
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
384
832
|
if (v instanceof Date) return v.toISOString()
|
|
385
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
|
+
}
|
|
386
838
|
if (Array.isArray(v)) return `[…] (${v.length})`
|
|
387
839
|
if (typeof v === 'object') return `{…} (${Object.keys(v).length})`
|
|
388
840
|
return String(v)
|
|
@@ -390,6 +842,96 @@ function previewValue (v) {
|
|
|
390
842
|
|
|
391
843
|
function safeGet (f) { try { return f() } catch { return undefined } }
|
|
392
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
|
+
|
|
393
935
|
// Hex dump of a chunk's raw bytes. Truncates at maxLen so a giant value
|
|
394
936
|
// chunk doesn't blow up the page.
|
|
395
937
|
function hexDump (bytes, maxLen = 256) {
|
|
@@ -410,25 +952,127 @@ function hexDump (bytes, maxLen = 256) {
|
|
|
410
952
|
|
|
411
953
|
const appEl = document.getElementById('app')
|
|
412
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.
|
|
413
961
|
mount(h`${() => {
|
|
414
962
|
dep()
|
|
415
963
|
switch (view.kind) {
|
|
416
|
-
case 'registry': return RegistryView()
|
|
417
|
-
case '
|
|
418
|
-
case 'at': return AtView({ keyHex: view.keyHex, address: view.address })
|
|
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>`
|
|
419
966
|
default: return h`<div class="empty">?</div>`
|
|
420
967
|
}
|
|
421
968
|
}}`, appEl, recaller)
|
|
422
969
|
|
|
423
970
|
// ── Click delegation ──────────────────────────────────────────────────────
|
|
424
971
|
|
|
972
|
+
let suppressClickUntil = 0
|
|
425
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
|
|
426
978
|
const el = e.target.closest('[data-action]')
|
|
427
979
|
if (!el) return
|
|
428
980
|
switch (el.dataset.action) {
|
|
429
|
-
case 'open-repo': return go({ kind: '
|
|
981
|
+
case 'open-repo': return go({ kind: 'at', keyHex: el.dataset.key, address: 'HEAD' })
|
|
430
982
|
case 'open-at': return go({ kind: 'at', keyHex: el.dataset.keyhex, address: +el.dataset.addr })
|
|
431
983
|
case 'back-registry': return go({ kind: 'registry' })
|
|
432
|
-
case 'back-repo': return go({ kind: '
|
|
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 {}
|
|
433
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'))
|
|
434
1078
|
})
|