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,304 @@
1
+ import { useEffect, useState } from 'octane'
2
+ import type { AnalyzerSettings, ApplyResult, DiffLine, FileReport, FileSummary, RefactorSuggestion, RefactorTarget } from '../shared/types.ts'
3
+ import CodeView from './CodeView.btsx'
4
+ import FileList from './FileList.btsx'
5
+ import { applyRefactor, copyText, openInEditor, undoRefactor } from './api.ts'
6
+ import { cx, scrollTarget, type RecentRefactor, type ScrollTarget } from './util.ts'
7
+
8
+ module
9
+ interface RefactorPanelProps {
10
+ files: readonly FileSummary[]
11
+ file: string | null
12
+ report: FileReport | null
13
+ loadError: string | null
14
+ settings: AnalyzerSettings
15
+ recent: RecentRefactor | null
16
+ onSelectFile: (path: string) => void
17
+ onSettingsChange: (settings: AnalyzerSettings) => void
18
+ onRecentChange: (recent: RecentRefactor | null) => void
19
+ }
20
+
21
+ /** A refactor being reviewed: previewed as a dry run, then confirmed. */
22
+ interface PendingRefactor {
23
+ suggestionId: string
24
+ target: RefactorTarget
25
+ status: 'loading' | 'ready' | 'applying'
26
+ result: ApplyResult | null
27
+ error: string | null
28
+ }
29
+
30
+ function targetLabel(target: RefactorTarget, suggestion: RefactorSuggestion): string {
31
+ return target === 'file' ? `create ⯌` : ` hoist ✦`
32
+
33
+
34
+ }
35
+
36
+ function targets(suggestion: RefactorSuggestion): RefactorTarget[] {
37
+ if (suggestion.autoApply.blocked !== null) return []
38
+ const other: RefactorTarget = suggestion.autoApply.target === 'file' ? 'inline' : 'file'
39
+ const allowed = other === 'file' ? suggestion.autoApply.fileBlocked === null : true
40
+ return allowed ? [suggestion.autoApply.target, other] : [suggestion.autoApply.target]
41
+ }
42
+
43
+ const DIFF_SIGN: Record<DiffLine['type'], string> = { add: '+', remove: '-', context: ' ' }
44
+
45
+ function message(error: unknown): string {
46
+ return error instanceof Error ? error.message : String(error)
47
+ }
48
+
49
+ function tone(depth: number, limit: number): string {
50
+ return depth > limit ? 'is-over' : depth === limit ? 'is-near' : ''
51
+ }
52
+
53
+ function kindLabel(suggestion: RefactorSuggestion): string {
54
+ return suggestion.kind === 'extract' ? '✦' : `${suggestion.occurrences.length}× same shape`
55
+ }
56
+
57
+ function applyGuide(suggestion: RefactorSuggestion): string {
58
+ const where = suggestion.occurrences.map((range) => `${range.startLine}–${range.endLine}`).join(', ')
59
+ return [
60
+ `// 1. Add above line ${suggestion.insertBeforeLine}:`,
61
+ suggestion.snippet,
62
+ '',
63
+ `// 2. Replace line${suggestion.occurrences.length === 1 ? 's' : ' ranges'} ${where} with:`,
64
+ suggestion.usage.trimStart(),
65
+ ].join('\n')
66
+ }
67
+
68
+ function readLimit(value: string, min: number, max: number): number | null {
69
+ const parsed = Number.parseInt(value, 10)
70
+ return Number.isInteger(parsed) && parsed >= min && parsed <= max ? parsed : null
71
+ }
72
+
73
+ props { files, file, report, loadError, settings, recent, onSelectFile, onSettingsChange, onRecentChange }: RefactorPanelProps
74
+ setup
75
+ const [pending, setPending] = useState<PendingRefactor | null>(null);
76
+ const [undoError, setUndoError] = useState<string | null>(null);
77
+ const [selectedId, setSelectedId] = useState<string | null>(null);
78
+ const [openId, setOpenId] = useState<string | null>(null);
79
+ const [copied, setCopied] = useState<string | null>(null);
80
+ const [scroll, setScroll] = useState<ScrollTarget | null>(null);
81
+ const current = report !== null && report.path === file ? report : null;
82
+ const analysis = current?.analysis ?? null;
83
+ const selected = analysis?.suggestions.find((suggestion) => suggestion.id === selectedId) ?? null;
84
+ const histogramMax = Math.max(1, ...(analysis?.histogram ?? [1]));
85
+ const select = (suggestion: RefactorSuggestion) => {
86
+ setSelectedId(suggestion.id);
87
+ setScroll(scrollTarget(suggestion.startLine));
88
+ };
89
+ const copy = async (key: string, text: string) => {
90
+ if (!(await copyText(text))) return;
91
+ setCopied(key);
92
+ window.setTimeout(() => setCopied((value) => (value === key ? null : value)), 1400);
93
+ };
94
+ useEffect(() => {
95
+ setSelectedId(null);
96
+ setOpenId(null);
97
+ setPending(null);
98
+ }, [file]);
99
+ // A preview describes one version of the file; drop it once the file changes.
100
+ useEffect(() => {
101
+ setPending((value) => (value?.status === 'applying' ? value : null));
102
+ }, [current?.hash]);
103
+
104
+ const preview = async (suggestion: RefactorSuggestion, target: RefactorTarget) => {
105
+ if (current === null) return;
106
+ const request = { path: current.path, hash: current.hash, settings, suggestionId: suggestion.id, target };
107
+ setPending({ suggestionId: suggestion.id, target, status: 'loading', result: null, error: null });
108
+ const same = (value: PendingRefactor | null) => value?.suggestionId === suggestion.id && value.target === target;
109
+ try {
110
+ const result = await applyRefactor({ ...request, dryRun: true });
111
+ setPending((value) => (same(value) ? { ...value!, status: 'ready', result } : value));
112
+ } catch (error) {
113
+ setPending((value) => (same(value) ? { ...value!, status: 'ready', error: message(error) } : value));
114
+ }
115
+ };
116
+ const confirm = async () => {
117
+ if (pending === null || current === null) return;
118
+ const request = { path: current.path, hash: current.hash, settings, suggestionId: pending.suggestionId, target: pending.target };
119
+ setPending({ ...pending, status: 'applying' });
120
+ try {
121
+ const result = await applyRefactor({ ...request, dryRun: false });
122
+ setPending(null);
123
+ setSelectedId(null);
124
+ setOpenId(null);
125
+ setUndoError(null);
126
+ onRecentChange({ undoId: result.undoId!, summary: result.summary });
127
+ } catch (error) {
128
+ setPending({ ...pending, status: 'ready', error: message(error) });
129
+ }
130
+ };
131
+ const undo = async () => {
132
+ if (recent === null) return;
133
+ try {
134
+ await undoRefactor(recent.undoId);
135
+ setUndoError(null);
136
+ onRecentChange(null);
137
+ } catch (error) {
138
+ setUndoError(message(error));
139
+ }
140
+ };
141
+
142
+ FileList(files={files} active={file} metrics={true} onSelect={onSelectFile})
143
+ .bdt-pane
144
+ .bdt-toolbar
145
+ span.bdt-toolbar-title.bdt-mono #{file ?? 'No file selected'}
146
+ .bdt-spacer
147
+ label.bdt-field(title="Template nesting depth above which lines count as too deep")
148
+ | Depth limit
149
+ input.bdt-input.is-number(
150
+ ~ type="number"
151
+ ~ min="1"
152
+ ~ max="20"
153
+ ~ value={settings.depthLimit}
154
+ ~ onInput={(event) => { const depthLimit = readLimit(event.currentTarget.value, 1, 20); if (depthLimit !== null) onSettingsChange({ ...settings, depthLimit }); }}
155
+ ~ )
156
+ label.bdt-field(title="Smallest section, in lines, worth extracting")
157
+ | Min lines
158
+ input.bdt-input.is-number(
159
+ ~ type="number"
160
+ ~ min="2"
161
+ ~ max="200"
162
+ ~ value={settings.minLines}
163
+ ~ onInput={(event) => { const minLines = readLimit(event.currentTarget.value, 2, 200); if (minLines !== null) onSettingsChange({ ...settings, minLines }); }}
164
+ ~ )
165
+ label.bdt-field(title="Sections with at least this many lines move to their own .btsx file by default")
166
+ | New file at
167
+ input.bdt-input.is-number(
168
+ ~ type="number"
169
+ ~ min="2"
170
+ ~ max="1000"
171
+ ~ value={settings.fileLines}
172
+ ~ onInput={(event) => { const fileLines = readLimit(event.currentTarget.value, 2, 1000); if (fileLines !== null) onSettingsChange({ ...settings, fileLines }); }}
173
+ ~ )
174
+ if recent !== null
175
+ div(className={cx('bdt-toast', undoError !== null && 'is-error')} role="status")
176
+ span #{undoError ?? `✓ ${recent.summary}`}
177
+ .bdt-spacer
178
+ if undoError === null
179
+ button.bdt-button(type="button" onClick={undo}) Undo
180
+ button.bdt-icon-button(type="button" aria-label="Dismiss" onClick={() => { setUndoError(null); onRecentChange(null); }}) ×
181
+ if loadError !== null && current === null
182
+ .bdt-empty
183
+ p Could not analyze #{file ?? 'the file'}: #{loadError}
184
+ elseif current === null
185
+ .bdt-empty
186
+ p #{files.length === 0 ? 'No .btsx files found under the configured include directories.' : 'Analyzing…'}
187
+ elseif analysis === null
188
+ .bdt-empty
189
+ .bdt-empty-card
190
+ p.bdt-empty-title This file does not compile
191
+ p Fix the error shown in BTSX → TSRX to analyze its structure.
192
+ else
193
+ fragment
194
+ .bdt-metrics
195
+ .bdt-metric(className={analysis.maxDepth > settings.depthLimit && 'metric-alert'})
196
+ span(className={cx('bdt-metric-value', analysis.maxDepth > settings.depthLimit && 'is-over')}) #{analysis.maxDepth}
197
+ span.bdt-metric-label Max depth
198
+ .bdt-metric
199
+ span.bdt-metric-value #{analysis.averageDepth}
200
+ span.bdt-metric-label Avg depth
201
+ .bdt-metric(className={analysis.deepLines > 0 && 'metric-alert'})
202
+ span(className={cx('bdt-metric-value', analysis.deepLines > 0 && 'is-over')}) #{analysis.deepLines}
203
+ span.bdt-metric-label Lines over #{settings.depthLimit}
204
+ .bdt-metric
205
+ span.bdt-metric-value #{analysis.templateLines}
206
+ span.bdt-metric-label Template lines
207
+ .bdt-metric
208
+ span.bdt-metric-value #{analysis.indentUnit}
209
+ span.bdt-metric-label Indent spaces
210
+ div.bdt-histogram(role="img" aria-label={`Template lines per depth: ${analysis.histogram.map((count, depth) => `depth ${depth}: ${count}`).join(', ')}`})
211
+ each count, depth in analysis.histogram key depth
212
+ .bdt-histogram-col(title={`Depth ${depth}: ${count} line${count === 1 ? '' : 's'}`})
213
+ span(className={cx('bdt-histogram-bar', tone(depth, settings.depthLimit))} style={{ height: `${Math.max(2, Math.round((count / histogramMax) * 24))}px` }})
214
+ span.bdt-histogram-label #{depth}
215
+ .bdt-refactor
216
+ section.bdt-suggestions(aria-label="Refactor suggestions")
217
+ if analysis.suggestions.length === 0
218
+ .bdt-empty
219
+ .bdt-empty-card
220
+ p.bdt-empty-title Nothing to extract
221
+ p Every section stays within depth #{settings.depthLimit}, and no blocks of #{settings.minLines}+ lines repeat.
222
+ else
223
+ each suggestion in analysis.suggestions key suggestion.id
224
+ article(
225
+ ~ className={cx('bdt-card', suggestion.id === selectedId && 'is-selected')}
226
+ ~ onClick={() => select(suggestion)}
227
+ ~ )
228
+ .bdt-card-head
229
+ span(className={cx('bdt-severity', `is-${suggestion.severity}`)} title={suggestion.severity})
230
+ span.bdt-card-title #{suggestion.name}
231
+ span(className={cx('bdt-badge', suggestion.kind === 'extract' ? 'is-warn' : 'is-info')}) #{kindLabel(suggestion)}
232
+ span.bdt-dim.bdt-metric-label L#{suggestion.startLine}–#{suggestion.endLine}
233
+ p.bdt-card-reason #{suggestion.reason}
234
+ .bdt-card-meta
235
+ span.bdt-chip #{suggestion.label} in #{suggestion.host}
236
+ each prop in suggestion.props key prop.name
237
+ span.bdt-chip(title={`${prop.name}: ${prop.type}`})
238
+ | #{prop.name}
239
+ span.bdt-chip-type #{': ' + prop.type}
240
+ .bdt-card-actions
241
+ button.bdt-button(type="button" aria-expanded={openId === suggestion.id} onClick={() => setOpenId(openId === suggestion.id ? null : suggestion.id)}) #{openId === suggestion.id ? '🞁' : '🞃'}
242
+ button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:component`, suggestion.snippet)}) #{copied === `${suggestion.id}:component` ? 'component ⮻' : 'component ⮺'}
243
+ button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:usage`, suggestion.usage.trimStart())}) #{copied === `${suggestion.id}:usage` ? 'usage ⮻' : 'usage ⮺'}
244
+ button.bdt-button(type="button" onClick={() => openInEditor(current.absolutePath, suggestion.startLine)}) open ⧽
245
+ .bdt-card-actions
246
+ each target, index in targets(suggestion) key target
247
+ button(
248
+ ~ type="button"
249
+ ~ className={cx('bdt-button-critical', index === 0 && '')}
250
+ ~ title={target === 'file' ? `Write ${suggestion.name}.btsx next to this file and import it` : 'Add a local component to this file'}
251
+ ~ disabled={pending?.status === 'applying' || pending?.status === 'loading'}
252
+ ~ onClick={() => preview(suggestion, target)}
253
+ ~ )
254
+ | #{targetLabel(target, suggestion)}
255
+
256
+ if suggestion.autoApply.blocked !== null
257
+ p.bdt-card-note Manual only: #{suggestion.autoApply.blocked}
258
+ elseif suggestion.autoApply.fileBlocked !== null
259
+ p.bdt-card-note Stays in this file: #{suggestion.autoApply.fileBlocked}
260
+ if pending !== null && pending.suggestionId === suggestion.id
261
+ div.bdt-plan(role="group" aria-label="Review refactor")
262
+ if pending.status === 'loading'
263
+ p.bdt-muted Preparing #{targetLabel(pending.target, suggestion).toLowerCase()}…
264
+ else
265
+ fragment
266
+ if pending.result !== null
267
+ fragment
268
+ p.bdt-plan-title Review · #{targetLabel(pending.target, suggestion)} · #{pending.result.files.length} file#{pending.result.files.length === 1 ? '' : 's'}
269
+ each change in pending.result.files key change.path
270
+ .bdt-plan-file
271
+ .bdt-plan-file-head
272
+ span #{change.path}
273
+ span(className={cx('bdt-badge', change.action === 'create' ? 'is-ok' : 'is-info')}) #{change.action === 'create' ? 'new file' : 'edit'}
274
+ span.bdt-added +#{change.added}
275
+ span.bdt-removed −#{change.removed}
276
+ .bdt-diff
277
+ .bdt-diff-inner
278
+ each hunk, hunkIndex in change.hunks key hunkIndex
279
+ fragment
280
+ p.bdt-diff-head @@ −#{hunk.oldStart} +#{hunk.newStart} @@
281
+ each line, lineIndex in hunk.lines key lineIndex
282
+ div(className={`bdt-diff-line is-${line.type}`}) #{DIFF_SIGN[line.type] + ' ' + line.text}
283
+ if pending.error !== null
284
+ pre.bdt-plan-error #{pending.error}
285
+ .bdt-card-actions
286
+ if pending.result !== null && pending.error === null
287
+ button.bdt-button-critical(type="button" disabled={pending.status === 'applying'} onClick={confirm}) #{pending.status === 'applying' ? 'Applying…' : 'Apply changes'}
288
+ button.bdt-button(type="button" disabled={pending.status === 'applying'} onClick={() => setPending(null)}) Cancel
289
+ if openId === suggestion.id
290
+ pre.bdt-pre #{applyGuide(suggestion)}
291
+ section.bdt-pane(aria-label="Source with nesting depth")
292
+ .bdt-pane-head
293
+ span Nesting depth per line
294
+ .bdt-spacer
295
+ span.bdt-dim #{selected === null ? 'Select a suggestion to highlight it' : `${selected.name}: ${selected.occurrences.length === 1 ? 'section' : `${selected.occurrences.length} copies`} highlighted`}
296
+ CodeView(
297
+ ~ source={current.source}
298
+ ~ language="btsx"
299
+ ~ label="BTSX source with nesting depth"
300
+ ~ depths={analysis.lineDepths}
301
+ ~ depthLimit={settings.depthLimit}
302
+ ~ ranges={selected?.occurrences}
303
+ ~ scrollTo={scroll}
304
+ ~ )
@@ -0,0 +1,69 @@
1
+ import {
2
+ API_BASE,
3
+ SOURCE_CHANGED_EVENT,
4
+ type AnalyzerSettings,
5
+ type ApplyRequest,
6
+ type ApplyResult,
7
+ type FileReport,
8
+ type ProjectReport,
9
+ type UndoResult,
10
+ } from '../shared/types.ts'
11
+
12
+ async function get<T>(endpoint: string, params: Record<string, string | number>): Promise<T> {
13
+ const query = new URLSearchParams(Object.entries(params).map(([key, value]) => [key, String(value)]))
14
+ const response = await fetch(`${API_BASE}${endpoint}?${query}`)
15
+ const body = (await response.json()) as T | { error: string }
16
+ if (!response.ok) throw new Error((body as { error: string }).error ?? response.statusText)
17
+ return body as T
18
+ }
19
+
20
+ async function post<T>(endpoint: string, body: unknown): Promise<T> {
21
+ const response = await fetch(`${API_BASE}${endpoint}`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify(body),
25
+ })
26
+ const result = (await response.json()) as T | { error: string }
27
+ if (!response.ok) throw new Error((result as { error: string }).error ?? response.statusText)
28
+ return result as T
29
+ }
30
+
31
+ export function fetchProject(settings: AnalyzerSettings): Promise<ProjectReport> {
32
+ return get<ProjectReport>('/project', { ...settings })
33
+ }
34
+
35
+ export function fetchFile(path: string, settings: AnalyzerSettings): Promise<FileReport> {
36
+ return get<FileReport>('/file', { path, ...settings })
37
+ }
38
+
39
+ /** Preview (`dryRun`) or write a refactor suggestion. */
40
+ export function applyRefactor(request: ApplyRequest): Promise<ApplyResult> {
41
+ return post<ApplyResult>('/apply', request)
42
+ }
43
+
44
+ export function undoRefactor(id: string): Promise<UndoResult> {
45
+ return post<UndoResult>('/undo', { id })
46
+ }
47
+
48
+ /** Called with the project-relative path whenever a `.btsx` file changes on disk. */
49
+ export function onSourceChanged(listener: (path: string) => void): () => void {
50
+ const hot = import.meta.hot
51
+ if (hot === undefined) return () => {}
52
+ const handler = (data: { path: string }) => listener(data.path)
53
+ hot.on(SOURCE_CHANGED_EVENT, handler)
54
+ return () => hot.off(SOURCE_CHANGED_EVENT, handler)
55
+ }
56
+
57
+ /** Ask Vite's built-in launch-editor endpoint to open a file at a position. */
58
+ export function openInEditor(absolutePath: string, line = 1, column = 1): void {
59
+ void fetch(`/__open-in-editor?file=${encodeURIComponent(`${absolutePath}:${line}:${column}`)}`)
60
+ }
61
+
62
+ export async function copyText(text: string): Promise<boolean> {
63
+ try {
64
+ await navigator.clipboard.writeText(text)
65
+ return true
66
+ } catch {
67
+ return false
68
+ }
69
+ }
@@ -0,0 +1,22 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { readdirSync, readFileSync } from 'node:fs'
3
+ import { compileBeastResult, componentNameFromPath, mapGeneratedError } from 'beast-tsrx'
4
+ import { createOctaneCompiler } from 'octane/compiler/bundler'
5
+
6
+ // The overlay only compiles under `vite dev`, so `vite build` never sees it.
7
+ // Compile every overlay component through Beast and Octane here instead.
8
+ const dir = new URL('.', import.meta.url).pathname
9
+ const octane = createOctaneCompiler({ root: process.cwd(), environment: 'client', hmr: false, dev: true })
10
+
11
+ for (const name of readdirSync(dir).filter((file) => file.endsWith('.btsx'))) {
12
+ test(`${name} compiles through Beast and Octane`, () => {
13
+ const filename = `${dir}${name}`
14
+ const source = readFileSync(filename, 'utf8')
15
+ const { code, map } = compileBeastResult(source, { filename, componentName: componentNameFromPath(filename) })
16
+ try {
17
+ expect(octane.transform(code, filename.replace(/\.btsx$/, '.tsrx'), { environment: 'client', dev: true })).not.toBeNull()
18
+ } catch (error) {
19
+ throw mapGeneratedError(error, map, source, filename)
20
+ }
21
+ })
22
+ }