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,210 @@
1
+ /**
2
+ * A small line-oriented highlighter for BTSX and generated TSRX. It favors
3
+ * cheap, predictable output over precision: every line is tokenized
4
+ * independently, except BTSX `module`/`setup`/`style` blocks, whose indented
5
+ * bodies switch the lexer into TypeScript or CSS mode.
6
+ */
7
+
8
+ export type TokenType =
9
+ | 'text'
10
+ | 'keyword'
11
+ | 'string'
12
+ | 'number'
13
+ | 'comment'
14
+ | 'tag'
15
+ | 'component'
16
+ | 'selector'
17
+ | 'attr'
18
+ | 'directive'
19
+ | 'interp'
20
+ | 'punct'
21
+ | 'type'
22
+
23
+ export interface Token {
24
+ type: TokenType
25
+ value: string
26
+ }
27
+
28
+ export type Language = 'btsx' | 'tsrx'
29
+
30
+ const JS_KEYWORDS = new Set([
31
+ 'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'else', 'export',
32
+ 'extends', 'false', 'for', 'from', 'function', 'if', 'import', 'in', 'interface', 'let', 'new', 'null', 'of',
33
+ 'return', 'switch', 'throw', 'true', 'try', 'type', 'typeof', 'undefined', 'var', 'void', 'while',
34
+ ])
35
+
36
+ const BTSX_KEYWORDS = new Set([
37
+ 'case', 'catch', 'component', 'default', 'each', 'else', 'elseif', 'empty', 'fragment', 'if', 'import', 'in',
38
+ 'key', 'module', 'pending', 'props', 'scope', 'setup', 'style', 'switch', 'try',
39
+ ])
40
+
41
+ /** Header words of `each item, i in items key item.id` and `@for (...; index i; key k)`. */
42
+ const EACH_KEYWORDS = new Set(['in', 'key'])
43
+ const FOR_KEYWORDS = new Set(['index', 'key'])
44
+
45
+ /** BTSX keywords whose bare form opens an indented TypeScript or CSS block. */
46
+ const BLOCK_OPENERS: Record<string, 'ts' | 'css'> = { module: 'ts', setup: 'ts', style: 'css' }
47
+
48
+ export function highlight(source: string, language: Language): Token[][] {
49
+ const lines = source.split('\n')
50
+ if (language === 'tsrx') {
51
+ return lines.map((line) => merge(lexCode(line, true, /@for\b/.test(line) ? FOR_KEYWORDS : undefined)))
52
+ }
53
+
54
+ let block: { indent: number; mode: 'ts' | 'css' } | null = null
55
+ return lines.map((line) => {
56
+ const indent = /^ */.exec(line)![0].length
57
+ const trimmed = line.trim()
58
+ if (block !== null && (trimmed === '' || indent > block.indent)) {
59
+ return merge(block.mode === 'css' ? lexCss(line) : lexCode(line, false))
60
+ }
61
+ block = null
62
+ const opener = BLOCK_OPENERS[trimmed]
63
+ if (opener !== undefined) block = { indent, mode: opener }
64
+ return merge(lexBtsxLine(line))
65
+ })
66
+ }
67
+
68
+ function lexBtsxLine(line: string): Token[] {
69
+ const lead = /^ */.exec(line)![0]
70
+ const body = line.slice(lead.length)
71
+ const tokens: Token[] = [{ type: 'text', value: lead }]
72
+ if (body.startsWith('//')) return [...tokens, { type: 'comment', value: body }]
73
+ if (body.startsWith('~')) return [...tokens, { type: 'punct', value: '~' }, ...lexCode(body.slice(1), true)]
74
+ if (body.startsWith('|')) return [...tokens, { type: 'punct', value: '|' }, ...lexText(body.slice(1))]
75
+
76
+ const word = /^[A-Za-z_$][\w$]*/.exec(body)?.[0]
77
+ if (word !== undefined && BTSX_KEYWORDS.has(word) && !/^[.#(]/.test(body.slice(word.length))) {
78
+ const rest = body.slice(word.length)
79
+ // `component Name` names a component; other keywords are followed by code.
80
+ if (word === 'component') {
81
+ return [...tokens, { type: 'keyword', value: word }, ...lexCode(rest, false).map(asComponentName)]
82
+ }
83
+ return [...tokens, { type: 'keyword', value: word }, ...lexCode(rest, false, word === 'each' ? EACH_KEYWORDS : undefined)]
84
+ }
85
+
86
+ const selector = /^([A-Za-z][\w.$-]*)?((?:[#.][\w-]+)*)/.exec(body)
87
+ if (selector === null || selector[0] === '') return [...tokens, ...lexText(body)]
88
+ const [, tag = '', suffix = ''] = selector
89
+ const isComponent = /^[A-Z]/.test(tag)
90
+ if (tag !== '') tokens.push({ type: isComponent ? 'component' : 'tag', value: tag })
91
+ if (suffix !== '') tokens.push({ type: 'selector', value: suffix })
92
+
93
+ let rest = body.slice(selector[0].length)
94
+ if (rest.startsWith('(')) {
95
+ const close = matchingParen(rest)
96
+ tokens.push(...lexCode(rest.slice(0, close + 1), true))
97
+ rest = rest.slice(close + 1)
98
+ }
99
+ return [...tokens, ...lexText(rest)]
100
+ }
101
+
102
+ function asComponentName(token: Token): Token {
103
+ return token.type === 'type' ? { type: 'component', value: token.value } : token
104
+ }
105
+
106
+ /** Literal text with `#{...}` interpolations. */
107
+ function lexText(text: string): Token[] {
108
+ const tokens: Token[] = []
109
+ let i = 0
110
+ while (i < text.length) {
111
+ const start = text.indexOf('#{', i)
112
+ if (start === -1) {
113
+ tokens.push({ type: 'text', value: text.slice(i) })
114
+ break
115
+ }
116
+ if (start > i) tokens.push({ type: 'text', value: text.slice(i, start) })
117
+ let depth = 0
118
+ let end = start + 1
119
+ for (; end < text.length; end++) {
120
+ if (text[end] === '{') depth++
121
+ else if (text[end] === '}' && --depth === 0) break
122
+ }
123
+ tokens.push({ type: 'interp', value: '#{' }, ...lexCode(text.slice(start + 2, end), false))
124
+ if (end < text.length) tokens.push({ type: 'interp', value: '}' })
125
+ i = end + 1
126
+ }
127
+ return tokens
128
+ }
129
+
130
+ type Classifier = (match: string, markup: boolean, keywords: ReadonlySet<string> | undefined) => TokenType | null
131
+
132
+ /** Ordered lexer rules; a classifier returning null lets later rules try. */
133
+ const CODE_RULES: Array<[RegExp, TokenType | Classifier]> = [
134
+ [/^\/\/.*/, 'comment'],
135
+ [/^\/\*.*?(\*\/|$)/, 'comment'],
136
+ [/^(['"])(?:\\.|(?!\1).)*\1?/, 'string'],
137
+ [/^`(?:\\.|[^`])*`?/, 'string'],
138
+ [/^\d[\d_]*(?:\.\d+)?/, 'number'],
139
+ [/^@(?:if|else|for|switch|case|default|try|pending|catch|empty)\b/, 'directive'],
140
+ [/^<\/?[A-Za-z][\w.:-]*/, (match, markup) => (!markup ? null : /^<\/?[A-Z]/.test(match) ? 'component' : 'tag')],
141
+ [/^\/?>/, (_match, markup) => (markup ? 'tag' : 'punct')],
142
+ [/^[A-Za-z_$][\w$-]*(?==(?!=))/, (match, markup) => (markup || match.includes('-') ? 'attr' : 'text')],
143
+ [/^[A-Za-z_$][\w$]*/, (match, _markup, keywords) =>
144
+ JS_KEYWORDS.has(match) || keywords?.has(match) === true ? 'keyword' : /^[A-Z]/.test(match) ? 'type' : 'text'],
145
+ [/^\s+/, 'text'],
146
+ [/^[{}()[\];:,.=+\-*/%!?&|<>~^@]/, 'punct'],
147
+ ]
148
+
149
+ /** TypeScript-ish code; `markup` enables JSX-style tag and attribute coloring. */
150
+ function lexCode(code: string, markup: boolean, keywords?: ReadonlySet<string>): Token[] {
151
+ const tokens: Token[] = []
152
+ let rest = code
153
+ outer: while (rest.length > 0) {
154
+ for (const [pattern, rule] of CODE_RULES) {
155
+ const match = pattern.exec(rest)?.[0]
156
+ if (match === undefined || match === '') continue
157
+ const type = typeof rule === 'function' ? rule(match, markup, keywords) : rule
158
+ if (type === null) continue
159
+ tokens.push({ type, value: match })
160
+ rest = rest.slice(match.length)
161
+ continue outer
162
+ }
163
+ tokens.push({ type: 'text', value: rest[0]! })
164
+ rest = rest.slice(1)
165
+ }
166
+ return tokens
167
+ }
168
+
169
+ function lexCss(line: string): Token[] {
170
+ const comment = line.indexOf('/*')
171
+ if (comment !== -1) return [...lexCss(line.slice(0, comment)), { type: 'comment', value: line.slice(comment) }]
172
+ const declaration = /^(\s*)([\w-]+)(\s*:\s*)(.*?)(;?\s*)$/.exec(line)
173
+ if (declaration !== null && !line.trimEnd().endsWith('{')) {
174
+ const [, lead = '', property = '', colon = '', value = '', end = ''] = declaration
175
+ return [
176
+ { type: 'text', value: lead },
177
+ { type: 'attr', value: property },
178
+ { type: 'punct', value: colon },
179
+ { type: 'string', value },
180
+ { type: 'punct', value: end },
181
+ ]
182
+ }
183
+ return [{ type: 'selector', value: line }]
184
+ }
185
+
186
+ function matchingParen(text: string): number {
187
+ let depth = 0
188
+ let quote: string | null = null
189
+ for (let i = 0; i < text.length; i++) {
190
+ const char = text[i]!
191
+ if (quote !== null) {
192
+ if (char === '\\') i++
193
+ else if (char === quote) quote = null
194
+ } else if (char === '"' || char === "'" || char === '`') quote = char
195
+ else if (char === '(' || char === '{' || char === '[') depth++
196
+ else if ((char === ')' || char === '}' || char === ']') && --depth === 0) return i
197
+ }
198
+ return text.length - 1
199
+ }
200
+
201
+ function merge(tokens: Token[]): Token[] {
202
+ const merged: Token[] = []
203
+ for (const token of tokens) {
204
+ if (token.value === '') continue
205
+ const last = merged.at(-1)
206
+ if (last !== undefined && last.type === token.type) last.value += token.value
207
+ else merged.push({ ...token })
208
+ }
209
+ return merged
210
+ }
@@ -0,0 +1,16 @@
1
+ import { createRoot } from 'octane'
2
+ import BeastDevtools from './BeastDevtools.btsx'
3
+ import './devtools.css'
4
+
5
+ const HOST_ID = 'beast-devtools'
6
+
7
+ function mount(): void {
8
+ if (document.getElementById(HOST_ID) !== null) return
9
+ const host = document.createElement('div')
10
+ host.id = HOST_ID
11
+ document.body.append(host)
12
+ createRoot(host).render(BeastDevtools, {})
13
+ }
14
+
15
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true })
16
+ else mount()
@@ -0,0 +1,168 @@
1
+ /**
2
+ * External store over Octane's `__OCTANE_DEVTOOLS__` inspection hook.
3
+ *
4
+ * The overlay is itself an Octane root, so every overlay render also flushes
5
+ * and notifies the hook. The store therefore publishes a new snapshot only
6
+ * when the (overlay-filtered) tree or the selected node's detail actually
7
+ * changes; an overlay-only flush produces an identical key and stops there.
8
+ */
9
+
10
+ interface HookTreeNode {
11
+ id: number
12
+ name: string
13
+ kind: string
14
+ children: HookTreeNode[]
15
+ }
16
+
17
+ export interface HookCell {
18
+ kind: 'state' | 'reducer' | 'ref' | 'memo-or-callback' | 'other'
19
+ value: unknown
20
+ }
21
+
22
+ export interface NodeDetail {
23
+ id: number
24
+ name: string
25
+ hooks: HookCell[]
26
+ context: Array<{ name: string; value: unknown }>
27
+ effectCount: number
28
+ }
29
+
30
+ interface OctaneDevtoolsHook {
31
+ version: number
32
+ getTree(): HookTreeNode[]
33
+ inspect(id: number): NodeDetail | null
34
+ subscribe(listener: () => void): () => void
35
+ }
36
+
37
+ export interface RuntimeNode {
38
+ id: number
39
+ name: string
40
+ /** Display name: generated control-flow scopes such as `__item$1` become `@item`. */
41
+ label: string
42
+ kind: string
43
+ controlFlow: boolean
44
+ children: RuntimeNode[]
45
+ }
46
+
47
+ export interface RuntimeSnapshot {
48
+ status: 'connecting' | 'unavailable' | 'connected'
49
+ roots: RuntimeNode[]
50
+ componentCount: number
51
+ selectedId: number | null
52
+ detail: NodeDetail | null
53
+ }
54
+
55
+ /** The overlay's own root component; it is hidden from the inspected tree. */
56
+ export const OVERLAY_ROOT = 'BeastDevtools'
57
+
58
+ const listeners = new Set<() => void>()
59
+ let snapshot: RuntimeSnapshot = { status: 'connecting', roots: [], componentCount: 0, selectedId: null, detail: null }
60
+ let treeKey = ''
61
+ let detailKey = ''
62
+ let selectedId: number | null = null
63
+ let frame = 0
64
+ let retry: ReturnType<typeof setTimeout> | undefined
65
+ let disconnect: (() => void) | null = null
66
+
67
+ function hook(): OctaneDevtoolsHook | undefined {
68
+ return (globalThis as { __OCTANE_DEVTOOLS__?: OctaneDevtoolsHook }).__OCTANE_DEVTOOLS__
69
+ }
70
+
71
+ export function subscribeRuntime(listener: () => void): () => void {
72
+ listeners.add(listener)
73
+ if (listeners.size === 1) connect(0)
74
+ return () => {
75
+ listeners.delete(listener)
76
+ if (listeners.size > 0) return
77
+ disconnect?.()
78
+ disconnect = null
79
+ clearTimeout(retry)
80
+ cancelAnimationFrame(frame)
81
+ frame = 0
82
+ }
83
+ }
84
+
85
+ export function getRuntimeSnapshot(): RuntimeSnapshot {
86
+ return snapshot
87
+ }
88
+
89
+ export function selectRuntimeNode(id: number | null): void {
90
+ selectedId = id
91
+ refresh()
92
+ }
93
+
94
+ function connect(attempt: number): void {
95
+ const current = hook()
96
+ if (current === undefined) {
97
+ // Octane installs the hook with its first profiled root. Give slow boots a
98
+ // few seconds before reporting that profiling is off.
99
+ if (attempt < 10) retry = setTimeout(() => connect(attempt + 1), 300)
100
+ else publish({ ...snapshot, status: 'unavailable' })
101
+ return
102
+ }
103
+ disconnect = current.subscribe(schedule)
104
+ refresh()
105
+ }
106
+
107
+ function schedule(): void {
108
+ if (frame !== 0) return
109
+ frame = requestAnimationFrame(() => {
110
+ frame = 0
111
+ refresh()
112
+ })
113
+ }
114
+
115
+ function refresh(): void {
116
+ const current = hook()
117
+ if (current === undefined) return
118
+
119
+ const roots = current
120
+ .getTree()
121
+ .filter((root) => root.name !== OVERLAY_ROOT)
122
+ .map(describe)
123
+ const ids = new Set<number>()
124
+ let componentCount = 0
125
+ const visit = (node: RuntimeNode) => {
126
+ ids.add(node.id)
127
+ if (!node.controlFlow) componentCount++
128
+ node.children.forEach(visit)
129
+ }
130
+ roots.forEach(visit)
131
+ if (selectedId !== null && !ids.has(selectedId)) selectedId = null
132
+
133
+ const detail = selectedId === null ? null : current.inspect(selectedId)
134
+ const nextTreeKey = JSON.stringify(roots)
135
+ const nextDetailKey = `${selectedId}:${safeStringify(detail)}`
136
+ if (snapshot.status === 'connected' && nextTreeKey === treeKey && nextDetailKey === detailKey) return
137
+
138
+ treeKey = nextTreeKey
139
+ detailKey = nextDetailKey
140
+ publish({ status: 'connected', roots, componentCount, selectedId, detail })
141
+ }
142
+
143
+ function publish(next: RuntimeSnapshot): void {
144
+ snapshot = next
145
+ for (const listener of listeners) listener()
146
+ }
147
+
148
+ function describe(node: HookTreeNode): RuntimeNode {
149
+ const generated = /^__([A-Za-z]+)\$\d+$/.exec(node.name)
150
+ const controlFlow = node.kind === 'control-flow' || generated !== null
151
+ return {
152
+ id: node.id,
153
+ name: node.name,
154
+ label: generated === null ? node.name : `@${generated[1]}`,
155
+ kind: node.kind,
156
+ controlFlow,
157
+ children: node.children.map(describe),
158
+ }
159
+ }
160
+
161
+ function safeStringify(value: unknown): string {
162
+ try {
163
+ return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? `${item}n` : item)) ?? 'undefined'
164
+ } catch {
165
+ // Must stay stable: a changing key would republish on every overlay flush.
166
+ return '[unserializable]'
167
+ }
168
+ }
@@ -0,0 +1,136 @@
1
+ import { DEFAULT_SETTINGS, type AnalyzerSettings } from '../shared/types.ts'
2
+
3
+ export function cx(...parts: Array<string | false | null | undefined>): string {
4
+ return parts.filter(Boolean).join(' ')
5
+ }
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Value previews for inspected hook and context values
9
+
10
+ export function isExpandable(value: unknown): boolean {
11
+ return value !== null && typeof value === 'object' && Object.keys(value).length > 0
12
+ }
13
+
14
+ /** One-line, length-capped rendering of an inspected value. */
15
+ export function preview(value: unknown, budget = 90): string {
16
+ const text = previewInner(value, 0)
17
+ return text.length > budget ? `${text.slice(0, budget - 1)}…` : text
18
+ }
19
+
20
+ function previewInner(value: unknown, depth: number): string {
21
+ if (value === undefined) return 'undefined'
22
+ if (value === null) return 'null'
23
+ if (typeof value === 'string') return /^\[(Function|Node|Getter|Array|Object|Unavailable)\]$/.test(value) ? value : JSON.stringify(value)
24
+ if (typeof value !== 'object') return String(value)
25
+ if (depth >= 2) return Array.isArray(value) ? `Array(${value.length})` : '{…}'
26
+ if (Array.isArray(value)) return `[${value.map((item) => previewInner(item, depth + 1)).join(', ')}]`
27
+ const entries = Object.entries(value).map(([key, item]) => `${key}: ${previewInner(item, depth + 1)}`)
28
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
29
+ }
30
+
31
+ export function pretty(value: unknown): string {
32
+ return JSON.stringify(value, (_key, item) => (item === undefined ? '__undefined__' : item), 2)
33
+ .replaceAll('"__undefined__"', 'undefined')
34
+ }
35
+
36
+ export function valueTone(value: unknown): string {
37
+ if (value === null || value === undefined) return 'bdt-v-nil'
38
+ if (typeof value === 'string') return /^\[\w+\]$/.test(value) ? 'bdt-v-special' : 'bdt-v-string'
39
+ if (typeof value === 'number' || typeof value === 'bigint') return 'bdt-v-number'
40
+ if (typeof value === 'boolean') return 'bdt-v-boolean'
41
+ return 'bdt-v-object'
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Preferences, persisted per browser
46
+
47
+ export type TabId = 'components' | 'inspector' | 'refactor'
48
+
49
+ export interface Preferences {
50
+ open: boolean
51
+ tab: TabId
52
+ height: number
53
+ file: string | null
54
+ showControlFlow: boolean
55
+ settings: AnalyzerSettings
56
+ }
57
+
58
+ const STORAGE_KEY = 'beast-devtools:preferences'
59
+
60
+ export const DEFAULT_PREFERENCES: Preferences = {
61
+ open: false,
62
+ tab: 'components',
63
+ height: 360,
64
+ file: null,
65
+ showControlFlow: true,
66
+ settings: DEFAULT_SETTINGS,
67
+ }
68
+
69
+ export function loadPreferences(): Preferences {
70
+ try {
71
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Preferences>
72
+ return {
73
+ ...DEFAULT_PREFERENCES,
74
+ ...stored,
75
+ settings: { ...DEFAULT_SETTINGS, ...stored.settings },
76
+ height: clampHeight(stored.height ?? DEFAULT_PREFERENCES.height),
77
+ }
78
+ } catch {
79
+ return DEFAULT_PREFERENCES
80
+ }
81
+ }
82
+
83
+ export function savePreferences(preferences: Preferences): void {
84
+ try {
85
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))
86
+ } catch {
87
+ // Storage can be unavailable (private mode, blocked site data); preferences are optional.
88
+ }
89
+ }
90
+
91
+ export function clampHeight(height: number): number {
92
+ const max = Math.max(240, (typeof window === 'undefined' ? 900 : window.innerHeight) - 48)
93
+ return Math.round(Math.min(max, Math.max(200, height)))
94
+ }
95
+
96
+ /** Drive a vertical resize from a pointer-down on the panel's top edge. */
97
+ export function startResize(event: PointerEvent, height: number, setHeight: (height: number) => void): void {
98
+ event.preventDefault()
99
+ const startY = event.clientY
100
+ const move = (moveEvent: PointerEvent) => setHeight(clampHeight(height + startY - moveEvent.clientY))
101
+ const stop = () => {
102
+ window.removeEventListener('pointermove', move)
103
+ window.removeEventListener('pointerup', stop)
104
+ document.documentElement.style.removeProperty('cursor')
105
+ }
106
+ document.documentElement.style.cursor = 'ns-resize'
107
+ window.addEventListener('pointermove', move)
108
+ window.addEventListener('pointerup', stop)
109
+ }
110
+
111
+ /** Scroll a line into the middle of its scroll container without moving the page. */
112
+ export function scrollToLine(container: HTMLElement | null, line: number | null): void {
113
+ if (container === null || line === null) return
114
+ const row = container.querySelector<HTMLElement>(`[data-line="${line}"]`)
115
+ if (row === null) return
116
+ const top = row.offsetTop - container.clientHeight / 2 + row.offsetHeight / 2
117
+ container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
118
+ }
119
+
120
+ /** A scroll request; the nonce lets the same line be requested twice. */
121
+ export interface ScrollTarget {
122
+ line: number
123
+ nonce: number
124
+ }
125
+
126
+ let scrollNonce = 0
127
+
128
+ export function scrollTarget(line: number): ScrollTarget {
129
+ return { line, nonce: ++scrollNonce }
130
+ }
131
+
132
+ /** The last applied refactor, kept by the shell so Undo survives tab switches. */
133
+ export interface RecentRefactor {
134
+ undoId: string
135
+ summary: string
136
+ }
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@beastjs/devtools",
3
+ "version": "0.1.0",
4
+ "description": "In-page devtools for Beast (BTSX) and Octane apps: live component state, BTSX → TSRX inspection, and automatic component extraction for deeply nested templates.",
5
+ "license": "ISC",
6
+ "author": "phtn",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/beastjs/devtools.git",
11
+ "directory": "devtools"
12
+ },
13
+ "homepage": "https://github.com/beastjs/devtools/tree/main/devtools#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/beastjs/devtools/issues"
16
+ },
17
+ "keywords": [
18
+ "beast",
19
+ "btsx",
20
+ "tsrx",
21
+ "octane",
22
+ "devtools",
23
+ "vite",
24
+ "vite-plugin",
25
+ "refactor"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/vite.d.ts",
30
+ "default": "./dist/vite.js"
31
+ },
32
+ "./vite": {
33
+ "types": "./dist/vite.d.ts",
34
+ "default": "./dist/vite.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "main": "./dist/vite.js",
39
+ "types": "./dist/vite.d.ts",
40
+ "files": [
41
+ "dist",
42
+ "client",
43
+ "shared",
44
+ "!**/*.test.ts",
45
+ "README.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE"
48
+ ],
49
+ "sideEffects": false,
50
+ "engines": {
51
+ "node": ">=22.22.2"
52
+ },
53
+ "scripts": {
54
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
55
+ "test": "bun test",
56
+ "prepublishOnly": "bun run build && bun test"
57
+ },
58
+ "peerDependencies": {
59
+ "beast-tsrx": "^0.4.3",
60
+ "octane": "^0.4.3",
61
+ "vite": "^8.0.16"
62
+ },
63
+ "devDependencies": {
64
+ "@types/node": "^26.2.0",
65
+ "beast-tsrx": "0.4.3",
66
+ "octane": "0.4.3",
67
+ "typescript": "^5.9.3",
68
+ "vite": "^8.0.16"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ }
73
+ }