beast-devtools 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/launch.json +11 -0
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +87 -0
  4. package/devtools/CHANGELOG.md +26 -0
  5. package/devtools/LICENSE +15 -0
  6. package/devtools/README.md +164 -0
  7. package/devtools/client/BeastDevtools.btsx +185 -0
  8. package/devtools/client/CodeView.btsx +61 -0
  9. package/devtools/client/ComponentsPanel.btsx +212 -0
  10. package/devtools/client/FileList.btsx +35 -0
  11. package/devtools/client/InspectorPanel.btsx +131 -0
  12. package/devtools/client/RefactorPanel.btsx +304 -0
  13. package/devtools/client/api.ts +69 -0
  14. package/devtools/client/compile.test.ts +22 -0
  15. package/devtools/client/devtools.css +1279 -0
  16. package/devtools/client/highlight.ts +210 -0
  17. package/devtools/client/mount.ts +16 -0
  18. package/devtools/client/runtime.ts +168 -0
  19. package/devtools/client/util.ts +136 -0
  20. package/devtools/package.json +73 -0
  21. package/devtools/server/analyze.test.ts +148 -0
  22. package/devtools/server/analyze.ts +737 -0
  23. package/devtools/server/diff.ts +82 -0
  24. package/devtools/server/line-map.ts +63 -0
  25. package/devtools/server/octane-bundler.d.ts +17 -0
  26. package/devtools/server/project.ts +369 -0
  27. package/devtools/server/refactor.test.ts +224 -0
  28. package/devtools/server/refactor.ts +224 -0
  29. package/devtools/server/source-scan.ts +377 -0
  30. package/devtools/shared/types.ts +208 -0
  31. package/devtools/test/fixtures/App.btsx +193 -0
  32. package/devtools/tsconfig.build.json +17 -0
  33. package/devtools/vite.ts +159 -0
  34. package/favicon.ico +0 -0
  35. package/index.html +14 -0
  36. package/package.json +34 -0
  37. package/public/beast.svg +1 -0
  38. package/src/App.btsx +144 -0
  39. package/src/AppHeader.btsx +24 -0
  40. package/src/LeftArticle.btsx +22 -0
  41. package/src/RightArticle.btsx +19 -0
  42. package/src/env.d.ts +8 -0
  43. package/src/lib/utils.ts +6 -0
  44. package/src/main.ts +8 -0
  45. package/src/style.css +44 -0
  46. package/tsconfig.json +39 -0
  47. package/vite.config.ts +19 -0
