Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/json-render-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"build": "tsdown && vite build --config src/spa/vite.config.ts",
"watch": "tsdown --watch",
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6014 --host 0.0.0.0",
"storybook": "storybook dev -p 6014",
"build-storybook": "storybook build",
"test": "vitest run",
"prepack": "pnpm run build"
Expand Down
2 changes: 1 addition & 1 deletion plugins/a11y/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"build:inject": "vite build --config src/inject/vite.config.ts",
"watch": "tsdown --watch",
"dev": "node bin.mjs",
"storybook": "storybook dev -p 6015 --host 0.0.0.0",
"storybook": "storybook dev -p 6015",
"build-storybook": "storybook build",
"cli:build": "node bin.mjs build --out-dir dist/static",
"demo": "node demo/server.mjs",
Expand Down
4 changes: 2 additions & 2 deletions plugins/code-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
"build": "tsdown && vite build --config src/spa/vite.config.ts",
"watch": "tsdown --watch",
"typecheck": "tsc --noEmit",
"dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0",
"storybook": "storybook dev -p 6013 --host 0.0.0.0",
"dev": "vite --config src/spa/vite.config.ts",
"storybook": "storybook dev -p 6013",
"build-storybook": "storybook build",
"test": "vitest run",
"prepack": "pnpm run build"
Expand Down
21 changes: 21 additions & 0 deletions plugins/data-inspector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@ ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources)
})
```

## Writable sources

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`).

`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`:

```ts
const handle = registerDataSource({
id: 'my-plugin:state',
title: 'My plugin state',
data: () => store,
writable: true, // opt-in: the inspector may mutate the live object
subscribe: (notify) => { // optional: push the source's own change signal
store.on('change', notify)
return () => store.off('change', notify)
},
})

handle.notifyChanged() // or notify imperatively
```

## Mount

