@foldkit/devtools 0.112.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/README.md +42 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/internal/optionExtensions.d.ts +6 -0
- package/dist/internal/optionExtensions.d.ts.map +1 -0
- package/dist/internal/optionExtensions.js +2 -0
- package/dist/overlay-styles.d.ts +3 -0
- package/dist/overlay-styles.d.ts.map +1 -0
- package/dist/overlay-styles.js +706 -0
- package/dist/overlay.d.ts +66 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +1242 -0
- package/package.json +62 -0
package/dist/overlay.js
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
import { clsx } from 'clsx';
|
|
2
|
+
import { Array as Array_, Context, Effect, Equal, Function, HashSet, Match as M, Number as Number_, Option, Order, Predicate, Queue, Record, Schema as S, Stream, String as String_, SubscriptionRef, pipe, } from 'effect';
|
|
3
|
+
import * as Command from 'foldkit/command';
|
|
4
|
+
import { DEVTOOLS_HOST_ID, GOT_MESSAGE_PATTERN, INIT_INDEX, extractSubmodelInfo, isTagged, latestEntryIndex, toInspectableValue, } from 'foldkit/devtools-host';
|
|
5
|
+
import { lockScroll, unlockScroll } from 'foldkit/dom';
|
|
6
|
+
import { childAttributes, createKeyedLazy, createLazy, html, } from 'foldkit/html';
|
|
7
|
+
import { m } from 'foldkit/message';
|
|
8
|
+
import { makeElement } from 'foldkit/runtime';
|
|
9
|
+
import { evo } from 'foldkit/struct';
|
|
10
|
+
import * as Subscription from 'foldkit/subscription';
|
|
11
|
+
import * as Listbox from '@foldkit/ui/listbox';
|
|
12
|
+
import * as Slider from '@foldkit/ui/slider';
|
|
13
|
+
import * as Tabs from '@foldkit/ui/tabs';
|
|
14
|
+
import * as OptionExt from './internal/optionExtensions.js';
|
|
15
|
+
import { overlayStyles } from './overlay-styles.js';
|
|
16
|
+
const SubmodelFilterListbox = Listbox.create();
|
|
17
|
+
// MODEL
|
|
18
|
+
const DisplayCommand = S.Struct({
|
|
19
|
+
name: S.String,
|
|
20
|
+
args: S.Option(S.Record(S.String, S.Unknown)),
|
|
21
|
+
});
|
|
22
|
+
const DisplayMount = S.Struct({
|
|
23
|
+
name: S.String,
|
|
24
|
+
args: S.Option(S.Record(S.String, S.Unknown)),
|
|
25
|
+
});
|
|
26
|
+
const DisplayEntry = S.Struct({
|
|
27
|
+
tag: S.String,
|
|
28
|
+
submodelPath: S.Array(S.String),
|
|
29
|
+
maybeLeafTag: S.Option(S.String),
|
|
30
|
+
commands: S.Array(DisplayCommand),
|
|
31
|
+
mountStarts: S.Array(DisplayMount),
|
|
32
|
+
mountEnds: S.Array(DisplayMount),
|
|
33
|
+
timestamp: S.Number,
|
|
34
|
+
isModelChanged: S.Boolean,
|
|
35
|
+
});
|
|
36
|
+
const INSPECTOR_TABS_ID = 'dt-inspector';
|
|
37
|
+
const SUBMODEL_FILTER_ID = 'dt-submodel-filter';
|
|
38
|
+
const SCRUBBER_SLIDER_ID = 'dt-scrubber';
|
|
39
|
+
const InspectorTabsModel = S.Struct({
|
|
40
|
+
id: S.String,
|
|
41
|
+
activeIndex: S.Number,
|
|
42
|
+
focusedIndex: S.Number,
|
|
43
|
+
activationMode: S.Literals(['Automatic', 'Manual']),
|
|
44
|
+
});
|
|
45
|
+
const INSPECTOR_TABS = [
|
|
46
|
+
'Model',
|
|
47
|
+
'Message',
|
|
48
|
+
'Commands',
|
|
49
|
+
'Mounts',
|
|
50
|
+
];
|
|
51
|
+
const InspectorTabs = Tabs.create();
|
|
52
|
+
/**
|
|
53
|
+
* `S.Unknown` whose equivalence is reference equality. Effect 4's default
|
|
54
|
+
* equivalence for `S.Unknown` is `Equal.equals`, which walks the value
|
|
55
|
+
* structurally (hash + compareRecords) instead of falling back to `===` like
|
|
56
|
+
* Effect 3. The DevTools overlay holds whole user Model and Message snapshots
|
|
57
|
+
* in fields typed as `S.Unknown`, so the runtime's per-dispatch
|
|
58
|
+
* `modelEquivalence` check would otherwise walk the entire payload three
|
|
59
|
+
* times every time the user dispatches a Message. The snapshots are
|
|
60
|
+
* through-traffic (different reference per frame iff different content),
|
|
61
|
+
* which makes reference equality the correct comparison.
|
|
62
|
+
*/
|
|
63
|
+
const UnknownByReference = S.Unknown.pipe(S.overrideToEquivalence(() => (a, b) => a === b));
|
|
64
|
+
const Model = S.Struct({
|
|
65
|
+
isOpen: S.Boolean,
|
|
66
|
+
isMobile: S.Boolean,
|
|
67
|
+
entries: S.Array(DisplayEntry),
|
|
68
|
+
initCommands: S.Array(DisplayCommand),
|
|
69
|
+
initMountStarts: S.Array(DisplayMount),
|
|
70
|
+
startIndex: S.Number,
|
|
71
|
+
isPaused: S.Boolean,
|
|
72
|
+
pausedAtIndex: S.Number,
|
|
73
|
+
selectedIndex: S.Number,
|
|
74
|
+
isFollowingLatest: S.Boolean,
|
|
75
|
+
isFollowingTop: S.Boolean,
|
|
76
|
+
maybeInspectedModel: S.Option(UnknownByReference),
|
|
77
|
+
maybeInspectedMessage: S.Option(UnknownByReference),
|
|
78
|
+
submodelTags: S.Array(S.String),
|
|
79
|
+
maybeSubmodelFilter: S.Option(S.String),
|
|
80
|
+
submodelFilterListbox: Listbox.Model,
|
|
81
|
+
expandedPaths: S.HashSet(S.String),
|
|
82
|
+
changedPaths: S.HashSet(S.String),
|
|
83
|
+
affectedPaths: S.HashSet(S.String),
|
|
84
|
+
inspectorTabs: InspectorTabsModel,
|
|
85
|
+
// NOTE: empirically, inlining `Slider.Model` here throws
|
|
86
|
+
// "Cannot read properties of undefined (reading 'ast')" when running slider
|
|
87
|
+
// tests, because slider imports html → runtime → overlay, and overlay
|
|
88
|
+
// references Slider.Model mid-cycle. S.suspend defers the read until after
|
|
89
|
+
// the cycle resolves. Inlining Listbox.Model works in practice but goes
|
|
90
|
+
// through the same import chain; the exact cause of the asymmetry isn't
|
|
91
|
+
// pinned down. Suspend is the conservative fix until the runtime ↔ overlay
|
|
92
|
+
// cycle is broken at the source.
|
|
93
|
+
scrubberSlider: S.suspend(() => Slider.Model),
|
|
94
|
+
});
|
|
95
|
+
const Flags = S.Struct({
|
|
96
|
+
isMobile: S.Boolean,
|
|
97
|
+
entries: S.Array(DisplayEntry),
|
|
98
|
+
initCommands: S.Array(DisplayCommand),
|
|
99
|
+
initMountStarts: S.Array(DisplayMount),
|
|
100
|
+
startIndex: S.Number,
|
|
101
|
+
isPaused: S.Boolean,
|
|
102
|
+
pausedAtIndex: S.Number,
|
|
103
|
+
});
|
|
104
|
+
// MESSAGE
|
|
105
|
+
const ClickedToggle = m('ClickedToggle');
|
|
106
|
+
const ClickedRow = m('ClickedRow', { index: S.Number });
|
|
107
|
+
const ClickedResume = m('ClickedResume');
|
|
108
|
+
const ClickedClear = m('ClickedClear');
|
|
109
|
+
const CompletedJump = m('CompletedJump');
|
|
110
|
+
const CompletedResume = m('CompletedResume');
|
|
111
|
+
const ClickedFollowLatest = m('ClickedFollowLatest');
|
|
112
|
+
const ClickedScrollToTopPill = m('ClickedScrollToTopPill');
|
|
113
|
+
const ScrolledMessageList = m('ScrolledMessageList', { scrollTop: S.Number });
|
|
114
|
+
const CompletedClear = m('CompletedClear');
|
|
115
|
+
const LockedScroll = m('LockedScroll');
|
|
116
|
+
const UnlockedScroll = m('UnlockedScroll');
|
|
117
|
+
const ScrolledToTop = m('ScrolledToTop');
|
|
118
|
+
const CrossedMobileBreakpoint = m('CrossedMobileBreakpoint', {
|
|
119
|
+
isMobile: S.Boolean,
|
|
120
|
+
});
|
|
121
|
+
const ReceivedInspectedState = m('ReceivedInspectedState', {
|
|
122
|
+
model: S.Unknown,
|
|
123
|
+
maybeMessage: S.Option(S.Unknown),
|
|
124
|
+
changedPaths: S.HashSet(S.String),
|
|
125
|
+
affectedPaths: S.HashSet(S.String),
|
|
126
|
+
});
|
|
127
|
+
const ToggledTreeNode = m('ToggledTreeNode', { path: S.String });
|
|
128
|
+
const GotInspectorTabsMessage = m('GotInspectorTabsMessage', {
|
|
129
|
+
message: S.Unknown,
|
|
130
|
+
});
|
|
131
|
+
const ReceivedStoreUpdate = m('ReceivedStoreUpdate', {
|
|
132
|
+
entries: S.Array(DisplayEntry),
|
|
133
|
+
initCommands: S.Array(DisplayCommand),
|
|
134
|
+
initMountStarts: S.Array(DisplayMount),
|
|
135
|
+
startIndex: S.Number,
|
|
136
|
+
isPaused: S.Boolean,
|
|
137
|
+
pausedAtIndex: S.Number,
|
|
138
|
+
});
|
|
139
|
+
const GotSubmodelFilterMessage = m('GotSubmodelFilterMessage', {
|
|
140
|
+
message: Listbox.Message,
|
|
141
|
+
});
|
|
142
|
+
// NOTE: suspend for the same init-order reason as scrubberSlider above.
|
|
143
|
+
const GotScrubberSliderMessage = m('GotScrubberSliderMessage', {
|
|
144
|
+
message: S.suspend(() => Slider.Message),
|
|
145
|
+
});
|
|
146
|
+
const Message = S.Union([
|
|
147
|
+
ClickedToggle,
|
|
148
|
+
ClickedRow,
|
|
149
|
+
ClickedResume,
|
|
150
|
+
ClickedClear,
|
|
151
|
+
ClickedFollowLatest,
|
|
152
|
+
ClickedScrollToTopPill,
|
|
153
|
+
ScrolledMessageList,
|
|
154
|
+
CompletedJump,
|
|
155
|
+
CompletedResume,
|
|
156
|
+
CompletedClear,
|
|
157
|
+
LockedScroll,
|
|
158
|
+
UnlockedScroll,
|
|
159
|
+
ScrolledToTop,
|
|
160
|
+
CrossedMobileBreakpoint,
|
|
161
|
+
ReceivedInspectedState,
|
|
162
|
+
ToggledTreeNode,
|
|
163
|
+
GotInspectorTabsMessage,
|
|
164
|
+
ReceivedStoreUpdate,
|
|
165
|
+
GotSubmodelFilterMessage,
|
|
166
|
+
GotScrubberSliderMessage,
|
|
167
|
+
]);
|
|
168
|
+
// HELPERS
|
|
169
|
+
const MILLIS_PER_SECOND = 1000;
|
|
170
|
+
const MOBILE_BREAKPOINT = 767;
|
|
171
|
+
const MOBILE_BREAKPOINT_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
|
|
172
|
+
const TREE_INDENT_PX = 12;
|
|
173
|
+
const MAX_PREVIEW_KEYS = 3;
|
|
174
|
+
const ALL_MESSAGES_VALUE = '';
|
|
175
|
+
const NO_COMMANDS = [];
|
|
176
|
+
const NO_MOUNTS = [];
|
|
177
|
+
const formatTimeDelta = (deltaMs) => M.value(deltaMs).pipe(M.when(0, () => '0ms'), M.when(Number_.isLessThan(MILLIS_PER_SECOND), ms => `+${Math.round(ms)}ms`), M.orElse(ms => `+${(ms / MILLIS_PER_SECOND).toFixed(1)}s`));
|
|
178
|
+
const MESSAGE_LIST_SELECTOR = '.message-list';
|
|
179
|
+
// NOTE: scrubber slider value space is independent of the store's host
|
|
180
|
+
// indices. Slider value 0 represents init; 1..entries.length represents
|
|
181
|
+
// positions after each buffered message. Passing pausedAtIndex (a host
|
|
182
|
+
// index) straight into setValue, or treating ChangedValue.value as a host
|
|
183
|
+
// index in jumpTo, will silently produce wrong navigation. Translate at
|
|
184
|
+
// the boundaries via the helpers below.
|
|
185
|
+
const hostIndexToSliderValue = (hostIndex, startIndex) => (hostIndex === INIT_INDEX ? 0 : hostIndex - startIndex + 1);
|
|
186
|
+
const sliderValueToHostIndex = (sliderValue, startIndex) => (sliderValue === 0 ? INIT_INDEX : startIndex + sliderValue - 1);
|
|
187
|
+
const SCROLL_FOLLOW_THRESHOLD_PX = 8;
|
|
188
|
+
const computeSubmodelTags = (entries) => pipe(entries, Array_.flatMap(({ submodelPath }) => submodelPath), Array_.dedupe, Array_.sort(Order.String));
|
|
189
|
+
const toDisplayCommand = (command) => ({
|
|
190
|
+
name: command.name,
|
|
191
|
+
args: Option.fromNullishOr(command.args),
|
|
192
|
+
});
|
|
193
|
+
const toDisplayMount = (mount) => ({
|
|
194
|
+
name: mount.name,
|
|
195
|
+
args: Option.fromNullishOr(mount.args),
|
|
196
|
+
});
|
|
197
|
+
const toDisplayEntries = ({ entries }) => Array_.map(entries, entry => {
|
|
198
|
+
const { submodelPath, maybeLeafTag } = extractSubmodelInfo(entry.tag, entry.message);
|
|
199
|
+
return {
|
|
200
|
+
tag: entry.tag,
|
|
201
|
+
submodelPath,
|
|
202
|
+
maybeLeafTag,
|
|
203
|
+
commands: Array_.map(entry.commands, toDisplayCommand),
|
|
204
|
+
mountStarts: Array_.map(entry.mountStarts, toDisplayMount),
|
|
205
|
+
mountEnds: Array_.map(entry.mountEnds, toDisplayMount),
|
|
206
|
+
timestamp: entry.timestamp,
|
|
207
|
+
isModelChanged: entry.isModelChanged,
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
const toDisplayState = (state) => ({
|
|
211
|
+
entries: toDisplayEntries(state),
|
|
212
|
+
initCommands: Array_.map(state.initCommands, toDisplayCommand),
|
|
213
|
+
initMountStarts: Array_.map(state.initMountStarts, toDisplayMount),
|
|
214
|
+
startIndex: state.startIndex,
|
|
215
|
+
isPaused: state.isPaused,
|
|
216
|
+
pausedAtIndex: state.pausedAtIndex,
|
|
217
|
+
});
|
|
218
|
+
const isExpandable = Predicate.isObjectOrArray;
|
|
219
|
+
const objectPreview = (value) => pipe(value, Record.keys, Array_.filter(key => key !== '_tag'), Array_.match({
|
|
220
|
+
onEmpty: () => '{}',
|
|
221
|
+
onNonEmpty: keys => {
|
|
222
|
+
const preview = pipe(keys, Array_.take(MAX_PREVIEW_KEYS), Array_.join(', '));
|
|
223
|
+
return Array_.length(keys) > MAX_PREVIEW_KEYS
|
|
224
|
+
? `{ ${preview}, … }`
|
|
225
|
+
: `{ ${preview} }`;
|
|
226
|
+
},
|
|
227
|
+
}));
|
|
228
|
+
const collapsedPreview = (value) => M.value(value).pipe(M.when(Array.isArray, array => `(${array.length})`), M.when(Predicate.isObject, objectPreview), M.orElse(() => ''));
|
|
229
|
+
class StoreService extends Context.Service()('foldkit/DevToolsStore') {
|
|
230
|
+
}
|
|
231
|
+
class ShadowRootService extends Context.Service()('foldkit/DevToolsShadowRoot') {
|
|
232
|
+
}
|
|
233
|
+
export const LockScroll = Command.define('LockScroll', LockedScroll)(lockScroll.pipe(Effect.as(LockedScroll())));
|
|
234
|
+
export const UnlockScroll = Command.define('UnlockScroll', UnlockedScroll)(unlockScroll.pipe(Effect.as(UnlockedScroll())));
|
|
235
|
+
const buildInspectionEffect = (index) => Effect.gen(function* () {
|
|
236
|
+
const store = yield* StoreService;
|
|
237
|
+
const model = yield* store.getModelAtIndex(index);
|
|
238
|
+
const maybeMessage = yield* store.getMessageAtIndex(index);
|
|
239
|
+
const diff = yield* store.getDiffAtIndex(index);
|
|
240
|
+
return ReceivedInspectedState({ model, maybeMessage, ...diff });
|
|
241
|
+
});
|
|
242
|
+
export const JumpTo = Command.define('JumpTo', { index: S.Number }, CompletedJump)(({ index }) => Effect.gen(function* () {
|
|
243
|
+
const store = yield* StoreService;
|
|
244
|
+
yield* store.jumpTo(index);
|
|
245
|
+
return CompletedJump();
|
|
246
|
+
}));
|
|
247
|
+
export const InspectState = Command.define('InspectState', { index: S.Number }, ReceivedInspectedState)(({ index }) => buildInspectionEffect(index));
|
|
248
|
+
export const InspectLatest = Command.define('InspectLatest', ReceivedInspectedState)(Effect.gen(function* () {
|
|
249
|
+
const store = yield* StoreService;
|
|
250
|
+
const state = yield* SubscriptionRef.get(store.stateRef);
|
|
251
|
+
return yield* buildInspectionEffect(latestEntryIndex(state));
|
|
252
|
+
}));
|
|
253
|
+
export const Resume = Command.define('Resume', CompletedResume)(Effect.gen(function* () {
|
|
254
|
+
const store = yield* StoreService;
|
|
255
|
+
yield* store.resume;
|
|
256
|
+
return CompletedResume();
|
|
257
|
+
}));
|
|
258
|
+
export const Clear = Command.define('Clear', CompletedClear)(Effect.gen(function* () {
|
|
259
|
+
const store = yield* StoreService;
|
|
260
|
+
yield* store.clear;
|
|
261
|
+
return CompletedClear();
|
|
262
|
+
}));
|
|
263
|
+
export const ScrollToTop = Command.define('ScrollToTop', ScrolledToTop)(Effect.gen(function* () {
|
|
264
|
+
const shadow = yield* ShadowRootService;
|
|
265
|
+
const messageList = shadow.querySelector(MESSAGE_LIST_SELECTOR);
|
|
266
|
+
if (messageList instanceof HTMLElement) {
|
|
267
|
+
messageList.scrollTop = 0;
|
|
268
|
+
}
|
|
269
|
+
return ScrolledToTop();
|
|
270
|
+
}));
|
|
271
|
+
const makeUpdate = (store, shadow, mode) => {
|
|
272
|
+
const provideContext = (effect) => effect.pipe(Effect.provideService(StoreService, store), Effect.provideService(ShadowRootService, shadow));
|
|
273
|
+
const inspectLatest = Command.mapEffect(InspectLatest(), provideContext);
|
|
274
|
+
const resume = Command.mapEffect(Resume(), provideContext);
|
|
275
|
+
const clear = Command.mapEffect(Clear(), provideContext);
|
|
276
|
+
const scrollToTop = Command.mapEffect(ScrollToTop(), provideContext);
|
|
277
|
+
const jumpTo = (index) => Command.mapEffect(JumpTo({ index }), provideContext);
|
|
278
|
+
const inspectState = (index) => Command.mapEffect(InspectState({ index }), provideContext);
|
|
279
|
+
const toggleScrollLock = (shouldLock) => shouldLock ? LockScroll() : UnlockScroll();
|
|
280
|
+
return (model, message) => M.value(message).pipe(M.withReturnType(), M.tags({
|
|
281
|
+
ClickedToggle: () => {
|
|
282
|
+
const nextIsOpen = !model.isOpen;
|
|
283
|
+
return [
|
|
284
|
+
evo(model, { isOpen: () => nextIsOpen }),
|
|
285
|
+
OptionExt.when(model.isMobile, toggleScrollLock(nextIsOpen)).pipe(Option.toArray),
|
|
286
|
+
];
|
|
287
|
+
},
|
|
288
|
+
CrossedMobileBreakpoint: ({ isMobile }) => [
|
|
289
|
+
evo(model, { isMobile: () => isMobile }),
|
|
290
|
+
OptionExt.when(model.isOpen, toggleScrollLock(isMobile)).pipe(Option.toArray),
|
|
291
|
+
],
|
|
292
|
+
ClickedRow: ({ index }) => M.value(mode).pipe(M.withReturnType(), M.when('TimeTravel', () => [
|
|
293
|
+
model,
|
|
294
|
+
[jumpTo(index), inspectState(index)],
|
|
295
|
+
]), M.when('Inspect', () => [
|
|
296
|
+
evo(model, {
|
|
297
|
+
selectedIndex: () => index,
|
|
298
|
+
isFollowingLatest: () => false,
|
|
299
|
+
}),
|
|
300
|
+
[inspectState(index)],
|
|
301
|
+
]), M.exhaustive),
|
|
302
|
+
ClickedResume: () => [
|
|
303
|
+
evo(model, {
|
|
304
|
+
isFollowingTop: () => true,
|
|
305
|
+
expandedPaths: () => HashSet.empty(),
|
|
306
|
+
changedPaths: () => HashSet.empty(),
|
|
307
|
+
affectedPaths: () => HashSet.empty(),
|
|
308
|
+
}),
|
|
309
|
+
[resume, inspectLatest, scrollToTop],
|
|
310
|
+
],
|
|
311
|
+
ClickedClear: () => [
|
|
312
|
+
evo(model, {
|
|
313
|
+
selectedIndex: () => INIT_INDEX,
|
|
314
|
+
isFollowingLatest: () => true,
|
|
315
|
+
isFollowingTop: () => true,
|
|
316
|
+
maybeSubmodelFilter: () => Option.none(),
|
|
317
|
+
expandedPaths: () => HashSet.empty(),
|
|
318
|
+
changedPaths: () => HashSet.empty(),
|
|
319
|
+
affectedPaths: () => HashSet.empty(),
|
|
320
|
+
}),
|
|
321
|
+
[clear, inspectLatest, scrollToTop],
|
|
322
|
+
],
|
|
323
|
+
ClickedFollowLatest: () => {
|
|
324
|
+
const latestIndex = Array_.match(model.entries, {
|
|
325
|
+
onEmpty: () => INIT_INDEX,
|
|
326
|
+
onNonEmpty: () => model.startIndex + model.entries.length - 1,
|
|
327
|
+
});
|
|
328
|
+
return [
|
|
329
|
+
evo(model, {
|
|
330
|
+
selectedIndex: () => latestIndex,
|
|
331
|
+
isFollowingLatest: () => true,
|
|
332
|
+
isFollowingTop: () => true,
|
|
333
|
+
expandedPaths: () => HashSet.empty(),
|
|
334
|
+
changedPaths: () => HashSet.empty(),
|
|
335
|
+
affectedPaths: () => HashSet.empty(),
|
|
336
|
+
}),
|
|
337
|
+
[inspectLatest, scrollToTop],
|
|
338
|
+
];
|
|
339
|
+
},
|
|
340
|
+
ClickedScrollToTopPill: () => [
|
|
341
|
+
evo(model, {
|
|
342
|
+
isFollowingTop: () => true,
|
|
343
|
+
}),
|
|
344
|
+
[scrollToTop],
|
|
345
|
+
],
|
|
346
|
+
ScrolledMessageList: ({ scrollTop }) => {
|
|
347
|
+
const isAtTop = scrollTop <= SCROLL_FOLLOW_THRESHOLD_PX;
|
|
348
|
+
return isAtTop === model.isFollowingTop
|
|
349
|
+
? [model, []]
|
|
350
|
+
: [evo(model, { isFollowingTop: () => isAtTop }), []];
|
|
351
|
+
},
|
|
352
|
+
ReceivedInspectedState: ({ model: inspectedModel, maybeMessage, changedPaths, affectedPaths, }) => [
|
|
353
|
+
evo(model, {
|
|
354
|
+
maybeInspectedModel: () => Option.some(inspectedModel),
|
|
355
|
+
maybeInspectedMessage: () => maybeMessage,
|
|
356
|
+
changedPaths: () => changedPaths,
|
|
357
|
+
affectedPaths: () => affectedPaths,
|
|
358
|
+
}),
|
|
359
|
+
[],
|
|
360
|
+
],
|
|
361
|
+
GotInspectorTabsMessage: ({ message: tabsMessage }) => {
|
|
362
|
+
const [nextTabsModel, tabsCommands] = InspectorTabs.update(model.inspectorTabs,
|
|
363
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
364
|
+
tabsMessage);
|
|
365
|
+
return [
|
|
366
|
+
evo(model, {
|
|
367
|
+
inspectorTabs: () => nextTabsModel,
|
|
368
|
+
}),
|
|
369
|
+
Command.mapMessages(tabsCommands, innerMessage => GotInspectorTabsMessage({ message: innerMessage })),
|
|
370
|
+
];
|
|
371
|
+
},
|
|
372
|
+
ToggledTreeNode: ({ path }) => [
|
|
373
|
+
evo(model, {
|
|
374
|
+
expandedPaths: paths => HashSet.has(paths, path)
|
|
375
|
+
? HashSet.remove(paths, path)
|
|
376
|
+
: HashSet.add(paths, path),
|
|
377
|
+
}),
|
|
378
|
+
[],
|
|
379
|
+
],
|
|
380
|
+
ReceivedStoreUpdate: ({ entries, initCommands, initMountStarts, startIndex, isPaused, pausedAtIndex, }) => {
|
|
381
|
+
const shouldFollowSelection = M.value(mode).pipe(M.when('TimeTravel', () => !isPaused), M.when('Inspect', () => model.isFollowingLatest), M.exhaustive);
|
|
382
|
+
const shouldFollowScroll = M.value(mode).pipe(M.when('TimeTravel', () => !isPaused && model.isFollowingTop), M.when('Inspect', () => model.isFollowingTop), M.exhaustive);
|
|
383
|
+
const latestIndex = Array_.match(entries, {
|
|
384
|
+
onEmpty: () => INIT_INDEX,
|
|
385
|
+
onNonEmpty: () => startIndex + entries.length - 1,
|
|
386
|
+
});
|
|
387
|
+
const nextSubmodelTags = computeSubmodelTags(entries);
|
|
388
|
+
const isFilterStale = Option.exists(model.maybeSubmodelFilter, filterTag => !Array_.contains(nextSubmodelTags, filterTag));
|
|
389
|
+
return [
|
|
390
|
+
evo(model, {
|
|
391
|
+
entries: () => entries,
|
|
392
|
+
initCommands: () => initCommands,
|
|
393
|
+
initMountStarts: () => initMountStarts,
|
|
394
|
+
startIndex: () => startIndex,
|
|
395
|
+
isPaused: () => isPaused,
|
|
396
|
+
pausedAtIndex: () => pausedAtIndex,
|
|
397
|
+
submodelTags: () => nextSubmodelTags,
|
|
398
|
+
maybeSubmodelFilter: current => isFilterStale ? Option.none() : current,
|
|
399
|
+
submodelFilterListbox: current => isFilterStale
|
|
400
|
+
? evo(current, {
|
|
401
|
+
maybeSelectedItem: () => Option.some(ALL_MESSAGES_VALUE),
|
|
402
|
+
})
|
|
403
|
+
: current,
|
|
404
|
+
selectedIndex: current => shouldFollowSelection ? latestIndex : current,
|
|
405
|
+
scrubberSlider: current => {
|
|
406
|
+
const sliderMax = entries.length;
|
|
407
|
+
const targetSliderValue = isPaused
|
|
408
|
+
? hostIndexToSliderValue(pausedAtIndex, startIndex)
|
|
409
|
+
: sliderMax;
|
|
410
|
+
return Slider.reflectValue(Slider.reflectRange(current, { min: 0, max: sliderMax }), targetSliderValue);
|
|
411
|
+
},
|
|
412
|
+
}),
|
|
413
|
+
[
|
|
414
|
+
...(shouldFollowSelection ? [inspectLatest] : []),
|
|
415
|
+
...(shouldFollowScroll ? [scrollToTop] : []),
|
|
416
|
+
],
|
|
417
|
+
];
|
|
418
|
+
},
|
|
419
|
+
GotSubmodelFilterMessage: ({ message: listboxMessage }) => {
|
|
420
|
+
const [nextListboxModel, listboxCommands, maybeOutMessage] = SubmodelFilterListbox.update(model.submodelFilterListbox, listboxMessage);
|
|
421
|
+
const mappedCommands = Command.mapMessages(listboxCommands, innerMessage => GotSubmodelFilterMessage({ message: innerMessage }));
|
|
422
|
+
return Option.match(maybeOutMessage, {
|
|
423
|
+
onNone: () => [
|
|
424
|
+
evo(model, { submodelFilterListbox: () => nextListboxModel }),
|
|
425
|
+
mappedCommands,
|
|
426
|
+
],
|
|
427
|
+
onSome: M.type().pipe(M.withReturnType(), M.tagsExhaustive({
|
|
428
|
+
Selected: ({ value }) => [
|
|
429
|
+
evo(model, {
|
|
430
|
+
maybeSubmodelFilter: () => Option.liftPredicate(value, String_.isNonEmpty),
|
|
431
|
+
submodelFilterListbox: () => nextListboxModel,
|
|
432
|
+
}),
|
|
433
|
+
mappedCommands,
|
|
434
|
+
],
|
|
435
|
+
})),
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
GotScrubberSliderMessage: ({ message: sliderMessage }) => {
|
|
439
|
+
const [nextSlider, sliderCommands, maybeOutMessage] = Slider.update(model.scrubberSlider, sliderMessage);
|
|
440
|
+
const mappedSliderCommands = Command.mapMessages(sliderCommands, innerMessage => GotScrubberSliderMessage({ message: innerMessage }));
|
|
441
|
+
const additionalCommands = Option.match(maybeOutMessage, {
|
|
442
|
+
onNone: () => [],
|
|
443
|
+
onSome: outMessage => M.value(outMessage).pipe(M.tagsExhaustive({
|
|
444
|
+
ChangedValue: ({ value }) => {
|
|
445
|
+
const hostIndex = sliderValueToHostIndex(value, model.startIndex);
|
|
446
|
+
return [jumpTo(hostIndex), inspectState(hostIndex)];
|
|
447
|
+
},
|
|
448
|
+
})),
|
|
449
|
+
});
|
|
450
|
+
return [
|
|
451
|
+
evo(model, { scrubberSlider: () => nextSlider }),
|
|
452
|
+
[...mappedSliderCommands, ...additionalCommands],
|
|
453
|
+
];
|
|
454
|
+
},
|
|
455
|
+
}), M.tag('CompletedJump', 'CompletedResume', 'CompletedClear', 'LockedScroll', 'UnlockedScroll', 'ScrolledToTop', () => [model, []]), M.exhaustive);
|
|
456
|
+
};
|
|
457
|
+
// SUBSCRIPTION
|
|
458
|
+
const makeOverlaySubscriptions = (store, shadow) => {
|
|
459
|
+
const sliderSubscriptions = Slider.subscriptionsForRoot(() => shadow);
|
|
460
|
+
const scrubberSubscriptions = Subscription.lift({
|
|
461
|
+
scrubberPointer: sliderSubscriptions.dragPointer,
|
|
462
|
+
scrubberEscape: sliderSubscriptions.dragEscape,
|
|
463
|
+
})({
|
|
464
|
+
toChildModel: model => model.scrubberSlider,
|
|
465
|
+
toParentMessage: message => GotScrubberSliderMessage({ message }),
|
|
466
|
+
});
|
|
467
|
+
const ownSubscriptions = Subscription.make()(_entry => ({
|
|
468
|
+
storeUpdates: Subscription.persistent(Stream.concat(Stream.fromEffect(SubscriptionRef.get(store.stateRef).pipe(Effect.map(state => ReceivedStoreUpdate(toDisplayState(state))))), Stream.map(SubscriptionRef.changes(store.stateRef), state => ReceivedStoreUpdate(toDisplayState(state))))),
|
|
469
|
+
mobileBreakpoint: Subscription.persistent(Stream.callback(queue => Effect.acquireRelease(Effect.sync(() => {
|
|
470
|
+
const mediaQuery = window.matchMedia(MOBILE_BREAKPOINT_QUERY);
|
|
471
|
+
const handler = (event) => {
|
|
472
|
+
Queue.offerUnsafe(queue, CrossedMobileBreakpoint({ isMobile: event.matches }));
|
|
473
|
+
};
|
|
474
|
+
mediaQuery.addEventListener('change', handler);
|
|
475
|
+
return { mediaQuery, handler };
|
|
476
|
+
}), ({ mediaQuery, handler }) => Effect.sync(() => mediaQuery.removeEventListener('change', handler))).pipe(Effect.flatMap(() => Effect.never)))),
|
|
477
|
+
}));
|
|
478
|
+
return Subscription.aggregate()(ownSubscriptions, scrubberSubscriptions);
|
|
479
|
+
};
|
|
480
|
+
// VIEW
|
|
481
|
+
const indexClass = 'text-2xs text-dt-muted font-mono min-w-5';
|
|
482
|
+
const headerButtonClass = 'dt-header-button bg-transparent border-none text-dt-muted cursor-pointer text-base font-mono transition-colors';
|
|
483
|
+
const ROW_BASE = 'dt-row flex items-center py-1 px-1 cursor-pointer gap-1.5 transition-colors border-b';
|
|
484
|
+
const BADGE_POSITION_CLASS = {
|
|
485
|
+
BottomRight: 'dt-pos-br',
|
|
486
|
+
BottomLeft: 'dt-pos-bl',
|
|
487
|
+
TopRight: 'dt-pos-tr',
|
|
488
|
+
TopLeft: 'dt-pos-tl',
|
|
489
|
+
};
|
|
490
|
+
const PANEL_POSITION_CLASS = {
|
|
491
|
+
BottomRight: 'dt-panel-br',
|
|
492
|
+
BottomLeft: 'dt-panel-bl',
|
|
493
|
+
TopRight: 'dt-panel-tr',
|
|
494
|
+
TopLeft: 'dt-panel-tl',
|
|
495
|
+
};
|
|
496
|
+
const makeView = (position, mode, shadow, maybeBanner) => {
|
|
497
|
+
const h = html();
|
|
498
|
+
const lazyTreeNode = createKeyedLazy();
|
|
499
|
+
const lazyMessageRow = createKeyedLazy();
|
|
500
|
+
const lazyTabContent = createKeyedLazy();
|
|
501
|
+
const lazyMessageList = createLazy();
|
|
502
|
+
// JSON TREE
|
|
503
|
+
const leafValueView = (value) => M.value(value).pipe(M.when(Predicate.isNull, () => h.span([h.Key('value'), h.Class('json-null italic')], ['null'])), M.when(Predicate.isUndefined, () => h.span([h.Key('value'), h.Class('json-null italic')], ['undefined'])), M.when(Predicate.isString, stringValue => h.span([h.Key('value'), h.Class('json-string')], [`"${stringValue}"`])), M.when(Predicate.isNumber, numberValue => h.span([h.Key('value'), h.Class('json-number')], [String(numberValue)])), M.when(Predicate.isBoolean, booleanValue => h.span([h.Key('value'), h.Class('json-boolean')], [String(booleanValue)])), M.orElse(unknownValue => h.span([h.Key('value'), h.Class('json-null')], [String(unknownValue)])));
|
|
504
|
+
// NOTE: each row-child view declares an explicit key. snabbdom's
|
|
505
|
+
// `sameVnode` only checks `key + sel`, and foldkit element vnodes carry
|
|
506
|
+
// `sel = tagName` with classes stored in `data.class`. Without per-role
|
|
507
|
+
// keys, two unkeyed spans with different classes (e.g. `.json-key` and
|
|
508
|
+
// `.diff-dot`) are sameVnode to snabbdom, so a single DOM span gets
|
|
509
|
+
// recycled across roles as rows transition shape \u2014 and the text-node
|
|
510
|
+
// children from the old role can leak into the new role's element.
|
|
511
|
+
// Keying by role pins each slot to its own DOM element.
|
|
512
|
+
const keyView = (key) => h.span([h.Key('key'), h.Class('json-key')], [`${key}:\u00a0`]);
|
|
513
|
+
const CHEVRON_RIGHT = 'M8.25 4.5l7.5 7.5-7.5 7.5';
|
|
514
|
+
const CHEVRON_DOWN = 'M19.5 8.25l-7.5 7.5-7.5-7.5';
|
|
515
|
+
const arrowView = (isExpanded) => h.svg([
|
|
516
|
+
h.Key('arrow'),
|
|
517
|
+
h.AriaHidden(true),
|
|
518
|
+
h.Class('json-arrow shrink-0'),
|
|
519
|
+
h.Xmlns('http://www.w3.org/2000/svg'),
|
|
520
|
+
h.Fill('none'),
|
|
521
|
+
h.ViewBox('0 0 24 24'),
|
|
522
|
+
h.StrokeWidth('2'),
|
|
523
|
+
h.Stroke('currentColor'),
|
|
524
|
+
], [
|
|
525
|
+
h.path([
|
|
526
|
+
h.StrokeLinecap('round'),
|
|
527
|
+
h.StrokeLinejoin('round'),
|
|
528
|
+
h.D(isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT),
|
|
529
|
+
], []),
|
|
530
|
+
]);
|
|
531
|
+
const tagLabelView = (tag) => h.span([h.Key('tag'), h.Class('json-tag')], [tag]);
|
|
532
|
+
const diffDotView = h.span([h.Key('diffdot'), h.Class('diff-dot')], []);
|
|
533
|
+
const inlineDiffDotView = h.span([h.Class('diff-dot-inline')], []);
|
|
534
|
+
const flattenTree = ({ value, treePath, depth, key, ...shared }) => {
|
|
535
|
+
const { rootPath, expandedPaths, changedPaths, affectedPaths, accumulator, indentRootChildren, } = shared;
|
|
536
|
+
const isRoot = treePath === rootPath;
|
|
537
|
+
const nodeIsExpandable = isExpandable(value);
|
|
538
|
+
const isExpanded = nodeIsExpandable && (isRoot || HashSet.has(expandedPaths, treePath));
|
|
539
|
+
const tag = isTagged(value) ? value._tag : '';
|
|
540
|
+
accumulator.push({
|
|
541
|
+
value,
|
|
542
|
+
treePath,
|
|
543
|
+
depth,
|
|
544
|
+
key,
|
|
545
|
+
isExpandable: nodeIsExpandable,
|
|
546
|
+
isExpanded,
|
|
547
|
+
isChanged: HashSet.has(changedPaths, treePath),
|
|
548
|
+
isAffected: HashSet.has(affectedPaths, treePath),
|
|
549
|
+
isRoot,
|
|
550
|
+
tag,
|
|
551
|
+
});
|
|
552
|
+
if (!isExpanded) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const childDepth = isRoot && !indentRootChildren ? depth : depth + 1;
|
|
556
|
+
if (Array.isArray(value)) {
|
|
557
|
+
value.forEach((item, arrayIndex) => flattenTree({
|
|
558
|
+
...shared,
|
|
559
|
+
value: item,
|
|
560
|
+
treePath: `${treePath}.${arrayIndex}`,
|
|
561
|
+
depth: childDepth,
|
|
562
|
+
key: String(arrayIndex),
|
|
563
|
+
}));
|
|
564
|
+
}
|
|
565
|
+
else if (Predicate.isObject(value)) {
|
|
566
|
+
pipe(value, Record.toEntries, Array_.filter(([entryKey]) => entryKey !== '_tag'), Array_.forEach(([entryKey, childValue]) => flattenTree({
|
|
567
|
+
...shared,
|
|
568
|
+
value: childValue,
|
|
569
|
+
treePath: `${treePath}.${entryKey}`,
|
|
570
|
+
depth: childDepth,
|
|
571
|
+
key: entryKey,
|
|
572
|
+
})));
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const flatNodeView = (value, treePath, depth, key, nodeIsExpandable, isExpanded, isChanged, isAffected, isRoot, tag) => {
|
|
576
|
+
const indent = h.Style({ paddingLeft: `${depth * TREE_INDENT_PX}px` });
|
|
577
|
+
const hasDiffDot = isChanged || isAffected;
|
|
578
|
+
if (!nodeIsExpandable) {
|
|
579
|
+
return h.div([
|
|
580
|
+
h.Key(treePath),
|
|
581
|
+
h.Class(clsx('tree-row flex items-center gap-px font-mono text-2xs', {
|
|
582
|
+
'diff-changed': isChanged,
|
|
583
|
+
})),
|
|
584
|
+
indent,
|
|
585
|
+
], [
|
|
586
|
+
...(hasDiffDot ? [diffDotView] : []),
|
|
587
|
+
...(String_.isNonEmpty(key) ? [keyView(key)] : []),
|
|
588
|
+
leafValueView(value),
|
|
589
|
+
]);
|
|
590
|
+
}
|
|
591
|
+
const preview = isExpanded
|
|
592
|
+
? Array.isArray(value)
|
|
593
|
+
? `(${value.length})`
|
|
594
|
+
: ''
|
|
595
|
+
: collapsedPreview(value);
|
|
596
|
+
return h.div([
|
|
597
|
+
h.Key(treePath),
|
|
598
|
+
h.Class(clsx('tree-row flex items-center gap-px font-mono text-2xs', {
|
|
599
|
+
'tree-row-expandable cursor-pointer': !isRoot,
|
|
600
|
+
'diff-changed': isChanged,
|
|
601
|
+
})),
|
|
602
|
+
indent,
|
|
603
|
+
...(isRoot ? [] : [h.OnClick(ToggledTreeNode({ path: treePath }))]),
|
|
604
|
+
], [
|
|
605
|
+
...(isRoot ? [] : [arrowView(isExpanded)]),
|
|
606
|
+
...(!isRoot && hasDiffDot ? [diffDotView] : []),
|
|
607
|
+
...(String_.isNonEmpty(key) ? [keyView(key)] : []),
|
|
608
|
+
...(String_.isNonEmpty(tag) ? [tagLabelView(tag)] : []),
|
|
609
|
+
h.span([h.Key('value'), h.Class('json-preview')], [preview]),
|
|
610
|
+
]);
|
|
611
|
+
};
|
|
612
|
+
const renderFlatNode = (node) => lazyTreeNode(node.treePath, flatNodeView, [
|
|
613
|
+
node.value,
|
|
614
|
+
node.treePath,
|
|
615
|
+
node.depth,
|
|
616
|
+
node.key,
|
|
617
|
+
node.isExpandable,
|
|
618
|
+
node.isExpanded,
|
|
619
|
+
node.isChanged,
|
|
620
|
+
node.isAffected,
|
|
621
|
+
node.isRoot,
|
|
622
|
+
node.tag,
|
|
623
|
+
]);
|
|
624
|
+
const treeView = (value, rootPath, expandedPaths, changedPaths, affectedPaths, maybeRootLabel, indentRootChildren) => {
|
|
625
|
+
const nodes = [];
|
|
626
|
+
flattenTree({
|
|
627
|
+
value: toInspectableValue(value),
|
|
628
|
+
treePath: rootPath,
|
|
629
|
+
rootPath,
|
|
630
|
+
expandedPaths,
|
|
631
|
+
changedPaths,
|
|
632
|
+
affectedPaths,
|
|
633
|
+
depth: 0,
|
|
634
|
+
key: Option.getOrElse(maybeRootLabel, () => ''),
|
|
635
|
+
accumulator: nodes,
|
|
636
|
+
indentRootChildren,
|
|
637
|
+
});
|
|
638
|
+
return h.div([
|
|
639
|
+
h.Class('inspector-tree flex-1 overflow-auto min-h-0 min-w-0 overscroll-none'),
|
|
640
|
+
], nodes.map(renderFlatNode));
|
|
641
|
+
};
|
|
642
|
+
const inspectedTimestamp = (model) => {
|
|
643
|
+
const selectedIndex = selectedHistoryIndex(model);
|
|
644
|
+
if (selectedIndex === INIT_INDEX) {
|
|
645
|
+
return '0ms';
|
|
646
|
+
}
|
|
647
|
+
const baseTimestamp = pipe(model.entries, Array_.head, Option.match({
|
|
648
|
+
onNone: () => 0,
|
|
649
|
+
onSome: ({ timestamp }) => timestamp,
|
|
650
|
+
}));
|
|
651
|
+
return pipe(Array_.get(model.entries, selectedIndex - model.startIndex), Option.map(entry => {
|
|
652
|
+
const delta = entry.timestamp - baseTimestamp;
|
|
653
|
+
const seconds = Math.floor(delta / MILLIS_PER_SECOND);
|
|
654
|
+
const remainingMs = delta % MILLIS_PER_SECOND;
|
|
655
|
+
return seconds > 0
|
|
656
|
+
? `+${seconds}s ${remainingMs.toFixed(1)}ms`
|
|
657
|
+
: `+${remainingMs.toFixed(1)}ms`;
|
|
658
|
+
}), Option.getOrElse(() => ''));
|
|
659
|
+
};
|
|
660
|
+
const emptyInspectorView = h.div([
|
|
661
|
+
h.Class('flex-1 flex items-center justify-center text-dt-muted text-2xs font-mono min-w-0'),
|
|
662
|
+
], ['Click a message to inspect']);
|
|
663
|
+
const noMessageView = h.div([
|
|
664
|
+
h.Class('flex-1 flex items-center justify-center text-dt-muted text-2xs font-mono min-w-0'),
|
|
665
|
+
], ['init: no Message']);
|
|
666
|
+
const modelTabContent = (inspectedModel, expandedPaths, changedPaths, affectedPaths) => treeView(inspectedModel, 'root', expandedPaths, changedPaths, affectedPaths, Option.none(), true);
|
|
667
|
+
const unwrapIfFiltered = (message, maybeSubmodelFilter) => {
|
|
668
|
+
if (Option.isNone(maybeSubmodelFilter)) {
|
|
669
|
+
return message;
|
|
670
|
+
}
|
|
671
|
+
const { value: filterTag } = maybeSubmodelFilter;
|
|
672
|
+
let current = message;
|
|
673
|
+
let matched = false;
|
|
674
|
+
while (isTagged(current) && GOT_MESSAGE_PATTERN.test(current._tag)) {
|
|
675
|
+
if (current._tag === filterTag) {
|
|
676
|
+
matched = true;
|
|
677
|
+
}
|
|
678
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
679
|
+
const inner = current?.['message'];
|
|
680
|
+
if (inner === undefined) {
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
current = inner;
|
|
684
|
+
if (matched) {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return current;
|
|
689
|
+
};
|
|
690
|
+
const messageTabContent = (maybeInspectedMessage, maybeSubmodelFilter, expandedPaths, timestamp) => Option.match(maybeInspectedMessage, {
|
|
691
|
+
onNone: () => noMessageView,
|
|
692
|
+
onSome: rawMessage => {
|
|
693
|
+
const message = unwrapIfFiltered(rawMessage, maybeSubmodelFilter);
|
|
694
|
+
return h.div([h.Class('flex flex-col flex-1 min-h-0 min-w-0')], [
|
|
695
|
+
h.div([
|
|
696
|
+
h.Class('px-2 py-1 border-b text-2xs text-dt-muted font-mono shrink-0'),
|
|
697
|
+
], [timestamp]),
|
|
698
|
+
h.div([h.Class('flex flex-col flex-1 min-h-0 min-w-0 pt-1 pl-1')], [
|
|
699
|
+
treeView(message, 'root', expandedPaths, HashSet.empty(), HashSet.empty(), Option.none(), false),
|
|
700
|
+
]),
|
|
701
|
+
]);
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
const selectedHistoryIndex = (model) => {
|
|
705
|
+
const lastIndex = Array_.match(model.entries, {
|
|
706
|
+
onEmpty: () => INIT_INDEX,
|
|
707
|
+
onNonEmpty: () => model.startIndex + model.entries.length - 1,
|
|
708
|
+
});
|
|
709
|
+
return M.value(mode).pipe(M.when('TimeTravel', () => model.isPaused ? model.pausedAtIndex : lastIndex), M.when('Inspect', () => model.selectedIndex), M.exhaustive);
|
|
710
|
+
};
|
|
711
|
+
const selectedCommands = (model) => {
|
|
712
|
+
const selectedIndex = selectedHistoryIndex(model);
|
|
713
|
+
if (selectedIndex === INIT_INDEX) {
|
|
714
|
+
return model.initCommands;
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
return pipe(model.entries, Array_.get(selectedIndex - model.startIndex), Option.map(entry => entry.commands), Option.getOrElse(() => NO_COMMANDS));
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
const flattenCommand = (command, index, expandedPaths) => {
|
|
721
|
+
const taggedValue = Option.match(command.args, {
|
|
722
|
+
onNone: () => ({ _tag: command.name }),
|
|
723
|
+
onSome: argsValue => ({ ...argsValue, _tag: command.name }),
|
|
724
|
+
});
|
|
725
|
+
const rootPath = `command-${index}`;
|
|
726
|
+
const nodes = [];
|
|
727
|
+
flattenTree({
|
|
728
|
+
value: toInspectableValue(taggedValue),
|
|
729
|
+
treePath: rootPath,
|
|
730
|
+
rootPath,
|
|
731
|
+
expandedPaths,
|
|
732
|
+
changedPaths: HashSet.empty(),
|
|
733
|
+
affectedPaths: HashSet.empty(),
|
|
734
|
+
depth: 0,
|
|
735
|
+
key: '',
|
|
736
|
+
accumulator: nodes,
|
|
737
|
+
indentRootChildren: false,
|
|
738
|
+
});
|
|
739
|
+
return nodes;
|
|
740
|
+
};
|
|
741
|
+
const commandsTabContent = (commands, expandedPaths) => Array_.match(commands, {
|
|
742
|
+
onEmpty: () => h.div([
|
|
743
|
+
h.Class('flex-1 flex items-center justify-center text-dt-muted text-2xs font-mono min-w-0'),
|
|
744
|
+
], ['No Commands returned']),
|
|
745
|
+
onNonEmpty: commandList => h.div([
|
|
746
|
+
h.Class('flex flex-col flex-1 min-h-0 min-w-0 overflow-auto overscroll-none'),
|
|
747
|
+
], Array_.map(commandList, (command, index) => h.div([h.Class('flex items-start px-2 py-1 border-b gap-1.5')], [
|
|
748
|
+
h.span([h.Class(indexClass)], [String(index + 1)]),
|
|
749
|
+
h.div([h.Class('flex flex-col flex-1 min-w-0')], Array_.map(flattenCommand(command, index, expandedPaths), renderFlatNode)),
|
|
750
|
+
]))),
|
|
751
|
+
});
|
|
752
|
+
const selectedMountActivity = (model) => {
|
|
753
|
+
const selectedIndex = selectedHistoryIndex(model);
|
|
754
|
+
if (selectedIndex === INIT_INDEX) {
|
|
755
|
+
return { starts: model.initMountStarts, ends: NO_MOUNTS };
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
return pipe(model.entries, Array_.get(selectedIndex - model.startIndex), Option.match({
|
|
759
|
+
onNone: () => ({ starts: NO_MOUNTS, ends: NO_MOUNTS }),
|
|
760
|
+
onSome: entry => ({
|
|
761
|
+
starts: entry.mountStarts,
|
|
762
|
+
ends: entry.mountEnds,
|
|
763
|
+
}),
|
|
764
|
+
}));
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
const flattenMount = (mount, sectionLabel, index, expandedPaths) => {
|
|
768
|
+
const taggedValue = Option.match(mount.args, {
|
|
769
|
+
onNone: () => ({ _tag: mount.name }),
|
|
770
|
+
onSome: argsValue => ({ ...argsValue, _tag: mount.name }),
|
|
771
|
+
});
|
|
772
|
+
const rootPath = `mount-${sectionLabel}-${index}`;
|
|
773
|
+
const nodes = [];
|
|
774
|
+
flattenTree({
|
|
775
|
+
value: toInspectableValue(taggedValue),
|
|
776
|
+
treePath: rootPath,
|
|
777
|
+
rootPath,
|
|
778
|
+
expandedPaths,
|
|
779
|
+
changedPaths: HashSet.empty(),
|
|
780
|
+
affectedPaths: HashSet.empty(),
|
|
781
|
+
depth: 0,
|
|
782
|
+
key: '',
|
|
783
|
+
accumulator: nodes,
|
|
784
|
+
indentRootChildren: false,
|
|
785
|
+
});
|
|
786
|
+
return nodes;
|
|
787
|
+
};
|
|
788
|
+
const mountListSection = (label, mounts, expandedPaths) => h.div([h.Class('flex flex-col shrink-0')], [
|
|
789
|
+
h.div([
|
|
790
|
+
h.Class('px-2 py-1 border-b text-2xs text-dt-muted font-mono shrink-0'),
|
|
791
|
+
], [label]),
|
|
792
|
+
...Array_.map(mounts, (mount, index) => h.div([h.Class('flex items-start px-2 py-1 border-b gap-1.5')], [
|
|
793
|
+
h.span([h.Class(indexClass)], [String(index + 1)]),
|
|
794
|
+
h.div([h.Class('flex flex-col flex-1 min-w-0')], Array_.map(flattenMount(mount, label, index, expandedPaths), renderFlatNode)),
|
|
795
|
+
])),
|
|
796
|
+
]);
|
|
797
|
+
const mountsTabContent = (starts, ends, expandedPaths) => {
|
|
798
|
+
const hasAny = Array_.isReadonlyArrayNonEmpty(starts) ||
|
|
799
|
+
Array_.isReadonlyArrayNonEmpty(ends);
|
|
800
|
+
if (!hasAny) {
|
|
801
|
+
return h.div([
|
|
802
|
+
h.Class('flex-1 flex items-center justify-center text-dt-muted text-2xs font-mono min-w-0'),
|
|
803
|
+
], ['No Mounts during this render']);
|
|
804
|
+
}
|
|
805
|
+
return h.div([
|
|
806
|
+
h.Class('flex flex-col flex-1 min-h-0 min-w-0 overflow-auto overscroll-none'),
|
|
807
|
+
], [
|
|
808
|
+
...(Array_.isReadonlyArrayNonEmpty(starts)
|
|
809
|
+
? [mountListSection('Started', starts, expandedPaths)]
|
|
810
|
+
: []),
|
|
811
|
+
...(Array_.isReadonlyArrayNonEmpty(ends)
|
|
812
|
+
? [mountListSection('Ended', ends, expandedPaths)]
|
|
813
|
+
: []),
|
|
814
|
+
]);
|
|
815
|
+
};
|
|
816
|
+
const inspectorTabContent = (model, tab, inspectedModel) => M.value(tab).pipe(M.when('Model', () => lazyTabContent('Model', modelTabContent, [
|
|
817
|
+
inspectedModel,
|
|
818
|
+
model.expandedPaths,
|
|
819
|
+
model.changedPaths,
|
|
820
|
+
model.affectedPaths,
|
|
821
|
+
])), M.when('Message', () => lazyTabContent('Message', messageTabContent, [
|
|
822
|
+
model.maybeInspectedMessage,
|
|
823
|
+
model.maybeSubmodelFilter,
|
|
824
|
+
model.expandedPaths,
|
|
825
|
+
inspectedTimestamp(model),
|
|
826
|
+
])), M.when('Commands', () => lazyTabContent('Commands', commandsTabContent, [
|
|
827
|
+
selectedCommands(model),
|
|
828
|
+
model.expandedPaths,
|
|
829
|
+
])), M.when('Mounts', () => {
|
|
830
|
+
const { starts, ends } = selectedMountActivity(model);
|
|
831
|
+
return lazyTabContent('Mounts', mountsTabContent, [
|
|
832
|
+
starts,
|
|
833
|
+
ends,
|
|
834
|
+
model.expandedPaths,
|
|
835
|
+
]);
|
|
836
|
+
}), M.exhaustive);
|
|
837
|
+
const inspectorPaneView = (model) => h.div([
|
|
838
|
+
h.Class('flex flex-col border-l min-w-0 min-h-0 flex-1 dt-inspector-pane'),
|
|
839
|
+
], [
|
|
840
|
+
h.submodel({
|
|
841
|
+
slotId: model.inspectorTabs.id,
|
|
842
|
+
model: model.inspectorTabs,
|
|
843
|
+
view: InspectorTabs.view,
|
|
844
|
+
viewInputs: {
|
|
845
|
+
tabs: INSPECTOR_TABS,
|
|
846
|
+
ariaLabel: 'Inspector tabs',
|
|
847
|
+
toView: ({ tablist, tabs, activeIndex }) => h.div([h.Class('flex flex-col flex-1 min-h-0')], [
|
|
848
|
+
h.div([...tablist, h.Class('flex border-b shrink-0')], tabs.map(tab => h.button([
|
|
849
|
+
...tab.tab,
|
|
850
|
+
h.Class(clsx('dt-tab-button cursor-pointer text-base font-mono px-3 py-1', tab.isActive
|
|
851
|
+
? 'text-dt dt-tab-active'
|
|
852
|
+
: 'text-dt-muted')),
|
|
853
|
+
], [h.span([], [tab.value])]))),
|
|
854
|
+
...tabs.map(tab => h.div([
|
|
855
|
+
...tab.panel,
|
|
856
|
+
h.Class('flex flex-col flex-1 min-h-0 min-w-0'),
|
|
857
|
+
h.Hidden(tab.index !== activeIndex),
|
|
858
|
+
...(tab.index === activeIndex
|
|
859
|
+
? []
|
|
860
|
+
: [h.Style({ display: 'none' })]),
|
|
861
|
+
], [
|
|
862
|
+
Option.match(model.maybeInspectedModel, {
|
|
863
|
+
onNone: () => emptyInspectorView,
|
|
864
|
+
onSome: inspectedModel => inspectorTabContent(model, tab.value, inspectedModel),
|
|
865
|
+
}),
|
|
866
|
+
])),
|
|
867
|
+
]),
|
|
868
|
+
},
|
|
869
|
+
toParentMessage: message => GotInspectorTabsMessage({ message }),
|
|
870
|
+
}),
|
|
871
|
+
]);
|
|
872
|
+
// MESSAGE LIST
|
|
873
|
+
const badgeView = (model) => h.button([
|
|
874
|
+
h.Class(clsx('fixed bg-dt-bg text-dt cursor-pointer flex flex-col items-center justify-center font-mono outline-none dt-badge', BADGE_POSITION_CLASS[position], model.isPaused ? 'dt-badge-paused' : 'dt-badge-accent')),
|
|
875
|
+
h.Style({ width: '22px', height: '56px', fontSize: '10px' }),
|
|
876
|
+
h.OnClick(ClickedToggle()),
|
|
877
|
+
], [
|
|
878
|
+
model.isOpen
|
|
879
|
+
? h.svg([
|
|
880
|
+
h.AriaHidden(true),
|
|
881
|
+
h.Xmlns('http://www.w3.org/2000/svg'),
|
|
882
|
+
h.Fill('none'),
|
|
883
|
+
h.ViewBox('0 0 24 24'),
|
|
884
|
+
h.StrokeWidth('1.5'),
|
|
885
|
+
h.Stroke('currentColor'),
|
|
886
|
+
h.Style({ width: '12px', height: '12px' }),
|
|
887
|
+
], [
|
|
888
|
+
h.path([
|
|
889
|
+
h.StrokeLinecap('round'),
|
|
890
|
+
h.StrokeLinejoin('round'),
|
|
891
|
+
h.D('M6 18L18 6M6 6l12 12'),
|
|
892
|
+
], []),
|
|
893
|
+
])
|
|
894
|
+
: h.div([
|
|
895
|
+
h.Class(clsx('flex flex-col items-center gap-0.5 font-semibold tracking-wider leading-none', model.isPaused ? 'text-dt-bg' : 'text-dt-muted')),
|
|
896
|
+
], [h.span([], ['D']), h.span([], ['E']), h.span([], ['V'])]),
|
|
897
|
+
]);
|
|
898
|
+
const headerClass = 'flex items-center justify-between px-3 py-1.5 border-b shrink-0';
|
|
899
|
+
const actionButtonClass = 'dt-resume-button bg-transparent border-none text-dt-live cursor-pointer text-base font-mono font-medium';
|
|
900
|
+
const statusClass = 'text-base font-mono';
|
|
901
|
+
const clearHistoryButton = h.button([h.Class(headerButtonClass), h.OnClick(ClickedClear())], ['Clear history']);
|
|
902
|
+
const submodelLabel = (tag) => pipe(tag, String_.replace(/^Got/, ''), String_.replace(/Message$/, ''));
|
|
903
|
+
const CHECK_ICON = 'M4.5 12.75l6 6 9-13.5';
|
|
904
|
+
const checkIconView = h.svg([
|
|
905
|
+
h.AriaHidden(true),
|
|
906
|
+
h.Class('dt-filter-check shrink-0'),
|
|
907
|
+
h.Xmlns('http://www.w3.org/2000/svg'),
|
|
908
|
+
h.Fill('none'),
|
|
909
|
+
h.ViewBox('0 0 24 24'),
|
|
910
|
+
h.StrokeWidth('2'),
|
|
911
|
+
h.Stroke('currentColor'),
|
|
912
|
+
], [
|
|
913
|
+
h.path([h.D(CHECK_ICON), h.StrokeLinecap('round'), h.StrokeLinejoin('round')], []),
|
|
914
|
+
]);
|
|
915
|
+
const filterItemLabel = (item) => String_.isNonEmpty(item) ? submodelLabel(item) : 'All Messages';
|
|
916
|
+
const ARROW_UP = 'M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18';
|
|
917
|
+
const arrowUpIconView = h.svg([
|
|
918
|
+
h.AriaHidden(true),
|
|
919
|
+
h.Class('dt-scroll-pill-icon shrink-0'),
|
|
920
|
+
h.Xmlns('http://www.w3.org/2000/svg'),
|
|
921
|
+
h.Fill('none'),
|
|
922
|
+
h.ViewBox('0 0 24 24'),
|
|
923
|
+
h.StrokeWidth('2'),
|
|
924
|
+
h.Stroke('currentColor'),
|
|
925
|
+
], [
|
|
926
|
+
h.path([h.D(ARROW_UP), h.StrokeLinecap('round'), h.StrokeLinejoin('round')], []),
|
|
927
|
+
]);
|
|
928
|
+
const scrollToTopPillView = h.button([
|
|
929
|
+
h.Key('scroll-pill'),
|
|
930
|
+
h.Class('dt-scroll-pill'),
|
|
931
|
+
h.OnClick(ClickedScrollToTopPill()),
|
|
932
|
+
], [
|
|
933
|
+
arrowUpIconView,
|
|
934
|
+
h.span([h.Class('dt-scroll-pill-text')], ['Jump to top']),
|
|
935
|
+
]);
|
|
936
|
+
const submodelFilterView = (model) => {
|
|
937
|
+
const buttonLabel = Option.match(model.maybeSubmodelFilter, {
|
|
938
|
+
onNone: () => 'All Messages',
|
|
939
|
+
onSome: submodelLabel,
|
|
940
|
+
});
|
|
941
|
+
return h.submodel({
|
|
942
|
+
slotId: 'submodel-filter',
|
|
943
|
+
model: model.submodelFilterListbox,
|
|
944
|
+
view: SubmodelFilterListbox.view,
|
|
945
|
+
viewInputs: {
|
|
946
|
+
items: [ALL_MESSAGES_VALUE, ...model.submodelTags],
|
|
947
|
+
itemToConfig: item => ({
|
|
948
|
+
className: 'dt-filter-item',
|
|
949
|
+
content: h.div([h.Class('flex items-center gap-2')], [checkIconView, h.span([], [filterItemLabel(item)])]),
|
|
950
|
+
}),
|
|
951
|
+
buttonContent: h.span([h.Class('flex flex-1 items-center justify-between')], [
|
|
952
|
+
h.span([], [buttonLabel]),
|
|
953
|
+
h.svg([
|
|
954
|
+
h.AriaHidden(true),
|
|
955
|
+
h.Class('json-arrow shrink-0'),
|
|
956
|
+
h.Xmlns('http://www.w3.org/2000/svg'),
|
|
957
|
+
h.Fill('none'),
|
|
958
|
+
h.ViewBox('0 0 24 24'),
|
|
959
|
+
h.StrokeWidth('2'),
|
|
960
|
+
h.Stroke('currentColor'),
|
|
961
|
+
], [
|
|
962
|
+
h.path([
|
|
963
|
+
h.D(CHEVRON_DOWN),
|
|
964
|
+
h.StrokeLinecap('round'),
|
|
965
|
+
h.StrokeLinejoin('round'),
|
|
966
|
+
], []),
|
|
967
|
+
]),
|
|
968
|
+
]),
|
|
969
|
+
buttonClassName: 'dt-filter-button',
|
|
970
|
+
itemsClassName: 'dt-filter-items',
|
|
971
|
+
className: 'dt-filter-wrapper',
|
|
972
|
+
attributes: childAttributes([h.Key('submodel-filter')]),
|
|
973
|
+
backdropClassName: 'dt-filter-backdrop',
|
|
974
|
+
},
|
|
975
|
+
toParentMessage: message => GotSubmodelFilterMessage({ message }),
|
|
976
|
+
});
|
|
977
|
+
};
|
|
978
|
+
const headerView = (model) => {
|
|
979
|
+
const { status, maybeAction } = M.value(mode).pipe(M.withReturnType(), M.when('TimeTravel', () => model.isPaused
|
|
980
|
+
? {
|
|
981
|
+
status: h.span([h.Class(`${statusClass} text-dt-paused`)], [
|
|
982
|
+
model.pausedAtIndex === INIT_INDEX
|
|
983
|
+
? 'Paused (init)'
|
|
984
|
+
: `Paused (${model.pausedAtIndex + 1})`,
|
|
985
|
+
]),
|
|
986
|
+
maybeAction: Option.some(h.button([h.Class(actionButtonClass), h.OnClick(ClickedResume())], ['Resume →'])),
|
|
987
|
+
}
|
|
988
|
+
: {
|
|
989
|
+
status: h.span([h.Class(`${statusClass} text-dt-live font-medium`)], ['Live']),
|
|
990
|
+
maybeAction: Option.none(),
|
|
991
|
+
}), M.when('Inspect', () => ({
|
|
992
|
+
status: h.span([h.Class(`${statusClass} text-dt-accent`)], [
|
|
993
|
+
model.selectedIndex === INIT_INDEX
|
|
994
|
+
? 'Inspecting (init)'
|
|
995
|
+
: `Inspecting (${model.selectedIndex + 1})`,
|
|
996
|
+
]),
|
|
997
|
+
maybeAction: OptionExt.when(!model.isFollowingLatest, h.button([h.Class(actionButtonClass), h.OnClick(ClickedFollowLatest())], ['Follow Latest →'])),
|
|
998
|
+
})), M.exhaustive);
|
|
999
|
+
const maybeClearHistoryButton = OptionExt.when(!model.isPaused, clearHistoryButton);
|
|
1000
|
+
return h.header([h.Class(headerClass)], [
|
|
1001
|
+
status,
|
|
1002
|
+
...Option.toArray(maybeAction),
|
|
1003
|
+
...Option.toArray(maybeClearHistoryButton),
|
|
1004
|
+
]);
|
|
1005
|
+
};
|
|
1006
|
+
const initRowView = (isSelected, isPausedHere) => h.keyed('li')('init', [
|
|
1007
|
+
h.Class(clsx(ROW_BASE, { selected: isSelected })),
|
|
1008
|
+
h.OnClick(ClickedRow({ index: INIT_INDEX })),
|
|
1009
|
+
], [
|
|
1010
|
+
...OptionExt.when(mode === 'TimeTravel', h.span([h.Class('pause-column')], isPausedHere ? [pauseIconView] : [])).pipe(Option.toArray),
|
|
1011
|
+
h.span([h.Class('dot-column')], []),
|
|
1012
|
+
h.span([h.Class(indexClass)], []),
|
|
1013
|
+
h.span([h.Class('text-base text-dt-muted font-mono')], ['init']),
|
|
1014
|
+
]);
|
|
1015
|
+
const pauseIconView = h.svg([
|
|
1016
|
+
h.AriaHidden(true),
|
|
1017
|
+
h.Class('dt-pause-icon'),
|
|
1018
|
+
h.Xmlns('http://www.w3.org/2000/svg'),
|
|
1019
|
+
h.Fill('none'),
|
|
1020
|
+
h.ViewBox('0 0 24 24'),
|
|
1021
|
+
h.StrokeWidth('2.5'),
|
|
1022
|
+
h.Stroke('currentColor'),
|
|
1023
|
+
], [
|
|
1024
|
+
h.path([
|
|
1025
|
+
h.StrokeLinecap('round'),
|
|
1026
|
+
h.StrokeLinejoin('round'),
|
|
1027
|
+
h.D('M5.75 3v18M18.25 3v18'),
|
|
1028
|
+
], []),
|
|
1029
|
+
]);
|
|
1030
|
+
const messageRowView = (tag, absoluteIndex, isSelected, isPausedHere, timeDelta, isModelChanged) => h.keyed('li')(String(absoluteIndex), [
|
|
1031
|
+
h.Class(clsx(ROW_BASE, { selected: isSelected })),
|
|
1032
|
+
h.OnClick(ClickedRow({ index: absoluteIndex })),
|
|
1033
|
+
], [
|
|
1034
|
+
...OptionExt.when(mode === 'TimeTravel', h.span([h.Class('pause-column')], isPausedHere ? [pauseIconView] : [])).pipe(Option.toArray),
|
|
1035
|
+
h.span([h.Class('dot-column')], isModelChanged ? [inlineDiffDotView] : []),
|
|
1036
|
+
h.span([h.Class(indexClass)], [String(absoluteIndex + 1)]),
|
|
1037
|
+
h.span([h.Class('text-base text-dt font-mono flex-1 truncate')], [tag]),
|
|
1038
|
+
h.span([
|
|
1039
|
+
h.Class('text-2xs text-dt-muted font-mono shrink-0 text-right min-w-5'),
|
|
1040
|
+
], [formatTimeDelta(timeDelta)]),
|
|
1041
|
+
]);
|
|
1042
|
+
const messageListBody = (entries, startIndex, selectedIndex, isPaused, pausedAtIndex, maybeFilterTag) => {
|
|
1043
|
+
const baseTimestamp = pipe(entries, Array_.head, Option.match({
|
|
1044
|
+
onNone: () => 0,
|
|
1045
|
+
onSome: ({ timestamp }) => timestamp,
|
|
1046
|
+
}));
|
|
1047
|
+
const isInitSelected = selectedIndex === INIT_INDEX;
|
|
1048
|
+
const isFiltered = Option.isSome(maybeFilterTag);
|
|
1049
|
+
const indexedEntries = pipe(entries, Array_.map((entry, arrayIndex) => ({
|
|
1050
|
+
entry,
|
|
1051
|
+
absoluteIndex: startIndex + arrayIndex,
|
|
1052
|
+
})), isFiltered
|
|
1053
|
+
? Array_.filter(({ entry }) => Array_.contains(entry.submodelPath, maybeFilterTag.value))
|
|
1054
|
+
: Function.identity);
|
|
1055
|
+
const messageRows = pipe(indexedEntries, Array_.map(({ entry, absoluteIndex }) => {
|
|
1056
|
+
const isSelected = selectedIndex === absoluteIndex;
|
|
1057
|
+
const isPausedHere = isPaused && pausedAtIndex === absoluteIndex;
|
|
1058
|
+
const displayTag = isFiltered
|
|
1059
|
+
? pipe(entry.submodelPath, Array_.findFirstIndex(Equal.equals(maybeFilterTag.value)), Option.flatMap(filterIndex => Array_.get(entry.submodelPath, Number_.increment(filterIndex))), Option.orElse(() => entry.maybeLeafTag), Option.getOrElse(() => entry.tag))
|
|
1060
|
+
: entry.tag;
|
|
1061
|
+
return lazyMessageRow(String(absoluteIndex), messageRowView, [
|
|
1062
|
+
displayTag,
|
|
1063
|
+
absoluteIndex,
|
|
1064
|
+
isSelected,
|
|
1065
|
+
isPausedHere,
|
|
1066
|
+
entry.timestamp - baseTimestamp,
|
|
1067
|
+
entry.isModelChanged,
|
|
1068
|
+
]);
|
|
1069
|
+
}), Array_.reverse);
|
|
1070
|
+
return h.ul([
|
|
1071
|
+
h.Key('message-list'),
|
|
1072
|
+
h.Class('message-list flex-1 overflow-y-auto min-h-0 overscroll-none'),
|
|
1073
|
+
h.OnScroll(scrollTop => ScrolledMessageList({ scrollTop })),
|
|
1074
|
+
], isFiltered
|
|
1075
|
+
? messageRows
|
|
1076
|
+
: [
|
|
1077
|
+
...messageRows,
|
|
1078
|
+
initRowView(isInitSelected, isPaused && pausedAtIndex === INIT_INDEX),
|
|
1079
|
+
]);
|
|
1080
|
+
};
|
|
1081
|
+
const messageListView = (model) => {
|
|
1082
|
+
const selectedIndex = selectedHistoryIndex(model);
|
|
1083
|
+
return lazyMessageList(messageListBody, [
|
|
1084
|
+
model.entries,
|
|
1085
|
+
model.startIndex,
|
|
1086
|
+
selectedIndex,
|
|
1087
|
+
model.isPaused,
|
|
1088
|
+
model.pausedAtIndex,
|
|
1089
|
+
model.maybeSubmodelFilter,
|
|
1090
|
+
]);
|
|
1091
|
+
};
|
|
1092
|
+
// SCRUBBER
|
|
1093
|
+
const scrubberPositionLabel = (model) => {
|
|
1094
|
+
const total = String(model.entries.length).padStart(3, '0');
|
|
1095
|
+
const current = String(model.scrubberSlider.value).padStart(3, '0');
|
|
1096
|
+
return `${current} / ${total}`;
|
|
1097
|
+
};
|
|
1098
|
+
const scrubberView = (model) => h.submodel({
|
|
1099
|
+
slotId: model.scrubberSlider.id,
|
|
1100
|
+
model: model.scrubberSlider,
|
|
1101
|
+
view: Slider.view,
|
|
1102
|
+
viewInputs: {
|
|
1103
|
+
ariaLabel: 'Session scrubber',
|
|
1104
|
+
getTrackRoot: () => shadow,
|
|
1105
|
+
formatValue: value => value === 0 ? 'init' : `Message ${String(value)}`,
|
|
1106
|
+
toView: attributes => h.div([
|
|
1107
|
+
h.Class('dt-scrubber-row flex items-center gap-3 px-3 py-2 border-t shrink-0'),
|
|
1108
|
+
], [
|
|
1109
|
+
h.div([
|
|
1110
|
+
...attributes.root,
|
|
1111
|
+
h.Class('dt-scrubber-control flex-1 flex items-center'),
|
|
1112
|
+
], [
|
|
1113
|
+
h.div([...attributes.track, h.Class('dt-scrubber-track')], [
|
|
1114
|
+
h.div([
|
|
1115
|
+
...attributes.filledTrack,
|
|
1116
|
+
h.Class('dt-scrubber-fill'),
|
|
1117
|
+
], []),
|
|
1118
|
+
h.div([...attributes.thumb, h.Class('dt-scrubber-thumb')], []),
|
|
1119
|
+
]),
|
|
1120
|
+
]),
|
|
1121
|
+
h.span([
|
|
1122
|
+
h.Class('dt-scrubber-position text-2xs text-dt-muted font-mono shrink-0 tabular-nums'),
|
|
1123
|
+
], [scrubberPositionLabel(model)]),
|
|
1124
|
+
]),
|
|
1125
|
+
},
|
|
1126
|
+
toParentMessage: message => GotScrubberSliderMessage({ message }),
|
|
1127
|
+
});
|
|
1128
|
+
// PANEL
|
|
1129
|
+
const isScrubberVisible = mode === 'TimeTravel';
|
|
1130
|
+
const panelView = (model) => h.keyed('div')('dt-panel', [
|
|
1131
|
+
h.Class(clsx('fixed dt-panel dt-panel-wide bg-dt-bg border rounded-lg flex flex-col overflow-hidden font-mono text-dt', PANEL_POSITION_CLASS[position])),
|
|
1132
|
+
], [
|
|
1133
|
+
...Option.map(maybeBanner, banner => h.div([
|
|
1134
|
+
h.Class('px-3 py-2 border-b text-sm text-dt-muted font-mono shrink-0 leading-snug'),
|
|
1135
|
+
], [banner])).pipe(Option.toArray),
|
|
1136
|
+
headerView(model),
|
|
1137
|
+
h.div([h.Class('flex flex-1 min-h-0 dt-content')], [
|
|
1138
|
+
h.div([h.Class('flex flex-col min-h-0 dt-message-pane')], [
|
|
1139
|
+
...Array_.match(model.submodelTags, {
|
|
1140
|
+
onEmpty: () => [],
|
|
1141
|
+
onNonEmpty: () => [submodelFilterView(model)],
|
|
1142
|
+
}),
|
|
1143
|
+
...OptionExt.when(!model.isFollowingTop, scrollToTopPillView).pipe(Option.toArray),
|
|
1144
|
+
messageListView(model),
|
|
1145
|
+
]),
|
|
1146
|
+
inspectorPaneView(model),
|
|
1147
|
+
]),
|
|
1148
|
+
...OptionExt.when(isScrubberVisible, scrubberView(model)).pipe(Option.toArray),
|
|
1149
|
+
]);
|
|
1150
|
+
const interactionBlocker = h.div([h.Class('dt-interaction-blocker')], []);
|
|
1151
|
+
return (model) => h.div([], [
|
|
1152
|
+
...OptionExt.when(model.isPaused && mode === 'TimeTravel', interactionBlocker).pipe(Option.toArray),
|
|
1153
|
+
...OptionExt.when(model.isOpen, panelView(model)).pipe(Option.toArray),
|
|
1154
|
+
badgeView(model),
|
|
1155
|
+
]);
|
|
1156
|
+
};
|
|
1157
|
+
// CREATE
|
|
1158
|
+
const createShadowContainer = () => {
|
|
1159
|
+
const existingHost = document.getElementById(DEVTOOLS_HOST_ID);
|
|
1160
|
+
if (existingHost) {
|
|
1161
|
+
existingHost.remove();
|
|
1162
|
+
}
|
|
1163
|
+
const host = document.createElement('div');
|
|
1164
|
+
host.id = DEVTOOLS_HOST_ID;
|
|
1165
|
+
host.addEventListener('pointerdown', event => {
|
|
1166
|
+
const activeElement = document.activeElement;
|
|
1167
|
+
if (activeElement !== null &&
|
|
1168
|
+
activeElement !== host &&
|
|
1169
|
+
activeElement !== document.body) {
|
|
1170
|
+
event.preventDefault();
|
|
1171
|
+
}
|
|
1172
|
+
}, { capture: true });
|
|
1173
|
+
document.body.appendChild(host);
|
|
1174
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
1175
|
+
const styleElement = document.createElement('style');
|
|
1176
|
+
styleElement.textContent = overlayStyles;
|
|
1177
|
+
shadow.appendChild(styleElement);
|
|
1178
|
+
const container = document.createElement('div');
|
|
1179
|
+
shadow.appendChild(container);
|
|
1180
|
+
return { container, shadow };
|
|
1181
|
+
};
|
|
1182
|
+
export const createOverlay = (store, position, mode, maybeBanner) => Effect.gen(function* () {
|
|
1183
|
+
const { container, shadow } = yield* Effect.acquireRelease(Effect.sync(() => createShadowContainer()), createdShadowContainer => Effect.sync(() => {
|
|
1184
|
+
createdShadowContainer.shadow.host.remove();
|
|
1185
|
+
}));
|
|
1186
|
+
container.id = '__foldkit_devtools_overlay__';
|
|
1187
|
+
const flags = Effect.gen(function* () {
|
|
1188
|
+
const storeState = yield* SubscriptionRef.get(store.stateRef);
|
|
1189
|
+
return {
|
|
1190
|
+
isMobile: window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches,
|
|
1191
|
+
...toDisplayState(storeState),
|
|
1192
|
+
};
|
|
1193
|
+
});
|
|
1194
|
+
const init = (flags) => {
|
|
1195
|
+
const sliderMax = flags.entries.length;
|
|
1196
|
+
const initialSliderValue = flags.isPaused
|
|
1197
|
+
? hostIndexToSliderValue(flags.pausedAtIndex, flags.startIndex)
|
|
1198
|
+
: sliderMax;
|
|
1199
|
+
return [
|
|
1200
|
+
{
|
|
1201
|
+
isOpen: false,
|
|
1202
|
+
...flags,
|
|
1203
|
+
selectedIndex: INIT_INDEX,
|
|
1204
|
+
isFollowingLatest: true,
|
|
1205
|
+
isFollowingTop: true,
|
|
1206
|
+
submodelTags: computeSubmodelTags(flags.entries),
|
|
1207
|
+
maybeSubmodelFilter: Option.none(),
|
|
1208
|
+
submodelFilterListbox: Listbox.init({
|
|
1209
|
+
id: SUBMODEL_FILTER_ID,
|
|
1210
|
+
selectedItem: ALL_MESSAGES_VALUE,
|
|
1211
|
+
}),
|
|
1212
|
+
maybeInspectedModel: Option.none(),
|
|
1213
|
+
maybeInspectedMessage: Option.none(),
|
|
1214
|
+
expandedPaths: HashSet.empty(),
|
|
1215
|
+
changedPaths: HashSet.empty(),
|
|
1216
|
+
affectedPaths: HashSet.empty(),
|
|
1217
|
+
inspectorTabs: Tabs.init({ id: INSPECTOR_TABS_ID }),
|
|
1218
|
+
scrubberSlider: Slider.init({
|
|
1219
|
+
id: SCRUBBER_SLIDER_ID,
|
|
1220
|
+
min: 0,
|
|
1221
|
+
max: sliderMax,
|
|
1222
|
+
step: 1,
|
|
1223
|
+
initialValue: initialSliderValue,
|
|
1224
|
+
}),
|
|
1225
|
+
},
|
|
1226
|
+
[],
|
|
1227
|
+
];
|
|
1228
|
+
};
|
|
1229
|
+
const overlayRuntime = makeElement({
|
|
1230
|
+
Model,
|
|
1231
|
+
Flags,
|
|
1232
|
+
flags,
|
|
1233
|
+
init,
|
|
1234
|
+
update: makeUpdate(store, shadow, mode),
|
|
1235
|
+
view: makeView(position, mode, shadow, maybeBanner),
|
|
1236
|
+
container,
|
|
1237
|
+
subscriptions: makeOverlaySubscriptions(store, shadow),
|
|
1238
|
+
devTools: false,
|
|
1239
|
+
freezeModel: false,
|
|
1240
|
+
});
|
|
1241
|
+
yield* Effect.forkScoped(overlayRuntime.start());
|
|
1242
|
+
});
|