@atom.io/spa-devtools 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,331 @@
1
+ import type { VNode } from "@atom.io/spa"
2
+ import { useSetter, useTimeline, useValue } from "@atom.io/spa"
3
+ import type {
4
+ ReadableToken,
5
+ TimelineToken,
6
+ TransactionToken,
7
+ WritableToken,
8
+ } from "atom.io"
9
+ import {
10
+ disposeFromStore,
11
+ findInStore,
12
+ isReservedIntrospectionKey,
13
+ } from "atom.io/internal"
14
+ import type { FamilyNode } from "atom.io/introspection"
15
+
16
+ import { JsonEditor } from "./JsonEditor.tsx"
17
+ import type { Tools } from "./store.ts"
18
+ import { useExpanded } from "./store.ts"
19
+ import { describe } from "./value.ts"
20
+
21
+ type Token = ReadableToken<unknown, any, any>
22
+ export function StateIndex(props: {
23
+ tools: Tools
24
+ type: `atoms` | `selectors`
25
+ }): VNode {
26
+ const index = useValue<Map<string, Token | FamilyNode<Token>>>(
27
+ props.type === `atoms` ? props.tools.atomIndex : props.tools.selectorIndex,
28
+ )
29
+ return (
30
+ <article class="index" data-testid="state-index">
31
+ {index().size === 0 ? (
32
+ <p>(no {props.type})</p>
33
+ ) : (
34
+ [...index().values()].map((node) => (
35
+ <StateNode key={node.key} tools={props.tools} node={node} />
36
+ ))
37
+ )}
38
+ </article>
39
+ )
40
+ }
41
+ function StateNode(props: {
42
+ tools: Tools
43
+ node: Token | FamilyNode<Token>
44
+ }): VNode {
45
+ const [open, toggle] = useExpanded(props.tools, [props.node.key])
46
+ return (
47
+ <section class="node" data-testid={`state-${props.node.key}`}>
48
+ {`familyMembers` in props.node ? (
49
+ <>
50
+ <header>
51
+ <button
52
+ type="button"
53
+ aria-expanded={open()}
54
+ data-testid={`open-close-state-family-${props.node.key}`}
55
+ onClick={toggle}
56
+ >
57
+ {open() ? `▾` : `▸`}
58
+ </button>
59
+ <h2>{props.node.key}</h2>
60
+ <small>family</small>
61
+ </header>
62
+ {open()
63
+ ? [...props.node.familyMembers.values()].map((token) => (
64
+ <StateLeaf key={token.key} tools={props.tools} token={token} />
65
+ ))
66
+ : null}
67
+ </>
68
+ ) : (
69
+ <StateLeaf tools={props.tools} token={props.node} />
70
+ )}
71
+ </section>
72
+ )
73
+ }
74
+ function StateLeaf(props: { tools: Tools; token: Token }): VNode {
75
+ const read = useValue(props.token)
76
+ const writable = !props.token.type.startsWith(`readonly`)
77
+ const set = writable
78
+ ? useSetter(props.token as WritableToken<unknown, any, any>)
79
+ : undefined
80
+ const value = () => {
81
+ try {
82
+ return read()
83
+ } catch (error) {
84
+ return error
85
+ }
86
+ }
87
+ return (
88
+ <section class="state-leaf" data-testid={`state-value-${props.token.key}`}>
89
+ <JsonEditor
90
+ label={<h3>{props.token.family?.subKey ?? props.token.key}</h3>}
91
+ actions={
92
+ props.token.family ? (
93
+ <button
94
+ type="button"
95
+ aria-label={`Dispose ${props.token.key}`}
96
+ onClick={() => {
97
+ props.tools.attempt(() => {
98
+ disposeFromStore(props.tools.store, props.token)
99
+ })
100
+ }}
101
+ >
102
+ ×
103
+ </button>
104
+ ) : null
105
+ }
106
+ tools={props.tools}
107
+ value={value()}
108
+ change={
109
+ set
110
+ ? (update) => {
111
+ // Validate edits before opening an atom.io write operation.
112
+ const next = update(read())
113
+ set(() => next)
114
+ }
115
+ : undefined
116
+ }
117
+ path={
118
+ props.token.family
119
+ ? [props.token.family.key, props.token.family.subKey]
120
+ : [props.token.key]
121
+ }
122
+ />
123
+ </section>
124
+ )
125
+ }
126
+ export function TransactionIndex(props: { tools: Tools }): VNode {
127
+ const index = useValue(props.tools.transactionIndex)
128
+ const visible = () =>
129
+ index().filter((token) => !isReservedIntrospectionKey(token.key))
130
+ return (
131
+ <article class="index" data-testid="transaction-index">
132
+ {visible().length === 0 ? (
133
+ <p>(no transactions)</p>
134
+ ) : (
135
+ visible().map((token) => (
136
+ <TransactionLog key={token.key} tools={props.tools} token={token} />
137
+ ))
138
+ )}
139
+ </article>
140
+ )
141
+ }
142
+ function TransactionLog(props: {
143
+ tools: Tools
144
+ token: TransactionToken<any>
145
+ }): VNode {
146
+ const log = useValue(
147
+ findInStore(
148
+ props.tools.store,
149
+ props.tools.transactionLogSelectors,
150
+ props.token.key,
151
+ ),
152
+ )
153
+ const [open, toggle] = useExpanded(props.tools, [props.token.key])
154
+ return (
155
+ <section class="node" data-testid={`transaction-${props.token.key}`}>
156
+ <header>
157
+ <button
158
+ type="button"
159
+ aria-expanded={open()}
160
+ data-testid={`open-close-transaction-${props.token.key}`}
161
+ onClick={toggle}
162
+ >
163
+ {open() ? `▾` : `▸`}
164
+ </button>
165
+ <h2>{props.token.key}</h2>
166
+ <small>({log().length})</small>
167
+ </header>
168
+ {open()
169
+ ? log().map((event, index) => (
170
+ <EventView
171
+ key={index}
172
+ tools={props.tools}
173
+ event={event}
174
+ index={index}
175
+ />
176
+ ))
177
+ : null}
178
+ </section>
179
+ )
180
+ }
181
+ // Events are recursive discriminated records, including user-defined transaction outputs.
182
+ export function EventView(props: {
183
+ tools: Tools
184
+ event: any
185
+ index: number
186
+ }): VNode {
187
+ const [open, toggle] = useExpanded(
188
+ props.tools,
189
+ [`event`, props.event.token?.key ?? ``, String(props.index)],
190
+ true,
191
+ )
192
+ return (
193
+ <section
194
+ class="event"
195
+ data-testid={`${props.event.type === `transaction_outcome` ? `transaction` : `timeline`}-update-${props.event.token?.key}-${props.index}`}
196
+ >
197
+ <header>
198
+ <button type="button" aria-expanded={open()} onClick={toggle}>
199
+ {open() ? `▾` : `▸`}
200
+ </button>
201
+ <strong>{props.event.token?.key}</strong>
202
+ <small>{props.event.type}</small>
203
+ </header>
204
+ {open() ? (
205
+ props.event.type === `transaction_outcome` ? (
206
+ <>
207
+ <p>
208
+ params: <code>{describe(props.event.params)}</code>
209
+ </p>
210
+ <p>
211
+ output: <code>{describe(props.event.output)}</code>
212
+ </p>
213
+ <section class="impact">
214
+ {props.event.subEvents.map((event: any, index: number) => (
215
+ <EventView
216
+ key={index}
217
+ tools={props.tools}
218
+ event={event}
219
+ index={index}
220
+ />
221
+ ))}
222
+ </section>
223
+ </>
224
+ ) : props.event.update ? (
225
+ <p>
226
+ <del>{describe(props.event.update.oldValue)}</del> →{` `}
227
+ <ins>{describe(props.event.update.newValue)}</ins>
228
+ </p>
229
+ ) : (
230
+ <code>{describe(props.event)}</code>
231
+ )
232
+ ) : null}
233
+ </section>
234
+ )
235
+ }
236
+ export function TimelineIndex(props: { tools: Tools }): VNode {
237
+ const index = useValue(props.tools.timelineIndex)
238
+ const groups = () => {
239
+ const result = new Map<string, TimelineToken<any>[]>()
240
+ for (const token of index()) {
241
+ if (isReservedIntrospectionKey(token.key) || token.key.startsWith(`👁‍🗨`))
242
+ continue
243
+ const key = token.family?.key ?? ``
244
+ const members = result.get(key) ?? []
245
+ members.push(token)
246
+ result.set(key, members)
247
+ }
248
+ return [...result]
249
+ }
250
+ return (
251
+ <article class="index" data-testid="timeline-index">
252
+ {groups().length === 0 ? (
253
+ <p>(no timelines)</p>
254
+ ) : (
255
+ groups().map(([family, members]) => (
256
+ <section
257
+ key={family}
258
+ data-testid={family ? `timeline-family-${family}` : undefined}
259
+ >
260
+ {family ? <h2>{family}</h2> : null}
261
+ {members.map((token) => (
262
+ <TimelineLog key={token.key} tools={props.tools} token={token} />
263
+ ))}
264
+ </section>
265
+ ))
266
+ )}
267
+ </article>
268
+ )
269
+ }
270
+ function TimelineLog(props: { tools: Tools; token: TimelineToken<any> }): VNode {
271
+ const controls = useTimeline(props.token)
272
+ const log = useValue(
273
+ findInStore(
274
+ props.tools.store,
275
+ props.tools.timelineSelectors,
276
+ props.token.key,
277
+ ),
278
+ )
279
+ const [open, toggle] = useExpanded(props.tools, [props.token.key])
280
+ return (
281
+ <section class="node" data-testid={`timeline-${props.token.key}`}>
282
+ <header>
283
+ <button
284
+ type="button"
285
+ aria-expanded={open()}
286
+ data-testid={`open-close-timeline-${props.token.key}`}
287
+ onClick={toggle}
288
+ >
289
+ {open() ? `▾` : `▸`}
290
+ </button>
291
+ <h2>{props.token.family?.subKey ?? props.token.key}</h2>
292
+ <small>
293
+ ({controls().at}/{controls().length})
294
+ </small>
295
+ <button
296
+ type="button"
297
+ disabled={controls().at === 0}
298
+ onClick={() => {
299
+ props.tools.attempt(controls().undo)
300
+ }}
301
+ >
302
+ undo
303
+ </button>
304
+ <button
305
+ type="button"
306
+ disabled={controls().at === controls().length}
307
+ onClick={() => {
308
+ props.tools.attempt(controls().redo)
309
+ }}
310
+ >
311
+ redo
312
+ </button>
313
+ </header>
314
+ {open() ? (
315
+ <section>
316
+ {log().history.map((event, index) => (
317
+ <section key={index}>
318
+ {index === log().at ? (
319
+ <p class="you-are-here">you are here</p>
320
+ ) : null}
321
+ <EventView tools={props.tools} event={event} index={index} />
322
+ </section>
323
+ ))}
324
+ {log().at === log().history.length ? (
325
+ <p class="you-are-here">you are here</p>
326
+ ) : null}
327
+ </section>
328
+ ) : null}
329
+ </section>
330
+ )
331
+ }
@@ -0,0 +1,259 @@
1
+ import type { Child, VNode } from "@atom.io/spa"
2
+
3
+ import type { Path, Tools } from "./store.ts"
4
+ import { useExpanded } from "./store.ts"
5
+ import type { Change, JsonKind } from "./value.ts"
6
+ import { convert, describe, kind } from "./value.ts"
7
+
8
+ interface EditorProps {
9
+ tools: Tools
10
+ value: unknown
11
+ change?: Change | undefined
12
+ path: Path
13
+ ancestors?: readonly unknown[]
14
+ label?: Child
15
+ actions?: Child
16
+ }
17
+ const TYPES: JsonKind[] = [
18
+ `string`,
19
+ `number`,
20
+ `boolean`,
21
+ `null`,
22
+ `array`,
23
+ `object`,
24
+ ]
25
+ export function JsonEditor(props: EditorProps): VNode {
26
+ const [expanded, toggle] = useExpanded(props.tools, props.path)
27
+ const circular = () =>
28
+ (props.ancestors?.includes(props.value) ?? false) || props.path.length > 50
29
+ const container = () =>
30
+ !circular() &&
31
+ (kind(props.value) === `object` || kind(props.value) === `array`)
32
+ const entries = () => Object.entries(props.value as Record<string, unknown>)
33
+ return (
34
+ <section
35
+ class="json-editor"
36
+ data-testid={`${props.path.join(`.`)}-state-editor`}
37
+ >
38
+ <header>
39
+ {container() ? (
40
+ <button
41
+ type="button"
42
+ aria-label={`Expand ${props.path.at(-1)}`}
43
+ aria-expanded={expanded()}
44
+ onClick={toggle}
45
+ >
46
+ {expanded() ? `▾` : `▸`}
47
+ </button>
48
+ ) : (
49
+ <span class="carat-spacer" />
50
+ )}
51
+ <span class="field-name">{props.label}</span>
52
+ {props.change ? (
53
+ <select
54
+ aria-label={`Type of ${props.path.at(-1)}`}
55
+ value={kind(props.value)}
56
+ onChange={(event) => {
57
+ const type = event.currentTarget.value as JsonKind
58
+ props.tools.attempt(() => {
59
+ props.change?.((previous) => convert(previous, type))
60
+ })
61
+ }}
62
+ >
63
+ <option value="other" disabled>
64
+ other
65
+ </option>
66
+ {TYPES.map((type) => (
67
+ <option value={type}>{type}</option>
68
+ ))}
69
+ </select>
70
+ ) : (
71
+ <small>{kind(props.value)}</small>
72
+ )}
73
+ {container() ? (
74
+ <code title={describe(props.value)}>{describe(props.value)}</code>
75
+ ) : circular() ? (
76
+ <code>[Circular]</code>
77
+ ) : (
78
+ <Primitive
79
+ value={props.value}
80
+ change={props.change}
81
+ tools={props.tools}
82
+ label={props.path.at(-1) ?? `value`}
83
+ />
84
+ )}
85
+ {props.actions}
86
+ </header>
87
+ {container() && expanded() ? (
88
+ <section class="properties">
89
+ {entries().map(([key]) => (
90
+ <Property key={key} name={key} editor={props} />
91
+ ))}
92
+ {props.change ? (
93
+ <footer>
94
+ <button
95
+ type="button"
96
+ onClick={() => {
97
+ props.tools.attempt(() => {
98
+ props.change?.((previous) => {
99
+ if (Array.isArray(previous)) return [...previous, null]
100
+ const object = previous as Record<string, unknown>
101
+ let name = `new`
102
+ while (Object.hasOwn(object, name)) name += `_`
103
+ return { ...object, [name]: null }
104
+ })
105
+ })
106
+ }}
107
+ >
108
+ add {Array.isArray(props.value) ? `item` : `property`}
109
+ </button>
110
+ {Array.isArray(props.value) ? (
111
+ <button
112
+ type="button"
113
+ onClick={() => {
114
+ props.tools.attempt(() => {
115
+ props.change?.((previous) =>
116
+ [...(previous as unknown[])].sort((a, b) =>
117
+ typeof a === `number` && typeof b === `number`
118
+ ? a - b
119
+ : describe(a).localeCompare(describe(b)),
120
+ ),
121
+ )
122
+ })
123
+ }}
124
+ >
125
+ sort
126
+ </button>
127
+ ) : null}
128
+ </footer>
129
+ ) : null}
130
+ </section>
131
+ ) : null}
132
+ </section>
133
+ )
134
+ }
135
+ function Primitive(props: {
136
+ value: unknown
137
+ change?: Change | undefined
138
+ tools: Tools
139
+ label: string
140
+ }): VNode {
141
+ const set = (value: unknown) => {
142
+ props.tools.attempt(() => {
143
+ props.change?.(() => value)
144
+ })
145
+ }
146
+ return (
147
+ <span class="primitive">
148
+ {kind(props.value) === `boolean` ? (
149
+ <input
150
+ aria-label={props.label}
151
+ type="checkbox"
152
+ checked={Boolean(props.value)}
153
+ disabled={!props.change}
154
+ onChange={(event) => {
155
+ set(event.currentTarget.checked)
156
+ }}
157
+ />
158
+ ) : kind(props.value) === `string` || kind(props.value) === `number` ? (
159
+ <input
160
+ aria-label={props.label}
161
+ type="text"
162
+ inputmode={kind(props.value) === `number` ? `decimal` : `text`}
163
+ value={String(props.value)}
164
+ readOnly={!props.change}
165
+ onInput={(event) => {
166
+ const text = event.currentTarget.value
167
+ if (kind(props.value) === `string`) set(text)
168
+ else if (text.trim() && Number.isFinite(Number(text)))
169
+ set(Number(text))
170
+ }}
171
+ onBlur={(event) => {
172
+ event.currentTarget.value = String(props.value)
173
+ }}
174
+ />
175
+ ) : (
176
+ <code>{describe(props.value)}</code>
177
+ )}
178
+ </span>
179
+ )
180
+ }
181
+ function Property(props: { name: string; editor: EditorProps }): VNode {
182
+ const parent = props.editor
183
+ const read = () => (parent.value as Record<string, unknown>)[props.name]
184
+ const change: Change = (update) => {
185
+ parent.change?.((previous) => {
186
+ if (Array.isArray(previous))
187
+ return previous.map((value, index) =>
188
+ index === Number(props.name) ? update(value) : value,
189
+ )
190
+ const object = previous as Record<string, unknown>
191
+ return { ...object, [props.name]: update(object[props.name]) }
192
+ })
193
+ }
194
+ return (
195
+ <section class="property">
196
+ <JsonEditor
197
+ label={
198
+ parent.change && !Array.isArray(parent.value) ? (
199
+ <input
200
+ aria-label={`Rename ${props.name}`}
201
+ style={`width:calc(${Math.max(1, props.name.length)}ch + 6px)`}
202
+ value={props.name}
203
+ onChange={(event) => {
204
+ const input = event.currentTarget
205
+ const name = input.value
206
+ parent.tools.attempt(() => {
207
+ parent.change?.((previous) => {
208
+ const object = previous as Record<string, unknown>
209
+ if (name !== props.name && Object.hasOwn(object, name))
210
+ throw new Error(`Property "${name}" already exists`)
211
+ return Object.fromEntries(
212
+ Object.entries(object).map(([key, value]) => [
213
+ key === props.name ? name : key,
214
+ value,
215
+ ]),
216
+ )
217
+ })
218
+ })
219
+ input.value = props.name
220
+ }}
221
+ />
222
+ ) : (
223
+ <strong>{props.name}</strong>
224
+ )
225
+ }
226
+ actions={
227
+ parent.change ? (
228
+ <button
229
+ type="button"
230
+ aria-label={`Delete ${props.name}`}
231
+ onClick={() => {
232
+ parent.tools.attempt(() => {
233
+ parent.change?.((previous) =>
234
+ Array.isArray(previous)
235
+ ? previous.filter(
236
+ (_, index) => index !== Number(props.name),
237
+ )
238
+ : Object.fromEntries(
239
+ Object.entries(previous as object).filter(
240
+ ([key]) => key !== props.name,
241
+ ),
242
+ ),
243
+ )
244
+ })
245
+ }}
246
+ >
247
+ ×
248
+ </button>
249
+ ) : null
250
+ }
251
+ tools={parent.tools}
252
+ path={[...parent.path, props.name]}
253
+ value={read()}
254
+ change={parent.change ? change : undefined}
255
+ ancestors={[...(parent.ancestors ?? []), parent.value]}
256
+ />
257
+ </section>
258
+ )
259
+ }
package/src/css.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ // TypeScript entrypoint for the separately imported stylesheet.
2
+ export {}