```ts
Expand Down
4 changes: 2 additions & 2 deletions plugins/data-inspector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
],
"scripts": {
"build": "tsdown && vite build --config src/spa/vite.config.ts",
"dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0",
"dev": "vite --config src/spa/vite.config.ts",
"watch": "tsdown --watch",
"storybook": "storybook dev -p 6016 --host 0.0.0.0",
"storybook": "storybook dev -p 6016",
"build-storybook": "storybook build",
"prepack": "pnpm build",
"test": "vitest run",
Expand Down
33 changes: 33 additions & 0 deletions plugins/data-inspector/src/engine/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,43 @@ export interface DataSourceMeta {
icon?: string
/** Data never changes; the server memoizes the resolved value. */
static: boolean
/** The source opted into live edits through the `write` RPC. */
writable: boolean
/** Suggested queries provided by the source (shown read-only). */
queries?: Query[]
}

/**
* A value carried inside a write request. JSON can't express `undefined`,
* so the payload is discriminated instead of raw.
*/
export type WriteValue
= | { kind: 'json', value: unknown }
| { kind: 'undefined' }

/**
* One mutation of a writable source's live object. Ops are container-generic:
* the server resolves the path and dispatches on what it finds there
* (object / array / Map / Set).
*
* - `set` — replace the value at `path`.
* - `delete` — remove the node at `path` from its container.
* - `add` — `path` addresses the CONTAINER; insert `key`/`value`
* (objects and Maps need `key`; arrays take an optional index
* `key` to splice at, else append; Sets take just `value`).
* - `rename` — re-key the node at `path` under `key`, atomically
* (objects and Maps; the renamed key lands last).
*/
export type WriteRequest
= | { op: 'set', path: NodePath, value: WriteValue }
| { op: 'delete', path: NodePath }
| { op: 'add', path: NodePath, key?: WriteValue, value: WriteValue }
| { op: 'rename', path: NodePath, key: WriteValue }

export type WriteOutcome
= | { ok: true }
| { ok: false, error: { name: string, message: string } }

/** One completion candidate: replace [from, to) with `value`. */
export interface SuggestItem {
type: string
Expand Down
1 change: 1 addition & 0 deletions plugins/data-inspector/src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './contract'
export * from './normalize'
export * from './query-engine'
export * from './skeleton'
export * from './write'
276 changes: 276 additions & 0 deletions plugins/data-inspector/src/engine/write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
/**
* The write applier: mutate a live object graph in place along a `NodePath`.
*
* Ops are container-generic on the wire (`set` / `delete` / `add` / `rename`);
* this module resolves the path with the same descent semantics as the
* normalizer's `navigate` (filter options shift array indices) and dispatches
* on the container it finds — plain object, array, Map, or Set. Every failure
* returns a named error outcome; nothing here throws.
*/
import type { NodePath, PathSegment, WriteOutcome, WriteRequest, WriteValue } from './contract'
import { navigate } from './normalize'

export interface WriteApplyOptions {
/** Must match the client's view so `['i', n]` indices line up. */
excludeFunctions?: boolean
}

class WriteError extends Error {
constructor(name: string, message: string) {
super(message)
this.name = name
}
}

/** Decode a discriminated wire value into the raw JS value to write. */
function decode(value: WriteValue): unknown {
return value.kind === 'undefined' ? undefined : value.value
}

/** A JSON-expressible Map key / Set element (string-coerced object keys aside). */
function decodeKey(value: WriteValue | undefined, op: string): unknown {
if (!value)
throw new WriteError('MissingKey', `"${op}" needs a key`)
return decode(value)
}

/** Map a filtered array index back onto the real one (mirrors `navigate`). */
function realIndex(arr: unknown[], filtered: number, opts: WriteApplyOptions): number {
if (!opts.excludeFunctions)
return filtered
let seen = -1
for (let i = 0; i < arr.length; i++) {
if (typeof arr[i] === 'function')
continue
seen++
if (seen === filtered)
return i
}
return -1
}

function entryAt(map: Map<unknown, unknown>, index: number): [unknown, unknown] {
const entry = [...map.entries()][index]
if (!entry)
throw new WriteError('PathNotFound', `Map entry ${index} does not exist`)
return entry
}

function elementAt(set: Set<unknown>, index: number): unknown {
const values = [...set]
if (index < 0 || index >= values.length)
throw new WriteError('PathNotFound', `Set element ${index} does not exist`)
return values[index]
}

function assertMutableObject(target: object): void {
if (Object.isFrozen(target))
throw new WriteError('FrozenTarget', 'the target object is frozen')
}

/** Resolve the container a path's final segment applies to. */
function resolveParent(root: unknown, path: NodePath, opts: WriteApplyOptions): object {
const parent = navigate(root, path.slice(0, -1), opts)
if (parent === null || typeof parent !== 'object')
throw new WriteError('PathNotFound', 'the path does not resolve to a container')
return parent
}

function setAt(parent: object, seg: PathSegment, value: unknown, opts: WriteApplyOptions): void {
const [kind, at] = seg
switch (kind) {
case 'k': {
if (parent instanceof Map) {
parent.set(at, value)
return
}
assertMutableObject(parent)
const key = at as string
const desc = Object.getOwnPropertyDescriptor(parent, key)
if (desc && !desc.writable && !desc.set)
throw new WriteError('ReadonlyProperty', `property "${key}" has no setter`)
const record = parent as Record<string, unknown>
record[key] = value
return
}
case 'i': {
if (!Array.isArray(parent))
throw new WriteError('WrongContainer', 'an index step needs an array')
const index = realIndex(parent, at as number, opts)
if (index < 0 || index >= parent.length)
throw new WriteError('PathNotFound', `array index ${at} does not exist`)
parent[index] = value
return
}
case 's': {
if (!(parent instanceof Set))
throw new WriteError('WrongContainer', 'a set step needs a Set')
// A Set has no positional assignment: replace = delete + add.
parent.delete(elementAt(parent, at as number))
parent.add(value)
return
}
case 'mk': {
if (!(parent instanceof Map))
throw new WriteError('WrongContainer', 'a map-key step needs a Map')
const [oldKey, entryValue] = entryAt(parent, at as number)
parent.delete(oldKey)
parent.set(value, entryValue)
return
}
case 'mv': {
if (!(parent instanceof Map))
throw new WriteError('WrongContainer', 'a map-value step needs a Map')
const [key] = entryAt(parent, at as number)
parent.set(key, value)
}
}
}

function deleteAt(parent: object, seg: PathSegment, opts: WriteApplyOptions): void {
const [kind, at] = seg
switch (kind) {
case 'k': {
if (parent instanceof Map) {
if (!parent.delete(at))
throw new WriteError('PathNotFound', `Map key "${at}" does not exist`)
return
}
assertMutableObject(parent)
const key = at as string
if (!Object.hasOwn(parent, key))
throw new WriteError('PathNotFound', `property "${key}" does not exist`)
if (!delete (parent as Record<string, unknown>)[key])
throw new WriteError('ReadonlyProperty', `property "${key}" cannot be deleted`)
return
}
case 'i': {
if (!Array.isArray(parent))
throw new WriteError('WrongContainer', 'an index step needs an array')
const index = realIndex(parent, at as number, opts)
if (index < 0 || index >= parent.length)
throw new WriteError('PathNotFound', `array index ${at} does not exist`)
parent.splice(index, 1)
return
}
case 's': {
if (!(parent instanceof Set))
throw new WriteError('WrongContainer', 'a set step needs a Set')
parent.delete(elementAt(parent, at as number))
return
}
// Deleting either half of a Map entry removes the entry.
case 'mk':
case 'mv': {
if (!(parent instanceof Map))
throw new WriteError('WrongContainer', 'a map-entry step needs a Map')
const [key] = entryAt(parent, at as number)
parent.delete(key)
}
}
}

function addTo(container: object, key: WriteValue | undefined, value: unknown, opts: WriteApplyOptions): void {
if (container instanceof Map) {
container.set(decodeKey(key, 'add'), value)
return
}
if (container instanceof Set) {
container.add(value)
return
}
if (Array.isArray(container)) {
const rawIndex = key ? decode(key) : undefined
if (rawIndex === undefined) {
container.push(value)
return
}
if (typeof rawIndex !== 'number' || !Number.isInteger(rawIndex))
throw new WriteError('InvalidKey', 'an array insertion index must be an integer')
const index = realIndex(container, rawIndex, opts)
container.splice(index < 0 ? container.length : index, 0, value)
return
}
assertMutableObject(container)
const propKey = decodeKey(key, 'add')
if (typeof propKey !== 'string')
throw new WriteError('InvalidKey', 'an object property key must be a string')
const record = container as Record<string, unknown>
record[propKey] = value
}

function renameAt(parent: object, seg: PathSegment, newKey: unknown): void {
const [kind, at] = seg
if (kind === 'k' && parent instanceof Map) {
if (!parent.has(at))
throw new WriteError('PathNotFound', `Map key "${at}" does not exist`)
if (newKey === at)
return
const value = parent.get(at)
parent.delete(at)
parent.set(newKey, value)
return
}
if (kind === 'k') {
assertMutableObject(parent)
const key = at as string
if (!Object.hasOwn(parent, key))
throw new WriteError('PathNotFound', `property "${key}" does not exist`)
if (typeof newKey !== 'string')
throw new WriteError('InvalidKey', 'an object property key must be a string')
if (newKey === key)
return
const value = (parent as Record<string, unknown>)[key]
delete (parent as Record<string, unknown>)[key]
;(parent as Record<string, unknown>)[newKey] = value
return
}
if (kind === 'mk' || kind === 'mv') {
if (!(parent instanceof Map))
throw new WriteError('WrongContainer', 'a map-entry step needs a Map')
const [oldKey, value] = entryAt(parent, at as number)
if (newKey === oldKey)
return
parent.delete(oldKey)
parent.set(newKey, value)
return
}
throw new WriteError('WrongContainer', 'only keyed entries (objects, Maps) can be renamed')
}

/**
* Apply one write request to a live root object. Mutates in place;
* returns a named error outcome instead of throwing.
*/
export function applyWrite(root: unknown, request: WriteRequest, options: WriteApplyOptions = {}): WriteOutcome {
try {
if (request.op === 'add') {
// `add` addresses the container itself, not a node inside it.
const container = navigate(root, request.path, options)
if (container === null || typeof container !== 'object')
throw new WriteError('PathNotFound', 'the path does not resolve to a container')
addTo(container, request.key, decode(request.value), options)
return { ok: true }
}
if (request.path.length === 0)
throw new WriteError('InvalidPath', 'the root itself cannot be replaced, deleted, or renamed')
const parent = resolveParent(root, request.path, options)
const seg = request.path[request.path.length - 1]
switch (request.op) {
case 'set':
setAt(parent, seg, decode(request.value), options)
break
case 'delete':
deleteAt(parent, seg, options)
break
case 'rename':
renameAt(parent, seg, decode(request.key))
break
}
return { ok: true }
}
catch (error) {
const e = error instanceof Error ? error : new Error(String(error))
return { ok: false, error: { name: e.name, message: e.message } }
}
}
Loading
Loading