@typeonce/effect-machine-devtools 0.23.0
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/NOTICE +3 -0
- package/README.md +81 -0
- package/dist/DevServer.d.ts +42 -0
- package/dist/DevServer.d.ts.map +1 -0
- package/dist/DevServer.js +28 -0
- package/dist/DevServer.js.map +1 -0
- package/dist/DevToolsProtocol.d.ts +954 -0
- package/dist/DevToolsProtocol.d.ts.map +1 -0
- package/dist/DevToolsProtocol.js +96 -0
- package/dist/DevToolsProtocol.js.map +1 -0
- package/dist/MachineDocument.d.ts +396 -0
- package/dist/MachineDocument.d.ts.map +1 -0
- package/dist/MachineDocument.js +172 -0
- package/dist/MachineDocument.js.map +1 -0
- package/dist/MachineRegistry.d.ts +349 -0
- package/dist/MachineRegistry.d.ts.map +1 -0
- package/dist/MachineRegistry.js +46 -0
- package/dist/MachineRegistry.js.map +1 -0
- package/dist/MachineSimulator.d.ts +169 -0
- package/dist/MachineSimulator.d.ts.map +1 -0
- package/dist/MachineSimulator.js +98 -0
- package/dist/MachineSimulator.js.map +1 -0
- package/dist/ProjectInspector.d.ts +116 -0
- package/dist/ProjectInspector.d.ts.map +1 -0
- package/dist/ProjectInspector.js +82 -0
- package/dist/ProjectInspector.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +23 -0
- package/dist/bin.js.map +1 -0
- package/dist/client/assets/index-BGOhE3Ng.css +1 -0
- package/dist/client/assets/index-DiqGhsGR.js +14 -0
- package/dist/client/index.html +14 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/devServer.d.ts +9 -0
- package/dist/internal/devServer.d.ts.map +1 -0
- package/dist/internal/devServer.js +119 -0
- package/dist/internal/devServer.js.map +1 -0
- package/dist/internal/evaluationWorker.d.ts +3 -0
- package/dist/internal/evaluationWorker.d.ts.map +1 -0
- package/dist/internal/evaluationWorker.js +69 -0
- package/dist/internal/evaluationWorker.js.map +1 -0
- package/dist/internal/evaluationWorkerBootstrap.d.ts +2 -0
- package/dist/internal/evaluationWorkerBootstrap.d.ts.map +1 -0
- package/dist/internal/evaluationWorkerBootstrap.js +21 -0
- package/dist/internal/evaluationWorkerBootstrap.js.map +1 -0
- package/dist/internal/machineDocument.d.ts +4 -0
- package/dist/internal/machineDocument.d.ts.map +1 -0
- package/dist/internal/machineDocument.js +122 -0
- package/dist/internal/machineDocument.js.map +1 -0
- package/dist/internal/machineRegistry.d.ts +12 -0
- package/dist/internal/machineRegistry.d.ts.map +1 -0
- package/dist/internal/machineRegistry.js +97 -0
- package/dist/internal/machineRegistry.js.map +1 -0
- package/dist/internal/machineSimulator.d.ts +5 -0
- package/dist/internal/machineSimulator.d.ts.map +1 -0
- package/dist/internal/machineSimulator.js +156 -0
- package/dist/internal/machineSimulator.js.map +1 -0
- package/dist/internal/projectInspector.d.ts +11 -0
- package/dist/internal/projectInspector.d.ts.map +1 -0
- package/dist/internal/projectInspector.js +150 -0
- package/dist/internal/projectInspector.js.map +1 -0
- package/index.html +13 -0
- package/package.json +69 -0
- package/src/DevServer.ts +45 -0
- package/src/DevToolsProtocol.ts +146 -0
- package/src/MachineDocument.ts +264 -0
- package/src/MachineRegistry.ts +69 -0
- package/src/MachineSimulator.ts +154 -0
- package/src/ProjectInspector.ts +140 -0
- package/src/bin.ts +56 -0
- package/src/index.ts +20 -0
- package/src/internal/browser/example-machine.ts +134 -0
- package/src/internal/browser/machine-index.ts +81 -0
- package/src/internal/browser/main.ts +42 -0
- package/src/internal/browser/styles.css +817 -0
- package/src/internal/browser/text-tree.ts +214 -0
- package/src/internal/browser/visualizer-app.ts +659 -0
- package/src/internal/browser/visualizer-model.ts +173 -0
- package/src/internal/browser/visualizer.ts +31 -0
- package/src/internal/devServer.ts +171 -0
- package/src/internal/evaluationWorker.ts +138 -0
- package/src/internal/evaluationWorkerBootstrap.js +21 -0
- package/src/internal/machineDocument.ts +138 -0
- package/src/internal/machineRegistry.ts +128 -0
- package/src/internal/machineSimulator.ts +199 -0
- package/src/internal/projectInspector.ts +226 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import type { Diagnostic } from "../../DevToolsProtocol.js"
|
|
2
|
+
import type {
|
|
3
|
+
Activity as VisualizationActivity,
|
|
4
|
+
Branch as VisualizationBranch,
|
|
5
|
+
MachineDocument as VisualizationDocument,
|
|
6
|
+
Transition as VisualizationTransition
|
|
7
|
+
} from "../../MachineDocument.js"
|
|
8
|
+
import * as MachineSimulator from "../../MachineSimulator.js"
|
|
9
|
+
import {
|
|
10
|
+
type EventInspection,
|
|
11
|
+
type IncomingTransition,
|
|
12
|
+
makeVisualizerModel,
|
|
13
|
+
type StateInspection,
|
|
14
|
+
type TopologyNode,
|
|
15
|
+
triggerLabel
|
|
16
|
+
} from "./visualizer-model.js"
|
|
17
|
+
|
|
18
|
+
const createElement = <Tag extends keyof HTMLElementTagNameMap>(
|
|
19
|
+
tag: Tag,
|
|
20
|
+
className?: string,
|
|
21
|
+
text?: string
|
|
22
|
+
): HTMLElementTagNameMap[Tag] => {
|
|
23
|
+
const element = document.createElement(tag)
|
|
24
|
+
if (className !== undefined) element.className = className
|
|
25
|
+
if (text !== undefined) element.textContent = text
|
|
26
|
+
return element
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const metadata = (items: ReadonlyArray<readonly [string, string]>): HTMLDListElement => {
|
|
30
|
+
const list = createElement("dl", "metadata")
|
|
31
|
+
for (const [label, value] of items) {
|
|
32
|
+
list.append(createElement("dt", undefined, label), createElement("dd", undefined, value))
|
|
33
|
+
}
|
|
34
|
+
return list
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const badge = (text: string, kind = "neutral"): HTMLSpanElement => createElement("span", `badge badge-${kind}`, text)
|
|
38
|
+
|
|
39
|
+
type StateNavigator = (path: string) => void
|
|
40
|
+
|
|
41
|
+
const stateLink = (path: string, label: string, navigate: StateNavigator): HTMLButtonElement => {
|
|
42
|
+
const link = createElement("button", "state-link", label)
|
|
43
|
+
link.type = "button"
|
|
44
|
+
link.addEventListener("click", () => navigate(path))
|
|
45
|
+
return link
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const renderBranch = (branch: VisualizationBranch, navigate: StateNavigator): HTMLElement => {
|
|
49
|
+
const row = createElement("div", "branch-row")
|
|
50
|
+
const main = createElement("div", "branch-main")
|
|
51
|
+
if (branch.type === "branch") main.append(badge(branch.title, "condition"))
|
|
52
|
+
main.append(createElement("span", "branch-arrow", "→"))
|
|
53
|
+
if (branch.target !== null) {
|
|
54
|
+
main.append(stateLink(branch.target, branch.target, navigate))
|
|
55
|
+
} else {
|
|
56
|
+
main.append(createElement("span", "branch-target", branch.updates.length > 0 ? "Remain in state" : "No target"))
|
|
57
|
+
}
|
|
58
|
+
row.append(main)
|
|
59
|
+
|
|
60
|
+
const details: Array<readonly [string, string]> = [
|
|
61
|
+
["Selection", branch.selection.kind],
|
|
62
|
+
["Scope", branch.selection.scope ?? "none"]
|
|
63
|
+
]
|
|
64
|
+
row.append(metadata(details))
|
|
65
|
+
if (branch.updates.length > 0) {
|
|
66
|
+
const updates = createElement("div", "branch-updates")
|
|
67
|
+
updates.append(createElement("span", "branch-updates-label", "Updates"))
|
|
68
|
+
branch.updates.forEach((path) => updates.append(stateLink(path, path, navigate)))
|
|
69
|
+
row.append(updates)
|
|
70
|
+
}
|
|
71
|
+
return row
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const renderTransition = (
|
|
75
|
+
transition: VisualizationTransition,
|
|
76
|
+
navigate: StateNavigator,
|
|
77
|
+
showSource = false
|
|
78
|
+
): HTMLElement => {
|
|
79
|
+
const card = createElement("article", "inspection-card transition-card")
|
|
80
|
+
const header = createElement("div", "card-header")
|
|
81
|
+
const title = createElement("div", "card-title")
|
|
82
|
+
title.append(badge(transition.trigger.type, "trigger"), createElement("strong", undefined, triggerLabel(transition)))
|
|
83
|
+
const flags = createElement("div", "card-flags")
|
|
84
|
+
if (transition.reenter) flags.append(badge("reenter"))
|
|
85
|
+
if (transition.acceptance === "declinable") flags.append(badge("declinable"))
|
|
86
|
+
header.append(title, flags)
|
|
87
|
+
card.append(header)
|
|
88
|
+
if (showSource) {
|
|
89
|
+
const source = createElement("div", "transition-source")
|
|
90
|
+
source.append(createElement("span", undefined, "From"), stateLink(transition.source, transition.source, navigate))
|
|
91
|
+
card.append(source)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const branches = createElement("div", "branch-list")
|
|
95
|
+
if (transition.branches.length === 0) {
|
|
96
|
+
branches.append(createElement("div", "empty-inline", "No transition branches"))
|
|
97
|
+
} else {
|
|
98
|
+
transition.branches.forEach((branch) => branches.append(renderBranch(branch, navigate)))
|
|
99
|
+
}
|
|
100
|
+
card.append(branches)
|
|
101
|
+
return card
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const renderIncomingTransition = (incoming: IncomingTransition, navigate: StateNavigator): HTMLElement => {
|
|
105
|
+
const card = createElement("article", "inspection-card incoming-card")
|
|
106
|
+
const header = createElement("div", "card-header")
|
|
107
|
+
const title = createElement("div", "card-title")
|
|
108
|
+
title.append(
|
|
109
|
+
badge(incoming.transition.trigger.type, "trigger"),
|
|
110
|
+
createElement("strong", undefined, triggerLabel(incoming.transition))
|
|
111
|
+
)
|
|
112
|
+
header.append(title, stateLink(incoming.transition.source, incoming.transition.source, navigate))
|
|
113
|
+
card.append(header)
|
|
114
|
+
|
|
115
|
+
const details: Array<readonly [string, string]> = [
|
|
116
|
+
["Selection", incoming.branch.selection.kind],
|
|
117
|
+
["Scope", incoming.branch.selection.scope ?? "none"]
|
|
118
|
+
]
|
|
119
|
+
if (incoming.branch.type === "branch") details.unshift(["Branch", incoming.branch.title])
|
|
120
|
+
card.append(metadata(details))
|
|
121
|
+
return card
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const activityTitle = (activity: VisualizationActivity): string => {
|
|
125
|
+
switch (activity.type) {
|
|
126
|
+
case "process":
|
|
127
|
+
case "effect":
|
|
128
|
+
case "timer":
|
|
129
|
+
case "stream":
|
|
130
|
+
return activity.lifecycleId
|
|
131
|
+
case "machine":
|
|
132
|
+
return `${activity.lifecycleId} → ${activity.child.machineId ?? activity.child.id}`
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const renderActivity = (activity: VisualizationActivity): HTMLElement => {
|
|
137
|
+
const card = createElement("article", "inspection-card activity-card")
|
|
138
|
+
const header = createElement("div", "card-header")
|
|
139
|
+
const title = createElement("div", "card-title")
|
|
140
|
+
title.append(badge(activity.type, "activity"), createElement("strong", undefined, activityTitle(activity)))
|
|
141
|
+
header.append(title)
|
|
142
|
+
card.append(header)
|
|
143
|
+
|
|
144
|
+
const details: Array<readonly [string, string]> = [["Owner", activity.source]]
|
|
145
|
+
if (activity.type === "timer") details.push(["Duration", activity.duration])
|
|
146
|
+
if (activity.type === "effect") {
|
|
147
|
+
details.push(["Success", activity.outcomes.success], ["Failure", activity.outcomes.failure])
|
|
148
|
+
}
|
|
149
|
+
if (activity.type === "machine") {
|
|
150
|
+
details.push(["Child address", activity.child.id], ["Machine", activity.child.machineId ?? "dynamic"])
|
|
151
|
+
}
|
|
152
|
+
card.append(metadata(details))
|
|
153
|
+
return card
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const inspectionSection = (title: string, count: number): HTMLElement => {
|
|
157
|
+
const header = createElement("div", "section-heading")
|
|
158
|
+
header.append(createElement("h3", undefined, title), createElement("span", "section-count", String(count)))
|
|
159
|
+
return header
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const simulationResultMessage = (result: MachineSimulator.StepResult): string => {
|
|
163
|
+
if (result._tag === "Applied") return `${result.event} applied · runtime code was skipped`
|
|
164
|
+
if (result._tag === "Blocked") return `${result.event} is not enabled in the current topology`
|
|
165
|
+
const reasons: Record<MachineSimulator.Indeterminate["reason"], string> = {
|
|
166
|
+
"multiple-transitions": "multiple active transitions",
|
|
167
|
+
"declinable-transition": "acceptance depends on runtime code",
|
|
168
|
+
"conditional-branches": "the selected branch depends on runtime code",
|
|
169
|
+
"history-target": "history resolution needs runtime state",
|
|
170
|
+
"choice-target": "choice resolution needs runtime code",
|
|
171
|
+
"missing-target": "the target is not present in the document"
|
|
172
|
+
}
|
|
173
|
+
return `${result.event} was not applied · ${reasons[result.reason]}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const renderVisualizer = (
|
|
177
|
+
root: HTMLElement,
|
|
178
|
+
visualization: VisualizationDocument,
|
|
179
|
+
diagnostics: ReadonlyArray<Diagnostic> = []
|
|
180
|
+
): void => {
|
|
181
|
+
const model = makeVisualizerModel(visualization)
|
|
182
|
+
const rows = new Map<string, HTMLElement>()
|
|
183
|
+
const nodes = new Map<string, HTMLElement>()
|
|
184
|
+
const statuses = new Map<string, HTMLElement>()
|
|
185
|
+
const eventButtons = new Map<string, HTMLElement>()
|
|
186
|
+
const relatedPaths = new Set<string>()
|
|
187
|
+
let selectedPath: string | undefined
|
|
188
|
+
let selectedEvent: string | undefined
|
|
189
|
+
let simulation: MachineSimulator.Session | undefined
|
|
190
|
+
|
|
191
|
+
const activePaths = (): ReadonlyArray<string> => simulation?.snapshot.activePaths ?? model.activePaths
|
|
192
|
+
const candidateEvents = (): ReadonlyArray<string> => simulation?.snapshot.candidateEvents ?? model.candidateEvents
|
|
193
|
+
|
|
194
|
+
const shell = createElement("main", "app-shell")
|
|
195
|
+
const workspace = createElement("section", "workspace")
|
|
196
|
+
const treePanel = createElement("section", "tree-panel")
|
|
197
|
+
treePanel.setAttribute("aria-label", `${model.machineId} topology`)
|
|
198
|
+
const inspector = createElement("aside", "inspector")
|
|
199
|
+
inspector.setAttribute("aria-live", "polite")
|
|
200
|
+
|
|
201
|
+
const clearButton = createElement("button", "toolbar-button", "Clear selection")
|
|
202
|
+
clearButton.type = "button"
|
|
203
|
+
clearButton.disabled = true
|
|
204
|
+
const expandButton = createElement("button", "toolbar-button", "Expand all")
|
|
205
|
+
expandButton.type = "button"
|
|
206
|
+
const collapseButton = createElement("button", "toolbar-button", "Collapse all")
|
|
207
|
+
collapseButton.type = "button"
|
|
208
|
+
const revealActiveButton = createElement("button", "toolbar-button", "Reveal active")
|
|
209
|
+
revealActiveButton.type = "button"
|
|
210
|
+
revealActiveButton.disabled = model.activePaths.length === 0
|
|
211
|
+
const simulationButton = createElement("button", "toolbar-button", "Start simulation")
|
|
212
|
+
simulationButton.type = "button"
|
|
213
|
+
simulationButton.disabled = model.roots.length === 0
|
|
214
|
+
|
|
215
|
+
const renderEmptyInspector = (): void => {
|
|
216
|
+
inspector.replaceChildren()
|
|
217
|
+
const summary = createElement("div", "inspector-empty")
|
|
218
|
+
summary.append(createElement("span", "inspector-empty-kind", "Machine"))
|
|
219
|
+
summary.append(createElement("h2", undefined, visualization.machineId))
|
|
220
|
+
summary.append(metadata([
|
|
221
|
+
["Source", visualization.source?.file ?? "in memory"],
|
|
222
|
+
["Export", visualization.source?.exportName ?? "none"],
|
|
223
|
+
["Initial", visualization.initial.target],
|
|
224
|
+
["Selection", visualization.initial.selection.kind],
|
|
225
|
+
["Revision", String(visualization.revision)]
|
|
226
|
+
]))
|
|
227
|
+
summary.append(createElement("p", undefined, "Select a state to inspect its transitions and activities."))
|
|
228
|
+
inspector.append(summary)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const renderInspection = (inspection: StateInspection): void => {
|
|
232
|
+
inspector.replaceChildren()
|
|
233
|
+
const header = createElement("header", "inspector-header")
|
|
234
|
+
const breadcrumbs = createElement("nav", "breadcrumbs")
|
|
235
|
+
breadcrumbs.setAttribute("aria-label", "State path")
|
|
236
|
+
inspection.breadcrumbs.forEach((item, index) => {
|
|
237
|
+
if (index > 0) breadcrumbs.append(createElement("span", "breadcrumb-separator", "/"))
|
|
238
|
+
breadcrumbs.append(stateLink(item.path, item.label, navigateToState))
|
|
239
|
+
})
|
|
240
|
+
const eyebrow = createElement("div", "inspector-eyebrow")
|
|
241
|
+
eyebrow.append(badge(inspection.state.type, "state"))
|
|
242
|
+
if (activePaths().includes(inspection.state.path)) eyebrow.append(badge("active", "active"))
|
|
243
|
+
if (inspection.initial) eyebrow.append(badge("initial", "initial"))
|
|
244
|
+
header.append(breadcrumbs, eyebrow, createElement("h2", undefined, inspection.label))
|
|
245
|
+
header.append(metadata([
|
|
246
|
+
["Path", inspection.state.path],
|
|
247
|
+
["Parent", inspection.state.parent ?? "root"],
|
|
248
|
+
["Children", String(inspection.state.children.length)],
|
|
249
|
+
["Initial child", inspection.state.initial ?? "none"],
|
|
250
|
+
["History", inspection.state.history ?? "none"]
|
|
251
|
+
]))
|
|
252
|
+
if (inspection.state.description !== null || inspection.state.documentation !== null) {
|
|
253
|
+
const annotations = createElement("div", "state-annotations")
|
|
254
|
+
if (inspection.state.description !== null) {
|
|
255
|
+
annotations.append(createElement("p", "state-description", inspection.state.description))
|
|
256
|
+
}
|
|
257
|
+
if (inspection.state.documentation !== null) {
|
|
258
|
+
annotations.append(createElement("p", "state-documentation", inspection.state.documentation))
|
|
259
|
+
}
|
|
260
|
+
header.append(annotations)
|
|
261
|
+
}
|
|
262
|
+
inspector.append(header)
|
|
263
|
+
|
|
264
|
+
const transitions = createElement("section", "inspector-section")
|
|
265
|
+
transitions.append(inspectionSection("Transitions", inspection.outgoing.length))
|
|
266
|
+
if (inspection.outgoing.length === 0) {
|
|
267
|
+
transitions.append(createElement("p", "section-empty", "No transitions leave this state."))
|
|
268
|
+
} else {
|
|
269
|
+
inspection.outgoing.forEach((transition) => transitions.append(renderTransition(transition, navigateToState)))
|
|
270
|
+
}
|
|
271
|
+
inspector.append(transitions)
|
|
272
|
+
|
|
273
|
+
const incoming = createElement("section", "inspector-section")
|
|
274
|
+
incoming.append(inspectionSection("Entered by", inspection.incoming.length))
|
|
275
|
+
if (inspection.incoming.length === 0) {
|
|
276
|
+
incoming.append(createElement("p", "section-empty", "No transitions target this state."))
|
|
277
|
+
} else {
|
|
278
|
+
inspection.incoming.forEach((transition) =>
|
|
279
|
+
incoming.append(renderIncomingTransition(transition, navigateToState))
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
inspector.append(incoming)
|
|
283
|
+
|
|
284
|
+
if (inspection.activities.length > 0) {
|
|
285
|
+
const activities = createElement("section", "inspector-section")
|
|
286
|
+
activities.append(inspectionSection("Activities", inspection.activities.length))
|
|
287
|
+
inspection.activities.forEach((activity) => activities.append(renderActivity(activity)))
|
|
288
|
+
inspector.append(activities)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const renderEventInspection = (inspection: EventInspection): void => {
|
|
293
|
+
inspector.replaceChildren()
|
|
294
|
+
const header = createElement("header", "inspector-header")
|
|
295
|
+
const eyebrow = createElement("div", "inspector-eyebrow")
|
|
296
|
+
eyebrow.append(badge("event", "trigger"))
|
|
297
|
+
const candidate = candidateEvents().includes(inspection.event)
|
|
298
|
+
if (candidate) eyebrow.append(badge("enabled", "active"))
|
|
299
|
+
header.append(eyebrow, createElement("h2", undefined, inspection.event))
|
|
300
|
+
header.append(metadata([
|
|
301
|
+
["Status", candidate ? "enabled" : "not enabled"],
|
|
302
|
+
["Registrations", String(inspection.transitions.length)]
|
|
303
|
+
]))
|
|
304
|
+
inspector.append(header)
|
|
305
|
+
|
|
306
|
+
const transitions = createElement("section", "inspector-section")
|
|
307
|
+
transitions.append(inspectionSection("Transitions", inspection.transitions.length))
|
|
308
|
+
inspection.transitions.forEach((transition) =>
|
|
309
|
+
transitions.append(renderTransition(transition, navigateToState, true))
|
|
310
|
+
)
|
|
311
|
+
inspector.append(transitions)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const clearRelations = (): void => {
|
|
315
|
+
for (const path of relatedPaths) {
|
|
316
|
+
nodes.get(path)?.classList.remove("is-related-source", "is-related-target", "is-related-update")
|
|
317
|
+
}
|
|
318
|
+
relatedPaths.clear()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const clearSelection = (): void => {
|
|
322
|
+
if (selectedPath !== undefined) {
|
|
323
|
+
nodes.get(selectedPath)?.classList.remove("is-selected")
|
|
324
|
+
rows.get(selectedPath)?.setAttribute("aria-selected", "false")
|
|
325
|
+
}
|
|
326
|
+
if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
|
|
327
|
+
clearRelations()
|
|
328
|
+
selectedPath = undefined
|
|
329
|
+
selectedEvent = undefined
|
|
330
|
+
clearButton.disabled = true
|
|
331
|
+
renderEmptyInspector()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const markTransitions = (transitions: ReadonlyArray<VisualizationTransition>): void => {
|
|
335
|
+
for (const transition of transitions) {
|
|
336
|
+
relatedPaths.add(transition.source)
|
|
337
|
+
nodes.get(transition.source)?.classList.add("is-related-source")
|
|
338
|
+
for (const branch of transition.branches) {
|
|
339
|
+
if (branch.target !== null) {
|
|
340
|
+
relatedPaths.add(branch.target)
|
|
341
|
+
nodes.get(branch.target)?.classList.add("is-related-target")
|
|
342
|
+
}
|
|
343
|
+
for (const update of branch.updates) {
|
|
344
|
+
relatedPaths.add(update)
|
|
345
|
+
nodes.get(update)?.classList.add("is-related-update")
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const markRelatedStates = (inspection: StateInspection): void => {
|
|
352
|
+
clearRelations()
|
|
353
|
+
for (const incoming of inspection.incoming) {
|
|
354
|
+
relatedPaths.add(incoming.transition.source)
|
|
355
|
+
nodes.get(incoming.transition.source)?.classList.add("is-related-source")
|
|
356
|
+
}
|
|
357
|
+
markTransitions(inspection.outgoing)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const expandAncestors = (inspection: StateInspection): void => {
|
|
361
|
+
for (const ancestor of inspection.breadcrumbs.slice(0, -1)) {
|
|
362
|
+
const row = rows.get(ancestor.path)
|
|
363
|
+
const children = nodes.get(ancestor.path)?.querySelector<HTMLElement>(":scope > .topology-children")
|
|
364
|
+
if (row === undefined || children === null || children === undefined) continue
|
|
365
|
+
row.setAttribute("aria-expanded", "true")
|
|
366
|
+
children.hidden = false
|
|
367
|
+
const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
|
|
368
|
+
if (disclosure !== null) disclosure.textContent = "▾"
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const selectState = (path: string, focus: boolean): void => {
|
|
373
|
+
const inspection = model.inspectState(path)
|
|
374
|
+
if (inspection === undefined) return
|
|
375
|
+
expandAncestors(inspection)
|
|
376
|
+
if (selectedPath !== undefined) {
|
|
377
|
+
nodes.get(selectedPath)?.classList.remove("is-selected")
|
|
378
|
+
rows.get(selectedPath)?.setAttribute("aria-selected", "false")
|
|
379
|
+
}
|
|
380
|
+
if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
|
|
381
|
+
selectedPath = path
|
|
382
|
+
selectedEvent = undefined
|
|
383
|
+
nodes.get(path)?.classList.add("is-selected")
|
|
384
|
+
rows.get(path)?.setAttribute("aria-selected", "true")
|
|
385
|
+
markRelatedStates(inspection)
|
|
386
|
+
clearButton.disabled = false
|
|
387
|
+
renderInspection(inspection)
|
|
388
|
+
if (focus) {
|
|
389
|
+
rows.get(path)?.focus({ preventScroll: true })
|
|
390
|
+
rows.get(path)?.scrollIntoView({ block: "nearest" })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function navigateToState(path: string): void {
|
|
395
|
+
selectState(path, true)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const selectEvent = (event: string): void => {
|
|
399
|
+
const inspection = model.inspectEvent(event)
|
|
400
|
+
if (selectedPath !== undefined) {
|
|
401
|
+
nodes.get(selectedPath)?.classList.remove("is-selected")
|
|
402
|
+
rows.get(selectedPath)?.setAttribute("aria-selected", "false")
|
|
403
|
+
}
|
|
404
|
+
if (selectedEvent !== undefined) eventButtons.get(selectedEvent)?.classList.remove("is-selected")
|
|
405
|
+
selectedPath = undefined
|
|
406
|
+
selectedEvent = event
|
|
407
|
+
eventButtons.get(event)?.classList.add("is-selected")
|
|
408
|
+
clearRelations()
|
|
409
|
+
markTransitions(inspection.transitions)
|
|
410
|
+
clearButton.disabled = false
|
|
411
|
+
renderEventInspection(inspection)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const setExpanded = (row: HTMLElement, expanded: boolean): void => {
|
|
415
|
+
const node = row.closest<HTMLElement>(".topology-node")
|
|
416
|
+
const children = node?.querySelector<HTMLElement>(":scope > .topology-children")
|
|
417
|
+
if (children === null || children === undefined) return
|
|
418
|
+
row.setAttribute("aria-expanded", String(expanded))
|
|
419
|
+
children.hidden = !expanded
|
|
420
|
+
const disclosure = row.querySelector<HTMLElement>(".state-disclosure")
|
|
421
|
+
if (disclosure !== null) disclosure.textContent = expanded ? "▾" : "▸"
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const renderNode = (node: TopologyNode, depth: number): HTMLElement => {
|
|
425
|
+
const container = createElement("div", "topology-node")
|
|
426
|
+
container.dataset.statePath = node.path
|
|
427
|
+
nodes.set(node.path, container)
|
|
428
|
+
|
|
429
|
+
const row = createElement("button", "state-row")
|
|
430
|
+
row.type = "button"
|
|
431
|
+
row.tabIndex = -1
|
|
432
|
+
row.setAttribute("role", "treeitem")
|
|
433
|
+
row.setAttribute("aria-level", String(depth + 1))
|
|
434
|
+
row.setAttribute("aria-selected", "false")
|
|
435
|
+
row.style.setProperty("--depth", String(depth))
|
|
436
|
+
row.dataset.statePath = node.path
|
|
437
|
+
rows.set(node.path, row)
|
|
438
|
+
|
|
439
|
+
const disclosure = createElement("span", "state-disclosure", node.children.length === 0 ? "" : "▾")
|
|
440
|
+
const status = createElement("span", `state-status${node.active ? " is-active" : ""}`)
|
|
441
|
+
status.setAttribute("aria-label", node.active ? "active" : "inactive")
|
|
442
|
+
statuses.set(node.path, status)
|
|
443
|
+
const label = createElement("span", "state-label", node.label)
|
|
444
|
+
const markers = createElement("span", "state-markers")
|
|
445
|
+
if (node.initial) markers.append(badge("initial", "initial"))
|
|
446
|
+
if (node.type !== "atomic") markers.append(badge(node.type, "state"))
|
|
447
|
+
if (node.transitionCount > 0) markers.append(badge(`${node.transitionCount}t`, "count"))
|
|
448
|
+
if (node.activityCount > 0) markers.append(badge(`${node.activityCount}a`, "count"))
|
|
449
|
+
row.append(disclosure, status, label, markers)
|
|
450
|
+
container.append(row)
|
|
451
|
+
row.addEventListener("focus", () => {
|
|
452
|
+
rows.forEach((candidate) => candidate.tabIndex = candidate === row ? 0 : -1)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
if (node.children.length > 0) {
|
|
456
|
+
const children = createElement("div", "topology-children")
|
|
457
|
+
children.setAttribute("role", "group")
|
|
458
|
+
node.children.forEach((child) => children.append(renderNode(child, depth + 1)))
|
|
459
|
+
container.append(children)
|
|
460
|
+
row.setAttribute("aria-expanded", "true")
|
|
461
|
+
row.addEventListener("click", () => {
|
|
462
|
+
const expanded = row.getAttribute("aria-expanded") === "true"
|
|
463
|
+
setExpanded(row, !expanded)
|
|
464
|
+
selectState(node.path, false)
|
|
465
|
+
})
|
|
466
|
+
} else {
|
|
467
|
+
row.addEventListener("click", () => selectState(node.path, false))
|
|
468
|
+
}
|
|
469
|
+
return container
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const setAllExpanded = (expanded: boolean): void => {
|
|
473
|
+
treePanel.querySelectorAll<HTMLElement>(".state-row[aria-expanded]").forEach((row) => {
|
|
474
|
+
setExpanded(row, expanded)
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
clearButton.addEventListener("click", clearSelection)
|
|
479
|
+
expandButton.addEventListener("click", () => setAllExpanded(true))
|
|
480
|
+
collapseButton.addEventListener("click", () => setAllExpanded(false))
|
|
481
|
+
revealActiveButton.addEventListener("click", () => {
|
|
482
|
+
const deepest = [...activePaths()].sort((left, right) => right.split(".").length - left.split(".").length)[0]
|
|
483
|
+
if (deepest !== undefined) navigateToState(deepest)
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const toolbar = createElement("div", "toolbar")
|
|
487
|
+
const runtime = createElement("div", "runtime-summary")
|
|
488
|
+
const runtimeDot = createElement("span", "runtime-dot")
|
|
489
|
+
const runtimeText = createElement("span")
|
|
490
|
+
runtime.append(runtimeDot, runtimeText)
|
|
491
|
+
const toolbarActions = createElement("div", "toolbar-actions")
|
|
492
|
+
toolbarActions.append(clearButton, simulationButton, revealActiveButton, expandButton, collapseButton)
|
|
493
|
+
toolbar.append(runtime, toolbarActions)
|
|
494
|
+
const tree = createElement("div", "topology-tree")
|
|
495
|
+
tree.setAttribute("role", "tree")
|
|
496
|
+
tree.setAttribute("aria-label", `${model.machineId} states`)
|
|
497
|
+
tree.append(createElement("div", "machine-id", model.machineId))
|
|
498
|
+
const events = createElement("div", "enabled-events")
|
|
499
|
+
const simulationFeedback = createElement("div", "simulation-feedback")
|
|
500
|
+
simulationFeedback.setAttribute("role", "status")
|
|
501
|
+
tree.append(events, simulationFeedback)
|
|
502
|
+
|
|
503
|
+
const renderEventButtons = (): void => {
|
|
504
|
+
events.replaceChildren(createElement("span", "enabled-events-label", "Enabled"))
|
|
505
|
+
eventButtons.clear()
|
|
506
|
+
const candidates = candidateEvents()
|
|
507
|
+
if (candidates.length === 0) {
|
|
508
|
+
events.append(createElement("span", "enabled-events-empty", "none"))
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
candidates.forEach((event) => {
|
|
512
|
+
const button = createElement("button", `event-button${event === selectedEvent ? " is-selected" : ""}`, event)
|
|
513
|
+
button.type = "button"
|
|
514
|
+
button.addEventListener("click", () => {
|
|
515
|
+
if (simulation !== undefined) {
|
|
516
|
+
const result = MachineSimulator.send(simulation, event)
|
|
517
|
+
if (result._tag === "Applied") simulation = { document: visualization, snapshot: result.session }
|
|
518
|
+
simulationFeedback.textContent = simulationResultMessage(result)
|
|
519
|
+
simulationFeedback.dataset.status = result._tag.toLowerCase()
|
|
520
|
+
updateSimulationUi()
|
|
521
|
+
}
|
|
522
|
+
selectEvent(event)
|
|
523
|
+
})
|
|
524
|
+
eventButtons.set(event, button)
|
|
525
|
+
events.append(button)
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const updateSimulationUi = (): void => {
|
|
530
|
+
const active = new Set(activePaths())
|
|
531
|
+
statuses.forEach((status, path) => {
|
|
532
|
+
const isActive = active.has(path)
|
|
533
|
+
status.classList.toggle("is-active", isActive)
|
|
534
|
+
status.setAttribute("aria-label", isActive ? "active" : "inactive")
|
|
535
|
+
})
|
|
536
|
+
const hasRuntimeState = simulation !== undefined || model.hasSnapshot
|
|
537
|
+
runtimeDot.classList.toggle("has-snapshot", hasRuntimeState)
|
|
538
|
+
runtimeText.textContent = simulation !== undefined
|
|
539
|
+
? `${active.size} active · step ${simulation.snapshot.step}`
|
|
540
|
+
: diagnostics.length > 0
|
|
541
|
+
? "Partial"
|
|
542
|
+
: model.hasSnapshot
|
|
543
|
+
? `${active.size} active`
|
|
544
|
+
: "Structure only"
|
|
545
|
+
simulationButton.textContent = simulation === undefined ? "Start simulation" : "Reset simulation"
|
|
546
|
+
revealActiveButton.disabled = active.size === 0
|
|
547
|
+
events.hidden = !hasRuntimeState
|
|
548
|
+
simulationFeedback.hidden = simulation === undefined
|
|
549
|
+
renderEventButtons()
|
|
550
|
+
if (selectedPath !== undefined) {
|
|
551
|
+
const inspection = model.inspectState(selectedPath)
|
|
552
|
+
if (inspection !== undefined) renderInspection(inspection)
|
|
553
|
+
} else if (selectedEvent !== undefined) {
|
|
554
|
+
renderEventInspection(model.inspectEvent(selectedEvent))
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
simulationButton.addEventListener("click", () => {
|
|
559
|
+
if (simulation === undefined) {
|
|
560
|
+
simulation = MachineSimulator.start(visualization)
|
|
561
|
+
simulationFeedback.textContent = "Best-effort simulation started · user code will not run"
|
|
562
|
+
simulationFeedback.dataset.status = "applied"
|
|
563
|
+
} else {
|
|
564
|
+
simulation = undefined
|
|
565
|
+
simulationFeedback.textContent = ""
|
|
566
|
+
delete simulationFeedback.dataset.status
|
|
567
|
+
}
|
|
568
|
+
updateSimulationUi()
|
|
569
|
+
})
|
|
570
|
+
if (model.roots.length === 0) {
|
|
571
|
+
const empty = createElement("div", "topology-empty")
|
|
572
|
+
empty.append(
|
|
573
|
+
createElement("strong", undefined, "No states yet"),
|
|
574
|
+
createElement("span", undefined, "The topology will appear as the machine definition becomes available.")
|
|
575
|
+
)
|
|
576
|
+
tree.append(empty)
|
|
577
|
+
} else {
|
|
578
|
+
model.roots.forEach((node) => tree.append(renderNode(node, 0)))
|
|
579
|
+
}
|
|
580
|
+
updateSimulationUi()
|
|
581
|
+
rows.values().next().value?.setAttribute("tabindex", "0")
|
|
582
|
+
tree.addEventListener("keydown", (event) => {
|
|
583
|
+
const current = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(".state-row") : null
|
|
584
|
+
if (current === null) return
|
|
585
|
+
const visible = [...rows.values()].filter((row) => row.getClientRects().length > 0)
|
|
586
|
+
const index = visible.indexOf(current)
|
|
587
|
+
const focus = (row: HTMLElement | undefined): void => {
|
|
588
|
+
if (row === undefined) return
|
|
589
|
+
event.preventDefault()
|
|
590
|
+
row.focus()
|
|
591
|
+
}
|
|
592
|
+
switch (event.key) {
|
|
593
|
+
case "ArrowDown":
|
|
594
|
+
focus(visible[index + 1])
|
|
595
|
+
break
|
|
596
|
+
case "ArrowUp":
|
|
597
|
+
focus(visible[index - 1])
|
|
598
|
+
break
|
|
599
|
+
case "Home":
|
|
600
|
+
focus(visible[0])
|
|
601
|
+
break
|
|
602
|
+
case "End":
|
|
603
|
+
focus(visible.at(-1))
|
|
604
|
+
break
|
|
605
|
+
case "ArrowRight": {
|
|
606
|
+
if (current.getAttribute("aria-expanded") === "false") {
|
|
607
|
+
event.preventDefault()
|
|
608
|
+
setExpanded(current, true)
|
|
609
|
+
} else {
|
|
610
|
+
const child = current.closest<HTMLElement>(".topology-node")
|
|
611
|
+
?.querySelector<HTMLElement>(":scope > .topology-children > .topology-node > .state-row")
|
|
612
|
+
focus(child ?? undefined)
|
|
613
|
+
}
|
|
614
|
+
break
|
|
615
|
+
}
|
|
616
|
+
case "ArrowLeft": {
|
|
617
|
+
if (current.getAttribute("aria-expanded") === "true") {
|
|
618
|
+
event.preventDefault()
|
|
619
|
+
setExpanded(current, false)
|
|
620
|
+
} else {
|
|
621
|
+
const parent = current.closest<HTMLElement>(".topology-children")
|
|
622
|
+
?.closest<HTMLElement>(".topology-node")
|
|
623
|
+
?.querySelector<HTMLElement>(":scope > .state-row")
|
|
624
|
+
focus(parent ?? undefined)
|
|
625
|
+
}
|
|
626
|
+
break
|
|
627
|
+
}
|
|
628
|
+
case "Enter":
|
|
629
|
+
case " ":
|
|
630
|
+
event.preventDefault()
|
|
631
|
+
current.click()
|
|
632
|
+
break
|
|
633
|
+
case "Escape":
|
|
634
|
+
event.preventDefault()
|
|
635
|
+
clearSelection()
|
|
636
|
+
break
|
|
637
|
+
}
|
|
638
|
+
})
|
|
639
|
+
treePanel.append(toolbar)
|
|
640
|
+
if (diagnostics.length > 0) {
|
|
641
|
+
const diagnosticList = createElement("div", "diagnostics")
|
|
642
|
+
diagnosticList.setAttribute("role", "status")
|
|
643
|
+
diagnostics.forEach((diagnostic) => {
|
|
644
|
+
const item = createElement("div", `diagnostic diagnostic-${diagnostic.severity}`)
|
|
645
|
+
item.append(badge(diagnostic.severity, diagnostic.severity), createElement("span", undefined, diagnostic.message))
|
|
646
|
+
if (diagnostic.statePath !== null && model.inspectState(diagnostic.statePath) !== undefined) {
|
|
647
|
+
item.append(stateLink(diagnostic.statePath, diagnostic.statePath, navigateToState))
|
|
648
|
+
}
|
|
649
|
+
diagnosticList.append(item)
|
|
650
|
+
})
|
|
651
|
+
treePanel.append(diagnosticList)
|
|
652
|
+
}
|
|
653
|
+
treePanel.append(tree)
|
|
654
|
+
|
|
655
|
+
renderEmptyInspector()
|
|
656
|
+
workspace.append(treePanel, inspector)
|
|
657
|
+
shell.append(workspace)
|
|
658
|
+
root.replaceChildren(shell)
|
|
659
|
+
}
|