Skip to content

Commit 6db7aee

Browse files
antfubotantfu
andauthored
feat(data-inspector): writable data sources with live edits and change notifications (#153)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 357cc99 commit 6db7aee

43 files changed

Lines changed: 1626 additions & 80 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/json-render-ui/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"build": "tsdown && vite build --config src/spa/vite.config.ts",
3434
"watch": "tsdown --watch",
3535
"typecheck": "tsc --noEmit",
36-
"storybook": "storybook dev -p 6014 --host 0.0.0.0",
36+
"storybook": "storybook dev -p 6014",
3737
"build-storybook": "storybook build",
3838
"test": "vitest run",
3939
"prepack": "pnpm run build"

‎plugins/a11y/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"build:inject": "vite build --config src/inject/vite.config.ts",
4444
"watch": "tsdown --watch",
4545
"dev": "node bin.mjs",
46-
"storybook": "storybook dev -p 6015 --host 0.0.0.0",
46+
"storybook": "storybook dev -p 6015",
4747
"build-storybook": "storybook build",
4848
"cli:build": "node bin.mjs build --out-dir dist/static",
4949
"demo": "node demo/server.mjs",

‎plugins/code-server/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
"build": "tsdown && vite build --config src/spa/vite.config.ts",
4444
"watch": "tsdown --watch",
4545
"typecheck": "tsc --noEmit",
46-
"dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0",
47-
"storybook": "storybook dev -p 6013 --host 0.0.0.0",
46+
"dev": "vite --config src/spa/vite.config.ts",
47+
"storybook": "storybook dev -p 6013",
4848
"build-storybook": "storybook build",
4949
"test": "vitest run",
5050
"prepack": "pnpm run build"

‎plugins/data-inspector/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,27 @@ ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources)
2626
})
2727
```
2828

29+
## Writable sources
30+
31+
Sources opt into live edits with `writable: true`: on the root view (`$`), every value grows an edit affordance that opens a side panel — set a value (string / number / boolean / null / undefined / JSON), add or delete entries, rename keys — and the mutation is applied **in place to the live object** through the `write` RPC. Read-only stays the default; `static: true` sources are memoized snapshots and always stay read-only (declaring both reports `DP_DATA_INSPECTOR_0004`).
32+
33+
`registerDataSource` returns a handle; call `notifyChanged()` whenever the data changes outside the inspector so connected views re-run, or hand the plugin a bridge to the source's own change signal via `subscribe`:
34+
35+
```ts
36+
const handle = registerDataSource({
37+
id: 'my-plugin:state',
38+
title: 'My plugin state',
39+
data: () => store,
40+
writable: true, // opt-in: the inspector may mutate the live object
41+
subscribe: (notify) => { // optional: push the source's own change signal
42+
store.on('change', notify)
43+
return () => store.off('change', notify)
44+
},
45+
})
46+
47+
handle.notifyChanged() // or notify imperatively
48+
```
49+
2950
## Mount
3051

3152
```ts

