@preset.nz/pom 0.0.0-stage → 0.1.5
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/LICENSE +21 -0
- package/README.md +64 -2
- package/package.json +36 -3
- package/src-ts/backend.ts +56 -0
- package/src-ts/channel.ts +132 -0
- package/src-ts/index.ts +8 -0
- package/src-ts/mirror.ts +248 -0
- package/src-ts/react.ts +73 -0
- package/src-ts/wire.ts +188 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 preset.nz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# rhizome
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The shared node API for the preset.nz desktop apps: mechanics only. A network of nodes with no fixed centre.
|
|
4
|
+
|
|
5
|
+
rhizome knows no app. It stores, validates, edits, undoes, diffs, saves and copies nodes of whatever types an app declares. Each app's **object model** holds its business logic, in the app's own repo.
|
|
6
|
+
|
|
7
|
+
**Status: first slice and POM phase 1 built.** `crates/rhizome-core` holds the mechanics: the registry, the tree, edits with undo, diff, the file format, opaque nodes, copy/paste, tree rules. `crates/rhizome-pom` is POM, the Preset Object Model: the base every app's object model is built on (documents, kinds with policy, presets, commands, projections). `crates/rhizome-pom-tauri` puts one open document behind Tauri commands and events. `@preset.nz/pom` (`src-ts/`) is the webview client of `rhizome-pom-tauri`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```toml
|
|
12
|
+
[dependencies]
|
|
13
|
+
rhizome-core = "0.1"
|
|
14
|
+
rhizome-pom = "0.1" # an object model on POM
|
|
15
|
+
rhizome-pom-tauri = "0.1" # a Tauri 2 app with one open document
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Take only what you need: `rhizome-pom` brings `rhizome-core` with it, and `rhizome-pom-tauri` brings both.
|
|
19
|
+
|
|
20
|
+
## TypeScript: `@preset.nz/pom`
|
|
21
|
+
|
|
22
|
+
The webview client of `rhizome-pom-tauri`: unbuilt TypeScript in `src-ts/`, versioned with the crates and not yet published. `react` and `@tauri-apps/api` are peer dependencies.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createChannel, createMirror, PomProvider, tauriBackend, useNode } from '@preset.nz/pom';
|
|
26
|
+
|
|
27
|
+
const channel = createChannel(tauriBackend); // commands reach Rust in the order they were made
|
|
28
|
+
const mirror = createMirror(tauriBackend); // made outside React
|
|
29
|
+
await mirror.start(); // connect, listen, then read the view
|
|
30
|
+
|
|
31
|
+
await channel.run('value.set', { at: '/threads/warp', key: 'thread.tension', value: 0.8 });
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **`PomBackend`** is the `pom_*` commands and the `pom://commit` and `pom://status` events, nothing else. `tauriBackend` implements it over Tauri; tests pass a fake.
|
|
35
|
+
- **`createChannel`** is one ordered queue. `run`, `begin`, `end` and `cancel` keep their order, so a gesture's `end` finds its token. `drag` is latest-wins, for scrubs and transforms only. The gate starts open; `{ held: true }` shuts it until `release()`.
|
|
36
|
+
- **`createMirror`** keeps the rows by id with a path index, from the view and the commits that follow. It re-reads the view on a gap in `seq` or a new `generation`.
|
|
37
|
+
- **Hooks**, through `useSyncExternalStore`: `useNode(id)`, `useNodeAt(path)`, `useChildren(id, order)` and `useStatus()`, under a `<PomProvider mirror={mirror}>`. A hook returns the same object until its own node changes.
|
|
38
|
+
- **Wire types** (`Row`, `View`, `Update`, `Status`, `Ran`, ...) follow the Rust structs. The tests read the Rust goldens, so a shape that moves in one half fails the other's build.
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
`just check` runs `check-rust` (fmt, clippy and every suite) and `check-ts` (tsc, Biome and vitest), then the licence check.
|
|
43
|
+
|
|
44
|
+
`just check` runs fmt, clippy and every suite.
|
|
45
|
+
|
|
46
|
+
- **Acid** (`tests/acid.rs`, `just acid`): builds one of everything through the Rust verbs and again through `Op`s, then operates every verb, refusal and cascade. The built file and the changeset log are goldens in `tests/golden/`.
|
|
47
|
+
- **Core workflows** (`tests/core_workflows.rs`, `just workflows`): twelve workflows a person does, stored as data in `tests/workflows/*.json` and driven only through the stable surface (`Op` JSON, the file format, edits, gestures, undo). Each has a transcript pinned beside it. A change to the SDK that alters a workflow fails here.
|
|
48
|
+
- **POM** (`crates/rhizome-pom/tests/pom.rs`, `just pom`) and **POM's core workflows** (`tests/workflows.rs`, data in `tests/workflows/*.json`): two made-up object models, and six workflows run through commands by id.
|
|
49
|
+
- **Wire** (`crates/rhizome-pom/tests/wire.rs`): the `View`, `Update`, `Status`, `Ran` and `CommandState` JSON a transport carries, pinned in `crates/rhizome-pom/tests/golden/*.json`. With core's `commit.json` and `view.json` they are what the TypeScript tests (`tests/`, `just test-ts`) read.
|
|
50
|
+
- **Invariants** (`tests/invariants.rs`, `just invariants`): seeded random `Op`s, checking after every step that nothing dangles, values fit their schema, a refused edit leaves no trace, a save reloads identically and undo walks back through every state.
|
|
51
|
+
|
|
52
|
+
`just bless` rewrites the goldens. Read every changed line before committing it.
|
|
53
|
+
|
|
54
|
+
## Design docs
|
|
55
|
+
|
|
56
|
+
`guidance/` holds **copies** of the planning docs so the work can continue away from the main machine. The canonical versions live in the private guidance repo (`design/node-api.md`, `design/rhizome-api.md`, `design/pom.md`, `handovers/rhizome/handover.md`). Edit there, then recopy. Relative links inside the copies won't resolve.
|
|
57
|
+
|
|
58
|
+
- [`guidance/node-api.md`](guidance/node-api.md): the model and decisions 1 to 35.
|
|
59
|
+
- [`guidance/rhizome-api.md`](guidance/rhizome-api.md): the API as built — verbs, shape, guarantees, and the line between rhizome and an object model.
|
|
60
|
+
- [`guidance/pom.md`](guidance/pom.md): POM, the Preset Object Model every app's object model builds on (draft).
|
|
61
|
+
- [`guidance/handover.md`](guidance/handover.md): code state and next steps.
|
|
62
|
+
|
|
63
|
+
## Licence
|
|
64
|
+
|
|
65
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preset.nz/pom",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "The webview client of rhizome-pom-tauri: an ordered command channel, a read-only mirror of the document and React hooks over it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/preset-nz/rhizome.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src-ts/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src-ts"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@tauri-apps/api": "^2",
|
|
23
|
+
"react": "^19"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@biomejs/biome": "^2.5.15",
|
|
27
|
+
"@tauri-apps/api": "^2",
|
|
28
|
+
"@testing-library/react": "^16",
|
|
29
|
+
"@types/node": "^24",
|
|
30
|
+
"@types/react": "^19",
|
|
31
|
+
"jsdom": "^27",
|
|
32
|
+
"lefthook": "^2.1.17",
|
|
33
|
+
"react": "^19",
|
|
34
|
+
"react-dom": "^19",
|
|
35
|
+
"@types/react-dom": "^19",
|
|
36
|
+
"typescript": "^5.8.3",
|
|
37
|
+
"vitest": "^4.1.11"
|
|
38
|
+
}
|
|
6
39
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one module that talks to `rhizome-pom-tauri`: its `pom_*` commands and its
|
|
3
|
+
* `pom://commit` and `pom://status` events. Commands only (rhizome decision 46): there is no
|
|
4
|
+
* way to send a raw edit. An app's own events and commands stay in the app.
|
|
5
|
+
*
|
|
6
|
+
* `PomBackend` is the interface; `tauriBackend` implements it over Tauri, and tests pass a fake.
|
|
7
|
+
*/
|
|
8
|
+
import { invoke } from '@tauri-apps/api/core';
|
|
9
|
+
import { listen } from '@tauri-apps/api/event';
|
|
10
|
+
import type { CommandState, Ran, Status, Update, View } from './wire.ts';
|
|
11
|
+
|
|
12
|
+
export type Unlisten = () => void;
|
|
13
|
+
|
|
14
|
+
export interface PomBackend {
|
|
15
|
+
/**
|
|
16
|
+
* The front end is listening. Ends any gesture a reloaded page left open, sends the status,
|
|
17
|
+
* and answers a path macOS asked the app to open before the page was listening, if any. A file
|
|
18
|
+
* opened that way wins over a restored one.
|
|
19
|
+
*/
|
|
20
|
+
connect(): Promise<string | null>;
|
|
21
|
+
view(): Promise<View>;
|
|
22
|
+
status(): Promise<Status>;
|
|
23
|
+
/** Every command with its label and whether it can run with `payload`. */
|
|
24
|
+
commands(payload?: unknown): Promise<CommandState[]>;
|
|
25
|
+
/**
|
|
26
|
+
* Runs a command by id. With `coalesce`, consecutive runs with that key are one undo step.
|
|
27
|
+
*/
|
|
28
|
+
run(id: string, payload?: unknown, coalesce?: string): Promise<Ran>;
|
|
29
|
+
/** Opens a gesture: commands run until `end` are one undo step. Answers its token. */
|
|
30
|
+
begin(label: string): Promise<number>;
|
|
31
|
+
end(token: number): Promise<void>;
|
|
32
|
+
/** Ends a gesture and takes back everything done in it. */
|
|
33
|
+
cancel(token: number): Promise<void>;
|
|
34
|
+
/** Every commit, in order: undo, redo and cancel included. */
|
|
35
|
+
onUpdate(f: (u: Update) => void): Promise<Unlisten>;
|
|
36
|
+
/** The status, whenever it changed. */
|
|
37
|
+
onStatus(f: (s: Status) => void): Promise<Unlisten>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The event names `rhizome-pom-tauri` emits. */
|
|
41
|
+
export const COMMIT_EVENT = 'pom://commit';
|
|
42
|
+
export const STATUS_EVENT = 'pom://status';
|
|
43
|
+
|
|
44
|
+
/** Tauri camelCases the argument names of a Rust command; these are all one word. */
|
|
45
|
+
export const tauriBackend: PomBackend = {
|
|
46
|
+
connect: () => invoke<string | null>('pom_connect'),
|
|
47
|
+
view: () => invoke<View>('pom_view'),
|
|
48
|
+
status: () => invoke<Status>('pom_status'),
|
|
49
|
+
commands: (payload) => invoke<CommandState[]>('pom_commands', { payload }),
|
|
50
|
+
run: (id, payload = {}, coalesce) => invoke<Ran>('pom_run', { id, payload, coalesce }),
|
|
51
|
+
begin: (label) => invoke<number>('pom_begin', { label }),
|
|
52
|
+
end: (token) => invoke<void>('pom_end', { token }),
|
|
53
|
+
cancel: (token) => invoke<void>('pom_cancel', { token }),
|
|
54
|
+
onUpdate: (f) => listen<Update>(COMMIT_EVENT, (e) => f(e.payload)),
|
|
55
|
+
onStatus: (f) => listen<Status>(STATUS_EVENT, (e) => f(e.payload)),
|
|
56
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ordering on top of a `PomBackend`. Every call goes through one promise chain, so commands
|
|
3
|
+
* reach Rust in the order they were made and a gesture's `end` finds its token.
|
|
4
|
+
*/
|
|
5
|
+
import type { PomBackend } from './backend.ts';
|
|
6
|
+
import type { CommandState, Ran, Status, View } from './wire.ts';
|
|
7
|
+
|
|
8
|
+
export interface ChannelOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Start with the gate shut: calls queue until `release()`. For an app that has to be ready
|
|
11
|
+
* before its first command may run. The default is open.
|
|
12
|
+
*/
|
|
13
|
+
held?: boolean;
|
|
14
|
+
/** Resolves after the next paint; a drag's next command waits for it. */
|
|
15
|
+
nextFrame?: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const defaultNextFrame = (): Promise<void> =>
|
|
19
|
+
new Promise((resolve) => {
|
|
20
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => resolve());
|
|
21
|
+
else setTimeout(resolve, 0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
interface Waiting {
|
|
25
|
+
id: string;
|
|
26
|
+
payload: unknown;
|
|
27
|
+
coalesce?: string;
|
|
28
|
+
done: Promise<Ran>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Channel {
|
|
32
|
+
/** Calls made and not yet answered; a drag that replaced a waiting move doesn't count. */
|
|
33
|
+
readonly pending: number;
|
|
34
|
+
/** Lets the queued calls through. A no-op on a gate that is already open. */
|
|
35
|
+
release(): void;
|
|
36
|
+
/** Called whenever the queue empties. Returns a function that stops listening. */
|
|
37
|
+
onIdle(f: () => void): () => void;
|
|
38
|
+
run(id: string, payload?: unknown, coalesce?: string): Promise<Ran>;
|
|
39
|
+
/**
|
|
40
|
+
* A command for a scrub or a transform only: latest wins. While one is on its way, a newer
|
|
41
|
+
* move with the same key replaces the one waiting instead of queueing behind it, and the
|
|
42
|
+
* next is sent only after a frame, once the last update could be applied and painted.
|
|
43
|
+
* Without this a slow redraw builds a backlog and the result trails the pointer.
|
|
44
|
+
*
|
|
45
|
+
* **Never use it for input that accumulates**, such as a paint stroke: the moves it drops
|
|
46
|
+
* are gone. A stroke batches its own cells and calls `run` once per frame.
|
|
47
|
+
*/
|
|
48
|
+
drag(key: string, id: string, payload: unknown, coalesce?: string): Promise<Ran>;
|
|
49
|
+
/** Opens a gesture; its token is what `end` and `cancel` take. */
|
|
50
|
+
begin(label: string): Promise<number>;
|
|
51
|
+
/** Takes the token, or its promise, so it is queued behind everything the gesture ran. */
|
|
52
|
+
end(token: number | Promise<number>): Promise<void>;
|
|
53
|
+
cancel(token: number | Promise<number>): Promise<void>;
|
|
54
|
+
view(): Promise<View>;
|
|
55
|
+
status(): Promise<Status>;
|
|
56
|
+
commands(payload?: unknown): Promise<CommandState[]>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createChannel(backend: PomBackend, options: ChannelOptions = {}): Channel {
|
|
60
|
+
const nextFrame = options.nextFrame ?? defaultNextFrame;
|
|
61
|
+
let release: () => void = () => undefined;
|
|
62
|
+
let chain: Promise<unknown> = options.held
|
|
63
|
+
? new Promise<void>((resolve) => {
|
|
64
|
+
release = resolve;
|
|
65
|
+
})
|
|
66
|
+
: Promise.resolve();
|
|
67
|
+
let pending = 0;
|
|
68
|
+
const idle = new Set<() => void>();
|
|
69
|
+
|
|
70
|
+
/** Runs `f` after every call made before it, and after the gate opens. */
|
|
71
|
+
function queued<T>(f: () => Promise<T>): Promise<T> {
|
|
72
|
+
pending += 1;
|
|
73
|
+
const next = chain.then(f);
|
|
74
|
+
chain = next.catch(() => undefined);
|
|
75
|
+
const settle = () => {
|
|
76
|
+
pending -= 1;
|
|
77
|
+
if (pending === 0) for (const f of [...idle]) f();
|
|
78
|
+
};
|
|
79
|
+
next.then(settle, settle);
|
|
80
|
+
return next;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const waiting = new Map<string, Waiting>();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
get pending() {
|
|
87
|
+
return pending;
|
|
88
|
+
},
|
|
89
|
+
release: () => release(),
|
|
90
|
+
onIdle: (f) => {
|
|
91
|
+
idle.add(f);
|
|
92
|
+
return () => {
|
|
93
|
+
idle.delete(f);
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
run: (id, payload = {}, coalesce) => queued(() => backend.run(id, payload, coalesce)),
|
|
97
|
+
drag(key, id, payload, coalesce) {
|
|
98
|
+
const w = waiting.get(key);
|
|
99
|
+
if (w) {
|
|
100
|
+
w.id = id;
|
|
101
|
+
w.payload = payload;
|
|
102
|
+
w.coalesce = coalesce;
|
|
103
|
+
return w.done;
|
|
104
|
+
}
|
|
105
|
+
const entry = { id, payload, coalesce } as Waiting;
|
|
106
|
+
waiting.set(key, entry);
|
|
107
|
+
entry.done = queued(async () => {
|
|
108
|
+
waiting.delete(key);
|
|
109
|
+
try {
|
|
110
|
+
return await backend.run(entry.id, entry.payload, entry.coalesce);
|
|
111
|
+
} finally {
|
|
112
|
+
await nextFrame();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return entry.done;
|
|
116
|
+
},
|
|
117
|
+
begin: (label) => queued(() => backend.begin(label)),
|
|
118
|
+
end: (token) =>
|
|
119
|
+
queued(async () => {
|
|
120
|
+
const t = await Promise.resolve(token).catch(() => null);
|
|
121
|
+
if (t !== null) await backend.end(t);
|
|
122
|
+
}),
|
|
123
|
+
cancel: (token) =>
|
|
124
|
+
queued(async () => {
|
|
125
|
+
const t = await Promise.resolve(token).catch(() => null);
|
|
126
|
+
if (t !== null) await backend.cancel(t);
|
|
127
|
+
}),
|
|
128
|
+
view: () => queued(() => backend.view()),
|
|
129
|
+
status: () => queued(() => backend.status()),
|
|
130
|
+
commands: (payload) => queued(() => backend.commands(payload)),
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src-ts/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { PomBackend, Unlisten } from './backend.ts';
|
|
2
|
+
export { COMMIT_EVENT, STATUS_EVENT, tauriBackend } from './backend.ts';
|
|
3
|
+
export type { Channel, ChannelOptions } from './channel.ts';
|
|
4
|
+
export { createChannel } from './channel.ts';
|
|
5
|
+
export type { Applied, MirrorOptions } from './mirror.ts';
|
|
6
|
+
export { createMirror, Mirror, Rows } from './mirror.ts';
|
|
7
|
+
export { PomProvider, useChildren, useMirror, useNode, useNodeAt, useStatus } from './react.ts';
|
|
8
|
+
export type * from './wire.ts';
|
package/src-ts/mirror.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document as rows, by id with a path index (rhizome decision 48): a view, then updates.
|
|
3
|
+
* Read-only: every edit is a command through the channel, and the commit that comes back is
|
|
4
|
+
* what changes the mirror. Create it outside React, `start()` it once, and read it through the
|
|
5
|
+
* hooks.
|
|
6
|
+
*/
|
|
7
|
+
import type { PomBackend, Unlisten } from './backend.ts';
|
|
8
|
+
import type { NodeId, Row, Schema, Status, Update, View } from './wire.ts';
|
|
9
|
+
|
|
10
|
+
export interface MirrorOptions {
|
|
11
|
+
/** A re-read that failed after the mirror started. The mirror keeps what it had. */
|
|
12
|
+
onError?: (error: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Event = { kind: 'update'; update: Update } | { kind: 'status'; status: Status };
|
|
16
|
+
|
|
17
|
+
/** What `Mirror.apply` did with an update. */
|
|
18
|
+
export type Applied = 'applied' | 'stale' | 'gap';
|
|
19
|
+
|
|
20
|
+
/** The rows and the rules for taking updates: no backend, no events. `Mirror` drives it. */
|
|
21
|
+
export class Rows {
|
|
22
|
+
private byId = new Map<NodeId, Row>();
|
|
23
|
+
private paths = new Map<string, NodeId>();
|
|
24
|
+
schema: Schema = { categories: [], types: [] };
|
|
25
|
+
seq = 0;
|
|
26
|
+
generation = 0;
|
|
27
|
+
|
|
28
|
+
get size(): number {
|
|
29
|
+
return this.byId.size;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ids(): IterableIterator<NodeId> {
|
|
33
|
+
return this.byId.keys();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(id: NodeId): Row | undefined {
|
|
37
|
+
return this.byId.get(id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
byPath(path: string): Row | undefined {
|
|
41
|
+
const id = this.paths.get(path);
|
|
42
|
+
return id === undefined ? undefined : this.byId.get(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
load(view: View): void {
|
|
46
|
+
this.byId.clear();
|
|
47
|
+
this.paths.clear();
|
|
48
|
+
for (const r of view.rows) this.put(r);
|
|
49
|
+
this.schema = view.schema;
|
|
50
|
+
this.seq = view.seq;
|
|
51
|
+
this.generation = view.generation;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An update applies when it is next in line. `stale` is one from an older generation, or at
|
|
56
|
+
* or before the seq the mirror has. `gap` is anything else: a seq that skips, or a
|
|
57
|
+
* generation the mirror hasn't loaded yet. Either way the caller re-reads the view.
|
|
58
|
+
*/
|
|
59
|
+
apply(u: Update): Applied {
|
|
60
|
+
if (u.generation < this.generation) return 'stale';
|
|
61
|
+
if (u.generation > this.generation) return 'gap';
|
|
62
|
+
if (u.seq <= this.seq) return 'stale';
|
|
63
|
+
if (u.seq !== this.seq + 1) return 'gap';
|
|
64
|
+
this.seq = u.seq;
|
|
65
|
+
for (const id of u.removed) this.drop(id);
|
|
66
|
+
for (const r of u.rows) this.put(r);
|
|
67
|
+
return 'applied';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private put(r: Row): void {
|
|
71
|
+
const old = this.byId.get(r.id);
|
|
72
|
+
if (old && old.path !== r.path && this.paths.get(old.path) === r.id) {
|
|
73
|
+
this.paths.delete(old.path);
|
|
74
|
+
}
|
|
75
|
+
this.byId.set(r.id, r);
|
|
76
|
+
this.paths.set(r.path, r.id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private drop(id: NodeId): void {
|
|
80
|
+
const old = this.byId.get(id);
|
|
81
|
+
if (old && this.paths.get(old.path) === id) this.paths.delete(old.path);
|
|
82
|
+
this.byId.delete(id);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class Mirror {
|
|
87
|
+
private readonly rows = new Rows();
|
|
88
|
+
private readonly listeners = new Set<() => void>();
|
|
89
|
+
private current: Status | null = null;
|
|
90
|
+
private loaded = false;
|
|
91
|
+
private reading = false;
|
|
92
|
+
private again = false;
|
|
93
|
+
private buffer: Event[] = [];
|
|
94
|
+
private unlisten: Unlisten[] = [];
|
|
95
|
+
private started = false;
|
|
96
|
+
private stopped = false;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly backend: PomBackend,
|
|
100
|
+
private readonly options: MirrorOptions = {},
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
/** `true` once the first view has loaded. */
|
|
104
|
+
get ready(): boolean {
|
|
105
|
+
return this.loaded;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get status(): Status | null {
|
|
109
|
+
return this.current;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get schema(): Schema {
|
|
113
|
+
return this.rows.schema;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get seq(): number {
|
|
117
|
+
return this.rows.seq;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get generation(): number {
|
|
121
|
+
return this.rows.generation;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get size(): number {
|
|
125
|
+
return this.rows.size;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The row for `id`. A row is immutable; a changed node is a new object. */
|
|
129
|
+
node(id: NodeId): Row | undefined {
|
|
130
|
+
return this.rows.get(id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
nodeAt(path: string): Row | undefined {
|
|
134
|
+
return this.rows.byPath(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ids(): IterableIterator<NodeId> {
|
|
138
|
+
return this.rows.ids();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Calls `f` after the mirror changes. Returns a function that stops listening. */
|
|
142
|
+
subscribe = (f: () => void): (() => void) => {
|
|
143
|
+
this.listeners.add(f);
|
|
144
|
+
return () => {
|
|
145
|
+
this.listeners.delete(f);
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Connects, listens, then reads the view, in that order, so nothing a commit sends in
|
|
151
|
+
* between is lost: updates and statuses that arrive before the view resolves wait, and are
|
|
152
|
+
* applied after it (the ones it already covers are dropped as stale). Answers what
|
|
153
|
+
* `connect` did: a path macOS asked the app to open before the page was listening.
|
|
154
|
+
* Calling it again is a no-op.
|
|
155
|
+
*/
|
|
156
|
+
async start(): Promise<string | null> {
|
|
157
|
+
if (this.started || this.stopped) return null;
|
|
158
|
+
this.started = true;
|
|
159
|
+
const held = await this.backend.connect();
|
|
160
|
+
this.reading = true; // from here on, events wait for the view
|
|
161
|
+
const u = await this.backend.onUpdate((update) => this.receive({ kind: 'update', update }));
|
|
162
|
+
const s = await this.backend.onStatus((status) => this.receive({ kind: 'status', status }));
|
|
163
|
+
this.unlisten = [u, s];
|
|
164
|
+
if (this.stopped) {
|
|
165
|
+
this.stop();
|
|
166
|
+
return held;
|
|
167
|
+
}
|
|
168
|
+
await this.reload(true);
|
|
169
|
+
// connect sends the status as an event; ask as well, for one that went before we listened
|
|
170
|
+
if (this.current === null && !this.stopped) {
|
|
171
|
+
const status = await this.backend.status();
|
|
172
|
+
if (this.current === null) this.setStatus(status);
|
|
173
|
+
}
|
|
174
|
+
return held;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Stops listening. The rows stay readable. */
|
|
178
|
+
stop(): void {
|
|
179
|
+
this.stopped = true;
|
|
180
|
+
for (const f of this.unlisten.splice(0)) f();
|
|
181
|
+
this.buffer = [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private receive(e: Event): void {
|
|
185
|
+
if (this.stopped) return;
|
|
186
|
+
if (this.reading) this.buffer.push(e);
|
|
187
|
+
else this.process(e);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private process(e: Event): void {
|
|
191
|
+
if (e.kind === 'status') {
|
|
192
|
+
this.setStatus(e.status);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const result = this.rows.apply(e.update);
|
|
196
|
+
if (result === 'applied') this.notify();
|
|
197
|
+
else if (result === 'gap') this.refresh();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private setStatus(s: Status): void {
|
|
201
|
+
this.current = s;
|
|
202
|
+
this.notify();
|
|
203
|
+
if (this.loaded && s.generation > this.rows.generation) this.refresh();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private refresh(): void {
|
|
207
|
+
this.reload().catch((error) => this.options.onError?.(error));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reads the view and takes it, then applies what arrived while it was on its way. A
|
|
212
|
+
* re-read asked for meanwhile (another gap, a newer generation) runs again after.
|
|
213
|
+
*/
|
|
214
|
+
private async reload(initial = false): Promise<void> {
|
|
215
|
+
if (this.reading && !initial) {
|
|
216
|
+
this.again = true;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.reading = true;
|
|
220
|
+
try {
|
|
221
|
+
do {
|
|
222
|
+
this.again = false;
|
|
223
|
+
const view = await this.backend.view();
|
|
224
|
+
if (this.stopped) return;
|
|
225
|
+
// only ever forward: a view older than what is held would undo an update
|
|
226
|
+
if (!this.loaded || view.generation > this.rows.generation || view.seq >= this.rows.seq) {
|
|
227
|
+
this.rows.load(view);
|
|
228
|
+
this.loaded = true;
|
|
229
|
+
this.notify();
|
|
230
|
+
}
|
|
231
|
+
const waiting = this.buffer.splice(0);
|
|
232
|
+
for (const e of waiting) this.process(e);
|
|
233
|
+
} while (this.again && !this.stopped);
|
|
234
|
+
} finally {
|
|
235
|
+
this.reading = false;
|
|
236
|
+
this.again = false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private notify(): void {
|
|
241
|
+
for (const f of [...this.listeners]) f();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A mirror for `backend`. Call `start()` on it once. */
|
|
246
|
+
export function createMirror(backend: PomBackend, options?: MirrorOptions): Mirror {
|
|
247
|
+
return new Mirror(backend, options);
|
|
248
|
+
}
|
package/src-ts/react.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks over a `Mirror`, through `useSyncExternalStore`. The mirror is made outside
|
|
3
|
+
* React and handed in with `PomProvider`, so StrictMode's double mount costs nothing. Rows are
|
|
4
|
+
* immutable and a changed node is a new object, so a hook returns the same value until its own
|
|
5
|
+
* node changes and the component re-renders only then.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
createContext,
|
|
9
|
+
createElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
useContext,
|
|
12
|
+
useSyncExternalStore,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import type { Mirror } from './mirror.ts';
|
|
15
|
+
import type { NodeId, Row, Status } from './wire.ts';
|
|
16
|
+
|
|
17
|
+
const MirrorContext = createContext<Mirror | null>(null);
|
|
18
|
+
|
|
19
|
+
export function PomProvider(props: { mirror: Mirror; children?: ReactNode }): ReactNode {
|
|
20
|
+
return createElement(MirrorContext.Provider, { value: props.mirror }, props.children);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The mirror from the nearest `PomProvider`. */
|
|
24
|
+
export function useMirror(): Mirror {
|
|
25
|
+
const mirror = useContext(MirrorContext);
|
|
26
|
+
if (!mirror) throw new Error('usePom hooks need a <PomProvider mirror={…}> above them');
|
|
27
|
+
return mirror;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The node with this id, or `undefined` while it isn't there (or before the first view). */
|
|
31
|
+
export function useNode(id: NodeId | null | undefined): Row | undefined {
|
|
32
|
+
const mirror = useMirror();
|
|
33
|
+
return useSyncExternalStore(
|
|
34
|
+
mirror.subscribe,
|
|
35
|
+
() => (id == null ? undefined : mirror.node(id)),
|
|
36
|
+
() => undefined,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The node at this path, e.g. `/images/sky`. It follows a rename or a move. */
|
|
41
|
+
export function useNodeAt(path: string | null | undefined): Row | undefined {
|
|
42
|
+
const mirror = useMirror();
|
|
43
|
+
return useSyncExternalStore(
|
|
44
|
+
mirror.subscribe,
|
|
45
|
+
() => (path == null ? undefined : mirror.nodeAt(path)),
|
|
46
|
+
() => undefined,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NONE: readonly NodeId[] = Object.freeze([]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The ids in the node's named order, such as `draw`: the row's own `orders[order]` list, so it
|
|
54
|
+
* is the same array until the node changes. Empty when the node or the order isn't there.
|
|
55
|
+
*/
|
|
56
|
+
export function useChildren(id: NodeId | null | undefined, order: string): readonly NodeId[] {
|
|
57
|
+
const mirror = useMirror();
|
|
58
|
+
return useSyncExternalStore(
|
|
59
|
+
mirror.subscribe,
|
|
60
|
+
() => (id == null ? undefined : mirror.node(id)?.orders?.[order]) ?? NONE,
|
|
61
|
+
() => NONE,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The document's status (title, unsaved, undo and redo labels, gesture), or `null` before it. */
|
|
66
|
+
export function useStatus(): Status | null {
|
|
67
|
+
const mirror = useMirror();
|
|
68
|
+
return useSyncExternalStore(
|
|
69
|
+
mirror.subscribe,
|
|
70
|
+
() => mirror.status,
|
|
71
|
+
() => null,
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src-ts/wire.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire types: what `rhizome-pom-tauri` carries, written from the Rust structs. Each says
|
|
3
|
+
* which one it follows. Rust skips empty maps and lists and unset options when it serialises,
|
|
4
|
+
* so those fields are optional here. A test reads the goldens in the crates' `tests/golden/` folders so
|
|
5
|
+
* a drift between the two fails the build.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A node's id: 26 characters of Crockford base32 (rhizome-core `NodeId`, as a string). */
|
|
9
|
+
export type NodeId = string;
|
|
10
|
+
|
|
11
|
+
/** Plain JSON, as a node's values travel (rhizome-core `Value::to_json`). */
|
|
12
|
+
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
13
|
+
|
|
14
|
+
/** What a node is to the tree (rhizome-core `Role`, snake_case). */
|
|
15
|
+
export type Role = 'root' | 'category' | 'group' | 'node' | 'opaque';
|
|
16
|
+
|
|
17
|
+
/** What a binding attaches to on its target (rhizome-core `On`, snake_case). */
|
|
18
|
+
export type On = { slot: string } | { value: string };
|
|
19
|
+
|
|
20
|
+
/** A reference by id as a row carries it (rhizome-core `RefRow`). */
|
|
21
|
+
export interface RefRow {
|
|
22
|
+
file?: string;
|
|
23
|
+
node?: NodeId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A reference as a change carries it (rhizome-core `Ref`): a row's, plus the path written to disk. */
|
|
27
|
+
export interface Ref extends RefRow {
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A binding onto a node, its values resolved (rhizome-core `BindingRow`). */
|
|
32
|
+
export interface BindingRow {
|
|
33
|
+
on: On;
|
|
34
|
+
source: NodeId;
|
|
35
|
+
values?: Record<string, Json>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One node as plain data (rhizome-core `Row`; `type_name` is `type` on the wire). */
|
|
39
|
+
export interface Row {
|
|
40
|
+
id: NodeId;
|
|
41
|
+
path: string;
|
|
42
|
+
name: string;
|
|
43
|
+
type: string;
|
|
44
|
+
role: Role;
|
|
45
|
+
parent: NodeId | null;
|
|
46
|
+
/** Every value in the schema, resolved. */
|
|
47
|
+
values?: Record<string, Json>;
|
|
48
|
+
/** The keys actually stored; the rest are defaults. */
|
|
49
|
+
set?: string[];
|
|
50
|
+
refs?: Record<string, RefRow>;
|
|
51
|
+
bindings?: BindingRow[];
|
|
52
|
+
/** Named child orders, such as a draw order. */
|
|
53
|
+
orders?: Record<string, NodeId[]>;
|
|
54
|
+
members?: NodeId[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether a category's nodes are owned by a file or built from a recipe (rhizome-core `Origin`). */
|
|
58
|
+
export type Origin = 'loaded' | 'calculated';
|
|
59
|
+
|
|
60
|
+
/** rhizome-core `ValueKind`, snake_case. */
|
|
61
|
+
export type ValueKind =
|
|
62
|
+
| 'bool'
|
|
63
|
+
| 'int'
|
|
64
|
+
| 'float'
|
|
65
|
+
| 'text'
|
|
66
|
+
| 'choice'
|
|
67
|
+
| 'vec2'
|
|
68
|
+
| 'vec3'
|
|
69
|
+
| 'colour'
|
|
70
|
+
| 'floats'
|
|
71
|
+
| 'shaped';
|
|
72
|
+
|
|
73
|
+
/** What a `shaped` value must look like (rhizome-core `Shape`, externally tagged, snake_case). */
|
|
74
|
+
export type Shape =
|
|
75
|
+
| 'bool'
|
|
76
|
+
| 'int'
|
|
77
|
+
| 'float'
|
|
78
|
+
| 'text'
|
|
79
|
+
| 'vec2'
|
|
80
|
+
| 'vec3'
|
|
81
|
+
| 'colour'
|
|
82
|
+
| { choice: string[] }
|
|
83
|
+
| { list: Shape }
|
|
84
|
+
| { record: [string, Shape][] }
|
|
85
|
+
| { optional: Shape };
|
|
86
|
+
|
|
87
|
+
/** rhizome-core `ValueSchema`. */
|
|
88
|
+
export interface ValueSchema {
|
|
89
|
+
key: string;
|
|
90
|
+
kind: ValueKind;
|
|
91
|
+
default: Json;
|
|
92
|
+
range?: [number, number];
|
|
93
|
+
choices?: string[];
|
|
94
|
+
len?: number;
|
|
95
|
+
shape?: Shape;
|
|
96
|
+
/** The range's max is a control's end, not a limit. Absent when false. */
|
|
97
|
+
soft_max?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** rhizome-core `CategorySchema`. */
|
|
101
|
+
export interface CategorySchema {
|
|
102
|
+
name: string;
|
|
103
|
+
origin: Origin;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** rhizome-core `TypeSchema`. */
|
|
107
|
+
export interface TypeSchema {
|
|
108
|
+
name: string;
|
|
109
|
+
categories?: string[];
|
|
110
|
+
values?: ValueSchema[];
|
|
111
|
+
refs?: string[];
|
|
112
|
+
slots?: string[];
|
|
113
|
+
bindable?: ValueSchema[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The registry as plain data (rhizome-core `Schema`). */
|
|
117
|
+
export interface Schema {
|
|
118
|
+
categories: CategorySchema[];
|
|
119
|
+
types: TypeSchema[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One entry of a commit, tagged by `change` (rhizome-core `Change` with its flattened `ChangeKind`). */
|
|
123
|
+
export type Change = { path: string; id: NodeId } & (
|
|
124
|
+
| { change: 'added'; type: string }
|
|
125
|
+
| { change: 'removed'; type: string }
|
|
126
|
+
| { change: 'moved'; from: string }
|
|
127
|
+
| { change: 'value'; key: string; from: Json | null; to: Json | null }
|
|
128
|
+
| { change: 'ref'; key: string; from: Ref | null; to: Ref | null }
|
|
129
|
+
| {
|
|
130
|
+
change: 'bound';
|
|
131
|
+
on: On;
|
|
132
|
+
source: NodeId;
|
|
133
|
+
from: Record<string, Json> | null;
|
|
134
|
+
to: Record<string, Json> | null;
|
|
135
|
+
}
|
|
136
|
+
| { change: 'reordered'; order: string; from: NodeId[]; to: NodeId[] }
|
|
137
|
+
| { change: 'membership'; added: NodeId[]; removed: NodeId[] }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/** A commit (rhizome-core `Commit`; a `Changeset` is its entries as a list). */
|
|
141
|
+
export interface Commit {
|
|
142
|
+
seq: number;
|
|
143
|
+
label: string;
|
|
144
|
+
changes: Change[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The whole document, for a mirror to start from (rhizome-pom `View`). */
|
|
148
|
+
export interface View {
|
|
149
|
+
seq: number;
|
|
150
|
+
generation: number;
|
|
151
|
+
schema: Schema;
|
|
152
|
+
rows: Row[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A commit as a mirror takes it, on `pom://commit` (rhizome-pom `Update`). */
|
|
156
|
+
export interface Update {
|
|
157
|
+
seq: number;
|
|
158
|
+
label: string;
|
|
159
|
+
changes: Change[];
|
|
160
|
+
/** The fresh rows of every node the commit touched. */
|
|
161
|
+
rows: Row[];
|
|
162
|
+
removed: NodeId[];
|
|
163
|
+
/** The tree this belongs to. */
|
|
164
|
+
generation: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The document as a window shows it, on `pom://status` (rhizome-pom `Status`). */
|
|
168
|
+
export interface Status {
|
|
169
|
+
title: string;
|
|
170
|
+
path: string | null;
|
|
171
|
+
unsaved: boolean;
|
|
172
|
+
/** What Undo and Redo would do, when they can. */
|
|
173
|
+
undo: string | null;
|
|
174
|
+
redo: string | null;
|
|
175
|
+
gesture: boolean;
|
|
176
|
+
/** Only grows; moves whenever the whole tree is replaced. */
|
|
177
|
+
generation: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What `pom_run` answers (rhizome-pom `Ran`, snake_case, externally tagged). */
|
|
181
|
+
export type Ran = 'nothing' | { committed: Commit } | { text: string };
|
|
182
|
+
|
|
183
|
+
/** A command as a menu shows it (rhizome-pom `CommandState`). */
|
|
184
|
+
export interface CommandState {
|
|
185
|
+
id: string;
|
|
186
|
+
label: string;
|
|
187
|
+
enabled: boolean;
|
|
188
|
+
}
|