@dtudury/streamo 4.0.0 → 4.0.2
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 +24 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/public/apps/chat/main.js +19 -21
- package/public/apps/explorer/index.html +61 -9
- package/public/apps/explorer/main.js +351 -276
- package/public/streamo/bridgeRegistry.js +64 -0
- package/public/streamo/chat-cli.js +1 -1
- package/public/streamo/mount.js +21 -14
package/README.md
CHANGED
|
@@ -126,6 +126,29 @@ const registry = new RepoRegistry(async key => {
|
|
|
126
126
|
const repo = await registry.open(publicKeyHex)
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
### bridgeRegistry — connect a multi-repo registry to your app's Recaller
|
|
130
|
+
|
|
131
|
+
Each `Repo` owns its own `Recaller` (so it can do fine-grained tracking on its
|
|
132
|
+
own internal keys), and your app uses a separate `Recaller` for its `mount()`
|
|
133
|
+
slots. Reading `repo.byteLength` inside a slot registers a dep on the *repo's*
|
|
134
|
+
recaller, not the app's, so without an explicit bridge the slot would never
|
|
135
|
+
re-run when chunks arrive. `bridgeRegistry` is that bridge:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { Recaller, bridgeRegistry, h, mount } from '@dtudury/streamo'
|
|
139
|
+
|
|
140
|
+
const recaller = new Recaller('app')
|
|
141
|
+
const { dep, fire } = bridgeRegistry(registry, recaller)
|
|
142
|
+
|
|
143
|
+
mount(h`${() => {
|
|
144
|
+
dep()
|
|
145
|
+
for (const [k, r] of registry) ... // freely read any repo's state
|
|
146
|
+
}}`, appEl, recaller)
|
|
147
|
+
|
|
148
|
+
// Non-repo state changes (route, async results) — call fire() to force a re-render.
|
|
149
|
+
window.addEventListener('hashchange', fire)
|
|
150
|
+
```
|
|
151
|
+
|
|
129
152
|
### registrySync — peer sync over WebSocket
|
|
130
153
|
|
|
131
154
|
```js
|
|
@@ -163,7 +186,7 @@ mount(h`
|
|
|
163
186
|
`, document.body, recaller)
|
|
164
187
|
```
|
|
165
188
|
|
|
166
|
-
Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes.
|
|
189
|
+
Functions interpolated as `${() => ...}` are reactive cells — they re-run automatically whenever the data they read changes. Only the exact DOM nodes bound to changed data update. Elements with stable `data-key` are recycled across re-renders so the outer element's identity and document position survive (helpful for animations or external DOM references). The recycled element's inner content is rebuilt from the new vnode on each re-render, so static interpolations (`${value}`) reflect current state. SVG namespaces propagate automatically — `` h`<svg><path d="..."/></svg>` `` works without any extra wiring. `class` accepts an array (`['btn', isActive && 'active']`) or an object (`{btn: true, active: false}`); falsy entries are filtered out.
|
|
167
190
|
|
|
168
191
|
> **For lists that can reorder**, always set `data-key` on each item — the unkeyed positional fallback will recycle elements by tag in document order, which can attach the wrong DOM node (and any user focus/input on it) to the wrong vnode after a reorder.
|
|
169
192
|
|
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { Repo } from './public/streamo/Repo.js'
|
|
|
17
17
|
export { Signer, verifySignature } from './public/streamo/Signer.js'
|
|
18
18
|
export { Signature } from './public/streamo/Signature.js'
|
|
19
19
|
export { RepoRegistry } from './public/streamo/RepoRegistry.js'
|
|
20
|
+
export { bridgeRegistry } from './public/streamo/bridgeRegistry.js'
|
|
20
21
|
export { registrySync, handleRegistryPeer } from './public/streamo/registrySync.js'
|
|
21
22
|
export { archiveSync } from './public/streamo/archiveSync.js'
|
|
22
23
|
export { fileSync } from './public/streamo/fileSync.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dtudury/streamo",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"description": "peer-to-peer sync where your data and identity belong to you, not the server",
|
|
5
5
|
"keywords": ["p2p", "peer-to-peer", "sync", "reactive", "content-addressed", "websocket", "signed", "append-only", "offline-first", "cryptographic", "identity"],
|
|
6
6
|
"repository": "git@github.com:dtudury/streamo.git",
|
package/public/apps/chat/main.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Recaller } from '../../streamo/utils/Recaller.js'
|
|
|
4
4
|
import { Signer } from '../../streamo/Signer.js'
|
|
5
5
|
import { RepoRegistry } from '../../streamo/RepoRegistry.js'
|
|
6
6
|
import { registrySync } from '../../streamo/registrySync.js'
|
|
7
|
+
import { bridgeRegistry } from '../../streamo/bridgeRegistry.js'
|
|
7
8
|
import { bytesToHex } from '../../streamo/utils.js'
|
|
8
9
|
|
|
9
10
|
const { primaryKeyHex: rootKey } = await fetch('/api/info').then(r => r.json())
|
|
@@ -13,8 +14,10 @@ function fmt (ts) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function Msg ({ name, text, at, mine }) {
|
|
17
|
+
// +at coerces both Date and number to ms — stable key across old (number)
|
|
18
|
+
// and new (Date) message records as we transition.
|
|
16
19
|
return h`
|
|
17
|
-
<div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${at}>
|
|
20
|
+
<div class=${['msg', mine ? 'mine' : 'theirs']} data-key=${+at}>
|
|
18
21
|
${!mine ? h`<div class="sender">${name}</div>` : null}
|
|
19
22
|
<div class="text">${text}</div>
|
|
20
23
|
<div class="time">${fmt(at)}</div>
|
|
@@ -79,30 +82,25 @@ joinBtn.onclick = async () => {
|
|
|
79
82
|
// ── Reactive message list ──────────────────────────────────────────────
|
|
80
83
|
//
|
|
81
84
|
// Each repo has its own internal Recaller, so repo.get() inside a mount
|
|
82
|
-
// slot
|
|
83
|
-
//
|
|
84
|
-
// the slot
|
|
85
|
+
// slot doesn't automatically re-trigger mount's recaller. bridgeRegistry
|
|
86
|
+
// wires every repo (existing and future) into a single signal on the
|
|
87
|
+
// chat recaller; dep() inside the slot subscribes to it. See design.md
|
|
88
|
+
// §6 for the cross-recaller pattern.
|
|
85
89
|
|
|
86
90
|
const recaller = new Recaller('chat')
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
const { dep } = bridgeRegistry(registry, recaller, 'chat')
|
|
92
|
+
|
|
93
|
+
// Auto-scroll to the bottom whenever any chunk arrives. Subscribing
|
|
94
|
+
// via the same `dep` keeps it in lockstep with the mount slot — both
|
|
95
|
+
// re-run when the bridge fires, the slot updates the DOM, and this
|
|
96
|
+
// watcher schedules a post-layout scroll.
|
|
97
|
+
recaller.watch('chat-scroll', () => {
|
|
98
|
+
dep()
|
|
91
99
|
requestAnimationFrame(() => { msgsEl.scrollTop = msgsEl.scrollHeight })
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function watchRepo (keyHex, repo) {
|
|
95
|
-
repo.watch(`chat:${keyHex}`, () => {
|
|
96
|
-
repo.byteLength // register 'length' dep → re-fires on every commit and incoming sync chunk
|
|
97
|
-
triggerRender()
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
for (const [k, r] of registry) watchRepo(k, r)
|
|
102
|
-
registry.onOpen((keyHex, repo) => { watchRepo(keyHex, repo); triggerRender() })
|
|
100
|
+
})
|
|
103
101
|
|
|
104
102
|
mount(h`${function messages () {
|
|
105
|
-
|
|
103
|
+
dep()
|
|
106
104
|
const all = []
|
|
107
105
|
for (const [keyHex, repo] of registry) {
|
|
108
106
|
if (keyHex === rootKey) continue
|
|
@@ -129,7 +127,7 @@ joinBtn.onclick = async () => {
|
|
|
129
127
|
const messages = myRepo.get('messages') ?? []
|
|
130
128
|
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
131
129
|
myRepo.defaultMessage = `"${preview}" (web)`
|
|
132
|
-
myRepo.set({ name: username, messages: [...messages, { text, at: Date
|
|
130
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
|
|
133
131
|
}
|
|
134
132
|
|
|
135
133
|
sendBtn.onclick = sendMessage
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>streamo explorer</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 16 16%22><text y=%2214%22 font-size=%2214%22>🌊</text></svg>">
|
|
7
8
|
<link rel="stylesheet" href="/apps/styles/proto.css">
|
|
8
9
|
<style>
|
|
9
10
|
body { max-width: 60rem; margin: 0 auto; padding: 2rem 1.25rem; }
|
|
@@ -51,10 +52,10 @@
|
|
|
51
52
|
text-align: center;
|
|
52
53
|
align-self: center;
|
|
53
54
|
}
|
|
54
|
-
.row.commit .kind
|
|
55
|
-
.row.signature .kind
|
|
56
|
-
.row.signed-commit .kind
|
|
57
|
-
.row.
|
|
55
|
+
.row.commit .kind { color: var(--accent); border-color: var(--accent); }
|
|
56
|
+
.row.signature .kind { color: var(--warn); border-color: var(--warn); }
|
|
57
|
+
.row.signed-commit .kind { color: #16a34a; border-color: #16a34a; }
|
|
58
|
+
.row.signed-commit.unsigned .kind { color: var(--ink-dim); border-color: var(--ink-dim); }
|
|
58
59
|
|
|
59
60
|
/* HEAD card — the most-recent signed commit, prominent and self-orienting. */
|
|
60
61
|
.row.signed-commit.head-card {
|
|
@@ -64,6 +65,22 @@
|
|
|
64
65
|
}
|
|
65
66
|
.row.signed-commit.head-card .msg { font-size: 1rem; font-weight: 500; }
|
|
66
67
|
|
|
68
|
+
/* Detached card — same layout as the head-card but neutral styling.
|
|
69
|
+
Shown as the selector summary when the current address isn't a sig
|
|
70
|
+
(you've drilled into raw memory). The dropdown body is still the
|
|
71
|
+
way back — pick a real commit and you re-attach. */
|
|
72
|
+
.row.signed-commit.detached-card {
|
|
73
|
+
border: 1.5px dashed var(--rule);
|
|
74
|
+
background: transparent;
|
|
75
|
+
padding: 0.85rem;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
.row.signed-commit.detached-card .kind {
|
|
79
|
+
color: var(--ink-dim);
|
|
80
|
+
border-color: var(--ink-dim);
|
|
81
|
+
}
|
|
82
|
+
.row.signed-commit.detached-card .msg { font-size: 0.95rem; }
|
|
83
|
+
|
|
67
84
|
/* Commit selector: a real dropdown widget. Summary = currently-selected
|
|
68
85
|
commit (HEAD by default), styled as the green head-card. Body =
|
|
69
86
|
full list of signed commits, with the selected one marked. */
|
|
@@ -113,17 +130,25 @@
|
|
|
113
130
|
}
|
|
114
131
|
details.other-storage[open] > summary { color: var(--ink); }
|
|
115
132
|
|
|
116
|
-
/*
|
|
117
|
-
|
|
133
|
+
/* "What this is" banner — top of every value tab. Default neutral
|
|
134
|
+
border for storage codecs; green .verified for commits or sigs
|
|
135
|
+
backed by a valid signature; dim .unsigned for commits awaiting
|
|
136
|
+
a signature. */
|
|
137
|
+
.kind-banner {
|
|
118
138
|
display: flex; align-items: center; gap: 0.5rem;
|
|
119
139
|
padding: 0.65rem 0.85rem; margin: 0.5rem 0 1rem;
|
|
120
|
-
border: 1.5px solid
|
|
140
|
+
border: 1.5px solid var(--rule); border-radius: var(--radius);
|
|
141
|
+
}
|
|
142
|
+
.kind-banner.verified {
|
|
143
|
+
border-color: #16a34a;
|
|
121
144
|
background: rgba(22, 163, 74, 0.06);
|
|
122
145
|
}
|
|
123
|
-
.
|
|
146
|
+
.kind-banner.unsigned { border-style: dashed; }
|
|
147
|
+
.kind-banner .kind-label {
|
|
124
148
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em;
|
|
125
|
-
font-weight: 600; color:
|
|
149
|
+
font-weight: 600; color: var(--ink-dim);
|
|
126
150
|
}
|
|
151
|
+
.kind-banner.verified .kind-label { color: #16a34a; }
|
|
127
152
|
.commit-card {
|
|
128
153
|
padding: 0.6rem 0.85rem; margin: 0.4rem 0;
|
|
129
154
|
border: 1px solid var(--rule); border-radius: var(--radius);
|
|
@@ -224,6 +249,33 @@
|
|
|
224
249
|
.byte-map .chunk.current { stroke: var(--ink); stroke-width: 2; }
|
|
225
250
|
.byte-map .chunk.hovered { fill-opacity: 0.55; }
|
|
226
251
|
|
|
252
|
+
/* Streamo-typed value pills — every value gets a type-specific visual
|
|
253
|
+
identity instead of flattening through JSON.stringify. Colors echo
|
|
254
|
+
the byte-strip codec palette below so the visual language carries
|
|
255
|
+
across the page. */
|
|
256
|
+
.tv {
|
|
257
|
+
display: inline-flex;
|
|
258
|
+
align-items: center;
|
|
259
|
+
gap: 0.25rem;
|
|
260
|
+
padding: 0.05rem 0.4rem;
|
|
261
|
+
border-radius: var(--radius);
|
|
262
|
+
font-size: 0.85rem;
|
|
263
|
+
max-width: 100%;
|
|
264
|
+
vertical-align: baseline;
|
|
265
|
+
}
|
|
266
|
+
.tv-string { color: #047857; background: rgba(16, 185, 129, 0.10); font-family: monospace; }
|
|
267
|
+
.tv-string .tv-quote { color: #10b981; opacity: 0.7; font-weight: 600; }
|
|
268
|
+
.tv-num { color: #475569; background: rgba(100, 116, 139, 0.10); font-family: monospace; }
|
|
269
|
+
.tv-date { color: #475569; background: rgba(100, 116, 139, 0.10); }
|
|
270
|
+
.tv-date .tv-glyph { font-size: 0.75rem; }
|
|
271
|
+
.tv-date time { font-variant-numeric: tabular-nums; }
|
|
272
|
+
.tv-bool.tv-true { color: #15803d; background: rgba(22, 163, 74, 0.10); font-family: monospace; }
|
|
273
|
+
.tv-bool.tv-false { color: #b91c1c; background: rgba(220, 38, 38, 0.10); font-family: monospace; }
|
|
274
|
+
.tv-null, .tv-undefined { color: var(--ink-dim); background: transparent; font-style: italic; font-family: monospace; }
|
|
275
|
+
.tv-bytes { color: #4d7c0f; background: rgba(132, 204, 22, 0.10); font-family: monospace; }
|
|
276
|
+
.tv-array, .tv-object { color: #1e40af; background: rgba(59, 130, 246, 0.10); }
|
|
277
|
+
.tv-duple { color: #6b21a8; background: rgba(168, 85, 247, 0.10); }
|
|
278
|
+
|
|
227
279
|
/* codec category palette — used in both the legend and the SVG fills */
|
|
228
280
|
.cat-commit { fill: #f59e0b; background: #f59e0b; }
|
|
229
281
|
.cat-sig { fill: #ef4444; background: #ef4444; }
|
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
// storage chunks tucked into a <details>). Otherwise it's storage
|
|
14
14
|
// drilling — value/storage tabs for that chunk, no selector.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// Reactivity is bridged from each Repo's internal Recaller into the
|
|
17
|
+
// app-level Recaller via bridgeRegistry — see design.md §6 for why
|
|
18
|
+
// each Repo has its own Recaller and how the bridge connects them.
|
|
19
19
|
|
|
20
20
|
import { h } from '../../streamo/h.js'
|
|
21
21
|
import { mount } from '../../streamo/mount.js'
|
|
22
22
|
import { Recaller } from '../../streamo/utils/Recaller.js'
|
|
23
23
|
import { RepoRegistry } from '../../streamo/RepoRegistry.js'
|
|
24
24
|
import { registrySync } from '../../streamo/registrySync.js'
|
|
25
|
+
import { bridgeRegistry } from '../../streamo/bridgeRegistry.js'
|
|
25
26
|
import { changedPaths } from '../../streamo/Streamo.js'
|
|
26
27
|
import { hexToBytes } from '../../streamo/utils.js'
|
|
27
28
|
|
|
@@ -44,37 +45,19 @@ try {
|
|
|
44
45
|
// ── App-level reactivity ──────────────────────────────────────────────────
|
|
45
46
|
|
|
46
47
|
const recaller = new Recaller('explorer')
|
|
47
|
-
const
|
|
48
|
-
const dep = () => recaller.reportKeyAccess(signal, 'data')
|
|
48
|
+
const { dep, fire: bridgeFire } = bridgeRegistry(registry, recaller, 'explorer')
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
let
|
|
50
|
+
// Wrap bridgeFire to also schedule the byte-strip pin-to-HEAD side effect
|
|
51
|
+
// after the next render. Reactive mutation is synchronous (so the slot
|
|
52
|
+
// re-runs at next tick); only the post-render DOM peek goes through rAF.
|
|
53
|
+
let stripSyncScheduled = false
|
|
54
54
|
function fire () {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
})
|
|
55
|
+
bridgeFire()
|
|
56
|
+
if (stripSyncScheduled) return
|
|
57
|
+
stripSyncScheduled = true
|
|
58
|
+
requestAnimationFrame(() => { stripSyncScheduled = false; syncByteStrips() })
|
|
64
59
|
}
|
|
65
60
|
|
|
66
|
-
const watched = new Set()
|
|
67
|
-
function watchRepo (key, repo) {
|
|
68
|
-
if (watched.has(key)) return
|
|
69
|
-
watched.add(key)
|
|
70
|
-
repo.watch(`explorer:${key}`, () => {
|
|
71
|
-
repo.byteLength
|
|
72
|
-
fire()
|
|
73
|
-
})
|
|
74
|
-
}
|
|
75
|
-
for (const [k, r] of registry) watchRepo(k, r)
|
|
76
|
-
registry.onOpen((k, r) => { watchRepo(k, r); fire() })
|
|
77
|
-
|
|
78
61
|
// ── Hash routing ──────────────────────────────────────────────────────────
|
|
79
62
|
|
|
80
63
|
function viewFromHash () {
|
|
@@ -134,58 +117,49 @@ function safeJSON (value) {
|
|
|
134
117
|
}, 2)
|
|
135
118
|
}
|
|
136
119
|
|
|
137
|
-
// Walk every chunk newest-
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// Commits
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
// storage section.
|
|
148
|
-
function * signedCommits (repo) {
|
|
120
|
+
// Walk every chunk newest-first, yielding one entry per commit (with
|
|
121
|
+
// its covering signature attached) and one 'other' entry per non-commit
|
|
122
|
+
// non-sig chunk. A signature is part of *how* a commit is verified, not
|
|
123
|
+
// a thing of its own — so the user-level unit is the commit. Walking
|
|
124
|
+
// newest-first, we encounter each sig before the commits it covers
|
|
125
|
+
// (sig has higher address than the bytes it signed); we track the
|
|
126
|
+
// most-recently-seen sig and attach it to subsequent commits as their
|
|
127
|
+
// 'covering'. Commits encountered before any sig are uncovered (sign
|
|
128
|
+
// in flight or none yet) — those have covering: null.
|
|
129
|
+
function * commitsNewestFirst (repo) {
|
|
149
130
|
const len = repo.byteLength
|
|
150
131
|
if (len <= 0) return
|
|
151
132
|
let addr = len - 1
|
|
152
|
-
let
|
|
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
|
-
|
|
133
|
+
let covering = null // most-recent sig encountered in this walk
|
|
160
134
|
while (addr >= 0) {
|
|
161
135
|
const code = repo.resolve(addr)
|
|
162
136
|
if (!code || !code.length) break
|
|
163
137
|
const type = repo.footerToCodec[code.at(-1)]?.type
|
|
164
|
-
|
|
165
138
|
if (type === 'SIGNATURE') {
|
|
166
|
-
for (const e of flush()) yield e
|
|
167
139
|
let sig
|
|
168
140
|
try { sig = repo.decode(addr) } catch { sig = null }
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
141
|
+
if (sig) {
|
|
142
|
+
covering = {
|
|
143
|
+
sigAddress: addr,
|
|
144
|
+
signedFrom: sig.address,
|
|
145
|
+
signedTo: addr - code.length,
|
|
146
|
+
sigHex: truncHex(sig.compactRawBytes, 12)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
yield { kind: 'sig', address: addr, codecType: type }
|
|
178
150
|
} else if (type === 'OBJECT') {
|
|
179
151
|
let value
|
|
180
152
|
try { value = repo.decode(addr) } catch { value = null }
|
|
181
153
|
if (isCommitShape(value)) {
|
|
182
|
-
|
|
154
|
+
yield {
|
|
155
|
+
kind: 'commit',
|
|
183
156
|
address: addr,
|
|
184
157
|
message: value.message,
|
|
185
158
|
date: value.date,
|
|
186
159
|
dataAddress: value.dataAddress,
|
|
187
|
-
parent: value.parent
|
|
188
|
-
|
|
160
|
+
parent: value.parent,
|
|
161
|
+
covering
|
|
162
|
+
}
|
|
189
163
|
} else {
|
|
190
164
|
yield { kind: 'other', address: addr, codecType: type }
|
|
191
165
|
}
|
|
@@ -194,67 +168,47 @@ function * signedCommits (repo) {
|
|
|
194
168
|
}
|
|
195
169
|
addr -= code.length
|
|
196
170
|
}
|
|
197
|
-
for (const e of flush()) yield e
|
|
198
171
|
}
|
|
199
172
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// commit
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
173
|
+
// Find the covering signature for a commit — the first signature chunk
|
|
174
|
+
// newer than the commit whose [signedFrom, signedTo] range includes its
|
|
175
|
+
// address. Returns { sigAddress, signedFrom, signedTo, decoded } or null
|
|
176
|
+
// if the commit is uncovered (sign in flight or pending).
|
|
177
|
+
function findCoveringSig (repo, commitAddr) {
|
|
178
|
+
let scan = repo.byteLength - 1
|
|
179
|
+
while (scan > commitAddr) {
|
|
180
|
+
const code = repo.resolve(scan)
|
|
181
|
+
if (!code || !code.length) break
|
|
182
|
+
if (repo.footerToCodec[code.at(-1)]?.type === 'SIGNATURE') {
|
|
183
|
+
let sig
|
|
184
|
+
try { sig = repo.decode(scan) } catch { sig = null }
|
|
185
|
+
if (sig && sig.address <= commitAddr && (scan - code.length) >= commitAddr) {
|
|
186
|
+
return { sigAddress: scan, signedFrom: sig.address, signedTo: scan - code.length, decoded: sig }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
scan -= code.length
|
|
190
|
+
}
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Sig-detail view — when you're at a sig chunk directly (e.g., from
|
|
195
|
+
// drilling through storage). Sigs are auxiliary in the new model — the
|
|
196
|
+
// user-level unit is the commit — so this page shows the sig's content
|
|
197
|
+
// without trying to be the "polished signed commit" page. The kindBanner
|
|
198
|
+
// is rendered by the caller (AtView) so its variant matches the rest of
|
|
199
|
+
// the value-tab branches.
|
|
200
|
+
function sigDetailBody (repo, keyHex, sigAddress, decoded) {
|
|
208
201
|
const chunk = repo.resolve(sigAddress)
|
|
209
202
|
const chunkLen = chunk.length
|
|
210
203
|
const signedTo = sigAddress - chunkLen
|
|
211
204
|
const sigChunkStart = sigAddress - chunkLen + 1
|
|
212
205
|
const covered = commitsCoveredBySignature(repo, decoded.address, signedTo)
|
|
213
|
-
const head = covered[0]
|
|
214
206
|
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
207
|
<table class="kv">
|
|
252
208
|
<tbody>
|
|
253
209
|
<tr>
|
|
254
210
|
<td>covers</td>
|
|
255
|
-
<td
|
|
256
|
-
data-keyhex=${keyHex} data-addr=${decoded.address}
|
|
257
|
-
>@${decoded.address}</a> through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
|
|
211
|
+
<td>@${decoded.address} through @${signedTo} (${signedTo - decoded.address + 1} bytes)</td>
|
|
258
212
|
</tr>
|
|
259
213
|
<tr>
|
|
260
214
|
<td>sig chunk</td>
|
|
@@ -263,6 +217,19 @@ function signedCommitDetail (repo, keyHex, sigAddress) {
|
|
|
263
217
|
<tr><td>bytes</td><td class="mono">${truncHex(decoded.compactRawBytes, 32)}</td></tr>
|
|
264
218
|
</tbody>
|
|
265
219
|
</table>
|
|
220
|
+
${covered.length ? h`
|
|
221
|
+
<h3>commits in this signature ${covered.length > 1 ? h`<span class="dim">(${covered.length}, batched in one sign)</span>` : null}</h3>
|
|
222
|
+
${covered.map(c => h`
|
|
223
|
+
<div class="commit-card" data-key=${`cc${c.address}`} data-action="open-at"
|
|
224
|
+
data-keyhex=${keyHex} data-addr=${c.address}>
|
|
225
|
+
<div class="commit-msg">${c.message || h`<span class="dim">(no message)</span>`}</div>
|
|
226
|
+
<div class="commit-meta dim">
|
|
227
|
+
<span>${fmtDate(c.date)}</span>
|
|
228
|
+
<span> · @${c.address}</span>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
`)}
|
|
232
|
+
` : null}
|
|
266
233
|
`
|
|
267
234
|
}
|
|
268
235
|
|
|
@@ -314,11 +281,18 @@ function RegistryView () {
|
|
|
314
281
|
dep()
|
|
315
282
|
const rows = []
|
|
316
283
|
for (const [keyHex, repo] of registry) {
|
|
284
|
+
// No claims about state we can't verify — show the date when we
|
|
285
|
+
// resolve a commit, otherwise show the byte count. byteLength
|
|
286
|
+
// is honest: it's what we actually have on hand. The watcher
|
|
287
|
+
// fires as more chunks land and the row settles to a date once
|
|
288
|
+
// the commit chunk resolves at the end of the stream.
|
|
317
289
|
const last = repo.lastCommit
|
|
290
|
+
const len = repo.byteLength
|
|
291
|
+
const when = last ? fmtDate(last.date) : `${len} b`
|
|
318
292
|
rows.push(h`
|
|
319
293
|
<div class="row" data-key=${keyHex} data-action="open-repo">
|
|
320
294
|
<span class="mono">${truncKey(keyHex)}</span>
|
|
321
|
-
<span class
|
|
295
|
+
<span class=${['when', last ? null : 'dim']}>${when}</span>
|
|
322
296
|
<span class="msg dim">${last?.message || ''}</span>
|
|
323
297
|
</div>
|
|
324
298
|
`)
|
|
@@ -328,101 +302,108 @@ function RegistryView () {
|
|
|
328
302
|
`
|
|
329
303
|
}
|
|
330
304
|
|
|
331
|
-
// Resolve the symbolic HEAD address to the most-recent
|
|
332
|
-
// address
|
|
305
|
+
// Resolve the symbolic HEAD address to the most-recent COMMIT chunk's
|
|
306
|
+
// address — not the most-recent signature. The user-level unit is the
|
|
307
|
+
// commit; sigs are how it's verified, but HEAD-as-a-commit is what
|
|
308
|
+
// people mean by "the latest." Returns undefined if there are no commits.
|
|
333
309
|
function resolveHead (repo) {
|
|
334
310
|
let walk = repo.byteLength - 1
|
|
335
311
|
while (walk >= 0) {
|
|
336
312
|
const code = repo.resolve(walk)
|
|
337
313
|
if (!code || !code.length) break
|
|
338
|
-
if (repo.footerToCodec[code.at(-1)]?.type === '
|
|
314
|
+
if (repo.footerToCodec[code.at(-1)]?.type === 'OBJECT') {
|
|
315
|
+
let value
|
|
316
|
+
try { value = repo.decode(walk) } catch { value = null }
|
|
317
|
+
if (isCommitShape(value)) return walk
|
|
318
|
+
}
|
|
339
319
|
walk -= code.length
|
|
340
320
|
}
|
|
341
321
|
return undefined
|
|
342
322
|
}
|
|
343
323
|
|
|
344
|
-
// Commit selector dropdown —
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
324
|
+
// Commit selector dropdown — always rendered at the top of an at-view
|
|
325
|
+
// when the repo has any commits. The dropdown enumerates COMMITS (not
|
|
326
|
+
// sigs), since the commit is the user-level unit. Each entry's verify
|
|
327
|
+
// badge comes from its covering sig; uncovered commits show a "pending"
|
|
328
|
+
// badge. When the current address is a commit, that row is the summary;
|
|
329
|
+
// otherwise the summary is a "detached" card (you're at a sig chunk, a
|
|
330
|
+
// Duple, raw bytes, etc. — drill state, not a named ref).
|
|
331
|
+
function commitSelectorSection (repo, keyHex, currentAddr) {
|
|
332
|
+
const entries = [...commitsNewestFirst(repo)].filter(e => e.kind === 'commit')
|
|
351
333
|
if (!entries.length) return null
|
|
352
334
|
const tagFor = i => i === 0 ? 'HEAD' : `HEAD-${i}`
|
|
353
|
-
const
|
|
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 || ''}`
|
|
335
|
+
const commitRow = (c, tag, { asSummary = false, isSelected = false } = {}) => {
|
|
360
336
|
const cls = ['row', 'signed-commit',
|
|
361
337
|
asSummary ? 'head-card' : null,
|
|
362
|
-
isSelected ? 'selected' : null
|
|
338
|
+
isSelected ? 'selected' : null,
|
|
339
|
+
c.covering ? null : 'unsigned']
|
|
363
340
|
const action = asSummary ? null : 'select-commit'
|
|
341
|
+
const badge = () => {
|
|
342
|
+
dep()
|
|
343
|
+
if (!c.covering) return h`<span class="verify-badge pending" title="not yet signed">…</span>`
|
|
344
|
+
return verifyBadge(verifyStatus(repo, keyHex, c.covering.decoded || repo.decode(c.covering.sigAddress), c.covering.sigAddress))
|
|
345
|
+
}
|
|
364
346
|
return h`
|
|
365
347
|
<div class=${cls}
|
|
366
|
-
data-key=${`
|
|
348
|
+
data-key=${`c${c.address}`}
|
|
367
349
|
data-action=${action}
|
|
368
|
-
data-keyhex=${keyHex} data-addr=${
|
|
369
|
-
<span class="kind">${tag} ${
|
|
370
|
-
<span class="msg">${
|
|
371
|
-
<span class="when">${
|
|
372
|
-
<span class="mono dim">@${
|
|
350
|
+
data-keyhex=${keyHex} data-addr=${c.address}>
|
|
351
|
+
<span class="kind">${tag} ${badge}</span>
|
|
352
|
+
<span class="msg">${c.message || h`<span class="dim">(no message)</span>`}</span>
|
|
353
|
+
<span class="when">${fmtDate(c.date)}</span>
|
|
354
|
+
<span class="mono dim">@${c.address}</span>
|
|
373
355
|
</div>`
|
|
374
356
|
}
|
|
375
|
-
const selectedIdx = entries.findIndex(e => e.
|
|
376
|
-
const
|
|
357
|
+
const selectedIdx = entries.findIndex(e => e.address === currentAddr)
|
|
358
|
+
const isDetached = selectedIdx < 0
|
|
359
|
+
const detachedSummary = (() => {
|
|
360
|
+
let codec = ''
|
|
361
|
+
try { codec = repo.footerToCodec[repo.resolve(currentAddr).at(-1)]?.type || '' } catch {}
|
|
362
|
+
return h`
|
|
363
|
+
<div class="row signed-commit detached-card" data-key="detached">
|
|
364
|
+
<span class="kind">detached</span>
|
|
365
|
+
<span class="msg dim">exploring raw memory${codec ? ` · ${codec}` : ''}</span>
|
|
366
|
+
<span class="when"></span>
|
|
367
|
+
<span class="mono dim">@${currentAddr}</span>
|
|
368
|
+
</div>`
|
|
369
|
+
})()
|
|
370
|
+
const summary = isDetached
|
|
371
|
+
? detachedSummary
|
|
372
|
+
: commitRow(entries[selectedIdx], tagFor(selectedIdx), { asSummary: true })
|
|
377
373
|
return h`
|
|
378
374
|
<details class="commit-selector" data-key=${`selector-${keyHex}`}>
|
|
379
|
-
<summary>${
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
</div>
|
|
384
|
-
` : null}
|
|
375
|
+
<summary>${summary}</summary>
|
|
376
|
+
<div class="dropdown-body">
|
|
377
|
+
${entries.map((e, i) => commitRow(e, tagFor(i), { isSelected: !isDetached && i === selectedIdx }))}
|
|
378
|
+
</div>
|
|
385
379
|
</details>
|
|
386
380
|
`
|
|
387
381
|
}
|
|
388
382
|
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// so
|
|
383
|
+
// Repo-wide "other storage chunks" list — Duples, raw OBJECTs, ARRAYs,
|
|
384
|
+
// STRINGs, etc. The chunks underneath the commit graph. Tucked into a
|
|
385
|
+
// closed <details> so it doesn't compete with primary content. Unsigned
|
|
386
|
+
// commits already appear in the selector dropdown (with a pending badge),
|
|
387
|
+
// so they don't need a second listing here.
|
|
392
388
|
function repoExtras (repo, keyHex) {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const others = entries.filter(e => e.kind === 'other')
|
|
389
|
+
const others = [...commitsNewestFirst(repo)].filter(e => e.kind === 'other')
|
|
390
|
+
if (!others.length) return null
|
|
396
391
|
return h`
|
|
397
|
-
|
|
398
|
-
<
|
|
399
|
-
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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}
|
|
392
|
+
<details class="other-storage">
|
|
393
|
+
<summary>storage chunks <span class="dim">(${others.length}) — the chunks underneath</span></summary>
|
|
394
|
+
<table class="kv clickable">
|
|
395
|
+
<tbody>
|
|
396
|
+
${others.map(e => h`
|
|
397
|
+
<tr data-key=${`o${e.address}`} data-action="open-at"
|
|
398
|
+
data-keyhex=${keyHex} data-addr=${e.address}>
|
|
399
|
+
<td class="mono dim">${e.codecType}</td>
|
|
400
|
+
<td>${(() => { try { return typedValue(repo.decode(e.address)) } catch { return '' } })()}</td>
|
|
401
|
+
<td class="mono dim">@${e.address}</td>
|
|
402
|
+
</tr>
|
|
403
|
+
`)}
|
|
404
|
+
</tbody>
|
|
405
|
+
</table>
|
|
406
|
+
</details>
|
|
426
407
|
`
|
|
427
408
|
}
|
|
428
409
|
|
|
@@ -439,15 +420,15 @@ function AtView ({ keyHex, address }) {
|
|
|
439
420
|
if (!repo) return h`<div class="empty">opening…</div>`
|
|
440
421
|
|
|
441
422
|
// Resolve HEAD (symbolic) to the most-recent sig address. If the
|
|
442
|
-
// repo has no
|
|
443
|
-
// surfaces
|
|
423
|
+
// repo has no commits yet, render a useful "no HEAD" page that
|
|
424
|
+
// still surfaces any storage chunks.
|
|
444
425
|
let resolvedAddr = address
|
|
445
426
|
if (address === 'HEAD') {
|
|
446
427
|
resolvedAddr = resolveHead(repo)
|
|
447
428
|
if (resolvedAddr === undefined) {
|
|
448
429
|
return h`
|
|
449
|
-
<h2>at HEAD <span class="dim">(no
|
|
450
|
-
<div class="empty">this repo
|
|
430
|
+
<h2>at HEAD <span class="dim">(no commits yet)</span></h2>
|
|
431
|
+
<div class="empty">this repo doesn't have any commits yet — HEAD will resolve to the most-recent commit once one lands.</div>
|
|
451
432
|
${repoExtras(repo, keyHex)}
|
|
452
433
|
`
|
|
453
434
|
}
|
|
@@ -462,8 +443,11 @@ function AtView ({ keyHex, address }) {
|
|
|
462
443
|
const isCommit = isCommitShape(decoded)
|
|
463
444
|
const isSig = codecType === 'SIGNATURE'
|
|
464
445
|
|
|
465
|
-
// Tabs are part of the page content (not the static header) so
|
|
466
|
-
//
|
|
446
|
+
// Tabs are part of the page content (not the static header) so the
|
|
447
|
+
// commit selector renders ABOVE the tabs. The selector is always
|
|
448
|
+
// present (when the repo has any commits) so the UI doesn't shift
|
|
449
|
+
// as you click between commit pages and storage drilling — when
|
|
450
|
+
// the current address isn't a commit, the summary shows "detached".
|
|
467
451
|
const tabs = h`
|
|
468
452
|
<nav class="tabs">
|
|
469
453
|
<a class=${() => { dep(); return ['tab', atTab === 'value' ? 'active' : null] }}
|
|
@@ -472,7 +456,7 @@ function AtView ({ keyHex, address }) {
|
|
|
472
456
|
data-action="set-tab" data-tab="storage">storage</a>
|
|
473
457
|
</nav>
|
|
474
458
|
`
|
|
475
|
-
const selector =
|
|
459
|
+
const selector = commitSelectorSection(repo, keyHex, resolvedAddr)
|
|
476
460
|
|
|
477
461
|
// Storage tab: spatial view of where this chunk lives in the byte
|
|
478
462
|
// stream + outgoing references + this chunk's bytes + incoming
|
|
@@ -488,37 +472,84 @@ function AtView ({ keyHex, address }) {
|
|
|
488
472
|
`
|
|
489
473
|
}
|
|
490
474
|
|
|
475
|
+
// Every value-tab branch below prepends ${selector}${tabs} so the
|
|
476
|
+
// UI is stable across navigation: the selector is always at the
|
|
477
|
+
// top of the page when the repo has any sigs.
|
|
478
|
+
|
|
491
479
|
// Value tab — branches by codec.
|
|
480
|
+
// Helper: render the kv-table of decoded fields for any Object/Array
|
|
481
|
+
// (including commits, which are just OBJECTs with a known shape).
|
|
482
|
+
// Inline children render their value directly; addressable children
|
|
483
|
+
// get a clickable @addr link in the third column.
|
|
484
|
+
const refsTable = () => {
|
|
485
|
+
if (!refs || typeof refs !== 'object') return null
|
|
486
|
+
const isArray = Array.isArray(refs)
|
|
487
|
+
const fieldEntries = isArray
|
|
488
|
+
? refs.map((addr, i) => [String(i), addr])
|
|
489
|
+
: Object.entries(refs)
|
|
490
|
+
if (fieldEntries.length === 0) return h`<div class="empty">${isArray ? '[]' : '{}'}</div>`
|
|
491
|
+
return h`
|
|
492
|
+
<table class="kv clickable">
|
|
493
|
+
<tbody>
|
|
494
|
+
${fieldEntries.map(([k, childAddr]) => {
|
|
495
|
+
if (childAddr === undefined) {
|
|
496
|
+
const inlineValue = isArray ? decoded[+k] : decoded[k]
|
|
497
|
+
return h`
|
|
498
|
+
<tr>
|
|
499
|
+
<td class="mono">${k}</td>
|
|
500
|
+
<td>${typedValue(inlineValue)}</td>
|
|
501
|
+
<td class="dim">(inline)</td>
|
|
502
|
+
</tr>
|
|
503
|
+
`
|
|
504
|
+
}
|
|
505
|
+
let preview = ''
|
|
506
|
+
try { preview = typedValue(repo.decode(childAddr)) }
|
|
507
|
+
catch { preview = '(error)' }
|
|
508
|
+
return h`
|
|
509
|
+
<tr data-key=${k} data-action="open-at"
|
|
510
|
+
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
511
|
+
<td class="mono">${k}</td>
|
|
512
|
+
<td>${preview}</td>
|
|
513
|
+
<td class="mono dim">@${childAddr}</td>
|
|
514
|
+
</tr>
|
|
515
|
+
`
|
|
516
|
+
})}
|
|
517
|
+
</tbody>
|
|
518
|
+
</table>
|
|
519
|
+
`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Commit: same direct kv-table format as Object (it *is* an OBJECT —
|
|
523
|
+
// user requested the "dumber" version that names every field rather
|
|
524
|
+
// than packing them into a polished headline). Banner shows the
|
|
525
|
+
// verify state from the covering sig; the verification table at the
|
|
526
|
+
// bottom links to that sig and shows its bytes.
|
|
492
527
|
if (isCommit) {
|
|
528
|
+
const covering = findCoveringSig(repo, resolvedAddr)
|
|
493
529
|
const parentDataAddr = decoded.parent !== undefined
|
|
494
530
|
? safeGet(() => repo.decode(decoded.parent)?.dataAddress)
|
|
495
531
|
: undefined
|
|
496
532
|
const changes = parentDataAddr !== undefined
|
|
497
533
|
? [...changedPaths(repo, parentDataAddr, decoded.dataAddress)]
|
|
498
534
|
: null
|
|
535
|
+
const banner = kindBanner(
|
|
536
|
+
covering ? 'signed commit' : 'commit (unsigned)',
|
|
537
|
+
covering
|
|
538
|
+
? () => {
|
|
539
|
+
dep()
|
|
540
|
+
const status = verifyStatus(repo, keyHex, covering.decoded, covering.sigAddress)
|
|
541
|
+
return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
|
|
542
|
+
}
|
|
543
|
+
: h`<span class="verify-badge pending">…</span><span class="dim">not yet signed — sign in flight or pending</span>`,
|
|
544
|
+
covering ? 'verified' : 'unsigned'
|
|
545
|
+
)
|
|
499
546
|
return h`
|
|
547
|
+
${selector}
|
|
500
548
|
${tabs}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
<tr><td>date</td><td>${fmtDate(decoded.date)}</td></tr>
|
|
506
|
-
<tr>
|
|
507
|
-
<td>dataAddress</td>
|
|
508
|
-
<td><a class="addr-link" data-action="open-at"
|
|
509
|
-
data-keyhex=${keyHex} data-addr=${decoded.dataAddress}
|
|
510
|
-
>@${decoded.dataAddress}</a></td>
|
|
511
|
-
</tr>
|
|
512
|
-
<tr>
|
|
513
|
-
<td>parent</td>
|
|
514
|
-
<td>${decoded.parent === undefined
|
|
515
|
-
? h`<span class="dim">(none — first commit)</span>`
|
|
516
|
-
: h`<a class="addr-link" data-action="open-at"
|
|
517
|
-
data-keyhex=${keyHex} data-addr=${decoded.parent}
|
|
518
|
-
>@${decoded.parent}</a>`}</td>
|
|
519
|
-
</tr>
|
|
520
|
-
</tbody>
|
|
521
|
-
</table>
|
|
549
|
+
${banner}
|
|
550
|
+
${refsTable()}
|
|
551
|
+
<h3>rehydrated</h3>
|
|
552
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
522
553
|
${changes
|
|
523
554
|
? h`
|
|
524
555
|
<h3>changed paths <span class="dim">(${changes.length})</span></h3>
|
|
@@ -527,16 +558,32 @@ function AtView ({ keyHex, address }) {
|
|
|
527
558
|
: h`<div class="dim">(no path-level changes — same dataAddress)</div>`}
|
|
528
559
|
`
|
|
529
560
|
: null}
|
|
530
|
-
|
|
531
|
-
|
|
561
|
+
${covering ? h`
|
|
562
|
+
<h3>verification</h3>
|
|
563
|
+
<table class="kv">
|
|
564
|
+
<tbody>
|
|
565
|
+
<tr>
|
|
566
|
+
<td>signature</td>
|
|
567
|
+
<td><a class="addr-link" data-action="open-at" data-keyhex=${keyHex} data-addr=${covering.sigAddress}>@${covering.sigAddress}</a></td>
|
|
568
|
+
</tr>
|
|
569
|
+
<tr>
|
|
570
|
+
<td>covers</td>
|
|
571
|
+
<td>@${covering.signedFrom} through @${covering.signedTo} (${covering.signedTo - covering.signedFrom + 1} bytes)</td>
|
|
572
|
+
</tr>
|
|
573
|
+
<tr><td>sig bytes</td><td class="mono">${truncHex(covering.decoded.compactRawBytes, 32)}</td></tr>
|
|
574
|
+
</tbody>
|
|
575
|
+
</table>
|
|
576
|
+
` : null}
|
|
577
|
+
${repoExtras(repo, keyHex)}
|
|
532
578
|
`
|
|
533
579
|
}
|
|
534
580
|
|
|
535
581
|
// Duple: explain what this tree-node IS, then show its two children.
|
|
536
582
|
if (codecType === 'DUPLE') {
|
|
537
583
|
return h`
|
|
584
|
+
${selector}
|
|
538
585
|
${tabs}
|
|
539
|
-
|
|
586
|
+
${kindBanner('duple', h`<span class="dim">2-tuple, tree scaffolding</span>`)}
|
|
540
587
|
<p class="explainer">
|
|
541
588
|
A <strong>Duple</strong> is a 2-tuple — the building block streamo uses
|
|
542
589
|
to balance binary trees of OBJECT entries and ARRAY elements. Each Duple
|
|
@@ -548,21 +595,31 @@ function AtView ({ keyHex, address }) {
|
|
|
548
595
|
</p>
|
|
549
596
|
<table class="kv">
|
|
550
597
|
<tbody>
|
|
551
|
-
<tr><td class="mono">v[0]</td><td>${
|
|
552
|
-
<tr><td class="mono">v[1]</td><td>${
|
|
598
|
+
<tr><td class="mono">v[0]</td><td>${typedValue(decoded.v[0])}</td></tr>
|
|
599
|
+
<tr><td class="mono">v[1]</td><td>${typedValue(decoded.v[1])}</td></tr>
|
|
553
600
|
</tbody>
|
|
554
601
|
</table>
|
|
555
602
|
`
|
|
556
603
|
}
|
|
557
604
|
|
|
558
|
-
// Signature: the
|
|
559
|
-
//
|
|
605
|
+
// Signature: the sig-detail page (auxiliary in the new model — sigs
|
|
606
|
+
// are how commits are verified, not the user-level unit). Lists
|
|
607
|
+
// the commits this sig covers; pick one to land on its commit page.
|
|
560
608
|
if (isSig) {
|
|
609
|
+
const banner = kindBanner(
|
|
610
|
+
'signature chunk',
|
|
611
|
+
() => {
|
|
612
|
+
dep()
|
|
613
|
+
const status = verifyStatus(repo, keyHex, decoded, resolvedAddr)
|
|
614
|
+
return h`${verifyBadge(status)} <span class="dim">${verifyLabel(status)}</span>`
|
|
615
|
+
},
|
|
616
|
+
'verified'
|
|
617
|
+
)
|
|
561
618
|
return h`
|
|
562
619
|
${selector}
|
|
563
620
|
${tabs}
|
|
564
|
-
${
|
|
565
|
-
${
|
|
621
|
+
${banner}
|
|
622
|
+
${sigDetailBody(repo, keyHex, resolvedAddr, decoded)}
|
|
566
623
|
<div class="dim" style="margin-top: 0.5rem;">switch to the <strong>storage</strong> tab above to see the raw chunk bytes, outgoing references, and what else points at this address.</div>
|
|
567
624
|
`
|
|
568
625
|
}
|
|
@@ -570,59 +627,30 @@ function AtView ({ keyHex, address }) {
|
|
|
570
627
|
// Object/array: clickable children with their addresses.
|
|
571
628
|
if (refs && typeof refs === 'object') {
|
|
572
629
|
const isArray = Array.isArray(refs)
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
<div class="empty">${isArray ? '[]' : '{}'}</div>
|
|
581
|
-
`
|
|
582
|
-
}
|
|
630
|
+
const fieldCount = isArray ? refs.length : Object.keys(refs).length
|
|
631
|
+
const dim = fieldCount === 0
|
|
632
|
+
? null
|
|
633
|
+
: h`<span class="dim">${isArray ? `length ${fieldCount}` : `${fieldCount} field${fieldCount === 1 ? '' : 's'}`}</span>`
|
|
634
|
+
const label = fieldCount === 0
|
|
635
|
+
? (isArray ? 'empty array' : 'empty object')
|
|
636
|
+
: (isArray ? 'array' : 'object')
|
|
583
637
|
return h`
|
|
638
|
+
${selector}
|
|
584
639
|
${tabs}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
// Show those non-clickably with the decoded value pulled from
|
|
592
|
-
// the parent.
|
|
593
|
-
if (childAddr === undefined) {
|
|
594
|
-
const inlineValue = isArray ? decoded[+k] : decoded[k]
|
|
595
|
-
return h`
|
|
596
|
-
<tr>
|
|
597
|
-
<td class="mono">${k}</td>
|
|
598
|
-
<td>${previewValue(inlineValue)}</td>
|
|
599
|
-
<td class="dim">(inline)</td>
|
|
600
|
-
</tr>
|
|
601
|
-
`
|
|
602
|
-
}
|
|
603
|
-
let preview = ''
|
|
604
|
-
try { preview = previewValue(repo.decode(childAddr)) }
|
|
605
|
-
catch { preview = '(error)' }
|
|
606
|
-
return h`
|
|
607
|
-
<tr data-key=${k} data-action="open-at"
|
|
608
|
-
data-keyhex=${keyHex} data-addr=${childAddr}>
|
|
609
|
-
<td class="mono">${k}</td>
|
|
610
|
-
<td>${preview}</td>
|
|
611
|
-
<td class="mono dim">@${childAddr}</td>
|
|
612
|
-
</tr>
|
|
613
|
-
`
|
|
614
|
-
})}
|
|
615
|
-
</tbody>
|
|
616
|
-
</table>
|
|
617
|
-
<h3>rehydrated</h3>
|
|
618
|
-
<pre class="value">${safeJSON(decoded)}</pre>
|
|
640
|
+
${kindBanner(label, dim)}
|
|
641
|
+
${refsTable()}
|
|
642
|
+
${fieldCount > 0 ? h`
|
|
643
|
+
<h3>rehydrated</h3>
|
|
644
|
+
<pre class="value">${safeJSON(decoded)}</pre>
|
|
645
|
+
` : null}
|
|
619
646
|
`
|
|
620
647
|
}
|
|
621
648
|
|
|
622
649
|
// Primitive: just show it.
|
|
623
650
|
return h`
|
|
651
|
+
${selector}
|
|
624
652
|
${tabs}
|
|
625
|
-
|
|
653
|
+
${kindBanner(codecType.toLowerCase())}
|
|
626
654
|
<pre class="value">${safeJSON(decoded)}</pre>
|
|
627
655
|
`
|
|
628
656
|
}}
|
|
@@ -764,7 +792,7 @@ function outgoingReferencesSection (repo, keyHex, address) {
|
|
|
764
792
|
try {
|
|
765
793
|
const childCode = repo.resolve(childAddr)
|
|
766
794
|
codecType = repo.footerToCodec[childCode.at(-1)]?.type || '?'
|
|
767
|
-
preview =
|
|
795
|
+
preview = typedValue(repo.decode(childAddr))
|
|
768
796
|
} catch { preview = '(error)' }
|
|
769
797
|
return h`
|
|
770
798
|
<tr data-key=${`out${i}@${childAddr}`} data-action="open-at"
|
|
@@ -802,7 +830,7 @@ function referrersSection (repo, keyHex, address) {
|
|
|
802
830
|
<tbody>
|
|
803
831
|
${refs.map(r => {
|
|
804
832
|
let preview = ''
|
|
805
|
-
try { preview =
|
|
833
|
+
try { preview = typedValue(repo.decode(r.address)) }
|
|
806
834
|
catch { preview = '(error)' }
|
|
807
835
|
return h`
|
|
808
836
|
<tr data-key=${`r${r.address}`} data-action="open-at"
|
|
@@ -825,19 +853,44 @@ function isDuple (v) {
|
|
|
825
853
|
return v && typeof v === 'object' && Array.isArray(v.v) && v.v.length === 2 && Object.keys(v).length === 1
|
|
826
854
|
}
|
|
827
855
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
856
|
+
// Streamo-typed value renderer — every value gets a visual identity
|
|
857
|
+
// matching its underlying codec, instead of being flattened through
|
|
858
|
+
// JSON.stringify. Primitives render with type-specific styling
|
|
859
|
+
// (string → quoted mono in green frame, date → <time> with calendar
|
|
860
|
+
// chip, number → number chip, etc.); composites currently render as
|
|
861
|
+
// count chips ({ N fields } / [ N elements ]) — depth-controlled
|
|
862
|
+
// expansion is the next step in this thread (see THREADS.md).
|
|
863
|
+
function typedValue (v, depth = 0) {
|
|
864
|
+
if (v === null) return h`<span class="tv tv-null">null</span>`
|
|
865
|
+
if (v === undefined) return h`<span class="tv tv-undefined">undefined</span>`
|
|
866
|
+
if (typeof v === 'boolean') {
|
|
867
|
+
return h`<span class=${['tv', 'tv-bool', v ? 'tv-true' : 'tv-false']}>${v ? '✓' : '✗'} ${String(v)}</span>`
|
|
868
|
+
}
|
|
869
|
+
if (typeof v === 'string') {
|
|
870
|
+
const display = v.length > 60 ? v.slice(0, 60) + '…' : v
|
|
871
|
+
return h`<span class="tv tv-string"><span class="tv-quote">“</span>${display}<span class="tv-quote">”</span></span>`
|
|
872
|
+
}
|
|
873
|
+
if (typeof v === 'number') {
|
|
874
|
+
return h`<span class="tv tv-num">${String(v)}</span>`
|
|
875
|
+
}
|
|
876
|
+
if (v instanceof Date) {
|
|
877
|
+
return h`<span class="tv tv-date"><span class="tv-glyph">📅</span><time datetime=${v.toISOString()}>${v.toLocaleString()}</time></span>`
|
|
878
|
+
}
|
|
879
|
+
if (v instanceof Uint8Array) {
|
|
880
|
+
return h`<span class="tv tv-bytes">Uint8Array(${v.length})</span>`
|
|
881
|
+
}
|
|
834
882
|
if (isDuple(v)) {
|
|
835
|
-
if (depth >
|
|
836
|
-
return
|
|
883
|
+
if (depth > 1) return h`<span class="tv tv-duple">Duple(…)</span>`
|
|
884
|
+
return h`<span class="tv tv-duple">Duple(${typedValue(v.v[0], depth + 1)}, ${typedValue(v.v[1], depth + 1)})</span>`
|
|
885
|
+
}
|
|
886
|
+
if (Array.isArray(v)) {
|
|
887
|
+
return h`<span class="tv tv-array">[ ${v.length} ${v.length === 1 ? 'element' : 'elements'} ]</span>`
|
|
837
888
|
}
|
|
838
|
-
if (
|
|
839
|
-
|
|
840
|
-
|
|
889
|
+
if (typeof v === 'object') {
|
|
890
|
+
const n = Object.keys(v).length
|
|
891
|
+
return h`<span class="tv tv-object">{ ${n} ${n === 1 ? 'field' : 'fields'} }</span>`
|
|
892
|
+
}
|
|
893
|
+
return h`<span class="tv">${String(v)}</span>`
|
|
841
894
|
}
|
|
842
895
|
|
|
843
896
|
function safeGet (f) { try { return f() } catch { return undefined } }
|
|
@@ -925,6 +978,28 @@ function verifyStatus (repo, keyHex, sig, sigAddress) {
|
|
|
925
978
|
return 'pending'
|
|
926
979
|
}
|
|
927
980
|
|
|
981
|
+
// Consistent "what this is" banner at the top of every value-tab branch.
|
|
982
|
+
// label is the short codec/role name (e.g. "signed commit", "object",
|
|
983
|
+
// "duple"); content is whatever else goes in the banner (verify badge +
|
|
984
|
+
// label, field count, etc.); variant tints the surface — 'verified' for
|
|
985
|
+
// commits/sigs with a covering signature, 'unsigned' for commits awaiting
|
|
986
|
+
// one, undefined for everything else.
|
|
987
|
+
function kindBanner (label, content, variant) {
|
|
988
|
+
return h`
|
|
989
|
+
<div class=${['kind-banner', variant || null]}>
|
|
990
|
+
<span class="kind-label">${label}</span>
|
|
991
|
+
${content || null}
|
|
992
|
+
</div>
|
|
993
|
+
`
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function verifyLabel (status) {
|
|
997
|
+
if (status === 'valid') return 'verified — bytes match this repo’s public key'
|
|
998
|
+
if (status === 'invalid') return 'NOT VERIFIED — bytes do not match the repo key'
|
|
999
|
+
if (status === 'pending') return 'verifying…'
|
|
1000
|
+
return `error: ${status?.error ?? 'unknown'}`
|
|
1001
|
+
}
|
|
1002
|
+
|
|
928
1003
|
function verifyBadge (status) {
|
|
929
1004
|
if (status === 'valid') return h`<span class="verify-badge valid" title="signature verified against repo's public key">✓</span>`
|
|
930
1005
|
if (status === 'invalid') return h`<span class="verify-badge invalid" title="signature does NOT match repo's public key">✗</span>`
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bridgeRegistry — connect a multi-repo registry to an app Recaller.
|
|
3
|
+
*
|
|
4
|
+
* Each Repo has its own Recaller so it can track fine-grained dependencies
|
|
5
|
+
* on its own internal keys. An app that uses many Repos has its own
|
|
6
|
+
* different Recaller for its mount() slots. Reading repo.byteLength inside
|
|
7
|
+
* a slot registers a dep on the *repo's* recaller, not the app's, so
|
|
8
|
+
* without an explicit bridge the slot would never re-run when chunks
|
|
9
|
+
* arrive at the repo.
|
|
10
|
+
*
|
|
11
|
+
* bridgeRegistry sets up that bridge once: it watches every repo in the
|
|
12
|
+
* registry (existing and future) for chunk arrivals and forwards them as
|
|
13
|
+
* a single signal on the app recaller. The returned `dep` function is
|
|
14
|
+
* what slots call to register on that signal — call it inside any
|
|
15
|
+
* reactive cell that should re-run on any-repo-changed.
|
|
16
|
+
*
|
|
17
|
+
* const recaller = new Recaller('app')
|
|
18
|
+
* const { dep, fire } = bridgeRegistry(registry, recaller)
|
|
19
|
+
*
|
|
20
|
+
* mount(h`${() => {
|
|
21
|
+
* dep()
|
|
22
|
+
* for (const [k, r] of registry) ... // freely read any repo's state
|
|
23
|
+
* }}`, appEl, recaller)
|
|
24
|
+
*
|
|
25
|
+
* // Non-repo state changes (route, async results, etc.) — call fire()
|
|
26
|
+
* // to force a re-render; the slot re-runs at next tick.
|
|
27
|
+
* window.addEventListener('hashchange', fire)
|
|
28
|
+
*
|
|
29
|
+
* Mutation is synchronous so multiple mutations in a tick coalesce via the
|
|
30
|
+
* Recaller's own nextTick flush — one slot re-run per tick regardless of
|
|
31
|
+
* how many chunks arrive. Don't wrap fire() in requestAnimationFrame:
|
|
32
|
+
* when the tab loses focus, queued rAFs throttle and the display freezes
|
|
33
|
+
* (we learned this the hard way; see the design.md cross-recaller note).
|
|
34
|
+
*
|
|
35
|
+
* @param {import('./RepoRegistry.js').RepoRegistry} registry
|
|
36
|
+
* @param {import('./utils/Recaller.js').Recaller} recaller
|
|
37
|
+
* @param {string} [name='bridge'] used in watch names for debugging
|
|
38
|
+
* @returns {{dep: () => void, fire: () => void}}
|
|
39
|
+
* `dep()` registers the calling reactive cell as depending on bridge state.
|
|
40
|
+
* `fire()` forces the slot to re-run at next tick — useful for app-level
|
|
41
|
+
* state changes (route, async results, tab switches) that aren't repo
|
|
42
|
+
* mutations the bridge already forwards.
|
|
43
|
+
*/
|
|
44
|
+
export function bridgeRegistry (registry, recaller, name = 'bridge') {
|
|
45
|
+
const signal = {}
|
|
46
|
+
const watched = new Set()
|
|
47
|
+
|
|
48
|
+
const fire = () => recaller.reportKeyMutation(signal, 'data')
|
|
49
|
+
const dep = () => recaller.reportKeyAccess(signal, 'data')
|
|
50
|
+
|
|
51
|
+
function watchRepo (keyHex, repo) {
|
|
52
|
+
if (watched.has(keyHex)) return
|
|
53
|
+
watched.add(keyHex)
|
|
54
|
+
repo.watch(`${name}:${keyHex}`, () => {
|
|
55
|
+
repo.byteLength // register 'length' dep — fires on every chunk
|
|
56
|
+
fire()
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [k, r] of registry) watchRepo(k, r)
|
|
61
|
+
registry.onOpen((k, r) => { watchRepo(k, r); fire() })
|
|
62
|
+
|
|
63
|
+
return { dep, fire }
|
|
64
|
+
}
|
|
@@ -126,7 +126,7 @@ rl.on('line', async line => {
|
|
|
126
126
|
const messages = myRepo.get('messages') ?? []
|
|
127
127
|
const preview = text.length > 50 ? text.slice(0, 50).trim() + '…' : text
|
|
128
128
|
myRepo.defaultMessage = `"${preview}" (cli)`
|
|
129
|
-
myRepo.set({ name: username, messages: [...messages, { text, at: Date
|
|
129
|
+
myRepo.set({ name: username, messages: [...messages, { text, at: new Date() }] })
|
|
130
130
|
rl.prompt()
|
|
131
131
|
})
|
|
132
132
|
|
package/public/streamo/mount.js
CHANGED
|
@@ -192,11 +192,30 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
|
|
|
192
192
|
old.remove()
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
// Reinsert recycled elements
|
|
195
|
+
// Reinsert recycled elements and mount fresh ones, in order. For
|
|
196
|
+
// recycled elements we re-mount: clean up the existing subtree's
|
|
197
|
+
// watchers, clear inner DOM and any attributes set on the outer
|
|
198
|
+
// element, then apply fresh attrs and mount fresh children. The
|
|
199
|
+
// OUTER node identity is preserved (so document position and DOM
|
|
200
|
+
// references survive), but inner state (focus, scroll, slot
|
|
201
|
+
// anchors) is rebuilt — a static `${value}` child captured at
|
|
202
|
+
// first mount would otherwise be stale on every re-render.
|
|
203
|
+
// Inputs that need focus preservation across re-renders should
|
|
204
|
+
// be kept in their own data-keyed slot so reconcileSlot's matcher
|
|
205
|
+
// recycles them in place separately from this outer rebuild.
|
|
196
206
|
for (const vnode of newVNodes) {
|
|
197
207
|
const recycled = vnodeToEl.get(vnode)
|
|
198
208
|
if (recycled) {
|
|
199
|
-
|
|
209
|
+
cleanupNode(recycled, recaller)
|
|
210
|
+
while (recycled.firstChild) recycled.firstChild.remove()
|
|
211
|
+
// Clear all attributes (snapshot the names — removeAttribute mutates the live list)
|
|
212
|
+
const oldAttrNames = Array.from(recycled.attributes, a => a.name)
|
|
213
|
+
for (const name of oldAttrNames) recycled.removeAttribute(name)
|
|
214
|
+
for (const attr of vnode.attrs) {
|
|
215
|
+
if (attr == null) continue
|
|
216
|
+
applyAttr(recycled, attr, recaller)
|
|
217
|
+
}
|
|
218
|
+
mount(vnode.children, recycled, recaller, ns)
|
|
200
219
|
end.before(recycled)
|
|
201
220
|
} else {
|
|
202
221
|
const frag = document.createDocumentFragment()
|
|
@@ -206,18 +225,6 @@ function reconcileSlot (start, end, newVNodes, recaller, ns = HTML_NS) {
|
|
|
206
225
|
}
|
|
207
226
|
}
|
|
208
227
|
|
|
209
|
-
// Update static attributes on a recycled element.
|
|
210
|
-
// Reactive (function/array) attrs are already self-updating via their existing watchers.
|
|
211
|
-
function patchElement (el, vnode) {
|
|
212
|
-
for (const attr of vnode.attrs) {
|
|
213
|
-
if (attr == null) continue
|
|
214
|
-
if (typeof attr === 'object' && !attr.name) continue // spread — skip
|
|
215
|
-
if (typeof attr.value === 'function' || Array.isArray(attr.value)) continue // reactive — skip
|
|
216
|
-
if (attr.value !== undefined) setAttr(el, attr.name, attr.value)
|
|
217
|
-
else el.toggleAttribute(attr.name, true)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
228
|
// ── Function components ───────────────────────────────────────────────────
|
|
222
229
|
//
|
|
223
230
|
// When an HElement's tag is a function, call it with a props object instead
|