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.
- package/.claude/launch.json +11 -0
- package/CHANGELOG.md +37 -0
- package/README.md +87 -0
- package/devtools/CHANGELOG.md +26 -0
- package/devtools/LICENSE +15 -0
- package/devtools/README.md +164 -0
- package/devtools/client/BeastDevtools.btsx +185 -0
- package/devtools/client/CodeView.btsx +61 -0
- package/devtools/client/ComponentsPanel.btsx +212 -0
- package/devtools/client/FileList.btsx +35 -0
- package/devtools/client/InspectorPanel.btsx +131 -0
- package/devtools/client/RefactorPanel.btsx +304 -0
- package/devtools/client/api.ts +69 -0
- package/devtools/client/compile.test.ts +22 -0
- package/devtools/client/devtools.css +1279 -0
- package/devtools/client/highlight.ts +210 -0
- package/devtools/client/mount.ts +16 -0
- package/devtools/client/runtime.ts +168 -0
- package/devtools/client/util.ts +136 -0
- package/devtools/package.json +73 -0
- package/devtools/server/analyze.test.ts +148 -0
- package/devtools/server/analyze.ts +737 -0
- package/devtools/server/diff.ts +82 -0
- package/devtools/server/line-map.ts +63 -0
- package/devtools/server/octane-bundler.d.ts +17 -0
- package/devtools/server/project.ts +369 -0
- package/devtools/server/refactor.test.ts +224 -0
- package/devtools/server/refactor.ts +224 -0
- package/devtools/server/source-scan.ts +377 -0
- package/devtools/shared/types.ts +208 -0
- package/devtools/test/fixtures/App.btsx +193 -0
- package/devtools/tsconfig.build.json +17 -0
- package/devtools/vite.ts +159 -0
- package/favicon.ico +0 -0
- package/index.html +14 -0
- package/package.json +34 -0
- package/public/beast.svg +1 -0
- package/src/App.btsx +144 -0
- package/src/AppHeader.btsx +24 -0
- package/src/LeftArticle.btsx +22 -0
- package/src/RightArticle.btsx +19 -0
- package/src/env.d.ts +8 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +8 -0
- package/src/style.css +44 -0
- package/tsconfig.json +39 -0
- 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
|
+
}
|