@@ -0,0 +1,212 @@
1
+ import { useMemo, useState, useSyncExternalStore } from 'octane'
2
+ import type { ComponentLocation } from '../shared/types.ts'
3
+ import { openInEditor } from './api.ts'
4
+ import { getRuntimeSnapshot, selectRuntimeNode, subscribeRuntime, type HookCell, type RuntimeNode } from './runtime.ts'
5
+ import { cx, isExpandable, preview, pretty, valueTone } from './util.ts'
6
+
7
+ module
8
+ interface ComponentsPanelProps {
9
+ components: readonly ComponentLocation[]
10
+ showControlFlow: boolean
11
+ onShowControlFlowChange: (value: boolean) => void
12
+ onViewSource: (path: string, line: number) => void
13
+ onAnalyze: (path: string) => void
14
+ }
15
+
16
+ interface TreeRow {
17
+ node: RuntimeNode
18
+ depth: number
19
+ expandable: boolean
20
+ open: boolean
21
+ }
22
+
23
+ interface ValueRow {
24
+ key: string
25
+ label: string
26
+ kind: string
27
+ value: unknown
28
+ }
29
+
30
+ const KIND_LABEL: Record<HookCell['kind'], string> = {
31
+ state: 'state',
32
+ reducer: 'reducer',
33
+ ref: 'ref',
34
+ 'memo-or-callback': 'memo',
35
+ other: 'hook',
36
+ }
37
+
38
+ /** Setup hooks whose runtime cell `inspect()` reports, keyed to the cell kind. */
39
+ const VALUE_HOOKS: Record<string, HookCell['kind']> = {
40
+ useState: 'state',
41
+ useLinkedState: 'state',
42
+ useReducer: 'reducer',
43
+ useRef: 'ref',
44
+ useMemo: 'memo-or-callback',
45
+ useCallback: 'memo-or-callback',
46
+ }
47
+
48
+ function childrenOf(node: RuntimeNode, showControlFlow: boolean): RuntimeNode[] {
49
+ if (showControlFlow) return node.children
50
+ return node.children.flatMap((child) => (child.controlFlow ? childrenOf(child, false) : [child]))
51
+ }
52
+
53
+ function matches(node: RuntimeNode, query: string, showControlFlow: boolean): boolean {
54
+ return node.label.toLowerCase().includes(query) || childrenOf(node, showControlFlow).some((child) => matches(child, query, showControlFlow))
55
+ }
56
+
57
+ function treeRows(roots: readonly RuntimeNode[], collapsed: ReadonlySet<number>, showControlFlow: boolean, query: string): TreeRow[] {
58
+ const rows: TreeRow[] = []
59
+ const visit = (node: RuntimeNode, depth: number) => {
60
+ if (query !== '' && !matches(node, query, showControlFlow)) return
61
+ const children = childrenOf(node, showControlFlow)
62
+ const open = query !== '' || !collapsed.has(node.id)
63
+ rows.push({ node, depth, expandable: children.length > 0, open })
64
+ if (open) children.forEach((child) => visit(child, depth + 1))
65
+ }
66
+ roots.forEach((root) => visit(root, 0))
67
+ return rows
68
+ }
69
+
70
+ /**
71
+ * Name runtime hook cells after the setup declarations that created them.
72
+ * Labels are applied only when the value hooks line up one-to-one by kind,
73
+ * so a mismatch (custom hooks, conditional scopes) falls back to indices.
74
+ */
75
+ function hookRows(cells: readonly HookCell[], location: ComponentLocation | undefined): ValueRow[] {
76
+ const bindings = (location?.hooks ?? []).filter((binding) => binding.hook in VALUE_HOOKS)
77
+ const valueCells = cells.filter((cell) => cell.kind !== 'other')
78
+ const aligned = bindings.length === valueCells.length && bindings.every((binding, index) => VALUE_HOOKS[binding.hook] === valueCells[index]!.kind)
79
+ let next = 0
80
+ return cells.map((cell, index) => {
81
+ const binding = aligned && cell.kind !== 'other' ? bindings[next++] : undefined
82
+ return {
83
+ key: `hook-${index}`,
84
+ label: binding?.names[0] ?? `#${index}`,
85
+ kind: binding?.hook ?? KIND_LABEL[cell.kind],
86
+ value: cell.value,
87
+ }
88
+ })
89
+ }
90
+
91
+ props { components, showControlFlow, onShowControlFlowChange, onViewSource, onAnalyze }: ComponentsPanelProps
92
+ setup
93
+ const runtime = useSyncExternalStore(subscribeRuntime, getRuntimeSnapshot, getRuntimeSnapshot);
94
+ const [query, setQuery] = useState('');
95
+ const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(new Set());
96
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
97
+ const rows = useMemo(() => treeRows(runtime.roots, collapsed, showControlFlow, query.trim().toLowerCase()), [runtime.roots, collapsed, showControlFlow, query]);
98
+ const detail = runtime.detail;
99
+ const location = detail === null ? undefined : components.find((component) => component.name === detail.name);
100
+ const hooks = detail === null ? [] : hookRows(detail.hooks, location);
101
+ const context = detail === null ? [] : detail.context.map((entry, index) => ({ key: `context-${index}`, label: entry.name, kind: 'context', value: entry.value }));
102
+ const values = [...hooks, ...context];
103
+ const toggleNode = (id: number) => setCollapsed((current) => {
104
+ const next = new Set(current);
105
+ if (next.has(id)) next.delete(id);
106
+ else next.add(id);
107
+ return next;
108
+ });
109
+ const toggleValue = (key: string) => setExpanded((current) => {
110
+ const next = new Set(current);
111
+ if (next.has(key)) next.delete(key);
112
+ else next.add(key);
113
+ return next;
114
+ });
115
+
116
+ if runtime.status === 'unavailable'
117
+ .bdt-empty
118
+ .bdt-empty-card
119
+ p.bdt-empty-title Runtime inspection is off
120
+ p Octane exposes its component tree only when profiling metadata is compiled in. Enable it for dev builds in vite.config.ts:
121
+ pre.bdt-pre #{"beastOctane({ octane: { profile: 'auto' } })"}
122
+ p.bdt-dim BTSX → TSRX and Refactor keep working without it.
123
+ elseif runtime.status === 'connecting'
124
+ .bdt-empty
125
+ p Connecting to the Octane runtime…
126
+ else
127
+ fragment
128
+ .bdt-pane
129
+ .bdt-toolbar
130
+ input.bdt-input(
131
+ ~ type="search"
132
+ ~ placeholder="Filter components"
133
+ ~ aria-label="Filter components"
134
+ ~ value={query}
135
+ ~ onInput={(event) => setQuery(event.currentTarget.value)}
136
+ ~ )
137
+ label.bdt-check
138
+ input(type="checkbox" checked={showControlFlow} onChange={(event) => onShowControlFlowChange(event.currentTarget.checked)})
139
+ | Control flow
140
+ .bdt-spacer
141
+ span.bdt-dim #{runtime.componentCount} component#{runtime.componentCount === 1 ? '' : 's'}
142
+ .bdt-scroll
143
+ if rows.length === 0
144
+ p.bdt-empty #{query === '' ? 'No Octane roots are mounted.' : 'Nothing matches that filter.'}
145
+ else
146
+ div.bdt-tree(role="tree" aria-label="Component tree")
147
+ each row in rows key row.node.id
148
+ button(
149
+ ~ type="button"
150
+ ~ role="treeitem"
151
+ ~ aria-selected={runtime.selectedId === row.node.id}
152
+ ~ aria-expanded={row.expandable ? row.open : undefined}
153
+ ~ className={cx('bdt-tree-row', runtime.selectedId === row.node.id && 'is-selected')}
154
+ ~ style={{ paddingLeft: `${6 + row.depth * 14}px` }}
155
+ ~ onClick={() => selectRuntimeNode(row.node.id)}
156
+ ~ onDoubleClick={() => row.expandable && toggleNode(row.node.id)}
157
+ ~ )
158
+ span(
159
+ ~ className={cx('bdt-caret', row.open && 'is-open')}
160
+ ~ onClick={(event) => { event.stopPropagation(); if (row.expandable) toggleNode(row.node.id); }}
161
+ ~ )
162
+ | #{row.expandable ? '▸' : ''}
163
+ span(className={cx('bdt-tree-name', row.node.controlFlow && 'is-flow')}) #{row.node.label}
164
+ if row.node.kind === 'root' || row.node.kind === 'portal'
165
+ span.bdt-badge(style={{ marginLeft: '8px' }}) #{row.node.kind}
166
+ span.bdt-tree-id ##{row.node.id}
167
+ aside.bdt-detail(aria-label="Selected component")
168
+ if detail === null
169
+ .bdt-empty
170
+ p Select a component to inspect its state, context, and source.
171
+ else
172
+ fragment
173
+ .bdt-detail-head
174
+ p.bdt-detail-name
175
+ | #{detail.name}
176
+ span.bdt-badge ##{detail.id}
177
+ if location === undefined
178
+ p.bdt-dim No .btsx declaration found for this name.
179
+ else
180
+ fragment
181
+ button.bdt-link(type="button" title="Open in editor" onClick={() => openInEditor(location.absolutePath, location.line, location.column)}) #{location.path}:#{location.line}
182
+ .bdt-detail-actions
183
+ button.bdt-button(type="button" onClick={() => onViewSource(location.path, location.line)}) BTSX → TSRX
184
+ button.bdt-button(type="button" onClick={() => onAnalyze(location.path)}) Analyze file
185
+ button.bdt-button(type="button" onClick={() => openInEditor(location.absolutePath, location.line, location.column)}) Open in editor
186
+ .bdt-scroll
187
+ p.bdt-section-label State & hooks
188
+ if values.length === 0
189
+ p.bdt-dim(style={{ padding: '0 14px' }}) No hook state or context.
190
+ else
191
+ .bdt-props
192
+ each item in values key item.key
193
+ div.bdt-prop
194
+ span.bdt-prop-key
195
+ | #{item.label}
196
+ span.bdt-prop-kind #{item.kind}
197
+ if isExpandable(item.value)
198
+ span(
199
+ ~ role="button"
200
+ ~ tabIndex={0}
201
+ ~ aria-expanded={expanded.has(item.key)}
202
+ ~ className={cx('bdt-prop-value', 'is-toggle', valueTone(item.value))}
203
+ ~ onClick={() => toggleValue(item.key)}
204
+ ~ onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleValue(item.key); } }}
205
+ ~ )
206
+ | #{expanded.has(item.key) ? '▾ ' : '▸ '}#{preview(item.value)}
207
+ else
208
+ span(className={cx('bdt-prop-value', valueTone(item.value))}) #{preview(item.value)}
209
+ if expanded.has(item.key)
210
+ pre.bdt-pre #{pretty(item.value)}
211
+ p.bdt-section-label Effects
212
+ p.bdt-dim(style={{ padding: '0 14px 14px' }}) #{detail.effectCount} effect slot#{detail.effectCount === 1 ? '' : 's'}
@@ -0,0 +1,35 @@
1
+ import type { FileSummary } from '../shared/types.ts'
2
+ import { cx } from './util.ts'
3
+
4
+ module
5
+ interface FileListProps {
6
+ files: readonly FileSummary[]
7
+ active: string | null
8
+ /** Show refactor suggestion counts instead of line counts. */
9
+ metrics: boolean
10
+ onSelect: (path: string) => void
11
+ }
12
+
13
+ props { files, active, metrics, onSelect }: FileListProps
14
+
15
+ nav.bdt-sidebar(aria-label="Beast source files")
16
+ p.bdt-section-label Files · #{files.length}
17
+ .bdt-scroll
18
+ each file in files key file.path
19
+ button(
20
+ ~ type="button"
21
+ ~ title={file.path}
22
+ ~ aria-current={file.path === active ? 'true' : undefined}
23
+ ~ className={cx('bdt-file', file.path === active && 'is-active')}
24
+ ~ onClick={() => onSelect(file.path)}
25
+ ~ )
26
+ span.bdt-file-name #{file.path}
27
+ if file.error !== null
28
+ span.bdt-badge.is-bad(title={file.error}) error
29
+ elseif !metrics
30
+ span.bdt-dim.bdt-mono #{file.lines}
31
+ elseif file.suggestions > 0
32
+ span.bdt-badge.is-warn(title="Refactor suggestions") #{file.suggestions}
33
+ else
34
+ span.bdt-badge.is-ok(title={`Max depth ${file.maxDepth}`}) ok
35
+ div.infinite-lines
@@ -0,0 +1,131 @@
1
+ import { useEffect, useRef, useState } from 'octane'
2
+ import type { FileReport, FileSummary } from '../shared/types.ts'
3
+ import CodeView from './CodeView.btsx'
4
+ import FileList from './FileList.btsx'
5
+ import { openInEditor } from './api.ts'
6
+ import { scrollTarget, type ScrollTarget } from './util.ts'
7
+
8
+ module
9
+ interface InspectorPanelProps {
10
+ files: readonly FileSummary[]
11
+ file: string | null
12
+ report: FileReport | null
13
+ loadError: string | null
14
+ /** A BTSX line another panel asked to reveal. */
15
+ focus: ScrollTarget | null
16
+ onSelectFile: (path: string) => void
17
+ }
18
+
19
+ interface Link {
20
+ side: 'btsx' | 'tsrx'
21
+ line: number
22
+ }
23
+
24
+ props { files, file, report, loadError, focus, onSelectFile }: InspectorPanelProps
25
+ setup
26
+ const [hover, setHover] = useState<Link | null>(null);
27
+ const [pinned, setPinned] = useState<Link | null>(null);
28
+ const [btsxScroll, setBtsxScroll] = useState<ScrollTarget | null>(null);
29
+ const [tsrxScroll, setTsrxScroll] = useState<ScrollTarget | null>(null);
30
+ const appliedFocus = useRef<number | null>(null);
31
+ const shownFile = useRef(file);
32
+ const current = report !== null && report.path === file ? report : null;
33
+ const compiled = current?.compiled ?? null;
34
+ const btsxFor = (line: number) => (compiled?.ok ? (compiled.tsrxToBtsx[line - 1] ?? null) : null);
35
+ const tsrxFor = (line: number) => (compiled?.ok ? (compiled.btsxToTsrx[line - 1] ?? []) : []);
36
+ const link = hover ?? pinned;
37
+ const btsxLinked = link === null ? [] : link.side === 'btsx' ? [link.line] : [btsxFor(link.line)].filter((line): line is number => line !== null);
38
+ const tsrxLinked = link === null ? [] : link.side === 'tsrx' ? [link.line] : tsrxFor(link.line);
39
+ const editorLine = pinned === null ? 1 : pinned.side === 'btsx' ? pinned.line : (btsxFor(pinned.line) ?? 1);
40
+ const pinBtsx = (line: number) => {
41
+ setPinned({ side: 'btsx', line });
42
+ const target = tsrxFor(line)[0];
43
+ if (target !== undefined) setTsrxScroll(scrollTarget(target));
44
+ };
45
+ const pinTsrx = (line: number) => {
46
+ setPinned({ side: 'tsrx', line });
47
+ const target = btsxFor(line);
48
+ if (target !== null) setBtsxScroll(scrollTarget(target));
49
+ };
50
+ useEffect(() => {
51
+ if (shownFile.current === file) return;
52
+ shownFile.current = file;
53
+ setPinned(null);
54
+ setHover(null);
55
+ }, [file]);
56
+ // Reveal a line requested by another panel once its file report arrives.
57
+ useEffect(() => {
58
+ if (focus === null || current === null || appliedFocus.current === focus.nonce) return;
59
+ appliedFocus.current = focus.nonce;
60
+ setBtsxScroll(focus);
61
+ pinBtsx(focus.line);
62
+ }, [focus, current]);
63
+
64
+ FileList(files={files} active={file} metrics={false} onSelect={onSelectFile})
65
+ .bdt-pane
66
+ if loadError !== null && current === null
67
+ .bdt-empty
68
+ p Could not load #{file ?? 'the file'}: #{loadError}
69
+ elseif current === null
70
+ .bdt-empty
71
+ p #{files.length === 0 ? 'No .btsx files found under the configured include directories.' : 'Loading…'}
72
+ else
73
+ fragment
74
+ .bdt-toolbar
75
+ span.bdt-toolbar-title.bdt-mono #{current.path}
76
+ if compiled?.ok
77
+ span.bdt-badge.is-ok compiled
78
+ else
79
+ span.bdt-badge.is-bad compile error
80
+ .bdt-spacer
81
+ span.bdt-dim.bdt-hint Hover to link lines · click to pin
82
+ button.bdt-button(type="button" onClick={() => openInEditor(current.absolutePath, editorLine)}) Open in editor
83
+ if compiled !== null && !compiled.ok
84
+ .bdt-diagnostic
85
+ p
86
+ span.bdt-badge.is-bad #{compiled.error.code}
87
+ | #{' ' + compiled.error.message}
88
+ pre.bdt-pre #{compiled.error.formatted}
89
+ if compiled.error.hint !== undefined
90
+ p.bdt-muted Hint: #{compiled.error.hint}
91
+ elseif compiled !== null && compiled.diagnostics.length > 0
92
+ each diagnostic, index in compiled.diagnostics key index
93
+ div(className={diagnostic.severity === 'warning' ? 'bdt-diagnostic is-warning' : 'bdt-diagnostic'})
94
+ pre.bdt-pre #{diagnostic.formatted}
95
+ .bdt-split
96
+ section.bdt-pane(aria-label="BTSX source")
97
+ .bdt-pane-head
98
+ span BTSX
99
+ span.bdt-dim #{current.source.split('\n').length} lines
100
+ CodeView(
101
+ ~ source={current.source}
102
+ ~ language="btsx"
103
+ ~ label="BTSX source"
104
+ ~ linked={btsxLinked}
105
+ ~ activeLine={pinned?.side === 'btsx' ? pinned.line : null}
106
+ ~ errorLine={compiled !== null && !compiled.ok ? compiled.error.line : null}
107
+ ~ scrollTo={btsxScroll}
108
+ ~ onLineEnter={(line) => setHover({ side: 'btsx', line })}
109
+ ~ onLineClick={pinBtsx}
110
+ ~ onLeave={() => setHover(null)}
111
+ ~ )
112
+ section.bdt-pane(aria-label="Generated TSRX")
113
+ .bdt-pane-head
114
+ span Generated TSRX
115
+ if compiled?.ok
116
+ span.bdt-dim #{compiled.tsrx.split('\n').length} lines
117
+ if compiled?.ok
118
+ CodeView(
119
+ ~ source={compiled.tsrx}
120
+ ~ language="tsrx"
121
+ ~ label="Generated TSRX"
122
+ ~ linked={tsrxLinked}
123
+ ~ activeLine={pinned?.side === 'tsrx' ? pinned.line : null}
124
+ ~ scrollTo={tsrxScroll}
125
+ ~ onLineEnter={(line) => setHover({ side: 'tsrx', line })}
126
+ ~ onLineClick={pinTsrx}
127
+ ~ onLeave={() => setHover(null)}
128
+ ~ )
129
+ else
130
+ .bdt-empty
131
+ p Fix the BTSX error to see generated TSRX.