‎plugins/data-inspector/package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@
4141
],
4242
"scripts": {
4343
"build": "tsdown && vite build --config src/spa/vite.config.ts",
44-
"dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0",
44+
"dev": "vite --config src/spa/vite.config.ts",
4545
"watch": "tsdown --watch",
46-
"storybook": "storybook dev -p 6016 --host 0.0.0.0",
46+
"storybook": "storybook dev -p 6016",
4747
"build-storybook": "storybook build",
4848
"prepack": "pnpm build",
4949
"test": "vitest run",

‎plugins/data-inspector/src/engine/contract.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,43 @@ export interface DataSourceMeta {
4747
icon?: string
4848
/** Data never changes; the server memoizes the resolved value. */
4949
static: boolean
50+
/** The source opted into live edits through the `write` RPC. */
51+
writable: boolean
5052
/** Suggested queries provided by the source (shown read-only). */
5153
queries?: Query[]
5254
}
5355

56+
/**
57+
* A value carried inside a write request. JSON can't express `undefined`,
58+
* so the payload is discriminated instead of raw.
59+
*/
60+
export type WriteValue
61+
= | { kind: 'json', value: unknown }
62+
| { kind: 'undefined' }
63+
64+
/**
65+
* One mutation of a writable source's live object. Ops are container-generic:
66+
* the server resolves the path and dispatches on what it finds there
67+
* (object / array / Map / Set).
68+
*
69+
* - `set` — replace the value at `path`.
70+
* - `delete` — remove the node at `path` from its container.
71+
* - `add` — `path` addresses the CONTAINER; insert `key`/`value`
72+
* (objects and Maps need `key`; arrays take an optional index
73+
* `key` to splice at, else append; Sets take just `value`).
74+
* - `rename` — re-key the node at `path` under `key`, atomically
75+
* (objects and Maps; the renamed key lands last).
76+
*/
77+
export type WriteRequest
78+
= | { op: 'set', path: NodePath, value: WriteValue }
79+
| { op: 'delete', path: NodePath }
80+
| { op: 'add', path: NodePath, key?: WriteValue, value: WriteValue }
81+
| { op: 'rename', path: NodePath, key: WriteValue }
82+
83+
export type WriteOutcome
84+
= | { ok: true }
85+
| { ok: false, error: { name: string, message: string } }
86+
5487
/** One completion candidate: replace [from, to) with `value`. */
5588
export interface SuggestItem {
5689
type: string

‎plugins/data-inspector/src/engine/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './contract'
22
export * from './normalize'
33
export * from './query-engine'
44
export * from './skeleton'
5+
export * from './write'
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* The write applier: mutate a live object graph in place along a `NodePath`.
3+
*
4+
* Ops are container-generic on the wire (`set` / `delete` / `add` / `rename`);
5+
* this module resolves the path with the same descent semantics as the
6+
* normalizer's `navigate` (filter options shift array indices) and dispatches
7+
* on the container it finds — plain object, array, Map, or Set. Every failure
8+
* returns a named error outcome; nothing here throws.
9+
*/
10+
import type { NodePath, PathSegment, WriteOutcome, WriteRequest, WriteValue } from './contract'
11+
import { navigate } from './normalize'
12+
13+
export interface WriteApplyOptions {
14+
/** Must match the client's view so `['i', n]` indices line up. */
15+
excludeFunctions?: boolean
16+
}
17+
18+
class WriteError extends Error {
19+
constructor(name: string, message: string) {
20+
super(message)
21+
this.name = name
22+
}
23+
}
24+
25+
/** Decode a discriminated wire value into the raw JS value to write. */
26+
function decode(value: WriteValue): unknown {
27+
return value.kind === 'undefined' ? undefined : value.value
28+
}
29+
30+
/** A JSON-expressible Map key / Set element (string-coerced object keys aside). */
31+
function decodeKey(value: WriteValue | undefined, op: string): unknown {
32+
if (!value)
33+
throw new WriteError('MissingKey', `"${op}" needs a key`)
34+
return decode(value)
35+
}
36+
37+
/** Map a filtered array index back onto the real one (mirrors `navigate`). */
38+
function realIndex(arr: unknown[], filtered: number, opts: WriteApplyOptions): number {
39+
if (!opts.excludeFunctions)
40+
return filtered
41+
let seen = -1
42+
for (let i = 0; i < arr.length; i++) {
43+
if (typeof arr[i] === 'function')
44+
continue
45+
seen++
46+
if (seen === filtered)
47+
return i
48+
}
49+
return -1
50+
}
51+
52+
function entryAt(map: Map<unknown, unknown>, index: number): [unknown, unknown] {
53+
const entry = [...map.entries()][index]
54+
if (!entry)
55+
throw new WriteError('PathNotFound', `Map entry ${index} does not exist`)
56+
return entry
57+
}
58+
59+
function elementAt(set: Set<unknown>, index: number): unknown {
60+
const values = [...set]
61+
if (index < 0 || index >= values.length)
62+
throw new WriteError('PathNotFound', `Set element ${index} does not exist`)
63+
return values[index]
64+
}
65+
66+
function assertMutableObject(target: object): void {
67+
if (Object.isFrozen(target))
68+
throw new WriteError('FrozenTarget', 'the target object is frozen')
69+
}
70+
71+
/** Resolve the container a path's final segment applies to. */
72+
function resolveParent(root: unknown, path: NodePath, opts: WriteApplyOptions): object {
73+
const parent = navigate(root, path.slice(0, -1), opts)
74+
if (parent === null || typeof parent !== 'object')
75+
throw new WriteError('PathNotFound', 'the path does not resolve to a container')
76+
return parent
77+
}
78+
79+
function setAt(parent: object, seg: PathSegment, value: unknown, opts: WriteApplyOptions): void {
80+
const [kind, at] = seg
81+
switch (kind) {
82+
case 'k': {
83+
if (parent instanceof Map) {
84+
parent.set(at, value)
85+
return
86+
}
87+
assertMutableObject(parent)
88+
const key = at as string
89+
const desc = Object.getOwnPropertyDescriptor(parent, key)
90+
if (desc && !desc.writable && !desc.set)
91+
throw new WriteError('ReadonlyProperty', `property "${key}" has no setter`)
92+
const record = parent as Record<string, unknown>
93+
record[key] = value
94+
return
95+
}
96+
case 'i': {
97+
if (!Array.isArray(parent))
98+
throw new WriteError('WrongContainer', 'an index step needs an array')
99+
const index = realIndex(parent, at as number, opts)
100+
if (index < 0 || index >= parent.length)
101+
throw new WriteError('PathNotFound', `array index ${at} does not exist`)
102+
parent[index] = value
103+
return
104+
}
105+
case 's': {
106+
if (!(parent instanceof Set))
107+
throw new WriteError('WrongContainer', 'a set step needs a Set')
108+
// A Set has no positional assignment: replace = delete + add.
109+
parent.delete(elementAt(parent, at as number))
110+
parent.add(value)
111+
return
112+
}
113+
case 'mk': {
114+
if (!(parent instanceof Map))
115+
throw new WriteError('WrongContainer', 'a map-key step needs a Map')
116+
const [oldKey, entryValue] = entryAt(parent, at as number)
117+
parent.delete(oldKey)
118+
parent.set(value, entryValue)
119+
return
120+
}
121+
case 'mv': {
122+
if (!(parent instanceof Map))
123+
throw new WriteError('WrongContainer', 'a map-value step needs a Map')
124+
const [key] = entryAt(parent, at as number)
125+
parent.set(key, value)
126+
}
127+
}
128+
}
129+
130+
function deleteAt(parent: object, seg: PathSegment, opts: WriteApplyOptions): void {
131+
const [kind, at] = seg
132+
switch (kind) {
133+
case 'k': {
134+
if (parent instanceof Map) {
135+
if (!parent.delete(at))
136+
throw new WriteError('PathNotFound', `Map key "${at}" does not exist`)
137+
return
138+
}
139+
assertMutableObject(parent)
140+
const key = at as string
141+
if (!Object.hasOwn(parent, key))
142+
throw new WriteError('PathNotFound', `property "${key}" does not exist`)
143+
if (!delete (parent as Record<string, unknown>)[key])
144+
throw new WriteError('ReadonlyProperty', `property "${key}" cannot be deleted`)
145+
return
146+
}
147+
case 'i': {
148+
if (!Array.isArray(parent))
149+
throw new WriteError('WrongContainer', 'an index step needs an array')
150+
const index = realIndex(parent, at as number, opts)
151+
if (index < 0 || index >= parent.length)
152+
throw new WriteError('PathNotFound', `array index ${at} does not exist`)
153+
parent.splice(index, 1)
154+
return
155+
}
156+
case 's': {
157+
if (!(parent instanceof Set))
158+
throw new WriteError('WrongContainer', 'a set step needs a Set')
159+
parent.delete(elementAt(parent, at as number))
160+
return
161+
}
162+
// Deleting either half of a Map entry removes the entry.
163+
case 'mk':
164+
case 'mv': {
165+
if (!(parent instanceof Map))
166+
throw new WriteError('WrongContainer', 'a map-entry step needs a Map')
167+
const [key] = entryAt(parent, at as number)
168+
parent.delete(key)
169+
}
170+
}
171+
}
172+
173+
function addTo(container: object, key: WriteValue | undefined, value: unknown, opts: WriteApplyOptions): void {
174+
if (container instanceof Map) {
175+
container.set(decodeKey(key, 'add'), value)
176+
return
177+
}
178+
if (container instanceof Set) {
179+
container.add(value)
180+
return
181+
}
182+
if (Array.isArray(container)) {
183+
const rawIndex = key ? decode(key) : undefined
184+
if (rawIndex === undefined) {
185+
container.push(value)
186+
return
187+
}
188+
if (typeof rawIndex !== 'number' || !Number.isInteger(rawIndex))
189+
throw new WriteError('InvalidKey', 'an array insertion index must be an integer')
190+
const index = realIndex(container, rawIndex, opts)
191+
container.splice(index < 0 ? container.length : index, 0, value)
192+
return
193+
}
194+
assertMutableObject(container)
195+
const propKey = decodeKey(key, 'add')
196+
if (typeof propKey !== 'string')
197+
throw new WriteError('InvalidKey', 'an object property key must be a string')
198+
const record = container as Record<string, unknown>
199+
record[propKey] = value
200+
}
201+
202+
function renameAt(parent: object, seg: PathSegment, newKey: unknown): void {
203+
const [kind, at] = seg
204+
if (kind === 'k' && parent instanceof Map) {
205+
if (!parent.has(at))
206+
throw new WriteError('PathNotFound', `Map key "${at}" does not exist`)
207+
if (newKey === at)
208+
return
209+
const value = parent.get(at)
210+
parent.delete(at)
211+
parent.set(newKey, value)
212+
return
213+
}
214+
if (kind === 'k') {
215+
assertMutableObject(parent)
216+
const key = at as string
217+
if (!Object.hasOwn(parent, key))
218+
throw new WriteError('PathNotFound', `property "${key}" does not exist`)
219+
if (typeof newKey !== 'string')
220+
throw new WriteError('InvalidKey', 'an object property key must be a string')
221+
if (newKey === key)
222+
return
223+
const value = (parent as Record<string, unknown>)[key]
224+
delete (parent as Record<string, unknown>)[key]
225+
;(parent as Record<string, unknown>)[newKey] = value
226+
return
227+
}
228+
if (kind === 'mk' || kind === 'mv') {
229+
if (!(parent instanceof Map))
230+
throw new WriteError('WrongContainer', 'a map-entry step needs a Map')
231+
const [oldKey, value] = entryAt(parent, at as number)
232+
if (newKey === oldKey)
233+
return
234+
parent.delete(oldKey)
235+
parent.set(newKey, value)
236+
return
237+
}
238+
throw new WriteError('WrongContainer', 'only keyed entries (objects, Maps) can be renamed')
239+
}
240+
241+
/**
242+
* Apply one write request to a live root object. Mutates in place;
243+
* returns a named error outcome instead of throwing.
244+
*/
245+
export function applyWrite(root: unknown, request: WriteRequest, options: WriteApplyOptions = {}): WriteOutcome {
246+
try {
247+
if (request.op === 'add') {
248+
// `add` addresses the container itself, not a node inside it.
249+
const container = navigate(root, request.path, options)
250+
if (container === null || typeof container !== 'object')
251+
throw new WriteError('PathNotFound', 'the path does not resolve to a container')
252+
addTo(container, request.key, decode(request.value), options)
253+
return { ok: true }
254+
}
255+
if (request.path.length === 0)
256+
throw new WriteError('InvalidPath', 'the root itself cannot be replaced, deleted, or renamed')
257+
const parent = resolveParent(root, request.path, options)
258+
const seg = request.path[request.path.length - 1]
259+
switch (request.op) {
260+
case 'set':
261+
setAt(parent, seg, decode(request.value), options)
262+
break
263+
case 'delete':
264+
deleteAt(parent, seg, options)
265+
break
266+
case 'rename':
267+
renameAt(parent, seg, decode(request.key))
268+
break
269+
}
270+
return { ok: true }
271+
}
272+
catch (error) {
273+
const e = error instanceof Error ? error : new Error(String(error))
274+
return { ok: false, error: { name: e.name, message: e.message } }
275+
}
276+
}

0 commit comments

Comments
 (0)