From dd554b10b39d83253229a637bbc8fd1e74e1e0da Mon Sep 17 00:00:00 2001 From: phtn Date: Tue, 29 Sep 2026 01:18:29 +0800 Subject: [PATCH 1/7] ci:init --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aa447a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: + - 'main' + - 'hyper' + + pull_request: + branches: + - 'main' + - 'hyper' + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }} + - run: bun install + - run: bun run typecheck + - run: bun test From 37ec2a9b91b3e3a509d06bc998d3f2386564e864 Mon Sep 17 00:00:00 2001 From: phtn Date: Tue, 29 Sep 2026 01:30:20 +0800 Subject: [PATCH 2/7] ci:rm:test --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa447a1..d729116 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,4 +25,3 @@ jobs: key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb') }} - run: bun install - run: bun run typecheck - - run: bun test From de52450489da0a7d4edea02c34c7e6a4ad41047c Mon Sep 17 00:00:00 2001 From: phtn Date: Tue, 29 Sep 2026 03:57:24 +0800 Subject: [PATCH 3/7] update:continuation-refactor --- README.md | 24 ++++++++++++++- client/BeastDevtools.btsx | 22 ++++++++------ client/InspectorPanel.btsx | 48 ++++++++++++++++++++++++++--- client/RefactorPanel.btsx | 30 ++++++++++++++++--- client/api.ts | 5 ++++ client/devtools.css | 39 +++++++++++++----------- server/analyze.test.ts | 2 +- server/analyze.ts | 25 ++++++++++++++++ server/continuation.test.ts | 60 +++++++++++++++++++++++++++++++++++++ server/continuation.ts | 55 ++++++++++++++++++++++++++++++++++ server/devtools.test.ts | 52 ++++++++++++++++++++++++++++++++ server/devtools.ts | 7 +++++ server/project.ts | 24 +++++++++++++++ server/refactor.test.ts | 2 +- server/types.test.ts | 2 +- shared/types.ts | 15 ++++++++-- 16 files changed, 372 insertions(+), 40 deletions(-) create mode 100644 server/continuation.test.ts create mode 100644 server/continuation.ts diff --git a/README.md b/README.md index 7ba1439..0134cf5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Beast DevTools +# Beast Devtools | @beastjs/devtools > In-page devtools for [Beast](https://www.npmjs.com/package/beast-tsrx) (BTSX) > and [Octane](https://octanejs.dev/) apps. @@ -499,3 +499,25 @@ Released under the [ISC License](LICENSE). --- *Built for Beast and Octane.* + +### Continue inline props + +In **BTSX → TSRX**, click a component or element header with inline props, +then click **Continue props with ~**. Each prop moves onto its own continuation +line, preserving expressions, inline text and children: + +```btsx +Button( + ~ label="Save" + ~ onClick={save} + ~ ) +``` + +The change is saved after Beast and Octane validate it. **Undo** restores the +previous source unless the file has since been edited. + +The **Refactor** panel automatically suggests continuation for components and +elements with **5 or more inline props**. Adjust **Continue at props** to change +that minimum; it is saved with your browser preferences. Select **Continue props +with ~** on a suggestion to review its diff, then **Apply changes** to save it. +Already continued headers are excluded from automatic suggestions. diff --git a/client/BeastDevtools.btsx b/client/BeastDevtools.btsx index 89f2180..e6e6f3f 100644 --- a/client/BeastDevtools.btsx +++ b/client/BeastDevtools.btsx @@ -12,8 +12,8 @@ import type { RuntimeSnapshot } from './runtime.ts' module const TABS: ReadonlyArray<{ id: TabId; label: string }> = [ - { id: 'components', label: 'Components' }, { id: 'inspector', label: 'BTSX → TSRX' }, + { id: 'components', label: 'Components' }, { id: 'refactor', label: 'Refactor' }, ] const LOGO_PATH = 'm12.01.001c.002.002.152.006.333.01 1.672.036 3.366.451 4.905 1.204a12.06 12.06 0 0 1 6.141 7.002c.375 1.124.581 2.305.608 3.48a11.9 11.9 0 0 1-1.027 5.183c-.108.245-.355.736-.5.993a12.1 12.1 0 0 1-3.417 3.843c-.203.15-.666.456-.886.588a11.95 11.95 0 0 1-5.648 1.7c-.114.008-.978.008-1.028.001a12 12 0 0 1-1.675-.189 12.03 12.03 0 0 1-7.211-4.336 12.3 12.3 0 0 1-1.582-2.6c-.58-1.314-.89-2.565-1.008-4.065-.02-.255-.02-1.343 0-1.613.122-1.632.512-3.076 1.212-4.49a12 12 0 0 1 1.828-2.708c.19-.214.726-.75.936-.937A12 12 0 0 1 8.228.608a12 12 0 0 1 3.036-.585 10 10 0 0 1 .369-.011 10 10 0 0 0 .377-.011m1.768 4.219a9 9 0 0 0-1.31-.186c-.22-.011-.852-.006-1.056.01a8 8 0 0 0-2.938.79l-.077.039 1.8 1.8 1.8 1.799 1.799-1.8 1.8-1.8-.077-.038a8 8 0 0 0-1.74-.614m6.208 7.351a8 8 0 0 0-.777-3.006l-.075-.157-1.8 1.8-1.8 1.8 1.799 1.8 1.8 1.8.038-.077a7.9 7.9 0 0 0 .802-2.92c.021-.252.028-.81.013-1.04M8.46 12.01l-1.8-1.8-1.799-1.8-.048.097a8.1 8.1 0 0 0-.806 3.078c-.03.564.01 1.21.11 1.793.127.73.389 1.53.705 2.154l.04.077 1.799-1.8zm7.138 7.137L13.8 17.35c-.99-.989-1.8-1.798-1.804-1.798-.003 0-.814.81-1.803 1.798l-1.797 1.797.156.075c.835.4 1.727.645 2.706.742a12 12 0 0 0 1.235.017 8 8 0 0 0 2.947-.759z' @@ -67,10 +67,11 @@ module tab: TabId setTab: (next: TabId | ((prev: TabId) => TabId)) => void suggestionCount: number + componentCount: number } component MainTabs - props { tab, setTab, suggestionCount }: MainTabsProps + props { tab, setTab, suggestionCount, componentCount }: MainTabsProps nav.bdt-tabs(role="tablist" aria-label="Beast DevTools panels" className="has[:focus]:rounded-sm! rounded-full") each item in TABS key item.id button( @@ -81,8 +82,10 @@ component MainTabs ~ onClick={() => setTab(item.id)} ~ ) | #{item.label} + if item.id === 'components' && componentCount > 0 + span.bdt-tab-count.bdt-component-count #{componentCount ?? 0} if item.id === 'refactor' && suggestionCount > 0 - span.bdt-tab-count(title={`${suggestionCount} refactor suggestion${suggestionCount === 1 ? '' : 's'}`}) #{suggestionCount} + span.bdt-tab-count.bdt-refactor-count(title={`${suggestionCount} refactor suggestion${suggestionCount === 1 ? '' : 's'}`}) #{suggestionCount} component BdtTopbar props { tab, setTab, suggestionCount, picking, pickTitle, setPickerMode, inspecting, inspectTitle, project, projectId, selectProject, runtime, status, setOpen }: BdtTopbarProps @@ -90,17 +93,14 @@ component BdtTopbar .bdt-brand BeastLogo div(className="skew-2 font-semibold! text-text text-base!") Beast - span.bdt-brand-sub DevTools - MainTabs(tab={tab} setTab={setTab} suggestionCount={suggestionCount}) + span.bdt-brand-sub Devtools + MainTabs(tab={tab} setTab={setTab} suggestionCount={suggestionCount} componentCount={runtime.componentCount}) .bdt-spacer button(type="button" className={cx('bdt-icon-button', picking && 'is-picking')} aria-pressed={picking} aria-label="Pick an element" title={pickTitle} onClick={() => setPickerMode((value) => value === 'source' ? null : 'source')}) PickIcon button(type="button" className={cx('bdt-icon-button', inspecting && 'is-picking')} aria-pressed={inspecting} aria-label="Inspect element properties" title={inspectTitle} onClick={() => setPickerMode((value) => value === 'properties' ? null : 'properties')}) PropertiesIcon ProjectPicker(root={project?.root ?? ''} external={projectId !== ''} onSelect={selectProject}) - span.bdt-status - span(className={cx('bdt-launcher-dot', runtime.status === 'connected' && 'is-live')}) - | #{status} button.bdt-icon-button(type="button" aria-label="Close Beast DevTools" title="Close (Alt+Shift+D)" onClick={() => setOpen(false)}) svg(viewBox="0 0 16 16" fill="none" aria-hidden="true") path(d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round") @@ -154,7 +154,7 @@ setup const pickTitle = picking ? 'Stop picking (Esc)' : 'Pick an element to open its source (Alt+Shift+C)'; const suggestionCount = project?.files.reduce((sum, entry) => sum + entry.suggestions, 0) ?? 0; const status = runtime.status === 'connected' - ? `${runtime.componentCount} component${runtime.componentCount === 1 ? '' : 's'} live` + ? `${runtime.componentCount}` : runtime.status === 'connecting' ? 'Connecting…' : 'Runtime off'; useEffect(() => { @@ -304,6 +304,10 @@ if open || docked ~ ) case 'inspector' InspectorPanel( + ~ key={projectId} + ~ projectId={projectId} + ~ recent={recent} + ~ onRecentChange={setRecent} ~ files={project?.files ?? []} ~ file={file} ~ report={report} diff --git a/client/InspectorPanel.btsx b/client/InspectorPanel.btsx index 44a0175..4756816 100644 --- a/client/InspectorPanel.btsx +++ b/client/InspectorPanel.btsx @@ -2,13 +2,16 @@ import { useEffect, useRef, useState } from 'octane' import type { FileReport, FileSummary } from '../shared/types.ts' import CodeView from './CodeView.btsx' import FileList from './FileList.btsx' -import { openInEditor } from './api.ts' +import { continueProps, openInEditor, undoRefactor } from './api.ts' import PathLabel from './PathLabel.btsx' import Splitter from './Splitter.btsx' -import { scrollTarget, type ScrollTarget } from './util.ts' +import { scrollTarget, type ScrollTarget, type RecentRefactor } from './util.ts' module interface InspectorPanelProps { + projectId: string + recent: RecentRefactor | null + onRecentChange: (recent: RecentRefactor | null) => void files: readonly FileSummary[] file: string | null report: FileReport | null @@ -23,7 +26,7 @@ module line: number } -props { files, file, report, loadError, focus, onSelectFile }: InspectorPanelProps +props { projectId, recent, onRecentChange, files, file, report, loadError, focus, onSelectFile }: InspectorPanelProps setup const [hover, setHover] = useState(null); const [pinned, setPinned] = useState(null); @@ -38,7 +41,36 @@ setup const link = hover ?? pinned; const btsxLinked = link === null ? [] : link.side === 'btsx' ? [link.line] : [btsxFor(link.line)].filter((line): line is number => line !== null); const tsrxLinked = link === null ? [] : link.side === 'tsrx' ? [link.line] : tsrxFor(link.line); - const editorLine = pinned === null ? 1 : pinned.side === 'btsx' ? pinned.line : (btsxFor(pinned.line) ?? 1); + const [saving, setSaving] = useState(false); + const [actionError, setActionError] = useState(null); + const [savedHash, setSavedHash] = useState(null); + const selectedLine = pinned === null ? null : pinned.side === 'btsx' ? pinned.line : btsxFor(pinned.line); + const editorLine = selectedLine ?? 1; + const canContinue = selectedLine !== null && current !== null && current.hash !== savedHash && (current.continuationLines ?? []).includes(editorLine); + const convert = async () => { + if (!current || !canContinue || saving) return; + setSaving(true); + setActionError(null); + try { + const result = await continueProps({ path: current.path, hash: current.hash, line: editorLine, dryRun: false }, projectId); + setSavedHash(current.hash); + onRecentChange({ undoId: result.undoId!, summary: result.summary }); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } finally { setSaving(false); } + }; + const undo = async () => { + if (!recent || saving) return; + setSaving(true); + setActionError(null); + try { + await undoRefactor(recent.undoId, projectId); + onRecentChange(null); + setSavedHash(null); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } finally { setSaving(false); } + }; const pinBtsx = (line: number) => { setPinned({ side: 'btsx', line }); const target = tsrxFor(line)[0]; @@ -54,6 +86,7 @@ setup shownFile.current = file; setPinned(null); setHover(null); + setActionError(null); }, [file]); // Reveal a line requested by another panel once its file report arrives. useEffect(() => { @@ -82,7 +115,14 @@ FileList(files={files} active={file} metrics={false} onSelect={onSelectFile}) span.bdt-badge.is-bad compile error .bdt-spacer span.bdt-dim.bdt-hint Hover to link lines · click to pin + button.bdt-button(type="button" disabled={!canContinue || saving} title="Select an element or component line with inline props, then move each prop to a ~ continuation line" onClick={convert}) #{saving ? 'Saving…' : 'Continue props with ~'} button.bdt-button(type="button" onClick={() => openInEditor(current.absolutePath, editorLine)}) Open in editor + if actionError !== null + .bdt-diagnostic(role="alert") #{actionError} + if recent !== null + .bdt-toolbar(role="status") + span #{recent.summary} + button.bdt-button(type="button" disabled={saving} onClick={undo}) Undo if compiled !== null && !compiled.ok .bdt-diagnostic p diff --git a/client/RefactorPanel.btsx b/client/RefactorPanel.btsx index 0de2fe2..0418225 100644 --- a/client/RefactorPanel.btsx +++ b/client/RefactorPanel.btsx @@ -33,6 +33,7 @@ module /** Why a chosen name would be refused, or null when it is usable. */ function nameIssue(suggestion: RefactorSuggestion, name: string): string | null { + if (suggestion.kind === 'continuation') return null const component = suggestion.mapping === null if (name.trim() === '') return 'Enter a name' const pattern = component ? /^[A-Z][A-Za-z0-9_$]*$/ : /^[a-z_$][A-Za-z0-9_$]*$/ @@ -40,6 +41,7 @@ module } function targetLabel(target: RefactorTarget, suggestion: RefactorSuggestion): string { + if (suggestion.kind === 'continuation') return 'Continue props with ~' if (suggestion.mapping !== null) return 'map ⧉' return target === 'file' ? `create ⯌` : ` hoist ✦` @@ -48,7 +50,7 @@ module function targets(suggestion: RefactorSuggestion): RefactorTarget[] { if (suggestion.autoApply.blocked !== null) return [] - if (suggestion.mapping !== null) return ['inline'] + if (suggestion.kind === 'continuation' || suggestion.mapping !== null) return ['inline'] const other: RefactorTarget = suggestion.autoApply.target === 'file' ? 'inline' : 'file' const allowed = other === 'file' ? suggestion.autoApply.fileBlocked === null : true return allowed ? [suggestion.autoApply.target, other] : [suggestion.autoApply.target] @@ -65,12 +67,14 @@ module } function kindLabel(suggestion: RefactorSuggestion): string { + if (suggestion.kind === 'continuation') return 'continuation' if (suggestion.id.startsWith('manual:')) return 'manual' if (suggestion.mapping !== null) return `${suggestion.mapping.items.length}× repeated` return suggestion.kind === 'extract' ? '✦' : `${suggestion.occurrences.length}× same shape` } function applyGuide(suggestion: RefactorSuggestion): string { + if (suggestion.kind === 'continuation') return suggestion.usage const where = suggestion.occurrences.map((range) => `${range.startLine}–${range.endLine}`).join(', ') return [ `// 1. Add above line ${suggestion.insertBeforeLine}:`, @@ -121,6 +125,14 @@ setup }; const selectBlock = async (line: number) => { if (current === null || pending?.status === 'applying') return; + const continuation = suggestions.find((suggestion) => suggestion.kind === 'continuation' && suggestion.startLine === line); + if (continuation) { + selectionRequest.current += 1; + setSelecting(false); + select(continuation); + setSelectionError(null); + return; + } const request = ++selectionRequest.current; setSelecting(true); setSelectionError(null); @@ -238,6 +250,15 @@ FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) ~ value={settings.fileLines} ~ onInput={(event) => { const fileLines = readLimit(event.currentTarget.value, 2, 1000); if (fileLines !== null) onSettingsChange({ ...settings, fileLines }); }} ~ ) + label.bdt-field(title="Minimum inline props for an automatic continuation suggestion") + | Continue at props + input.bdt-input.is-number( + ~ type="number" + ~ min="1" + ~ max="1000" + ~ value={settings.continuationMinProps} + ~ onInput={(event) => { const continuationMinProps = readLimit(event.currentTarget.value, 1, 1000); if (continuationMinProps !== null) onSettingsChange({ ...settings, continuationMinProps }); }} + ~ ) if recent !== null div(className={cx('bdt-toast', undoError !== null && 'is-error')} role="status") span #{undoError ?? `✓ ${recent.summary}`} @@ -294,7 +315,7 @@ FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) ~ ) .bdt-card-head span(className={cx('bdt-severity', `is-${suggestion.severity}`)} title={suggestion.severity}) - if targets(suggestion).length > 0 + if suggestion.kind !== 'continuation' && targets(suggestion).length > 0 input( ~ className={cx('bdt-card-title', 'bdt-card-title-input', nameIssue(suggestion, nameOf(suggestion)) !== null && 'is-invalid')} ~ value={nameOf(suggestion)} @@ -320,7 +341,8 @@ FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) span.bdt-chip-type #{': ' + prop.type} .bdt-card-actions button.bdt-button(type="button" aria-expanded={openId === suggestion.id} onClick={() => setOpenId(openId === suggestion.id ? null : suggestion.id)}) #{openId === suggestion.id ? '🞁' : '🞃'} - button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:component`, suggestion.snippet)}) #{copied === `${suggestion.id}:component` ? 'component ⮻' : 'component ⮺'} + if suggestion.kind !== 'continuation' + button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:component`, suggestion.snippet)}) #{copied === `${suggestion.id}:component` ? 'component ⮻' : 'component ⮺'} button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:usage`, suggestion.usage.trimStart())}) #{copied === `${suggestion.id}:usage` ? 'usage ⮻' : 'usage ⮺'} button.bdt-button(type="button" onClick={() => openInEditor(current.absolutePath, suggestion.startLine)}) open ⧽ .bdt-card-actions @@ -328,7 +350,7 @@ FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) button( ~ type="button" ~ className={cx('bdt-button-critical', index === 0 && '')} - ~ title={suggestion.mapping !== null ? 'Render the repeated elements from an array with each' : target === 'file' ? `Write ${nameOf(suggestion).trim()}.btsx next to this file and import it` : 'Add a local component to this file'} + ~ title={suggestion.kind === 'continuation' ? 'Move each prop onto its own ~ continuation line' : suggestion.mapping !== null ? 'Render the repeated elements from an array with each' : target === 'file' ? `Write ${nameOf(suggestion).trim()}.btsx next to this file and import it` : 'Add a local component to this file'} ~ disabled={pending?.status === 'applying' || pending?.status === 'loading' || nameIssue(suggestion, nameOf(suggestion)) !== null} ~ onClick={() => preview(suggestion, target)} ~ ) diff --git a/client/api.ts b/client/api.ts index a737faf..ea96c95 100644 --- a/client/api.ts +++ b/client/api.ts @@ -3,6 +3,7 @@ import { SOURCE_CHANGED_EVENT, type AnalyzerSettings, type ApplyRequest, + type ContinuationRequest, type ApplyResult, type FileReport, type ProjectReport, @@ -81,3 +82,7 @@ export function browseProject(): Promise<{ path: string | null }> { export function fetchSelection(path: string, line: number, settings: AnalyzerSettings, project = ''): Promise { return get('/file', { path, line, ...settings, project }) } + +export function continueProps(request: ContinuationRequest, project = ''): Promise { + return post('/continue-props', request, project) +} diff --git a/client/devtools.css b/client/devtools.css index 6fbfe7b..479556f 100644 --- a/client/devtools.css +++ b/client/devtools.css @@ -35,13 +35,13 @@ --bdt-text: oklch(0.954 0.01 125.68); --bdt-muted: #a4a5a0; --bdt-dim: #8b8b8b; - --bdt-trl: oklch(0.783 0.015 142.21); + --bdt-trel: oklch(0.783 0.015 142.21); --bdt-accent: #ff415a; --bdt-accent-soft: rgba(255, 65, 90, 0.14); --bdt-ok: oklch(0.681 0.113 159.68); --bdt-warn: #ffb86b; --bdt-bad: #ff5d6c; - --bdt-info: #7cb7ff; + --bdt-info: oklch(0.768 0.121 254.06); --bdt-focus: #45c195; --bdt-active: oklch(0.6278 0.204 254.62); --bdt-active-soft: oklch(0.6278 0.204 254.62); @@ -188,15 +188,15 @@ } .bdt-launcher-dot { - width: 7px; - height: 7px; + width: 8px; + height: 8px; border-radius: 999px; background: var(--bdt-dim); } .bdt-launcher-dot.is-live { background: var(--bdt-ok); - box-shadow: 0 0 0 3px rgba(95, 211, 154, 0.18); + /*box-shadow: 0 0 0 3px rgba(95, 211, 154, 0.18);*/ } .bdt-logo { @@ -293,14 +293,16 @@ .bdt-tabs { display: flex; align-items: center; - flex: 0 1 auto; + justifiy-content: justify-evenly; + /*flex: 0 1 auto;*/ min-width: 0; overflow-x: auto; scrollbar-width: none; - gap: 3px; + gap: 8px; padding: 4px; background: var(--bdt-bg); border: 0.5px solid var(--bdt-bg-2); + border-radius: 9px; } .bdt-tab { @@ -308,14 +310,13 @@ align-items: center; gap: 6px; height: 26px; - padding: 0 12px; - border-radius: 999px; + padding: 0 6px 0 10px; + border-radius: 6.5px; color: var(--bdt-muted); font-weight: 600; white-space: nowrap; letter-spacing: -0.15px; } - .bdt-tab:hover { color: var(--bdt-text); } @@ -323,15 +324,17 @@ .bdt-tab.is-active { background: var(--bdt-bg-4); color: var(--bdt-text); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); } - +.bdt-component-count { + color: var(--bdt-info); +} +.bdt-refactor-count { + color: var(--bdt-warn); +} .bdt-tab-count { min-width: 18px; - padding: 0 3px; + padding: 0; border-radius: 999px; - /*background: var(--bdt-accent-soft);*/ - color: var(--bdt-warn); font-size: 12px; line-height: 16px; text-align: center; @@ -1399,7 +1402,7 @@ html.bdt-resizing * { min-width: 0; font-family: var(--bdt-mono); font-weight: 600; - font-size: 12.5px; + font-size: 14px; color: #ffb86b; overflow: hidden; text-overflow: ellipsis; @@ -1411,16 +1414,17 @@ html.bdt-resizing * { height: 8px; flex: none; border-radius: 4px; - margin: 0 0 5px 0; background: var(--bdt-info); } .bdt-severity.is-warning { background: var(--bdt-warn); + margin: 0 0 4.75px 0; } .bdt-severity.is-critical { background: var(--bdt-bad); + margin: 0 0 4.75px 0; } .bdt-card-reason { @@ -1650,6 +1654,7 @@ html.bdt-resizing * { .bdt-card-title-input:hover, .bdt-card-title-input:focus { border-bottom-color: var(--bdt-line-2); + border-radius: 0px!; outline: none; } diff --git a/server/analyze.test.ts b/server/analyze.test.ts index e16b3b4..074813a 100644 --- a/server/analyze.test.ts +++ b/server/analyze.test.ts @@ -11,7 +11,7 @@ const APP = readFileSync(new URL('../test/fixtures/App.btsx', import.meta.url), function analyze(source: string, settings: Partial = {}, name = 'Fixture', selectionLine?: number): FileAnalysis { const { ast } = compileBeastResult(source, { filename: `${name}.btsx`, componentName: name }) - return analyzeDocument(ast, source, name, { depthLimit: 5, minLines: 8, fileLines: 30, ...settings }, { selectionLine }) + return analyzeDocument(ast, source, name, { depthLimit: 5, minLines: 8, fileLines: 30, continuationMinProps: 5, ...settings }, { selectionLine }) } /** Apply a suggestion the way a developer would: insert the component, replace the section. */ diff --git a/server/analyze.ts b/server/analyze.ts index 9e02267..61163a9 100644 --- a/server/analyze.ts +++ b/server/analyze.ts @@ -17,6 +17,7 @@ import type { Severity, SuggestedProp, } from '../shared/types.js' +import { continuationEdits } from './continuation.js' import { attributeValue, replaceSlots, scanSlots, type Slot, type SlotKind, type SlotValue } from './slots.js' import { hookCall, identifiersIn, parsePropsParameter, patternNames, topLevelDeclarations } from './source-scan.js' import { PROBE_CALL, type ProbeFile, type ProbeResult } from './types.js' @@ -208,6 +209,30 @@ export function analyzeDocument( id: options.selectionLine === undefined ? `s${index + 1}` : `manual:${options.selectionLine}`, })) + if (options.selectionLine === undefined) { + const continuations = continuationEdits(document, source) + for (const info of hosts.flatMap((host) => host.roots.flatMap(flatten))) { + const node = info.node + if (node.kind !== 'element' || node.attrs.length < (settings.continuationMinProps ?? 5)) continue + const usage = continuations.get(info.start) + if (usage === undefined) continue + const idAttribute = node.attrs.find((attr) => attr.kind === 'attribute' && attr.name === 'id') + const idValue = idAttribute?.kind === 'attribute' ? idAttribute.value : null + const elementId = node.id ?? (idValue?.type === 'string' ? idValue.value : idValue?.type === 'expr' ? `{${idValue.code}}` : null) + const name = `${node.tag}${!node.isComponent && elementId ? `#${elementId}` : ''}` + suggestions.push({ + id: `continuation:${info.start}`, kind: 'continuation', severity: 'info', + host: info.host.name, name, label: name, + reason: `${node.attrs.length} inline props. Move each prop onto its own ~ continuation line.`, + startLine: info.start, endLine: info.start, lines: 1, depth: info.depth, reach: info.depth, + props: [], snippet: usage, usage, usages: [usage], insertBeforeLine: info.start, + occurrences: [{ startLine: info.start, endLine: info.start }], references: [], body: usage, + propsType: null, propsDeclaration: null, typeImports: [], typesDerived: true, mapping: null, + autoApply: { target: 'inline', blocked: null, fileBlocked: null }, + }) + } + } + const components: ComponentMetrics[] = hosts.map((host) => { const all = host.roots.flatMap(flatten) const templateLineSet = new Set() diff --git a/server/continuation.test.ts b/server/continuation.test.ts new file mode 100644 index 0000000..f52e7f6 --- /dev/null +++ b/server/continuation.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'bun:test' +import { compileBeastResult, parse } from 'beast-tsrx' +import { continuationEdits, continueProps } from './continuation.ts' + +function convert(source: string, line: number) { + const after = continueProps(parse(source, 'App.btsx'), source, line) + expect(compileBeastResult(after, { filename: 'App.btsx' }).code) + .toBe(compileBeastResult(source, { filename: 'App.btsx' }).code) + return after +} + +test('continues each prop and preserves inline text, children and expressions', () => { + const source = 'section\n button.action(type="button" disabled onClick={() => console.log("a ) b", { x: 1 })}) Hello #{name}\n span Child\n' + expect(convert(source, 2)).toBe('section\n button.action(\n ~ type="button"\n ~ disabled\n ~ onClick={() => console.log("a ) b", { x: 1 })}\n ~ ) Hello #{name}\n span Child\n') +}) + +test('supports component calls, spreads and quoted spaces', () => { + const source = 'Card(title="hello world" {...props} value={{ text: `x ${fn(1)}` }})\n' + expect(convert(source, 1)).toContain(' ~ {...props}\n') +}) + +test('finds elements inside local components and control flow', () => { + const source = 'component Card\n if visible\n div(title="local")\n\nCard(value={1})\n' + expect([...continuationEdits(parse(source, 'App.btsx'), source).keys()].sort()).toEqual([3, 5]) + convert(source, 3) +}) + +test('refuses lines without inline props and already continued headers', () => { + for (const source of ['div\n', 'div()\n', 'div(\n ~ title="hi"\n ~ )\n', 'div(title="hi")\n ~ .active\n']) { + const doc = parse(source, 'App.btsx') + expect(continuationEdits(doc, source).size).toBe(0) + expect(() => continueProps(doc, source, 1)).toThrow('Select a component or element') + } +}) + +test('preserves CRLF and non-ASCII text', () => { + const source = 'section\r\n p(title="👋 hello") Héllo\r\n' + const after = convert(source, 2) + expect(after).toContain(' p(\r\n ~ title="👋 hello"\r\n ~ ) Héllo\r\n') +}) + +import { analyzeDocument } from './analyze.ts' +import { DEFAULT_SETTINGS } from '../shared/types.ts' + +test('automatically suggests at five props and respects the adjustable threshold', () => { + const source = 'div(a={1} b={2} c={3} d={4})\nCard(a={1} b={2} c={3} d={4} e={5})\n' + const doc = parse(source, 'App.btsx') + const suggestions = (minimum = DEFAULT_SETTINGS.continuationMinProps) => + analyzeDocument(doc, source, 'App', { ...DEFAULT_SETTINGS, continuationMinProps: minimum }).suggestions + .filter((suggestion) => suggestion.kind === 'continuation') + expect(DEFAULT_SETTINGS.continuationMinProps).toBe(5) + expect(suggestions().map((suggestion) => suggestion.startLine)).toEqual([2]) + expect(suggestions(4).map((suggestion) => suggestion.startLine)).toEqual([1, 2]) + expect(suggestions(6)).toEqual([]) + const continued = continueProps(doc, source, 2) + expect(analyzeDocument(parse(continued, 'App.btsx'), continued, 'App', DEFAULT_SETTINGS).suggestions + .filter((suggestion) => suggestion.kind === 'continuation')).toEqual([]) + expect(analyzeDocument(doc, source, 'App', DEFAULT_SETTINGS, { selectionLine: 1 }).suggestions + .every((suggestion) => suggestion.id === 'manual:1')).toBe(true) +}) diff --git a/server/continuation.ts b/server/continuation.ts new file mode 100644 index 0000000..61c260c --- /dev/null +++ b/server/continuation.ts @@ -0,0 +1,55 @@ +import type { BeastDocument, BeastNode } from 'beast-tsrx' +import { RefactorError } from './refactor.js' + +/** Preserve attribute source verbatim, including expressions and spreads. */ +export function continuationEdits(document: BeastDocument, source: string): Map { + const edits = new Map() + const lines = source.split('\n') + const newline = source.includes('\r\n') ? '\r\n' : '\n' + const indents = lines.filter((line) => line.trim() !== '').map((line) => /^ */.exec(line)![0].length).filter(Boolean) + const step = ' '.repeat(Math.min(...indents, 2)) + const visit = (nodes: readonly BeastNode[]) => { + for (const node of nodes) { + if (node.kind === 'element') { + const line = node.span.start.line + const attrs = node.attrs + const raw = lines[line - 1]!.replace(/\r$/, '') + const start = node.span.start.column - 1 + const open = raw.indexOf('(', start) + const last = attrs.at(-1) + if (last && open >= start && open < attrs[0]!.span.start.column - 1 && + attrs.every((attr) => attr.span.start.line === line && attr.span.end.line === line)) { + const end = last.span.end.column - 1 + const close = end + (/^\s*/.exec(raw.slice(end))?.[0].length ?? 0) + if (raw[close] === ')' && close < raw.length && + !lines[line]?.trimStart().startsWith('~')) { + const indent = /^\s*/.exec(raw)![0] + step + const header = raw.slice(0, open + 1) + const props = attrs.map((attr) => `${indent}~ ${raw.slice(attr.span.start.column - 1, attr.span.end.column - 1).trim()}`) + const tail = raw.slice(close) + edits.set(line, [header, ...props, `${indent}~ ${tail}`].join(newline)) + } + } + visit(node.children) + } else if (node.kind === 'fragment' || node.kind === 'scope') visit(node.children) + else if (node.kind === 'if' || node.kind === 'switch') node.branches.forEach((branch) => visit(branch.children)) + else if (node.kind === 'each') { visit(node.children); visit(node.emptyChildren ?? []) } + else if (node.kind === 'try') { + visit(node.children) + visit(node.pendingBranch?.children ?? []) + visit(node.catchBranch?.children ?? []) + } + } + } + visit(document.children) + for (const declaration of document.declarations) if (declaration.kind === 'component') visit(declaration.children) + return edits +} + +export function continueProps(document: BeastDocument, source: string, line: number): string { + const replacement = continuationEdits(document, source).get(line) + if (replacement === undefined) throw new RefactorError('Select a component or element with inline props to continue.', 422) + const lines = source.split('\n') + lines[line - 1] = replacement + (lines[line - 1]!.endsWith('\r') ? '\r' : '') + return lines.join('\n') +} diff --git a/server/devtools.test.ts b/server/devtools.test.ts index c117145..c3a72a3 100644 --- a/server/devtools.test.ts +++ b/server/devtools.test.ts @@ -118,3 +118,55 @@ test('manual selections can be renamed, previewed, applied and undone in the sel writeFileSync(other.appPath, source + '\n// changed\n') expect((await post(`/apply?project=${id}`, request)).status).toBe(409) }) + +test('continuation endpoint previews, writes, rejects stale selections and supports undo', async () => { + const { running, other, get, post } = await fixture() + const source = 'button(type="button" disabled) Hello\n' + writeFileSync(other.appPath, source) + const { id } = await (await post('/open-project', { path: other.root })).json() + const file = await (await get(`/file?project=${id}&path=src/App.btsx`)).json() as FileReport + expect(file.continuationLines).toEqual([1]) + const request = { path: file.path, hash: file.hash, line: 1, dryRun: true } + const preview = await post(`/continue-props?project=${id}`, request) + expect(preview.status).toBe(200) + expect((await preview.json()).undoId).toBeNull() + expect(readFileSync(other.appPath, 'utf8')).toBe(source) + expect((await post(`/continue-props?project=${id}`, { ...request, line: 0 })).status).toBe(422) + expect((await post(`/continue-props?project=${id}`, { ...request, path: '../Outside.btsx' })).status).toBe(422) + const originalRunning = readFileSync(running.appPath, 'utf8') + const applied = await post(`/continue-props?project=${id}`, { ...request, dryRun: false }) + expect(applied.status).toBe(200) + const { undoId } = await applied.json() + expect(readFileSync(other.appPath, 'utf8')).toBe('button(\n ~ type="button"\n ~ disabled\n ~ ) Hello\n') + expect(readFileSync(running.appPath, 'utf8')).toBe(originalRunning) + expect((await post(`/continue-props?project=${id}`, { ...request, dryRun: false })).status).toBe(409) + expect((await post(`/undo?project=${id}`, { id: undoId })).status).toBe(200) + expect(readFileSync(other.appPath, 'utf8')).toBe(source) +}) + +test('refactor continuation suggestions honor settings and support preview, apply and undo', async () => { + const { running, get, post } = await fixture() + const source = 'button(type="button" disabled title="Save" aria-label="Save" tabIndex={0}) Save\n' + writeFileSync(running.appPath, source) + const file = await (await get('/file?path=src/App.btsx')).json() as FileReport + const suggestion = file.analysis!.suggestions.find((item) => item.kind === 'continuation')! + expect(suggestion.id).toBe('continuation:1') + const above = await (await get('/file?path=src/App.btsx&continuationMinProps=6')).json() as FileReport + expect(above.analysis!.suggestions.some((item) => item.kind === 'continuation')).toBe(false) + const project = await (await get('/project')).json() + expect(project.files[0].suggestions).toBe(file.analysis!.suggestions.length) + const request = { path: file.path, hash: file.hash, suggestionId: suggestion.id, settings: DEFAULT_SETTINGS, target: 'inline', dryRun: true } + const preview = await post('/apply', request) + expect(preview.status).toBe(200) + const plan = await preview.json() + expect(plan.undoId).toBeNull() + expect(plan.files[0].added).toBeGreaterThan(1) + expect(readFileSync(running.appPath, 'utf8')).toBe(source) + const applied = await post('/apply', { ...request, dryRun: false }) + expect(applied.status).toBe(200) + const { undoId } = await applied.json() + const after = await (await get('/file?path=src/App.btsx')).json() as FileReport + expect(after.analysis!.suggestions.some((item) => item.kind === 'continuation')).toBe(false) + expect((await post('/undo', { id: undoId })).status).toBe(200) + expect(readFileSync(running.appPath, 'utf8')).toBe(source) +}) diff --git a/server/devtools.ts b/server/devtools.ts index eba3abb..959b07f 100644 --- a/server/devtools.ts +++ b/server/devtools.ts @@ -176,6 +176,12 @@ export function createDevtoolsServer(options: DevtoolsServerOptions): DevtoolsSe } return send(200, selected.project.apply(request)) } + if (url.pathname === '/continue-props') { + return send(200, selected.project.continueProps({ + path: String(body.path ?? ''), hash: String(body.hash ?? ''), + line: Number(body.line), dryRun: body.dryRun !== false, + })) + } if (url.pathname === '/undo') return send(200, selected.project.undo(String(body.id ?? ''))) return send(404, { error: `Unknown endpoint ${url.pathname}` }) } catch (error) { @@ -244,6 +250,7 @@ function readSettings(get: (name: string) => unknown, defaults: AnalyzerSettings depthLimit: read('depthLimit', 1, 20), minLines: read('minLines', 2, 200), fileLines: read('fileLines', 2, 1000), + continuationMinProps: read('continuationMinProps', 1, 1000), } } diff --git a/server/project.ts b/server/project.ts index d4513b8..59a4dd5 100644 --- a/server/project.ts +++ b/server/project.ts @@ -14,6 +14,7 @@ import { createOctaneCompiler } from 'octane/compiler/bundler' import type { AnalyzerSettings, ApplyRequest, + ContinuationRequest, ApplyResult, ComponentLocation, DiagnosticInfo, @@ -24,6 +25,7 @@ import type { RefactorSuggestion, UndoResult, } from '../shared/types.js' +import { continuationEdits, continueProps } from './continuation.js' import { entryComponents } from './entry.js' import { analyzeDocument, renameSuggestion, type AnalyzeOptions } from './analyze.js' import { diffLines } from './diff.js' @@ -124,6 +126,7 @@ export class BeastProject { source: entry.source, compiled: { ok: false, error: diagnosticInfo(entry.error!, entry.source) }, analysis: null, + continuationLines: [], } } @@ -141,6 +144,7 @@ export class BeastProject { absolutePath, hash: contentHash(entry.source), source: entry.source, + continuationLines: [...continuationEdits(ast, entry.source).keys()], compiled: { ok: true, tsrx: code, @@ -179,6 +183,7 @@ export class BeastProject { ) const found = analysis.suggestions.find((candidate) => candidate.id === request.suggestionId) if (found === undefined) throw new RefactorError('That suggestion no longer applies.', 409) + if (found.kind === 'continuation') return this.continueProps({ ...request, line: found.startLine }) const name = request.name?.trim() || found.name if (name !== found.name) validateName(name, found, entry.source) const suggestion = renameSuggestion(found, name) @@ -208,6 +213,25 @@ export class BeastProject { return { undoId, component: plan.component, summary: plan.summary, files } } + continueProps(request: ContinuationRequest): ApplyResult { + const absolutePath = this.resolve(request.path) + if (absolutePath === null) throw new RefactorError('Unknown .btsx file.', 422) + this.invalidate(absolutePath) + const entry = this.#compile(absolutePath) + if (contentHash(entry.source) !== request.hash) throw new RefactorError('The file changed. Refresh and select the element again.', 409) + if (entry.result === null) throw new RefactorError('The file does not compile.', 422) + const change = { absolutePath, before: entry.source, after: continueProps(entry.result.ast, entry.source, request.line) } + this.#validate(change) + const summary = `Continued props on line ${request.line}` + const files = [{ path: this.#relative(absolutePath), action: 'edit' as const, ...diffLines(change.before.split('\n'), change.after.split('\n')) }] + if (request.dryRun) return { undoId: null, component: '', summary, files } + this.#write([change]) + const undoId = randomUUID() + this.#applied.set(undoId, { summary, changes: [change] }) + if (this.#applied.size > UNDO_LIMIT) this.#applied.delete(this.#applied.keys().next().value!) + return { undoId, component: '', summary, files } + } + /** Restore the files an applied refactor touched, if nobody has edited them since. */ undo(id: string): UndoResult { const applied = this.#applied.get(id) diff --git a/server/refactor.test.ts b/server/refactor.test.ts index 129a7e9..bcf29dd 100644 --- a/server/refactor.test.ts +++ b/server/refactor.test.ts @@ -10,7 +10,7 @@ import { BeastProject } from './project.ts' import { planRefactor, RefactorError, type RefactorPlan } from './refactor.ts' const APP = readFileSync(new URL('../test/fixtures/App.btsx', import.meta.url), 'utf8') -const SETTINGS: AnalyzerSettings = { depthLimit: 2, minLines: 8, fileLines: 30 } +const SETTINGS: AnalyzerSettings = { depthLimit: 2, minLines: 8, fileLines: 30, continuationMinProps: 5 } const octane = createOctaneCompiler({ root: process.cwd(), environment: 'client', hmr: false, dev: true }) function suggestionsFor(source: string, name: string, settings: Partial = {}) { diff --git a/server/types.test.ts b/server/types.test.ts index 3656669..bca7e90 100644 --- a/server/types.test.ts +++ b/server/types.test.ts @@ -9,7 +9,7 @@ import { planRefactor } from './refactor.ts' import { TypeResolver } from './types.ts' const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '') -const SETTINGS: AnalyzerSettings = { depthLimit: 2, minLines: 8, fileLines: 30 } +const SETTINGS: AnalyzerSettings = { depthLimit: 2, minLines: 8, fileLines: 30, continuationMinProps: 5 } const octane = createOctaneCompiler({ root: ROOT, environment: 'client', hmr: false, dev: true }) const resolver = new TypeResolver(ROOT, () => null) diff --git a/shared/types.ts b/shared/types.ts index f7c75ba..4598756 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -17,9 +17,11 @@ export interface AnalyzerSettings { minLines: number /** Sections at least this long are extracted into their own `.btsx` file by default. */ fileLines: number + /** Minimum inline prop count for automatic continuation suggestions. */ + continuationMinProps: number } -export const DEFAULT_SETTINGS: AnalyzerSettings = { depthLimit: 5, minLines: 8, fileLines: 30 } +export const DEFAULT_SETTINGS: AnalyzerSettings = { depthLimit: 5, minLines: 8, fileLines: 30, continuationMinProps: 5 } export type Severity = 'info' | 'warning' | 'critical' @@ -35,7 +37,7 @@ export interface SuggestedProp { export interface RefactorSuggestion extends LineRange { id: string - kind: 'extract' | 'duplicate' | 'map' + kind: 'extract' | 'duplicate' | 'map' | 'continuation' severity: Severity /** Component (default or local `component`) the section currently lives in. */ host: string @@ -195,7 +197,16 @@ export interface ProjectReport { entryComponents: string[] } +export interface ContinuationRequest { + path: string + hash: string + line: number + dryRun: boolean +} + export interface FileReport { + /** Element and component headers whose inline props can be continued. */ + continuationLines: number[] path: string absolutePath: string /** Content hash; refactors are refused when the file changed since it was analyzed. */ From 7538f6c6dc29149a87ff7461c7a29d349c229706 Mon Sep 17 00:00:00 2001 From: phtn Date: Tue, 29 Sep 2026 04:28:38 +0800 Subject: [PATCH 4/7] update:pickers-renamed --- README.md | 45 ++++----- UI.md | 46 ++++++---- client/BeastDevtools.btsx | 91 ++++++++++--------- client/devtools.css | 64 ++++++------- client/element-picker-position.test.ts | 19 ++++ ...position.ts => element-picker-position.ts} | 2 +- client/{picker.ts => element-tools.ts} | 74 ++++++++------- client/picker-position.test.ts | 19 ---- rsbuild.ts | 2 +- rspack.ts | 4 +- server/devtools.ts | 6 +- server/source-tags.ts | 2 +- test/dev-server.ts | 4 +- vite.ts | 2 +- 14 files changed, 205 insertions(+), 175 deletions(-) create mode 100644 client/element-picker-position.test.ts rename client/{picker-position.ts => element-picker-position.ts} (82%) rename client/{picker.ts => element-tools.ts} (73%) delete mode 100644 client/picker-position.test.ts diff --git a/README.md b/README.md index 0134cf5..38b6ab9 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ Production builds are untouched. The plugin runs on the dev server only. | Capability | What it does | Why it matters | | --- | --- | --- | | **Components** | Live component tree with hooks, context and effects | Debug state without logging | -| **Element properties** | Hover HTML or SVG elements to see spacing, size, ID and type | Inspect layout without source tags | -| **Element picker** | Hover the page to see a component and its `.btsx` line | Go from pixels to source | +| **Element Picker** | Hover HTML or SVG elements to see spacing, size, ID and type | Inspect layout without source tags | +| **Component Finder** | Hover the page to see a component and its `.btsx` line | Go from pixels to source | | **BTSX → TSRX** | Source and compiled output, linked line by line | See what Beast generates | | **Refactor** | Finds deep nesting, repeated markup and sibling runs | Keeps templates readable | | **Auto-refactor** | Writes the component, props interface and imports | Refactors in one reviewed step | @@ -150,9 +150,9 @@ click the **Beast** button in the bottom-right corner. | Action | How | | --- | --- | | Open or close the panel | Alt+Shift+D, or the **Beast** button | -| Start or stop the element picker | Alt+Shift+C, or the crosshair button | -| Start or stop element properties | Alt+Shift+E, or the ruler button | -| Cancel picking or inspecting | Esc | +| Start or stop the Component Finder | Alt+Shift+C, or the crosshair button | +| Start or stop Element Picker | Alt+Shift+E, or the ruler button | +| Exit Component Finder or Element Picker | Esc | | Resize the panel | Drag its top edge | | Resize a pane | Drag the edge between two panes | | Reset a pane's width | Double-click that edge | @@ -161,17 +161,17 @@ The panel remembers its height, pane widths, open tab and analyzer settings per browser. It slides in and out, and all motion becomes near-instant when the system asks for reduced motion. -### Element picker +### Component Finder -Turn on the picker and hover any element of your app. An outline follows the +Turn on Component Finder and hover any element of your app. An outline follows the pointer, labeled with the component that renders the element and its `.btsx` file and line. Click to open that line in your editor. That also ends -picking. +Component Finder. -While the picker is on, clicks go to the picker, not your app. The overlay's +While Component Finder is on, app clicks select a component to open in the editor. The overlay's own controls keep working. -### Element properties +### Element Picker Click the ruler button in the launcher or panel toolbar, or press Alt+Shift+E. Hover a page element to see its @@ -183,7 +183,7 @@ outline is shown. Elements without an ID show **—**. Dimensions use the elemen rectangle, including borders and CSS transforms. This mode works without source tags or the Octane runtime inspection hook. -Only one picker mode is active at a time. App clicks are suppressed while +Only one of Component Finder and Element Picker is active at a time. App clicks are suppressed while inspecting; the DevTools controls remain usable. Press Esc or toggle the ruler button to exit. Elements inside iframes and closed shadow roots are inspected at their containing element. @@ -328,7 +328,7 @@ can be opened too; they show an empty file list. The selection belongs to the current browser tab and resets on reload. Use **Back to running app** to return to the configured project. Live components -and element picking always inspect the running app; following a component's +and Component Finder always inspect the running app; following a component's source link switches back to that project. Opening a folder does not start its dev server. @@ -338,7 +338,7 @@ its dev server. beastDevtools({ include: ['src'], analyzer: { depthLimit: 5, minLines: 8, fileLines: 30 }, - elementPicker: true, + componentFinder: true, }) ``` @@ -348,7 +348,10 @@ beastDevtools({ | `analyzer.depthLimit` | `5` | Nesting depth (0 = component root) above which a line counts as deep | | `analyzer.minLines` | `8` | Smallest section, in lines, worth extracting | | `analyzer.fileLines` | `30` | Sections at least this long move to their own file by default | -| `elementPicker` | `true` | Tag elements with their component and source line for the picker | +| `componentFinder` | `true` | Tag elements with their component and source line for Component Finder | + +`elementPicker` remains a deprecated alias for `componentFinder`. When both are +provided, `componentFinder` takes precedence. Element Picker works without source tags. The project root is Vite's `root`, Rspack's `context`, or Rsbuild's root path. Analyzer settings changed in the panel override these defaults for that @@ -367,7 +370,7 @@ flowchart LR O[Overlay] --> H[Octane inspection hook] O --> API S --> O - P[Element picker] --> E[Editor endpoint] + P[Component Finder] --> E[Editor endpoint] end B --> Page ``` @@ -383,7 +386,7 @@ flowchart LR app's own Beast and Octane compile it. It reads the component tree from Octane's `__OCTANE_DEVTOOLS__` hook, which `profile` enables. The app and the overlay share one Octane runtime. -- **Element picker.** Before Beast compiles a project `.btsx` file, the plugin +- **Component Finder.** Before Beast compiles a project `.btsx` file, the plugin adds `data-beast-src="path:line:column"` and `data-beast-component` to each of its HTML elements. The attributes are static, so Octane builds them into its templates at no runtime cost. Component calls and files in @@ -418,7 +421,7 @@ requests from your own page. | --- | --- | | Components shows **Runtime off** | Octane's inspection hook is missing. Enable `profile` in `beastOctane()` for dev builds, as in [Quick start](#quick-start). | | The overlay is unstyled under Rspack | Add a rule for `.css` files: `{ test: /\.css$/, type: 'css' }`. | -| The picker outlines nothing | `elementPicker` is `false`, or the element comes from a package in `node_modules`, which isn't tagged. | +| Component Finder outlines nothing | `componentFinder` is `false`, or the element comes from a package in `node_modules`, which isn't tagged. | | **Open in editor** does nothing | Set `LAUNCH_EDITOR` to your editor's command (for example `code` or `cursor`) and restart the dev server. | | A file is missing from the file list | It sits outside the `include` directories. Add its directory to `include`. | | Panel sizes or settings look wrong | Clear the `beast-devtools:preferences` and `beast-devtools:layout` keys from the page's local storage. | @@ -434,10 +437,10 @@ requests from your own page. each other. That's safe, because the values are read at render time, but the import cycle is worth knowing about. - Imports that only the moved section used are left in the source file. -- Picker attributes are inserted into tagged lines, so dev-server error +- Component Finder attributes are inserted into tagged lines, so dev-server error columns on those lines can point slightly past the real position. Line numbers are exact. -- The picker names the component whose template holds an element. Markup +- Component Finder names the component whose template holds an element. Markup passed in as children belongs to the file that wrote it. ## Repository structure @@ -453,11 +456,11 @@ requests from your own page. │ ├── types.ts # Prop types from the project's TypeScript │ ├── slots.ts, source-scan.ts # BTSX and TypeScript source scanning │ ├── diff.ts, line-map.ts # Diff previews and source-map line links -│ └── source-tags*.ts # Element picker tagging and Rspack loader +│ └── source-tags*.ts # Component Finder tagging and Rspack loader ├── client/ # The overlay, shipped as BTSX source │ ├── BeastDevtools.btsx # Shell: dock, launcher, tabs │ ├── *Panel.btsx # Components, BTSX → TSRX, Refactor -│ ├── picker.ts, layout.ts # Element picker and resizable panes +│ ├── element-tools.ts, layout.ts # Component Finder, Element Picker and resizable panes │ ├── runtime.ts, api.ts # Octane hook store and API client │ └── devtools.css # Scoped styles (every class is bdt-*) ├── shared/types.ts # Wire types shared by both sides diff --git a/UI.md b/UI.md index a27e75f..4fb6dbc 100644 --- a/UI.md +++ b/UI.md @@ -1,26 +1,36 @@ -Both icons and the launcher's markup are in [client/BeastDevtools.btsx](client/BeastDevtools.btsx), and their styles are in [client/devtools.css](client/devtools.css). +The icons and launcher markup are in [client/BeastDevtools.btsx](client/BeastDevtools.btsx), with styles in [client/devtools.css](client/devtools.css). -**Icons** (each is defined once and used in both the launcher and the panel's top bar): +**Tools** -- **Beast logo:** the `BeastLogo` component at [BeastDevtools.btsx:31](client/BeastDevtools.btsx:31). It draws the SVG path in the `LOGO_PATH` constant at [line 17](client/BeastDevtools.btsx:17), so to swap the logo, replace that path string (it's drawn in a 16×16 viewBox). -- **Picker icon:** the `PickIcon` component at [BeastDevtools.btsx:35](client/BeastDevtools.btsx:35). It's a small inline SVG: corner brackets plus a dot. Edit its `path`/`circle`, or replace them with your own SVG in the same 16×16 viewBox. +- **Component Finder** identifies the component that owns an element and its source file. Click to open the source in your editor. Shortcut: Alt+Shift+C. +- **Element Picker** shows basic element properties on a card: type, ID, dimensions, padding and margin. Shortcut: Alt+Shift+E. -**Launcher markup:** the last block of the file, starting at [BeastDevtools.btsx:230](client/BeastDevtools.btsx:230). It holds: +Both tools appear in the launcher and panel toolbar. Only one is active at a time; Escape exits it. -- the picker button (`.bdt-launcher-pick`); -- the main launcher button (`.bdt-launcher`), with the logo, the "Beast" label and the live-status dot. +**Icons and state** -The panel's top-bar copies of the icons are at [line 175](client/BeastDevtools.btsx:175) (logo) and [line 192](client/BeastDevtools.btsx:192) (picker button). +- `BeastLogo` draws the SVG path stored in `LOGO_PATH`. +- `ComponentFinderIcon` draws the corner brackets and dot. +- `ElementPickerIcon` draws the ruler. +- `activeElementTool` selects `component-finder`, `element-picker`, or `null`. +- `componentFinderActive` and `elementPickerActive` control each button's active state. -**Styles in [devtools.css](client/devtools.css):** +**Behavior** -| What | Selector | Line | -| -------------------------------------------- | ----------------------- | ------------------------------ | -| Launcher position, gap, show/hide transition | `.bdt-launcher-host` | [93](client/devtools.css:93) | -| Round picker button, and its icon size | `.bdt-launcher-pick` | [118](client/devtools.css:118) | -| "Beast" pill button | `.bdt-launcher` | [145](client/devtools.css:145) | -| Status dot | `.bdt-launcher-dot` | [167](client/devtools.css:167) | -| Logo size and color | `.bdt-logo` | [179](client/devtools.css:179) | -| Picker button's active (red) state | `.bdt-root .is-picking` | (search for it) | +[client/element-tools.ts](client/element-tools.ts) exports `startComponentFinder` and `startElementPicker`, sharing pointer tracking and highlight behavior. [client/element-picker-position.ts](client/element-picker-position.ts) exports `placeElementPickerCard` for card placement. -The playground dev server hot-reloads both files, so edits show up right away. +**Styles** + +| What | Selector | +| --- | --- | +| Launcher placement and transitions | `.bdt-launcher-host` | +| Component Finder launcher button | `.bdt-launcher-component-finder` | +| Element Picker launcher button | `.bdt-launcher-element-picker` | +| Component Finder button active state | `.is-component-finder-active` | +| Element Picker button active state | `.is-element-picker-active` | +| Component Finder page highlight and label | `.bdt-component-finder-highlight`, `.bdt-component-finder-label` | +| Element Picker page highlight and card | `.bdt-element-picker-highlight`, `.bdt-element-picker-card` | +| Page cursor while a tool is active | `html.bdt-component-finder-active`, `html.bdt-element-picker-active` | +| Main launcher, status dot and logo | `.bdt-launcher`, `.bdt-launcher-dot`, `.bdt-logo` | + +The playground dev server hot-reloads the component and CSS files. diff --git a/client/BeastDevtools.btsx b/client/BeastDevtools.btsx index e6e6f3f..2193003 100644 --- a/client/BeastDevtools.btsx +++ b/client/BeastDevtools.btsx @@ -5,7 +5,7 @@ import ComponentsPanel from './ComponentsPanel.btsx' import InspectorPanel from './InspectorPanel.btsx' import RefactorPanel from './RefactorPanel.btsx' import { fetchFile, fetchProject, onSourceChanged, openInEditor } from './api.ts' -import { startPicker, type PickerMode } from './picker.ts' +import { startComponentFinder, startElementPicker, type ElementTool } from './element-tools.ts' import { getRuntimeSnapshot, subscribeRuntime } from './runtime.ts' import { clampHeight, cx, loadPreferences, savePreferences, scrollTarget, startResize, type RecentRefactor, type ScrollTarget, type TabId } from './util.ts' import type { RuntimeSnapshot } from './runtime.ts' @@ -25,21 +25,26 @@ module return event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && event.code === 'KeyD' } - /** Alt+Shift+C toggles the element picker. */ - function isPickShortcut(event: KeyboardEvent): boolean { + /** Alt+Shift+C toggles Component Finder. */ + function isComponentFinderShortcut(event: KeyboardEvent): boolean { return event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && event.code === 'KeyC' } + /** Alt+Shift+E toggles Element Picker. */ + function isElementPickerShortcut(event: KeyboardEvent): boolean { + return event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && event.code === 'KeyE' + } + component BeastLogo svg.bdt-logo(viewBox="0 0 24 24" fill="none" aria-hidden="true" width='20' height='20') path(fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d={LOGO_PATH}) -component PickIcon +component ComponentFinderIcon svg(viewBox="0 0 16 16" fill="none" aria-hidden="true") path(d="M2.5 6V3.5a1 1 0 0 1 1-1H6M10 2.5h2.5a1 1 0 0 1 1 1V6M13.5 10v2.5a1 1 0 0 1-1 1H10M6 13.5H3.5a1 1 0 0 1-1-1V10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round") circle(cx="8" cy="8" r="1.75" fill="currentColor") -component PropertiesIcon +component ElementPickerIcon svg(viewBox="0 0 16 16" fill="none" aria-hidden="true") rect(x="2" y="2" width="12" height="12" rx="1.5" stroke="currentColor" stroke-width="1.5") path(d="M5 2v3M8 2v2M11 2v3M2 5h3M2 8h2M2 11h3" stroke="currentColor" stroke-width="1.5") @@ -49,11 +54,11 @@ module tab: TabId setTab: (next: TabId | ((prev: TabId) => TabId)) => void suggestionCount: number - picking: boolean - pickTitle: 'Stop picking (Esc)' | 'Pick an element to open its source (Alt+Shift+C)' - setPickerMode: (next: PickerMode | ((prev: PickerMode | null) => PickerMode | null) | null) => void - inspecting: boolean - inspectTitle: 'Stop inspecting properties (Esc)' | 'Inspect element properties (Alt+Shift+E)' + componentFinderActive: boolean + componentFinderTitle: 'Stop Component Finder (Esc)' | 'Component Finder (Alt+Shift+C)' + setActiveElementTool: (next: ElementTool | ((prev: ElementTool | null) => ElementTool | null) | null) => void + elementPickerActive: boolean + elementPickerTitle: 'Stop Element Picker (Esc)' | 'Element Picker (Alt+Shift+E)' project: ProjectReport | null projectId: string selectProject: (id: string) => void @@ -88,7 +93,7 @@ component MainTabs span.bdt-tab-count.bdt-refactor-count(title={`${suggestionCount} refactor suggestion${suggestionCount === 1 ? '' : 's'}`}) #{suggestionCount} component BdtTopbar - props { tab, setTab, suggestionCount, picking, pickTitle, setPickerMode, inspecting, inspectTitle, project, projectId, selectProject, runtime, status, setOpen }: BdtTopbarProps + props { tab, setTab, suggestionCount, componentFinderActive, componentFinderTitle, setActiveElementTool, elementPickerActive, elementPickerTitle, project, projectId, selectProject, runtime, status, setOpen }: BdtTopbarProps header.bdt-topbar .bdt-brand BeastLogo @@ -96,10 +101,10 @@ component BdtTopbar span.bdt-brand-sub Devtools MainTabs(tab={tab} setTab={setTab} suggestionCount={suggestionCount} componentCount={runtime.componentCount}) .bdt-spacer - button(type="button" className={cx('bdt-icon-button', picking && 'is-picking')} aria-pressed={picking} aria-label="Pick an element" title={pickTitle} onClick={() => setPickerMode((value) => value === 'source' ? null : 'source')}) - PickIcon - button(type="button" className={cx('bdt-icon-button', inspecting && 'is-picking')} aria-pressed={inspecting} aria-label="Inspect element properties" title={inspectTitle} onClick={() => setPickerMode((value) => value === 'properties' ? null : 'properties')}) - PropertiesIcon + button(type="button" className={cx('bdt-icon-button', componentFinderActive && 'is-component-finder-active')} aria-pressed={componentFinderActive} aria-label="Component Finder" title={componentFinderTitle} onClick={() => setActiveElementTool((value) => value === 'component-finder' ? null : 'component-finder')}) + ComponentFinderIcon + button(type="button" className={cx('bdt-icon-button', elementPickerActive && 'is-element-picker-active')} aria-pressed={elementPickerActive} aria-label="Element Picker" title={elementPickerTitle} onClick={() => setActiveElementTool((value) => value === 'element-picker' ? null : 'element-picker')}) + ElementPickerIcon ProjectPicker(root={project?.root ?? ''} external={projectId !== ''} onSelect={selectProject}) button.bdt-icon-button(type="button" aria-label="Close Beast DevTools" title="Close (Alt+Shift+D)" onClick={() => setOpen(false)}) svg(viewBox="0 0 16 16" fill="none" aria-hidden="true") @@ -108,21 +113,21 @@ component BdtTopbar module interface DevtoolsInterfaceProps { open: boolean - picking: boolean - pickTitle: 'Stop picking (Esc)' | 'Pick an element to open its source (Alt+Shift+C)' - setPickerMode: (next: PickerMode | ((prev: PickerMode | null) => PickerMode | null) | null) => void - inspecting: boolean - inspectTitle: 'Stop inspecting properties (Esc)' | 'Inspect element properties (Alt+Shift+E)' + componentFinderActive: boolean + componentFinderTitle: 'Stop Component Finder (Esc)' | 'Component Finder (Alt+Shift+C)' + setActiveElementTool: (next: ElementTool | ((prev: ElementTool | null) => ElementTool | null) | null) => void + elementPickerActive: boolean + elementPickerTitle: 'Stop Element Picker (Esc)' | 'Element Picker (Alt+Shift+E)' setOpen: (next: boolean | ((prev: boolean) => boolean)) => void } component DevtoolsInterface - props { open, picking, pickTitle, setPickerMode, inspecting, inspectTitle, setOpen }: DevtoolsInterfaceProps + props { open, componentFinderActive, componentFinderTitle, setActiveElementTool, elementPickerActive, elementPickerTitle, setOpen }: DevtoolsInterfaceProps div(className={cx('bdt-root', 'bdt-launcher-host', open && 'is-hidden')} aria-hidden={open ? 'true' : undefined}) - button(type="button" className={cx('bdt-launcher-pick bg-white/50 backdrop-blur-3xl', picking && 'is-picking')} aria-pressed={picking} aria-label="Pick an element" title={pickTitle} onClick={() => setPickerMode((value) => value === 'source' ? null : 'source')}) - PickIcon - button( type="button" className={cx('bdt-launcher-pick bg-white/70 backdrop-blur-3xl hover:text-active!', inspecting && 'is-prop-picking')} aria-pressed={inspecting} aria-label="Inspect element properties" title={inspectTitle} onClick={() => setPickerMode((value) => value === 'properties' ? null : 'properties')}) - PropertiesIcon + button(type="button" className={cx('bdt-launcher-component-finder bg-white/50 backdrop-blur-3xl', componentFinderActive && 'is-component-finder-active')} aria-pressed={componentFinderActive} aria-label="Component Finder" title={componentFinderTitle} onClick={() => setActiveElementTool((value) => value === 'component-finder' ? null : 'component-finder')}) + ComponentFinderIcon + button( type="button" className={cx('bdt-launcher-element-picker bg-white/70 backdrop-blur-3xl hover:text-active!', elementPickerActive && 'is-element-picker-active')} aria-pressed={elementPickerActive} aria-label="Element Picker" title={elementPickerTitle} onClick={() => setActiveElementTool((value) => value === 'element-picker' ? null : 'element-picker')}) + ElementPickerIcon button.bdt-launcher(className=' backdrop-blur-3xl' type="button" title="Open Beast DevTools (Alt+Shift+D)" onClick={() => setOpen(true)}) BeastLogo @@ -147,11 +152,11 @@ setup const [report, setReport] = useState(null); const [loadError, setLoadError] = useState(null); const [recent, setRecent] = useState(null); - const [pickerMode, setPickerMode] = useState(null); - const picking = pickerMode === 'source'; - const inspecting = pickerMode === 'properties'; - const inspectTitle = inspecting ? 'Stop inspecting properties (Esc)' : 'Inspect element properties (Alt+Shift+E)'; - const pickTitle = picking ? 'Stop picking (Esc)' : 'Pick an element to open its source (Alt+Shift+C)'; + const [activeElementTool, setActiveElementTool] = useState(null); + const componentFinderActive = activeElementTool === 'component-finder'; + const elementPickerActive = activeElementTool === 'element-picker'; + const elementPickerTitle = elementPickerActive ? 'Stop Element Picker (Esc)' : 'Element Picker (Alt+Shift+E)'; + const componentFinderTitle = componentFinderActive ? 'Stop Component Finder (Esc)' : 'Component Finder (Alt+Shift+C)'; const suggestionCount = project?.files.reduce((sum, entry) => sum + entry.suggestions, 0) ?? 0; const status = runtime.status === 'connected' ? `${runtime.componentCount}` @@ -165,14 +170,14 @@ setup useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && event.code === 'KeyE') { + if (isElementPickerShortcut(event)) { event.preventDefault(); - setPickerMode((value) => value === 'properties' ? null : 'properties'); + setActiveElementTool((value) => value === 'element-picker' ? null : 'element-picker'); return; } - if (isPickShortcut(event)) { + if (isComponentFinderShortcut(event)) { event.preventDefault(); - setPickerMode((value) => value === 'source' ? null : 'source'); + setActiveElementTool((value) => value === 'component-finder' ? null : 'component-finder'); return; } if (!isToggleShortcut(event)) return; @@ -198,18 +203,18 @@ setup if (!open && event.target === event.currentTarget && event.propertyName === 'visibility') setDocked(false); }; - // Source mode ends on a pick; properties mode stays active until toggled or Escape. + // Component Finder ends on selection; Element Picker stays active until toggled or Escape. useEffect(() => { - if (pickerMode === null) return; - return startPicker( + if (activeElementTool === null) return; + if (activeElementTool === 'element-picker') return startElementPicker(() => setActiveElementTool(null)); + return startComponentFinder( (source) => { - setPickerMode(null); + setActiveElementTool(null); openInEditor(source.path, source.line, source.column); }, - () => setPickerMode(null), - pickerMode, + () => setActiveElementTool(null), ); - }, [pickerMode]); + }, [activeElementTool]); // The project list refreshes whenever a .btsx file changes on disk. useEffect(() => { @@ -288,7 +293,7 @@ if open || docked ~ aria-label="Resize Beast DevTools" ~ onPointerDown={(event) => startResize(event, height, setHeight)} ~ ) - BdtTopbar(tab={tab} setTab={setTab} suggestionCount={suggestionCount} picking={picking} pickTitle={pickTitle} setPickerMode={setPickerMode} inspecting={inspecting} inspectTitle={inspectTitle} project={project} projectId={projectId} selectProject={selectProject} runtime={runtime} status={status} setOpen={setOpen}) + BdtTopbar(tab={tab} setTab={setTab} suggestionCount={suggestionCount} componentFinderActive={componentFinderActive} componentFinderTitle={componentFinderTitle} setActiveElementTool={setActiveElementTool} elementPickerActive={elementPickerActive} elementPickerTitle={elementPickerTitle} project={project} projectId={projectId} selectProject={selectProject} runtime={runtime} status={status} setOpen={setOpen}) div.bdt-body(role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}) switch tab case 'components' @@ -329,4 +334,4 @@ if open || docked ~ onSettingsChange={setSettings} ~ onRecentChange={(value) => { if (generation === projectGeneration.current) setRecent(value); }} ~ ) -DevtoolsInterface(open={open} picking={picking} pickTitle={pickTitle} setPickerMode={setPickerMode} inspecting={inspecting} inspectTitle={inspectTitle} setOpen={setOpen}) +DevtoolsInterface(open={open} componentFinderActive={componentFinderActive} componentFinderTitle={componentFinderTitle} setActiveElementTool={setActiveElementTool} elementPickerActive={elementPickerActive} elementPickerTitle={elementPickerTitle} setOpen={setOpen}) diff --git a/client/devtools.css b/client/devtools.css index 479556f..5939e3e 100644 --- a/client/devtools.css +++ b/client/devtools.css @@ -140,7 +140,7 @@ visibility 0s linear 200ms; } -.bdt-launcher-pick { +:is(.bdt-launcher-component-finder, .bdt-launcher-element-picker) { display: inline-grid; place-items: center; width: 36px; @@ -152,22 +152,22 @@ /*color: var(--bdt-muted);*/ } -.bdt-launcher-pick:hover { +:is(.bdt-launcher-component-finder, .bdt-launcher-element-picker):hover { opacity: 1; } -.bdt-launcher-pick svg { +:is(.bdt-launcher-component-finder, .bdt-launcher-element-picker) svg { display: block; width: 16px; height: 16px; } -.bdt-root .is-picking { +.bdt-root .is-component-finder-active { color: var(--bdt-accent); background: #ffffff10; } -.bdt-root .is-prop-picking { +.bdt-root .is-element-picker-active { color: var(--bdt-active); background: #ffffff10; } @@ -1664,11 +1664,11 @@ html.bdt-resizing * { } /* ------------------------------------------------------------------------ */ -/* Element picker. The highlight sits on top of the app, outside `.bdt-root`, */ +/* Component Finder and Element Picker. The highlight sits on top of the app, outside `.bdt-root`, */ /* just under the overlay, and never takes pointer events. */ -.bdt-picker-box, -.bdt-picker-label { +:is(.bdt-component-finder-highlight, .bdt-element-picker-highlight), +:is(.bdt-component-finder-label, .bdt-element-picker-card) { position: fixed; top: 0; left: 0; @@ -1678,15 +1678,15 @@ html.bdt-resizing * { pointer-events: none; } -.bdt-picker-box { +:is(.bdt-component-finder-highlight, .bdt-element-picker-highlight) { border: 1.5px solid #ff415a; border-radius: 3px; background: rgba(255, 65, 90, 0.1); } /* The box and label glide between elements; `is-snapping` places them without gliding. */ -.bdt-picker-box, -.bdt-picker-label { +:is(.bdt-component-finder-highlight, .bdt-element-picker-highlight), +:is(.bdt-component-finder-label, .bdt-element-picker-card) { opacity: 0; transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), @@ -1696,17 +1696,17 @@ html.bdt-resizing * { opacity 120ms ease; } -.bdt-picker-box.is-visible, -.bdt-picker-label.is-visible { +:is(.bdt-component-finder-highlight, .bdt-element-picker-highlight).is-visible, +:is(.bdt-component-finder-label, .bdt-element-picker-card).is-visible { opacity: 1; } -.bdt-picker-box.is-snapping, -.bdt-picker-label.is-snapping { +:is(.bdt-component-finder-highlight, .bdt-element-picker-highlight).is-snapping, +:is(.bdt-component-finder-label, .bdt-element-picker-card).is-snapping { transition: opacity 120ms ease; } -.bdt-picker-label { +:is(.bdt-component-finder-label, .bdt-element-picker-card) { display: flex; align-items: baseline; gap: 8px; @@ -1729,19 +1729,19 @@ html.bdt-resizing * { white-space: nowrap; } -.bdt-picker-label strong { +:is(.bdt-component-finder-label, .bdt-element-picker-card) strong { color: #ff415a; font-weight: 600; } -.bdt-picker-label span { +:is(.bdt-component-finder-label, .bdt-element-picker-card) span { overflow: hidden; text-overflow: ellipsis; color: #a4a5a0; } -html.bdt-picking, -html.bdt-picking :not(#beast-devtools, #beast-devtools *) { +html:is(.bdt-component-finder-active, .bdt-element-picker-active), +html:is(.bdt-component-finder-active, .bdt-element-picker-active) :not(#beast-devtools, #beast-devtools *) { cursor: crosshair !important; } @@ -1750,8 +1750,8 @@ html.bdt-picking :not(#beast-devtools, #beast-devtools *) { .bdt-dock, .bdt-launcher-host, .bdt-splitter::after, - .bdt-picker-box, - .bdt-picker-label { + :is(.bdt-component-finder-highlight, .bdt-element-picker-highlight), + :is(.bdt-component-finder-label, .bdt-element-picker-card) { transition-duration: 1ms !important; transition-delay: 0s !important; } @@ -1761,8 +1761,8 @@ html.bdt-picking :not(#beast-devtools, #beast-devtools *) { } } -/* Computed element properties share the picker's non-interactive highlight. */ -.bdt-properties-label { +/* Element Picker card. */ +.bdt-element-picker-card { /* Hide immediately while moving; never animate the card across its target. */ visibility: hidden; transition: none; @@ -1775,11 +1775,11 @@ html.bdt-picking :not(#beast-devtools, #beast-devtools *) { overflow-wrap: anywhere; } -.bdt-properties-label > span { +.bdt-element-picker-card > span { display: block; } -.bdt-properties-label dl { +.bdt-element-picker-card dl { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 6px 10px; @@ -1788,26 +1788,26 @@ html.bdt-picking :not(#beast-devtools, #beast-devtools *) { font: inherit; } -.bdt-properties-label dt, -.bdt-properties-label dd { +.bdt-element-picker-card dt, +.bdt-element-picker-card dd { margin: 0; padding: 0; font: inherit; } -.bdt-properties-label dt, -.bdt-properties-label small { +.bdt-element-picker-card dt, +.bdt-element-picker-card small { color: #a4a5a0; } -.bdt-properties-label small { +.bdt-element-picker-card small { display: block; font: 10px/1.5 ui-monospace, monospace; } -.bdt-properties-label.is-visible { +.bdt-element-picker-card.is-visible { visibility: visible; } diff --git a/client/element-picker-position.test.ts b/client/element-picker-position.test.ts new file mode 100644 index 0000000..bd07122 --- /dev/null +++ b/client/element-picker-position.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'bun:test' +import { placeElementPickerCard } from './element-picker-position.ts' + +test('places the card above, below, right or left without covering the element', () => { + expect(placeElementPickerCard({ left: 100, top: 250, right: 300, bottom: 350 }, 340, 180, 800, 600)).toEqual({ left: 100, top: 62 }) + expect(placeElementPickerCard({ left: 100, top: 0, right: 300, bottom: 100 }, 340, 180, 800, 600)).toEqual({ left: 100, top: 108 }) + expect(placeElementPickerCard({ left: 10, top: 0, right: 200, bottom: 600 }, 340, 180, 800, 600)).toEqual({ left: 208, top: 4 }) + expect(placeElementPickerCard({ left: 500, top: 0, right: 800, bottom: 600 }, 340, 180, 800, 600)).toEqual({ left: 152, top: 4 }) +}) + +test('clamps along the free side for targets near or beyond viewport edges', () => { + expect(placeElementPickerCard({ left: 780, top: 250, right: 850, bottom: 350 }, 340, 180, 800, 600)).toEqual({ left: 456, top: 62 }) + expect(placeElementPickerCard({ left: -100, top: -50, right: 200, bottom: 100 }, 340, 180, 800, 600)).toEqual({ left: 4, top: 108 }) +}) + +test('hides details when there is no non-overlapping space or the viewport is too small', () => { + expect(placeElementPickerCard({ left: 0, top: 0, right: 800, bottom: 600 }, 340, 180, 800, 600)).toBeNull() + expect(placeElementPickerCard({ left: 10, top: 10, right: 50, bottom: 50 }, 340, 180, 300, 600)).toBeNull() +}) diff --git a/client/picker-position.ts b/client/element-picker-position.ts similarity index 82% rename from client/picker-position.ts rename to client/element-picker-position.ts index c23a371..1379dd6 100644 --- a/client/picker-position.ts +++ b/client/element-picker-position.ts @@ -6,7 +6,7 @@ interface Rect { } /** Keep the whole card in the viewport and outside the element, with an 8px gap. */ -export function placeDetailCard(rect: Rect, width: number, height: number, viewportWidth: number, viewportHeight: number): { left: number; top: number } | null { +export function placeElementPickerCard(rect: Rect, width: number, height: number, viewportWidth: number, viewportHeight: number): { left: number; top: number } | null { const gap = 8 const edge = 4 const maxLeft = viewportWidth - width - edge diff --git a/client/picker.ts b/client/element-tools.ts similarity index 73% rename from client/picker.ts rename to client/element-tools.ts index 0fdffc1..e91d959 100644 --- a/client/picker.ts +++ b/client/element-tools.ts @@ -1,18 +1,18 @@ /** - * Element picker: hover the app to see which component and `.btsx` line - * rendered an element, click to open that line in the editor. Properties mode - * inspects any page element without requiring source tags. + * Component Finder identifies the owning component and source file, and opens + * its source on click. Element Picker shows basic properties for any page + * element without requiring source tags. * * It reads the `data-beast-src`/`data-beast-component` attributes the dev * server adds to project `.btsx` elements (see `server/source-tags.ts`). The * highlight is plain DOM, so moving the pointer never re-renders the overlay. */ -import { placeDetailCard } from './picker-position.ts' +import { placeElementPickerCard } from './element-picker-position.ts' import { COMPONENT_ATTRIBUTE, SOURCE_ATTRIBUTE } from '../shared/types.ts' -export type PickerMode = 'source' | 'properties' +export type ElementTool = 'component-finder' | 'element-picker' -export interface PickedSource { +export interface ComponentFinderSource { component: string /** Project-relative path of the `.btsx` file. */ path: string @@ -20,22 +20,32 @@ export interface PickedSource { column: number } -/** The overlay's own host; it stays clickable while picking. */ +/** The overlay's own host; it stays clickable while either tool is active. */ const OVERLAY_HOST = 'beast-devtools' -const PICKING_CLASS = 'bdt-picking' -/** Start a hover inspector; returns cleanup. Only source mode calls `onPick`. */ -export function startPicker(onPick: (source: PickedSource) => void, onCancel: () => void, mode: PickerMode = 'source'): () => void { +/** Find a component and its source file; returns cleanup. */ +export function startComponentFinder(onComponentFound: (source: ComponentFinderSource) => void, onCancel: () => void): () => void { + return startElementTool(onComponentFound, onCancel, 'component-finder') +} + +/** Show basic element properties; returns cleanup. */ +export function startElementPicker(onCancel: () => void): () => void { + return startElementTool(() => {}, onCancel, 'element-picker') +} + +/** Shared pointer tracking and highlighting for both tools. */ +function startElementTool(onComponentFound: (source: ComponentFinderSource) => void, onCancel: () => void, mode: ElementTool): () => void { + const activeToolClass = `bdt-${mode}-active` const box = document.createElement('div') - box.className = 'bdt-picker-box' + box.className = `bdt-${mode}-highlight` const label = document.createElement('div') - label.className = mode === 'properties' ? 'bdt-picker-label bdt-properties-label' : 'bdt-picker-label' + label.className = mode === 'element-picker' ? 'bdt-element-picker-card' : 'bdt-component-finder-label' const name = document.createElement('strong') const where = document.createElement('span') label.append(name, where) const details = document.createElement('dl') const values = new Map() - if (mode === 'properties') { + if (mode === 'element-picker') { for (const title of ['Type', 'ID', 'H × W', 'Padding', 'Margin']) { const term = document.createElement('dt') term.textContent = title @@ -48,16 +58,16 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () label.append(details, hint) } document.body.append(box, label) - document.documentElement.classList.add(PICKING_CLASS) + document.documentElement.classList.add(activeToolClass) let current: Element | null = null let visible = false - let detailTimer: ReturnType | undefined + let elementPickerCardTimer: ReturnType | undefined const show = (element: Element | null, revealDetails = false) => { current = element - const source = element === null ? null : readSource(element) - if (element === null || (mode === 'source' && source === null)) { + const source = element === null ? null : readComponentSource(element) + if (element === null || (mode === 'component-finder' && source === null)) { visible = false box.classList.remove('is-visible') label.classList.remove('is-visible') @@ -76,8 +86,8 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () const style = getComputedStyle(element) box.style.borderRadius = cappedRadius(style.borderRadius, Math.min(rect.width, rect.height) / 2) - if (mode === 'properties') { - name.textContent = 'Element properties' + if (mode === 'element-picker') { + name.textContent = 'Element Picker' where.textContent = source === null ? '' : source.component const type = element.getAttribute('type') values.get('Type')!.textContent = type ? `${element.localName} (${type})` : element.localName @@ -91,9 +101,9 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () } const height = label.offsetHeight let showLabel = true - if (mode === 'properties') { + if (mode === 'element-picker') { const position = revealDetails - ? placeDetailCard(rect, label.offsetWidth, height, window.innerWidth, window.innerHeight) + ? placeElementPickerCard(rect, label.offsetWidth, height, window.innerWidth, window.innerHeight) : null showLabel = position !== null if (position !== null) label.style.transform = `translate(${position.left}px, ${position.top}px)` @@ -118,14 +128,14 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () const path = event.composedPath() if (path.some(inOverlay)) return null const target = path.find((node): node is Element => node instanceof Element) ?? null - return mode === 'properties' ? target : tagged(target) + return mode === 'element-picker' ? target : findTaggedElement(target) } // Outline immediately; restart the card's delay on movement, scroll or resize. const update = (element: Element | null) => { - clearTimeout(detailTimer) + clearTimeout(elementPickerCardTimer) show(element) - if (mode === 'properties' && element !== null) { - detailTimer = setTimeout(() => show(element.isConnected ? element : null, true), 200) + if (mode === 'element-picker' && element !== null) { + elementPickerCardTimer = setTimeout(() => show(element.isConnected ? element : null, true), 200) } } const onMove = (event: PointerEvent) => update(targetElement(event)) @@ -143,10 +153,10 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () const onClick = (event: MouseEvent) => { if (event.composedPath().some(inOverlay)) return onPress(event) - if (mode === 'properties') return + if (mode === 'element-picker') return const element = targetElement(event) - const source = element === null ? null : readSource(element) - if (source !== null) onPick(source) + const source = element === null ? null : readComponentSource(element) + if (source !== null) onComponentFound(source) } const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return @@ -167,7 +177,7 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () for (const type of presses) window.addEventListener(type, onPress, capture) return () => { - clearTimeout(detailTimer) + clearTimeout(elementPickerCardTimer) window.removeEventListener('pointermove', onMove, capture) window.removeEventListener('pointerout', onOut, capture) window.removeEventListener('blur', onLeave) @@ -176,13 +186,13 @@ export function startPicker(onPick: (source: PickedSource) => void, onCancel: () window.removeEventListener('click', onClick, capture) window.removeEventListener('keydown', onKeyDown, capture) for (const type of presses) window.removeEventListener(type, onPress, capture) - document.documentElement.classList.remove(PICKING_CLASS) + document.documentElement.classList.remove(activeToolClass) box.remove() label.remove() } } -function tagged(target: EventTarget | null): Element | null { +function findTaggedElement(target: EventTarget | null): Element | null { if (!(target instanceof Element) || inOverlay(target)) return null return target.closest(`[${SOURCE_ATTRIBUTE}]`) } @@ -200,7 +210,7 @@ function cappedRadius(radius: string, max: number): string { return radius.replace(/[\d.e+]+px/gu, (value) => `${Math.min(parseFloat(value), max)}px`) } -function readSource(element: Element): PickedSource | null { +function readComponentSource(element: Element): ComponentFinderSource | null { const match = /^(.+):(\d+):(\d+)$/.exec(element.getAttribute(SOURCE_ATTRIBUTE) ?? '') if (match === null) return null return { diff --git a/client/picker-position.test.ts b/client/picker-position.test.ts deleted file mode 100644 index 0ad8998..0000000 --- a/client/picker-position.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test } from 'bun:test' -import { placeDetailCard } from './picker-position.ts' - -test('places the card above, below, right or left without covering the element', () => { - expect(placeDetailCard({ left: 100, top: 250, right: 300, bottom: 350 }, 340, 180, 800, 600)).toEqual({ left: 100, top: 62 }) - expect(placeDetailCard({ left: 100, top: 0, right: 300, bottom: 100 }, 340, 180, 800, 600)).toEqual({ left: 100, top: 108 }) - expect(placeDetailCard({ left: 10, top: 0, right: 200, bottom: 600 }, 340, 180, 800, 600)).toEqual({ left: 208, top: 4 }) - expect(placeDetailCard({ left: 500, top: 0, right: 800, bottom: 600 }, 340, 180, 800, 600)).toEqual({ left: 152, top: 4 }) -}) - -test('clamps along the free side for targets near or beyond viewport edges', () => { - expect(placeDetailCard({ left: 780, top: 250, right: 850, bottom: 350 }, 340, 180, 800, 600)).toEqual({ left: 456, top: 62 }) - expect(placeDetailCard({ left: -100, top: -50, right: 200, bottom: 100 }, 340, 180, 800, 600)).toEqual({ left: 4, top: 108 }) -}) - -test('hides details when there is no non-overlapping space or the viewport is too small', () => { - expect(placeDetailCard({ left: 0, top: 0, right: 800, bottom: 600 }, 340, 180, 800, 600)).toBeNull() - expect(placeDetailCard({ left: 10, top: 10, right: 50, bottom: 50 }, 340, 180, 300, 600)).toBeNull() -}) diff --git a/rsbuild.ts b/rsbuild.ts index 3c70998..afc50b3 100644 --- a/rsbuild.ts +++ b/rsbuild.ts @@ -28,7 +28,7 @@ export function beastDevtools(options: BeastDevtoolsOptions = {}): RsbuildPlugin return mergeEnvironmentConfig(config, { source: { preEntry: [CLIENT_ENTRY] } }) }) - if (options.elementPicker !== false) { + if ((options.componentFinder ?? options.elementPicker) !== false) { api.modifyRspackConfig((config) => { ;(config.plugins ??= []).push(new SourceTagsPlugin()) }) diff --git a/rspack.ts b/rspack.ts index 1647895..e3a12c4 100644 --- a/rspack.ts +++ b/rspack.ts @@ -33,7 +33,7 @@ export class BeastDevtoolsRspackPlugin implements RspackPluginInstance { // A global entry joins every entrypoint, so the overlay shares the app's Octane runtime. new compiler.rspack.EntryPlugin(compiler.context, CLIENT_ENTRY, { name: undefined }).apply(compiler) } - if (this.options.elementPicker !== false) { + if ((this.options.componentFinder ?? this.options.elementPicker) !== false) { // Rules are read when the first compilation starts, which is still ahead of us. compiler.options.module.rules.push(sourceTagsRule(compiler.context)) } @@ -57,7 +57,7 @@ export class BeastDevtoolsRspackPlugin implements RspackPluginInstance { } /** - * A pre-loader rule that tags `.btsx` elements for the element picker. Beast's + * A pre-loader rule that tags `.btsx` elements for Component Finder. Beast's * loader is also `enforce: 'pre'`, and loaders run from the last matching rule * to the first, so this rule must be added after Beast's. */ diff --git a/server/devtools.ts b/server/devtools.ts index 959b07f..54d9aa4 100644 --- a/server/devtools.ts +++ b/server/devtools.ts @@ -15,9 +15,11 @@ export interface BeastDevtoolsOptions { analyzer?: Partial /** * Tag the DOM elements of project `.btsx` files with their component and - * source line, so the overlay's element picker can name them and open them + * source line, so the overlay's Component Finder can name them and open them * in your editor. Default: `true`. */ + componentFinder?: boolean + /** @deprecated Use componentFinder. This legacy option controls source tagging, not Element Picker. */ elementPicker?: boolean } @@ -114,7 +116,7 @@ export function createDevtoolsServer(options: DevtoolsServerOptions): DevtoolsSe const location = url.searchParams.get('file') if (location === null || location === '') return send(400, { error: 'Missing file.' }) res.statusCode = 307 - // The element picker sends project-relative locations; absolute ones pass through. + // Component Finder sends project-relative locations; absolute ones pass through. res.setHeader('Location', editorUrl(resolve(root, location))) return res.end() } diff --git a/server/source-tags.ts b/server/source-tags.ts index 7d2fe86..1cd6c35 100644 --- a/server/source-tags.ts +++ b/server/source-tags.ts @@ -4,7 +4,7 @@ import { COMPONENT_ATTRIBUTE, SOURCE_ATTRIBUTE } from '../shared/types.js' /** * Tag every DOM element of a `.btsx` source with the component that renders it - * and its `path:line:column`, for the overlay's element picker: + * and its `path:line:column`, for the overlay's Component Finder: * * li.item(key={id}) #{label} * li.item(data-beast-src="src/List.btsx:4:5" data-beast-component="List" key={id}) #{label} diff --git a/test/dev-server.ts b/test/dev-server.ts index 1b2ab40..1dfbd53 100644 --- a/test/dev-server.ts +++ b/test/dev-server.ts @@ -51,7 +51,7 @@ createRoot(document.getElementById('app')!).render(App, { docsUrl: 'https://exam /** Matches the overlay's mount point in compiled client code. */ export const OVERLAY_MARKER = /["']beast-devtools["']/ -/** Matches the element picker's source tag on the fixture's root element, as compiled into a template string. */ +/** Matches Component Finder's source tag on the fixture's root element, as compiled into a template string. */ export const SOURCE_TAG = /
Date: Tue, 29 Sep 2026 04:56:47 +0800 Subject: [PATCH 5/7] update:element-picker-click --- README.md | 37 +++++---- UI.md | 4 +- client/BeastDevtools.btsx | 21 ++++- client/ElementsPanel.btsx | 151 ++++++++++++++++++++++++++++++++++++ client/devtools.css | 102 ++++++++++++++---------- client/element-inspector.ts | 135 ++++++++++++++++++++++++++++++++ client/element-tools.ts | 35 +++------ client/util.ts | 2 +- 8 files changed, 405 insertions(+), 82 deletions(-) create mode 100644 client/ElementsPanel.btsx create mode 100644 client/element-inspector.ts diff --git a/README.md b/README.md index 38b6ab9..7cee401 100644 --- a/README.md +++ b/README.md @@ -173,20 +173,29 @@ own controls keep working. ### Element Picker -Click the ruler button in the launcher or panel toolbar, or press -Alt+Shift+E. Hover a page element to see its -HTML/SVG tag (and `type` attribute when present), ID, rendered height × width -in pixels, and computed padding and margin for top / right / bottom / left. -The detail card appears after 200 ms without pointer movement and is placed -outside the element. If no side has enough room in the viewport, only the -outline is shown. Elements without an ID show **—**. Dimensions use the element's bounding -rectangle, including borders and CSS transforms. - -This mode works without source tags or the Octane runtime inspection hook. -Only one of Component Finder and Element Picker is active at a time. App clicks are suppressed while -inspecting; the DevTools controls remain usable. Press Esc or toggle -the ruler button to exit. Elements inside iframes and closed shadow roots -are inspected at their containing element. +Click the ruler button in the launcher or toolbar, or press +Alt+Shift+E. Hover an element for a compact +preview of its tag, ID and dimensions. Click it to open **Elements** in DevTools. + +- **Styles** shows common layout and appearance properties. Enable **All computed + styles** or search to inspect the full computed list. Editing a value creates + an inline override; remove it to return to the stylesheet value. +- **Attributes** shows every attribute and lets you edit, add or remove values. +- **DOM properties** includes inherited properties and runtime values such as + `value`, `checked` and `disabled`. Editable primitive values can be changed; + browser-owned objects and methods are displayed as read-only summaries. + +Press Enter or leave a value field to apply it. **Undo** reverses each edit and +remains available when switching panels or reselecting the same element. +These are live page edits only: they do not modify BTSX files, and reloading or +an app rerender can replace them. Removed elements are marked as disconnected; +use **Pick another** to select their replacement. + +The preview appears after 200 ms without pointer movement and sits outside the +element. Element Picker works on HTML and SVG without source tags or a runtime +connection. Escape cancels picking. Only one tool is active at a time, and +selection clicks are consumed so they do not activate the underlying app. +Elements inside iframes and closed shadow roots are inspected at their container. ### Components diff --git a/UI.md b/UI.md index 4fb6dbc..0e9a639 100644 --- a/UI.md +++ b/UI.md @@ -3,7 +3,9 @@ The icons and launcher markup are in [client/BeastDevtools.btsx](client/BeastDev **Tools** - **Component Finder** identifies the component that owns an element and its source file. Click to open the source in your editor. Shortcut: Alt+Shift+C. -- **Element Picker** shows basic element properties on a card: type, ID, dimensions, padding and margin. Shortcut: Alt+Shift+E. +- **Element Picker** shows a compact tag/ID and dimensions preview. Clicking opens the Elements panel for live style, attribute and DOM-property edits with undo. Shortcut: Alt+Shift+E. + +[client/ElementsPanel.btsx](client/ElementsPanel.btsx) contains the live editor; [client/element-inspector.ts](client/element-inspector.ts) captures properties and manages edits and undo. Both tools appear in the launcher and panel toolbar. Only one is active at a time; Escape exits it. diff --git a/client/BeastDevtools.btsx b/client/BeastDevtools.btsx index 2193003..5bc43d4 100644 --- a/client/BeastDevtools.btsx +++ b/client/BeastDevtools.btsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'octane' import type { AnalyzerSettings, FileReport, ProjectReport } from '../shared/types.ts' import ProjectPicker from './ProjectPicker.btsx' import ComponentsPanel from './ComponentsPanel.btsx' +import ElementsPanel from './ElementsPanel.btsx' +import { ElementInspection } from './element-inspector.ts' import InspectorPanel from './InspectorPanel.btsx' import RefactorPanel from './RefactorPanel.btsx' import { fetchFile, fetchProject, onSourceChanged, openInEditor } from './api.ts' @@ -14,6 +16,7 @@ module const TABS: ReadonlyArray<{ id: TabId; label: string }> = [ { id: 'inspector', label: 'BTSX → TSRX' }, { id: 'components', label: 'Components' }, + { id: 'elements', label: 'Elements' }, { id: 'refactor', label: 'Refactor' }, ] const LOGO_PATH = 'm12.01.001c.002.002.152.006.333.01 1.672.036 3.366.451 4.905 1.204a12.06 12.06 0 0 1 6.141 7.002c.375 1.124.581 2.305.608 3.48a11.9 11.9 0 0 1-1.027 5.183c-.108.245-.355.736-.5.993a12.1 12.1 0 0 1-3.417 3.843c-.203.15-.666.456-.886.588a11.95 11.95 0 0 1-5.648 1.7c-.114.008-.978.008-1.028.001a12 12 0 0 1-1.675-.189 12.03 12.03 0 0 1-7.211-4.336 12.3 12.3 0 0 1-1.582-2.6c-.58-1.314-.89-2.565-1.008-4.065-.02-.255-.02-1.343 0-1.613.122-1.632.512-3.076 1.212-4.49a12 12 0 0 1 1.828-2.708c.19-.214.726-.75.936-.937A12 12 0 0 1 8.228.608a12 12 0 0 1 3.036-.585 10 10 0 0 1 .369-.011 10 10 0 0 0 .377-.011m1.768 4.219a9 9 0 0 0-1.31-.186c-.22-.011-.852-.006-1.056.01a8 8 0 0 0-2.938.79l-.077.039 1.8 1.8 1.8 1.799 1.799-1.8 1.8-1.8-.077-.038a8 8 0 0 0-1.74-.614m6.208 7.351a8 8 0 0 0-.777-3.006l-.075-.157-1.8 1.8-1.8 1.8 1.799 1.8 1.8 1.8.038-.077a7.9 7.9 0 0 0 .802-2.92c.021-.252.028-.81.013-1.04M8.46 12.01l-1.8-1.8-1.799-1.8-.048.097a8.1 8.1 0 0 0-.806 3.078c-.03.564.01 1.21.11 1.793.127.73.389 1.53.705 2.154l.04.077 1.799-1.8zm7.138 7.137L13.8 17.35c-.99-.989-1.8-1.798-1.804-1.798-.003 0-.814.81-1.803 1.798l-1.797 1.797.156.075c.835.4 1.727.645 2.706.742a12 12 0 0 0 1.235.017 8 8 0 0 0 2.947-.759z' @@ -152,6 +155,8 @@ setup const [report, setReport] = useState(null); const [loadError, setLoadError] = useState(null); const [recent, setRecent] = useState(null); + const elementInspections = useRef(new WeakMap()); + const [elementInspection, setElementInspection] = useState(null); const [activeElementTool, setActiveElementTool] = useState(null); const componentFinderActive = activeElementTool === 'component-finder'; const elementPickerActive = activeElementTool === 'element-picker'; @@ -203,10 +208,20 @@ setup if (!open && event.target === event.currentTarget && event.propertyName === 'visibility') setDocked(false); }; - // Component Finder ends on selection; Element Picker stays active until toggled or Escape. + // Both tools end on selection; Element Picker opens the live editor. useEffect(() => { if (activeElementTool === null) return; - if (activeElementTool === 'element-picker') return startElementPicker(() => setActiveElementTool(null)); + if (activeElementTool === 'element-picker') return startElementPicker( + (element) => { + const inspection = elementInspections.current.get(element) ?? new ElementInspection(element); + elementInspections.current.set(element, inspection); + setElementInspection(inspection); + setActiveElementTool(null); + setTab('elements'); + setOpen(true); + }, + () => setActiveElementTool(null), + ); return startComponentFinder( (source) => { setActiveElementTool(null); @@ -307,6 +322,8 @@ if open || docked ~ onViewSource={viewSource} ~ onAnalyze={analyze} ~ ) + case 'elements' + ElementsPanel(inspection={elementInspection} onPick={() => setActiveElementTool('element-picker')}) case 'inspector' InspectorPanel( ~ key={projectId} diff --git a/client/ElementsPanel.btsx b/client/ElementsPanel.btsx new file mode 100644 index 0000000..07b508f --- /dev/null +++ b/client/ElementsPanel.btsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'octane' +import { COMMON_STYLES, type ElementInspection, type ElementProperty, type ElementPropertyGroup } from './element-inspector.ts' +import { cx } from './util.ts' + +module + interface ElementsPanelProps { + inspection: ElementInspection | null + onPick: () => void + } + interface ElementPropertyRowProps { + property: ElementProperty + disabled: boolean + removable: boolean + onCommit: (name: string, value: string | null) => void + } + const GROUPS: ReadonlyArray<{ id: ElementPropertyGroup; label: string }> = [ + { id: 'styles', label: 'Styles' }, + { id: 'attributes', label: 'Attributes' }, + { id: 'properties', label: 'DOM properties' }, + ] + +component ElementPropertyRow + props { property, disabled, removable, onCommit }: ElementPropertyRowProps + setup + const [draft, setDraft] = useState(property.value); + const [editing, setEditing] = useState(false); + useEffect(() => { if (!editing) setDraft(property.value); }, [property.value, editing]); + const commit = () => { + setEditing(false); + if (draft !== property.value) onCommit(property.name, draft); + }; + .bdt-element-property + label.bdt-element-property-name(title={property.name}) #{property.name} + if property.editable + input.bdt-input.bdt-element-property-value( + ~ aria-label={property.name} + ~ value={draft} + ~ disabled={disabled} + ~ spellcheck="false" + ~ onFocus={() => setEditing(true)} + ~ onInput={(event) => setDraft(event.currentTarget.value)} + ~ onBlur={commit} + ~ onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur(); if (event.key === 'Escape') { setDraft(property.value); setEditing(false); } }} + ~ ) + else + span.bdt-element-property-readonly(title={property.value}) #{property.value.length > 240 ? property.value.slice(0, 240) + '…' : property.value} + if removable && property.editable + button.bdt-icon-button(type="button" disabled={disabled} title={`Remove ${property.name}`} aria-label={`Remove ${property.name}`} onClick={() => onCommit(property.name, null)}) × + elseif property.inline + span.bdt-element-inline(title="Inline override") edited + else + span.bdt-element-inline #{property.editable ? '' : 'read-only'} + +props { inspection, onPick }: ElementsPanelProps +setup + const [snapshot, setSnapshot] = useState(() => inspection?.capture() ?? null); + const [group, setGroup] = useState('styles'); + const [query, setQuery] = useState(''); + const [showAll, setShowAll] = useState(false); + const [error, setError] = useState(null); + const [newName, setNewName] = useState(''); + const [newValue, setNewValue] = useState(''); + const [undoCount, setUndoCount] = useState(inspection?.undoCount ?? 0); + const refresh = () => { + setSnapshot(inspection?.capture() ?? null); + setUndoCount(inspection?.undoCount ?? 0); + }; + useEffect(() => { + refresh(); + setError(null); + setQuery(''); + if (!inspection) return; + // Attributes, layout and runtime values can change independently of DevTools. + const timer = window.setInterval(refresh, 1000); + return () => window.clearInterval(timer); + }, [inspection]); + const edit = (name: string, value: string | null) => { + if (!inspection) return; + try { + inspection.edit(group, name, value); + setError(null); + refresh(); + } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); } + }; + const undo = () => { + if (!inspection) return; + try { inspection.undo(); setError(null); refresh(); } + catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); } + }; + const chooseGroup = (next: ElementPropertyGroup) => { setGroup(next); setQuery(''); setNewName(''); setNewValue(''); setError(null); }; + const add = (event: SubmitEvent) => { + event.preventDefault(); + if (!inspection || !newName.trim()) return; + try { + inspection.edit(group, newName.trim(), newValue); + setNewName(''); setNewValue(''); setError(null); refresh(); + } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); } + }; + const search = query.trim().toLowerCase(); + const rows = (snapshot?.[group] ?? []).filter((entry) => { + if (search) return entry.name.toLowerCase().includes(search) || entry.value.toLowerCase().includes(search); + return group !== 'styles' || showAll || entry.inline || COMMON_STYLES.has(entry.name); + }); + +section.bdt-pane.bdt-elements-panel(aria-label="Selected element") + if snapshot === null + .bdt-empty + .bdt-empty-card + p.bdt-empty-title A closer look + p Pick an element to explore its styles, attributes and properties. + button.bdt-button-critical(type="button" onClick={onPick}) Pick an element + else + fragment + .bdt-toolbar.bdt-element-toolbar + .bdt-element-identity + strong.bdt-mono #{snapshot.label} + span.bdt-dim #{`${Number(snapshot.width.toFixed(1))} × ${Number(snapshot.height.toFixed(1))} px${snapshot.component ? ' · ' + snapshot.component : ''}`} + .bdt-spacer + span.bdt-element-live Live edits + button.bdt-button(type="button" disabled={undoCount === 0 || !snapshot.connected} onClick={undo}) Undo#{undoCount > 0 ? ' · ' + undoCount : ''} + button.bdt-button(type="button" onClick={refresh}) Refresh + button.bdt-button-critical(type="button" onClick={onPick}) Pick another + if !snapshot.connected + p.bdt-element-notice(role="status") This element was removed from the page. Pick it again to continue editing. + if error !== null + p.bdt-element-notice.is-error(role="alert") #{error} + .bdt-element-workspace + aside.bdt-element-nav + each item in GROUPS key item.id + button(type="button" className={cx('bdt-element-group', group === item.id && 'is-active')} aria-pressed={group === item.id} onClick={() => chooseGroup(item.id)}) + span #{item.label} + span.bdt-dim #{snapshot[item.id].length} + p.bdt-element-note Changes apply to the live page. Reloading restores the app’s original values. + .bdt-element-content + .bdt-element-filter + input.bdt-input(type="search" aria-label="Filter element properties" placeholder="Find a property or value…" value={query} onInput={(event) => setQuery(event.currentTarget.value)}) + if group === 'styles' + label.bdt-element-all + input(type="checkbox" checked={showAll} onChange={(event) => setShowAll(event.currentTarget.checked)}) + | All computed styles + span.bdt-dim #{rows.length} properties + .bdt-element-properties + if rows.length === 0 + p.bdt-element-note No matching properties. + each property in rows key `${group}:${property.name}` + ElementPropertyRow(property={property} disabled={!snapshot.connected} removable={group === 'attributes' || (group === 'styles' && !!property.inline)} onCommit={edit}) + if group !== 'properties' + form.bdt-element-add(onSubmit={add}) + input.bdt-input(aria-label={group === 'styles' ? 'New CSS property' : 'New attribute'} placeholder={group === 'styles' ? 'CSS property' : 'Attribute name'} value={newName} disabled={!snapshot.connected} onInput={(event) => setNewName(event.currentTarget.value)}) + input.bdt-input(aria-label="New property value" placeholder="Value" value={newValue} disabled={!snapshot.connected} onInput={(event) => setNewValue(event.currentTarget.value)}) + button.bdt-button(type="submit" disabled={!snapshot.connected || !newName.trim()}) Add diff --git a/client/devtools.css b/client/devtools.css index 5939e3e..3e7f64c 100644 --- a/client/devtools.css +++ b/client/devtools.css @@ -1761,54 +1761,74 @@ html:is(.bdt-component-finder-active, .bdt-element-picker-active) :not(#beast-de } } -/* Element Picker card. */ +/* A quiet, compact hover preview. Details live in the Elements panel. */ .bdt-element-picker-card { - /* Hide immediately while moving; never animate the card across its target. */ visibility: hidden; transition: none; - display: block; - width: 340px; - max-height: calc(100vh - 8px); - overflow: hidden; - padding: 10px 12px; - white-space: normal; - overflow-wrap: anywhere; -} - -.bdt-element-picker-card > span { - display: block; -} - -.bdt-element-picker-card dl { display: grid; - grid-template-columns: 64px minmax(0, 1fr); - gap: 6px 10px; - margin: 10px 0; - padding: 0; - font: inherit; -} - -.bdt-element-picker-card dt, -.bdt-element-picker-card dd { - margin: 0; - padding: 0; - font: inherit; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 7px 20px; + width: max-content; + max-width: min(360px, calc(100vw - 16px)); + padding: 12px 15px; + border: 1px solid rgba(255,255,255,.14); + border-radius: 12px; + background: rgba(22,24,23,.96); + box-shadow: 0 8px 30px #0003, 0 1px 3px #0004; + backdrop-filter: blur(18px); + white-space: nowrap; } - -.bdt-element-picker-card dt, -.bdt-element-picker-card small { - color: #a4a5a0; +.bdt-element-picker-card strong { + overflow: hidden; + text-overflow: ellipsis; + color: #d9eadf; + font-weight: 500; } - +.bdt-element-picker-card > span { color: #a4a5a0; font-size: 11px; } .bdt-element-picker-card small { - display: block; - font: - 10px/1.5 ui-monospace, - monospace; -} - -.bdt-element-picker-card.is-visible { - visibility: visible; + grid-column: 1 / -1; + color: #838b86; + font: 10px/1.4 ui-sans-serif, system-ui, sans-serif; + letter-spacing: .02em; +} +.bdt-element-picker-card.is-visible { visibility: visible; } +.bdt-element-picker-highlight { border-color: #81b49b; background: #81b49b0c; } + +.bdt-element-toolbar { padding: 14px 18px; gap: 10px; } +.bdt-element-identity { display: grid; gap: 3px; min-width: 0; } +.bdt-element-identity strong { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bdt-element-live { color: var(--bdt-ok); font-size: 10px; white-space: nowrap; } +.bdt-element-workspace { display: flex; flex: 1; min-height: 0; } +.bdt-element-nav { width: 190px; flex: none; padding: 12px; border-right: 1px solid var(--bdt-line); overflow: auto; } +.bdt-element-group { display: flex; justify-content: space-between; width: 100%; padding: 10px; border-radius: 7px; cursor: pointer; background: transparent; text-align: left; } +.bdt-element-group:hover { background: var(--bdt-bg-3); } +.bdt-element-group.is-active { background: var(--bdt-bg-3); color: var(--bdt-text); } +.bdt-element-note { padding: 15px 10px; color: var(--bdt-dim); line-height: 1.7; font-size: 11px; } +.bdt-element-content { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.bdt-element-filter { display: flex; align-items: center; gap: 16px; padding: 10px 18px; border-bottom: 1px solid var(--bdt-line); } +.bdt-element-filter > .bdt-input { flex: 1; min-width: 90px; } +.bdt-element-all { display: flex; align-items: center; gap: 7px; white-space: nowrap; color: var(--bdt-muted); } +.bdt-element-all input { appearance: auto; width: 13px; height: 13px; accent-color: var(--bdt-ok); } +.bdt-element-properties { flex: 1; min-height: 0; overflow: auto; padding: 4px 18px 12px; } +.bdt-element-property { display: grid; grid-template-columns: minmax(130px, 32%) minmax(0, 1fr) 65px; align-items: center; gap: 12px; min-height: 37px; border-bottom: 1px solid var(--bdt-line); } +.bdt-element-property-name { color: var(--bdt-muted); overflow: hidden; text-overflow: ellipsis; font-family: var(--bdt-mono); font-size: 11px; } +.bdt-element-property .bdt-input { width: 100%; min-width: 0; background: transparent; border-color: transparent; font-family: var(--bdt-mono); font-size: 11px; } +.bdt-element-property .bdt-input:hover { background: var(--bdt-bg-2); } +.bdt-element-property .bdt-input:focus { background: var(--bdt-bg-3); border-color: var(--bdt-focus); } +.bdt-element-property-readonly { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--bdt-dim); font: 11px var(--bdt-mono); } +.bdt-element-inline { text-align: right; color: var(--bdt-dim); font-size: 10px; } +.bdt-element-property > .bdt-icon-button { justify-self: end; } +.bdt-element-add { display: flex; gap: 8px; padding: 10px 18px; border-top: 1px solid var(--bdt-line); } +.bdt-element-add > .bdt-input { flex: 1; min-width: 0; } +.bdt-element-notice { padding: 8px 18px; color: var(--bdt-warn); border-bottom: 1px solid var(--bdt-line); } +.bdt-element-notice.is-error { color: var(--bdt-bad); } +@media (max-width: 720px) { + .bdt-element-nav { width: 135px; padding: 6px; } + .bdt-element-toolbar { flex-wrap: wrap; } + .bdt-element-filter { flex-wrap: wrap; gap: 8px; } + .bdt-element-property { grid-template-columns: minmax(85px, 34%) minmax(0, 1fr) 24px; gap: 5px; } + .bdt-element-inline { display: none; } } .bdt-project-picker { diff --git a/client/element-inspector.ts b/client/element-inspector.ts new file mode 100644 index 0000000..5a7dc8f --- /dev/null +++ b/client/element-inspector.ts @@ -0,0 +1,135 @@ +import { COMPONENT_ATTRIBUTE, SOURCE_ATTRIBUTE } from '../shared/types.ts' + +export type ElementPropertyGroup = 'styles' | 'attributes' | 'properties' +export interface ElementProperty { + name: string + value: string + editable: boolean + type?: 'boolean' | 'number' | 'string' + inline?: boolean +} +export interface ElementSnapshot { + label: string + component: string | null + source: string | null + connected: boolean + width: number + height: number + attributes: ElementProperty[] + styles: ElementProperty[] + properties: ElementProperty[] +} + +const EDITABLE_PROPERTIES = new Set(['value', 'checked', 'selected', 'disabled', 'hidden', 'readOnly', 'required', 'multiple', 'tabIndex', 'title', 'id', 'className', 'placeholder', 'textContent']) +const PROTECTED_ATTRIBUTES = new Set([SOURCE_ATTRIBUTE, COMPONENT_ATTRIBUTE]) +export const COMMON_STYLES = new Set(['display', 'position', 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'gap', 'align-items', 'justify-content', 'color', 'background-color', 'font-size', 'font-weight', 'line-height', 'border-radius', 'border-width', 'border-color', 'opacity', 'overflow', 'box-shadow']) + +export function elementLabel(element: Element): string { + return `${element.localName}${element.id ? `#${element.id}` : ''}` +} + +function inlineStyle(element: Element): CSSStyleDeclaration { + if (!('style' in element)) throw new Error('This element does not support inline styles.') + return (element as HTMLElement | SVGElement).style +} + +function displayValue(value: unknown): string { + if (typeof value === 'function') return '[Function]' + if (value === null) return 'null' + if (value === undefined) return 'undefined' + if (typeof value === 'object') return Object.prototype.toString.call(value) + return String(value) +} + +export function captureElement(element: Element): ElementSnapshot { + const rect = element.getBoundingClientRect() + const computed = element.ownerDocument.defaultView!.getComputedStyle(element) + const style = 'style' in element ? inlineStyle(element) : null + const attributes = Array.from(element.attributes, (attribute) => ({ + name: attribute.name, value: attribute.value, + editable: !PROTECTED_ATTRIBUTES.has(attribute.name) && !/^on/i.test(attribute.name), + })) + const styles = Array.from(computed, (name) => ({ + name, value: style?.getPropertyValue(name) ? `${style.getPropertyValue(name)}${style.getPropertyPriority(name) ? ' !important' : ''}` : computed.getPropertyValue(name), + editable: style !== null, inline: !!style?.getPropertyValue(name), + })) + // Include custom properties and inline declarations omitted from computed enumeration. + if (style) for (const name of Array.from(style)) { + if (!styles.some((entry) => entry.name === name)) styles.push({ name, value: `${style.getPropertyValue(name)}${style.getPropertyPriority(name) ? ' !important' : ''}`, editable: true, inline: true }) + } + const names = new Set() + for (let object: object | null = element; object && object !== Object.prototype; object = Object.getPrototypeOf(object)) { + for (const name of Object.getOwnPropertyNames(object)) names.add(name) + } + const properties: ElementProperty[] = [...names].sort().map((name) => { + try { + const value = Reflect.get(element, name) + const type = typeof value + const primitive = type === 'string' || type === 'number' || type === 'boolean' + return { + name, value: displayValue(value), + editable: primitive && EDITABLE_PROPERTIES.has(name) && (name !== 'textContent' || element.childNodes.length === 0 || Array.from(element.childNodes).every((node) => node.nodeType === 3)), + ...(primitive ? { type: type as 'string' | 'number' | 'boolean' } : {}), + } + } catch { return { name, value: '[Unavailable]', editable: false } } + }) + return { + label: elementLabel(element), component: element.getAttribute(COMPONENT_ATTRIBUTE), source: element.getAttribute(SOURCE_ATTRIBUTE), + connected: element.isConnected, width: rect.width, height: rect.height, + attributes: attributes.sort((a, b) => a.name.localeCompare(b.name)), styles: styles.sort((a, b) => a.name.localeCompare(b.name)), properties, + } +} + +/** Live edits belong to the selected DOM node, and survive panel/tab changes. */ +export class ElementInspection { + readonly element: Element + readonly #undo: Array<() => void> = [] + constructor(element: Element) { this.element = element } + get undoCount(): number { return this.#undo.length } + capture(): ElementSnapshot { return captureElement(this.element) } + + edit(group: ElementPropertyGroup, name: string, value: string | null): void { + const element = this.element + if (!element.isConnected) throw new Error('This element is no longer on the page. Pick it again.') + if (group === 'styles') { + const style = inlineStyle(element) + const oldStyle = element.getAttribute('style') + if (value === null || value.trim() === '') style.removeProperty(name) + else { + const important = /\s*!important\s*$/i.test(value) + const next = value.replace(/\s*!important\s*$/i, '').trim() + if (!name.startsWith('--') && !CSS.supports(name, next)) throw new Error(`Invalid CSS value for ${name}.`) + style.setProperty(name, next, important ? 'important' : '') + } + if (element.getAttribute('style') === oldStyle) return + this.#undo.push(() => { + // Restore the complete declaration, including longhands affected by a shorthand. + if (oldStyle === null) element.removeAttribute('style') + else element.setAttribute('style', oldStyle) + }) + } else if (group === 'attributes') { + if (PROTECTED_ATTRIBUTES.has(name) || /^on/i.test(name)) throw new Error('This attribute is read-only.') + const before = element.getAttribute(name) + if (before === value) return + if (value === null) element.removeAttribute(name) + else element.setAttribute(name, value) + this.#undo.push(() => { if (before === null) element.removeAttribute(name); else element.setAttribute(name, before) }) + } else { + const property = this.capture().properties.find((entry) => entry.name === name) + if (!property?.editable || value === null) throw new Error('This DOM property is read-only.') + const next = property.type === 'boolean' ? value === 'true' : property.type === 'number' ? Number(value) : value + if (property.type === 'number' && !Number.isFinite(next)) throw new Error('Enter a finite number.') + if (property.type === 'boolean' && value !== 'true' && value !== 'false') throw new Error('Use true or false.') + const before = Reflect.get(element, name) + if (Object.is(before, next)) return + if (!Reflect.set(element, name, next)) throw new Error('This DOM property could not be changed.') + this.#undo.push(() => { Reflect.set(element, name, before) }) + } + } + + undo(): void { + if (!this.element.isConnected) throw new Error('This element is no longer on the page. Pick it again.') + const undo = this.#undo.at(-1) + if (undo) { undo(); this.#undo.pop() } + } +} diff --git a/client/element-tools.ts b/client/element-tools.ts index e91d959..e00bc71 100644 --- a/client/element-tools.ts +++ b/client/element-tools.ts @@ -7,6 +7,7 @@ * server adds to project `.btsx` elements (see `server/source-tags.ts`). The * highlight is plain DOM, so moving the pointer never re-renders the overlay. */ +import { elementLabel } from './element-inspector.ts' import { placeElementPickerCard } from './element-picker-position.ts' import { COMPONENT_ATTRIBUTE, SOURCE_ATTRIBUTE } from '../shared/types.ts' @@ -29,12 +30,12 @@ export function startComponentFinder(onComponentFound: (source: ComponentFinderS } /** Show basic element properties; returns cleanup. */ -export function startElementPicker(onCancel: () => void): () => void { - return startElementTool(() => {}, onCancel, 'element-picker') +export function startElementPicker(onElementPicked: (element: Element) => void, onCancel: () => void): () => void { + return startElementTool(() => {}, onCancel, 'element-picker', onElementPicked) } /** Shared pointer tracking and highlighting for both tools. */ -function startElementTool(onComponentFound: (source: ComponentFinderSource) => void, onCancel: () => void, mode: ElementTool): () => void { +function startElementTool(onComponentFound: (source: ComponentFinderSource) => void, onCancel: () => void, mode: ElementTool, onElementPicked?: (element: Element) => void): () => void { const activeToolClass = `bdt-${mode}-active` const box = document.createElement('div') box.className = `bdt-${mode}-highlight` @@ -43,19 +44,10 @@ function startElementTool(onComponentFound: (source: ComponentFinderSource) => v const name = document.createElement('strong') const where = document.createElement('span') label.append(name, where) - const details = document.createElement('dl') - const values = new Map() if (mode === 'element-picker') { - for (const title of ['Type', 'ID', 'H × W', 'Padding', 'Margin']) { - const term = document.createElement('dt') - term.textContent = title - const value = document.createElement('dd') - details.append(term, value) - values.set(title, value) - } const hint = document.createElement('small') - hint.textContent = 'Spacing: top / right / bottom / left · Esc to exit' - label.append(details, hint) + hint.textContent = 'Click to edit · Esc to exit' + label.append(hint) } document.body.append(box, label) document.documentElement.classList.add(activeToolClass) @@ -87,14 +79,8 @@ function startElementTool(onComponentFound: (source: ComponentFinderSource) => v box.style.borderRadius = cappedRadius(style.borderRadius, Math.min(rect.width, rect.height) / 2) if (mode === 'element-picker') { - name.textContent = 'Element Picker' - where.textContent = source === null ? '' : source.component - const type = element.getAttribute('type') - values.get('Type')!.textContent = type ? `${element.localName} (${type})` : element.localName - values.get('ID')!.textContent = element.id || '—' - values.get('H × W')!.textContent = `${Number(rect.height.toFixed(2))} × ${Number(rect.width.toFixed(2))} px` - values.get('Padding')!.textContent = [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft].join(' / ') - values.get('Margin')!.textContent = [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft].join(' / ') + name.textContent = elementLabel(element) + where.textContent = `${Number(rect.width.toFixed(1))} × ${Number(rect.height.toFixed(1))}` } else if (source !== null) { name.textContent = source.component where.textContent = `${source.path}:${source.line}` @@ -153,8 +139,11 @@ function startElementTool(onComponentFound: (source: ComponentFinderSource) => v const onClick = (event: MouseEvent) => { if (event.composedPath().some(inOverlay)) return onPress(event) - if (mode === 'element-picker') return const element = targetElement(event) + if (mode === 'element-picker') { + if (element !== null) onElementPicked?.(element) + return + } const source = element === null ? null : readComponentSource(element) if (source !== null) onComponentFound(source) } diff --git a/client/util.ts b/client/util.ts index 3568fb5..01dcfd2 100644 --- a/client/util.ts +++ b/client/util.ts @@ -45,7 +45,7 @@ export function valueTone(value: unknown): string { // --------------------------------------------------------------------------- // Preferences, persisted per browser -export type TabId = 'components' | 'inspector' | 'refactor' +export type TabId = 'components' | 'inspector' | 'refactor' | 'elements' export interface Preferences { open: boolean From 44115e807c589a2a4ec4f6e65a62f6a4f2f6b593 Mon Sep 17 00:00:00 2001 From: phtn Date: Tue, 29 Sep 2026 11:50:00 +0800 Subject: [PATCH 6/7] update:font-refs --- assets.test.ts | 32 ++++++++++++++++++++++++++++++++ client/devtools.css | 2 +- package.json | 1 + 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 assets.test.ts diff --git a/assets.test.ts b/assets.test.ts new file mode 100644 index 0000000..ed3f7be --- /dev/null +++ b/assets.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from 'bun:test' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = new URL('.', import.meta.url).pathname +const css = readFileSync(resolve(root, 'client/devtools.css'), 'utf8') +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { files: string[] } + +// Every font referenced by the overlay stylesheet must resolve to a file the +// bundler can emit in a production build. +test('overlay fonts resolve to published files', () => { + const refs = [...css.matchAll(/url\(['"]?([^'")]+)['"]?\)/g)].map((match) => match[1]!) + const fonts = refs.filter((ref) => /\.(woff2?|woff|ttf|otf)$/u.test(ref)) + expect(fonts.length).toBeGreaterThan(0) + for (const ref of fonts) { + const absolute = resolve(root, 'client', ref) + expect(existsSync(absolute)).toBe(true) + const posix = absolute.slice(root.length) + const covered = pkg.files.some((entry) => !entry.startsWith('!') && (posix === entry || posix.startsWith(`${entry}/`))) + expect({ ref, posix, covered }).toEqual({ ref, posix, covered: true }) + } +}) + +// A stray `!` (a half-typed `!important`) passes dev servers unminified but +// fails production minifiers, breaking the whole stylesheet including fonts. +test('overlay stylesheet has no stray important markers', () => { + const strays = [...css.matchAll(/!(?!\s*important\b)/g)].map((match) => { + const start = Math.max(0, (match.index ?? 0) - 40) + return css.slice(start, (match.index ?? 0) + 10).replace(/\n/g, ' ') + }) + expect(strays).toEqual([]) +}) diff --git a/client/devtools.css b/client/devtools.css index b23ccd9..d557d49 100644 --- a/client/devtools.css +++ b/client/devtools.css @@ -1654,7 +1654,7 @@ html.bdt-resizing * { .bdt-card-title-input:hover, .bdt-card-title-input:focus { border-bottom-color: var(--bdt-line-2); - border-radius: 0px!; + border-radius: 0; outline: none; } diff --git a/package.json b/package.json index 807fab6..0b2110a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dist", "client", "shared", + "assets", "!**/*.test.ts", "README.md", "CHANGELOG.md", From 16c8759d90998b3453d74ce9b287084d1c2728d8 Mon Sep 17 00:00:00 2001 From: phtn Date: Tue, 29 Sep 2026 14:45:09 +0800 Subject: [PATCH 7/7] update:refactor-layout --- client/RefactorPanel.btsx | 292 +++++++++++++++++++------------------- client/devtools.css | 43 +++++- client/layout.ts | 4 +- 3 files changed, 188 insertions(+), 151 deletions(-) diff --git a/client/RefactorPanel.btsx b/client/RefactorPanel.btsx index 0418225..f12de08 100644 --- a/client/RefactorPanel.btsx +++ b/client/RefactorPanel.btsx @@ -217,7 +217,6 @@ setup } }; -FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) .bdt-pane .bdt-toolbar span.bdt-toolbar-title.bdt-mono @@ -266,148 +265,153 @@ FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) if undoError === null button.bdt-button(type="button" onClick={undo}) Undo button.bdt-icon-button(type="button" aria-label="Dismiss" onClick={() => { setUndoError(null); onRecentChange(null); }}) × - if loadError !== null && current === null - .bdt-empty - p Could not analyze #{file ?? 'the file'}: #{loadError} - elseif current === null - .bdt-empty - p #{files.length === 0 ? 'No .btsx files found under the configured include directories.' : 'Analyzing…'} - elseif analysis === null - .bdt-empty - .bdt-empty-card - p.bdt-empty-title This file does not compile - p Fix the error shown in BTSX → TSRX to analyze its structure. + if current === null || analysis === null + .bdt-refactor-fallback + FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) + if loadError !== null && current === null + .bdt-empty + p Could not analyze #{file ?? 'the file'}: #{loadError} + elseif current === null + .bdt-empty + p #{files.length === 0 ? 'No .btsx files found under the configured include directories.' : 'Analyzing…'} + elseif analysis === null + .bdt-empty + .bdt-empty-card + p.bdt-empty-title This file does not compile + p Fix the error shown in BTSX → TSRX to analyze its structure. else - fragment - .bdt-metrics - .bdt-metric(className={analysis.maxDepth > settings.depthLimit && 'metric-alert'}) - span(className={cx('bdt-metric-value', analysis.maxDepth > settings.depthLimit && 'is-over')}) #{analysis.maxDepth} - span.bdt-metric-label Max depth - .bdt-metric - span.bdt-metric-value #{analysis.averageDepth} - span.bdt-metric-label Avg depth - .bdt-metric(className={analysis.deepLines > 0 && 'metric-alert'}) - span(className={cx('bdt-metric-value', analysis.deepLines > 0 && 'is-over')}) #{analysis.deepLines} - span.bdt-metric-label Lines over #{settings.depthLimit} - .bdt-metric - span.bdt-metric-value #{analysis.templateLines} - span.bdt-metric-label Template lines - .bdt-metric - span.bdt-metric-value #{analysis.indentUnit} - span.bdt-metric-label Indent spaces - div.bdt-histogram(role="img" aria-label={`Template lines per depth: ${analysis.histogram.map((count, depth) => `depth ${depth}: ${count}`).join(', ')}`}) - each count, depth in analysis.histogram key depth - .bdt-histogram-col(title={`Depth ${depth}: ${count} line${count === 1 ? '' : 's'}`}) - span(className={cx('bdt-histogram-bar', tone(depth, settings.depthLimit))} style={{ height: `${Math.max(2, Math.round((count / histogramMax) * 24))}px` }}) - span.bdt-histogram-label #{depth} - .bdt-refactor - section.bdt-suggestions(ref={suggestionsPane} aria-label="Refactor suggestions") - if suggestions.length === 0 - .bdt-empty - .bdt-empty-card - p.bdt-empty-title No automatic suggestions - p Click a starting line in the source to extract a block manually. - else - each suggestion in suggestions key suggestion.id - article( - ~ className={cx('bdt-card', suggestion.id === selectedId && 'is-selected')} - ~ onClick={() => select(suggestion)} - ~ ) - .bdt-card-head - span(className={cx('bdt-severity', `is-${suggestion.severity}`)} title={suggestion.severity}) - if suggestion.kind !== 'continuation' && targets(suggestion).length > 0 - input( - ~ className={cx('bdt-card-title', 'bdt-card-title-input', nameIssue(suggestion, nameOf(suggestion)) !== null && 'is-invalid')} - ~ value={nameOf(suggestion)} - ~ aria-label={suggestion.mapping === null ? 'Component name' : suggestion.mapping.placement === 'module' ? 'Array name' : 'Loop item name'} - ~ title={nameIssue(suggestion, nameOf(suggestion)) ?? 'Rename before applying'} - ~ spellcheck="false" - ~ autocomplete="off" - ~ onClick={(event) => event.stopPropagation()} - ~ onInput={(event) => rename(suggestion, event.currentTarget.value)} - ~ ) - else - span.bdt-card-title #{suggestion.name} - span(className={cx('bdt-badge', suggestion.kind === 'extract' ? 'is-warn' : 'is-info')}) #{kindLabel(suggestion)} - span.bdt-dim.bdt-metric-label L#{suggestion.startLine}–#{suggestion.endLine} - p.bdt-card-reason #{suggestion.reason} - .bdt-card-meta - span.bdt-chip #{suggestion.label} in #{suggestion.host} - if !suggestion.typesDerived && suggestion.props.length > 0 - span.bdt-chip(title="TypeScript could not be loaded from this project, so prop types are estimated") estimated types - each prop in suggestion.props key prop.name - span.bdt-chip(title={`${prop.name}: ${prop.type}`}) - | #{prop.name} - span.bdt-chip-type #{': ' + prop.type} - .bdt-card-actions - button.bdt-button(type="button" aria-expanded={openId === suggestion.id} onClick={() => setOpenId(openId === suggestion.id ? null : suggestion.id)}) #{openId === suggestion.id ? '🞁' : '🞃'} - if suggestion.kind !== 'continuation' - button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:component`, suggestion.snippet)}) #{copied === `${suggestion.id}:component` ? 'component ⮻' : 'component ⮺'} - button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:usage`, suggestion.usage.trimStart())}) #{copied === `${suggestion.id}:usage` ? 'usage ⮻' : 'usage ⮺'} - button.bdt-button(type="button" onClick={() => openInEditor(current.absolutePath, suggestion.startLine)}) open ⧽ - .bdt-card-actions - each target, index in targets(suggestion) key target - button( - ~ type="button" - ~ className={cx('bdt-button-critical', index === 0 && '')} - ~ title={suggestion.kind === 'continuation' ? 'Move each prop onto its own ~ continuation line' : suggestion.mapping !== null ? 'Render the repeated elements from an array with each' : target === 'file' ? `Write ${nameOf(suggestion).trim()}.btsx next to this file and import it` : 'Add a local component to this file'} - ~ disabled={pending?.status === 'applying' || pending?.status === 'loading' || nameIssue(suggestion, nameOf(suggestion)) !== null} - ~ onClick={() => preview(suggestion, target)} - ~ ) - | #{targetLabel(target, suggestion)} - - if suggestion.autoApply.blocked !== null - p.bdt-card-note Manual only: #{suggestion.autoApply.blocked} - elseif suggestion.autoApply.fileBlocked !== null && suggestion.mapping === null - p.bdt-card-note Stays in this file: #{suggestion.autoApply.fileBlocked} - if pending !== null && pending.suggestionId === suggestion.id - div.bdt-plan(role="group" aria-label="Review refactor") - if pending.status === 'loading' - p.bdt-muted Preparing #{targetLabel(pending.target, suggestion).toLowerCase()}… + .bdt-refactor + .bdt-refactor-overview + .bdt-metrics + .bdt-metric(className={analysis.maxDepth > settings.depthLimit && 'metric-alert'}) + span(className={cx('bdt-metric-value', analysis.maxDepth > settings.depthLimit && 'is-over')}) #{analysis.maxDepth} + span.bdt-metric-label Max depth + .bdt-metric + span.bdt-metric-value #{analysis.averageDepth} + span.bdt-metric-label Avg depth + .bdt-metric(className={analysis.deepLines > 0 && 'metric-alert'}) + span(className={cx('bdt-metric-value', analysis.deepLines > 0 && 'is-over')}) #{analysis.deepLines} + span.bdt-metric-label Lines over #{settings.depthLimit} + .bdt-metric + span.bdt-metric-value #{analysis.templateLines} + span.bdt-metric-label Template lines + .bdt-metric + span.bdt-metric-value #{analysis.indentUnit} + span.bdt-metric-label Indent spaces + div.bdt-histogram(role="img" aria-label={`Template lines per depth: ${analysis.histogram.map((count, depth) => `depth ${depth}: ${count}`).join(', ')}`}) + each count, depth in analysis.histogram key depth + .bdt-histogram-col(title={`Depth ${depth}: ${count} line${count === 1 ? '' : 's'}`}) + span(className={cx('bdt-histogram-bar', tone(depth, settings.depthLimit))} style={{ height: `${Math.max(2, Math.round((count / histogramMax) * 24))}px` }}) + span.bdt-histogram-label #{depth} + .bdt-refactor-left-body + FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) + section.bdt-suggestions(ref={suggestionsPane} aria-label="Refactor suggestions") + if suggestions.length === 0 + .bdt-empty + .bdt-empty-card + p.bdt-empty-title No automatic suggestions + p Click a starting line in the source to extract a block manually. + else + each suggestion in suggestions key suggestion.id + article( + ~ className={cx('bdt-card', suggestion.id === selectedId && 'is-selected')} + ~ onClick={() => select(suggestion)} + ~ ) + .bdt-card-head + span(className={cx('bdt-severity', `is-${suggestion.severity}`)} title={suggestion.severity}) + if suggestion.kind !== 'continuation' && targets(suggestion).length > 0 + input( + ~ className={cx('bdt-card-title', 'bdt-card-title-input', nameIssue(suggestion, nameOf(suggestion)) !== null && 'is-invalid')} + ~ value={nameOf(suggestion)} + ~ aria-label={suggestion.mapping === null ? 'Component name' : suggestion.mapping.placement === 'module' ? 'Array name' : 'Loop item name'} + ~ title={nameIssue(suggestion, nameOf(suggestion)) ?? 'Rename before applying'} + ~ spellcheck="false" + ~ autocomplete="off" + ~ onClick={(event) => event.stopPropagation()} + ~ onInput={(event) => rename(suggestion, event.currentTarget.value)} + ~ ) else - fragment - if pending.result !== null - fragment - p.bdt-plan-title Review · #{targetLabel(pending.target, suggestion)} · #{pending.result.files.length} file#{pending.result.files.length === 1 ? '' : 's'} - each change in pending.result.files key change.path - .bdt-plan-file - .bdt-plan-file-head - span - PathLabel(path={change.path}) - span(className={cx('bdt-badge', change.action === 'create' ? 'is-ok' : 'is-info')}) #{change.action === 'create' ? 'new file' : 'edit'} - span.bdt-added +#{change.added} - span.bdt-removed −#{change.removed} - .bdt-diff - .bdt-diff-inner - each hunk, hunkIndex in change.hunks key hunkIndex - fragment - p.bdt-diff-head @@ −#{hunk.oldStart} +#{hunk.newStart} @@ - each line, lineIndex in hunk.lines key lineIndex - div(className={`bdt-diff-line is-${line.type}`}) #{DIFF_SIGN[line.type] + ' ' + line.text} - if pending.error !== null - pre.bdt-plan-error #{pending.error} - .bdt-card-actions - if pending.result !== null && pending.error === null - button.bdt-button-critical(type="button" disabled={pending.status === 'applying'} onClick={confirm}) #{pending.status === 'applying' ? 'Applying…' : 'Apply changes'} - button.bdt-button(type="button" disabled={pending.status === 'applying'} onClick={() => setPending(null)}) Cancel - if openId === suggestion.id - pre.bdt-pre #{applyGuide(suggestion)} - section.bdt-pane(aria-label="Source with nesting depth") - Splitter(split="refactor" edge="start" label="Resize suggestions and source") - .bdt-pane-head - span Nesting depth per line - .bdt-spacer - span.bdt-dim #{selecting ? 'Selecting block…' : selected === null ? 'Click a starting line to extract a block' : `${selected.name}: ${selected.occurrences.length === 1 ? 'section' : `${selected.occurrences.length} copies`} highlighted`} - if selectionError !== null - p.bdt-project-message(role="status") #{selectionError} - CodeView( - ~ source={current.source} - ~ language="btsx" - ~ label="BTSX source with nesting depth" - ~ depths={analysis.lineDepths} - ~ depthLimit={settings.depthLimit} - ~ ranges={selected?.occurrences} - ~ activeLine={selected?.startLine} - ~ onLineClick={selectBlock} - ~ scrollTo={scroll} - ~ ) + span.bdt-card-title #{suggestion.name} + span(className={cx('bdt-badge', suggestion.kind === 'extract' ? 'is-warn' : 'is-info')}) #{kindLabel(suggestion)} + span.bdt-dim.bdt-metric-label L#{suggestion.startLine}–#{suggestion.endLine} + p.bdt-card-reason #{suggestion.reason} + .bdt-card-meta + span.bdt-chip #{suggestion.label} in #{suggestion.host} + if !suggestion.typesDerived && suggestion.props.length > 0 + span.bdt-chip(title="TypeScript could not be loaded from this project, so prop types are estimated") estimated types + each prop in suggestion.props key prop.name + span.bdt-chip(title={`${prop.name}: ${prop.type}`}) + | #{prop.name} + span.bdt-chip-type #{': ' + prop.type} + .bdt-card-actions + button.bdt-button(type="button" aria-expanded={openId === suggestion.id} onClick={() => setOpenId(openId === suggestion.id ? null : suggestion.id)}) #{openId === suggestion.id ? '🞁' : '🞃'} + if suggestion.kind !== 'continuation' + button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:component`, suggestion.snippet)}) #{copied === `${suggestion.id}:component` ? 'component ⮻' : 'component ⮺'} + button.bdt-button(type="button" onClick={() => copy(`${suggestion.id}:usage`, suggestion.usage.trimStart())}) #{copied === `${suggestion.id}:usage` ? 'usage ⮻' : 'usage ⮺'} + button.bdt-button(type="button" onClick={() => openInEditor(current.absolutePath, suggestion.startLine)}) open ⧽ + .bdt-card-actions + each target, index in targets(suggestion) key target + button( + ~ type="button" + ~ className={cx('bdt-button-critical', index === 0 && '')} + ~ title={suggestion.kind === 'continuation' ? 'Move each prop onto its own ~ continuation line' : suggestion.mapping !== null ? 'Render the repeated elements from an array with each' : target === 'file' ? `Write ${nameOf(suggestion).trim()}.btsx next to this file and import it` : 'Add a local component to this file'} + ~ disabled={pending?.status === 'applying' || pending?.status === 'loading' || nameIssue(suggestion, nameOf(suggestion)) !== null} + ~ onClick={() => preview(suggestion, target)} + ~ ) + | #{targetLabel(target, suggestion)} + + if suggestion.autoApply.blocked !== null + p.bdt-card-note Manual only: #{suggestion.autoApply.blocked} + elseif suggestion.autoApply.fileBlocked !== null && suggestion.mapping === null + p.bdt-card-note Stays in this file: #{suggestion.autoApply.fileBlocked} + if pending !== null && pending.suggestionId === suggestion.id + div.bdt-plan(role="group" aria-label="Review refactor") + if pending.status === 'loading' + p.bdt-muted Preparing #{targetLabel(pending.target, suggestion).toLowerCase()}… + else + fragment + if pending.result !== null + fragment + p.bdt-plan-title Review · #{targetLabel(pending.target, suggestion)} · #{pending.result.files.length} file#{pending.result.files.length === 1 ? '' : 's'} + each change in pending.result.files key change.path + .bdt-plan-file + .bdt-plan-file-head + span + PathLabel(path={change.path}) + span(className={cx('bdt-badge', change.action === 'create' ? 'is-ok' : 'is-info')}) #{change.action === 'create' ? 'new file' : 'edit'} + span.bdt-added +#{change.added} + span.bdt-removed −#{change.removed} + .bdt-diff + .bdt-diff-inner + each hunk, hunkIndex in change.hunks key hunkIndex + fragment + p.bdt-diff-head @@ −#{hunk.oldStart} +#{hunk.newStart} @@ + each line, lineIndex in hunk.lines key lineIndex + div(className={`bdt-diff-line is-${line.type}`}) #{DIFF_SIGN[line.type] + ' ' + line.text} + if pending.error !== null + pre.bdt-plan-error #{pending.error} + .bdt-card-actions + if pending.result !== null && pending.error === null + button.bdt-button-critical(type="button" disabled={pending.status === 'applying'} onClick={confirm}) #{pending.status === 'applying' ? 'Applying…' : 'Apply changes'} + button.bdt-button(type="button" disabled={pending.status === 'applying'} onClick={() => setPending(null)}) Cancel + if openId === suggestion.id + pre.bdt-pre #{applyGuide(suggestion)} + section.bdt-pane(aria-label="Source with nesting depth") + Splitter(split="refactor" edge="start" label="Resize suggestions and source") + .bdt-pane-head + span Nesting depth per line + .bdt-spacer + span.bdt-dim #{selecting ? 'Selecting block…' : selected === null ? 'Click a starting line to extract a block' : `${selected.name}: ${selected.occurrences.length === 1 ? 'section' : `${selected.occurrences.length} copies`} highlighted`} + if selectionError !== null + p.bdt-project-message(role="status") #{selectionError} + CodeView( + ~ source={current.source} + ~ language="btsx" + ~ label="BTSX source with nesting depth" + ~ depths={analysis.lineDepths} + ~ depthLimit={settings.depthLimit} + ~ ranges={selected?.occurrences} + ~ activeLine={selected?.startLine} + ~ onLineClick={selectBlock} + ~ scrollTo={scroll} + ~ ) diff --git a/client/devtools.css b/client/devtools.css index d557d49..4c00b9e 100644 --- a/client/devtools.css +++ b/client/devtools.css @@ -1266,14 +1266,17 @@ html.bdt-resizing * { .bdt-metrics { display: flex; - flex-wrap: wrap; + flex: none; + flex-wrap: nowrap; + overflow-x: auto; align-items: stretch; gap: 8px; - padding: 10px 10px; + padding: 10px; border-bottom: 1px solid var(--bdt-line); } .bdt-metric { + flex: none; display: grid; gap: 2px; min-width: 78px; @@ -1308,6 +1311,7 @@ html.bdt-resizing * { } .bdt-histogram { + flex: none; display: flex; align-items: flex-end; gap: 3px; @@ -1359,17 +1363,38 @@ html.bdt-resizing * { flex: 1; min-height: 0; display: grid; - grid-template-columns: minmax(0, calc(var(--bdt-refactor-share, 0.42) * 100%)) minmax(0, 1fr); + grid-template-columns: minmax(480px, calc(var(--bdt-refactor-share, 0.4) * 100%)) minmax(0, 1fr); +} + +.bdt-refactor-overview { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border-right: 1px solid var(--bdt-line); +} + +.bdt-refactor-left-body, +.bdt-refactor-fallback { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} + +.bdt-refactor-left-body > .bdt-sidebar { + max-width: calc(100% - 200px); } .bdt-suggestions { + flex: 1; + min-width: 0; min-height: 0; overflow: auto; padding: 4px; display: grid; align-content: start; gap: 8px; - border-right: 1px solid var(--bdt-line); } .bdt-card { @@ -1505,7 +1530,15 @@ html.bdt-resizing * { grid-template-rows: 1fr 1fr; } - .bdt-suggestions { + .bdt-refactor-left-body > .bdt-sidebar { + max-width: none; + } + + .bdt-refactor-left-body { + flex-direction: column; + } + + .bdt-refactor-overview { border-right: 0; border-bottom: 1px solid var(--bdt-line); } diff --git a/client/layout.ts b/client/layout.ts index d346222..9c08560 100644 --- a/client/layout.ts +++ b/client/layout.ts @@ -44,11 +44,11 @@ const SPLITS: Record = { measure: (x, width) => clamp(x / width, MIN_PANE / width, 1 - MIN_PANE / width), format: share, }, - // Suggestions next to the depth-annotated source. + // File list and suggestions next to the depth-annotated source. refactor: { variable: '--bdt-refactor-share', container: '.bdt-refactor', - measure: (x, width) => clamp(x / width, 260 / width, 1 - MIN_PANE / width), + measure: (x, width) => clamp(x / width, 480 / width, 1 - MIN_PANE / width), format: share, }, }