diff --git a/UI.md b/UI.md index ba265b1..d01b9df 100644 --- a/UI.md +++ b/UI.md @@ -3,9 +3,9 @@ The launcher markup is in [client/BeastDevtools.btsx](client/BeastDevtools.btsx) **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 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. +- **Element Picker** shows a compact tag/ID and dimensions preview. Clicking selects an element for source-backed style, attribute and DOM-property edits with undo. It switches to the Elements tab without opening a closed panel, so layout handles can be used on the unobstructed page. 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. [client/element-groups.ts](client/element-groups.ts) sorts styles and DOM properties into the panel's category groups. [client/element-layout.ts](client/element-layout.ts) draws the open element's outline and side handles for resizing it on the page. +[client/ElementsPanel.btsx](client/ElementsPanel.btsx) contains the source-backed editor; [client/element-inspector.ts](client/element-inspector.ts) captures properties and saves edits through the Elements API, with file-backed undo. Styles and layout drags update inline styles in the owning `.btsx` element. Untagged elements and runtime-only properties remain read-only; computed style expressions that cannot be safely patched report an error instead of claiming to save. [client/element-groups.ts](client/element-groups.ts) sorts styles and DOM properties into the panel's category groups. [client/element-layout.ts](client/element-layout.ts) draws the open element's outline and side handles for resizing it on the page. 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 48c4432..4cbc4ad 100644 --- a/client/BeastDevtools.btsx +++ b/client/BeastDevtools.btsx @@ -1,8 +1,9 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'octane' -import type { AnalyzerSettings, FileReport, ProjectReport } from '../shared/types.ts' +import type { AnalyzerSettings, ComponentLocation, FileReport, FileSummary, ProjectReport } from '../shared/types.ts' import { BeastLogo, ComponentFinderIcon, ElementPickerIcon } from './Icons.btsx' import ComponentsPanel from './ComponentsPanel.btsx' import ElementsPanel from './ElementsPanel.btsx' +import { startLayoutEditor } from './element-layout.ts' import { ElementInspection } from './element-inspector.ts' import InspectorPanel from './InspectorPanel.btsx' import RefactorPanel from './RefactorPanel.btsx' @@ -30,6 +31,17 @@ module return event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && event.code === 'KeyE' } + /** + * Shared empty props. `?? []` inline would mint a fresh array identity on + * every render and defeat the memoized tree/file derivations downstream. + */ + const EMPTY_COMPONENTS: readonly ComponentLocation[] = [] + const EMPTY_STRINGS: readonly string[] = [] + const EMPTY_FILES: readonly FileSummary[] = [] + + /** Settings inputs emit per keystroke; server fetches wait for a pause. */ + const SETTINGS_DEBOUNCE_MS = 300 + module interface DevtoolsInterfaceProps { open: boolean @@ -62,6 +74,14 @@ setup const [showControlFlow, setShowControlFlow] = useState(initial.showControlFlow); const [componentsView, setComponentsView] = useState(initial.componentsView); const [settings, setSettings] = useState(initial.settings); + // Fetches run against the settled value, so typing in a settings field + // fires one refresh instead of one per keystroke. + const [debouncedSettings, setDebouncedSettings] = useState(initial.settings); + useEffect(() => { + if (settings === debouncedSettings) return; + const timer = window.setTimeout(() => setDebouncedSettings(settings), SETTINGS_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [settings]); const [focus, setFocus] = useState(null); const [revision, setRevision] = useState(0); const [projectId, setProjectId] = useState(''); @@ -125,7 +145,7 @@ setup if (!open && event.target === event.currentTarget && event.propertyName === 'visibility') setDocked(false); }; - // Both tools end on selection; Element Picker opens the live editor. + // Picking selects the editor tab without changing whether the dock is open. useEffect(() => { if (activeElementTool === null) return; if (activeElementTool === 'element-picker') return startElementPicker( @@ -135,7 +155,6 @@ setup setElementInspection(inspection); setActiveElementTool(null); setTab('elements'); - setOpen(true); }, () => setActiveElementTool(null), ); @@ -148,11 +167,15 @@ setup ); }, [activeElementTool]); + // Layout handles remain usable with the dock closed, and pause while picking. + useEffect(() => elementInspection && activeElementTool === null + ? startLayoutEditor(elementInspection, () => {}) : undefined, [elementInspection, activeElementTool]); + // The project list refreshes whenever a .btsx file changes on disk. useEffect(() => { if (!open) return; let cancelled = false; - Promise.all([fetchProject(settings, projectId), fetchProject(settings)]).then( + Promise.all([fetchProject(debouncedSettings, projectId), fetchProject(debouncedSettings)]).then( ([next, running]) => { if (cancelled || generation !== projectGeneration.current) return; setRuntimeProject(running); @@ -167,12 +190,12 @@ setup return () => { cancelled = true; }; - }, [open, revision, settings, projectId]); + }, [open, revision, debouncedSettings, projectId]); useEffect(() => { if (!open || file === null) return; let cancelled = false; - fetchFile(file, settings, projectId).then( + fetchFile(file, debouncedSettings, projectId).then( (next) => { if (cancelled || generation !== projectGeneration.current) return; setReport(next); @@ -185,7 +208,7 @@ setup return () => { cancelled = true; }; - }, [open, file, revision, settings, projectId]); + }, [open, file, revision, debouncedSettings, projectId]); const selectProject = (id: string) => { projectGeneration.current += 1; @@ -225,13 +248,28 @@ if open || docked ~ aria-label="Resize Beast DevTools" ~ onPointerDown={(event) => startResize(event, height, setHeight)} ~ ) - Topbar(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}) + Topbar( + ~ 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' ComponentsPanel( - ~ components={runtimeProject?.components ?? []} - ~ entryComponents={runtimeProject?.entryComponents ?? []} + ~ components={runtimeProject?.components ?? EMPTY_COMPONENTS} + ~ entryComponents={runtimeProject?.entryComponents ?? EMPTY_STRINGS} ~ view={componentsView} ~ onViewChange={setComponentsView} ~ showControlFlow={showControlFlow} @@ -247,7 +285,7 @@ if open || docked ~ projectId={projectId} ~ recent={recent} ~ onRecentChange={setRecent} - ~ files={project?.files ?? []} + ~ files={project?.files ?? EMPTY_FILES} ~ file={file} ~ report={report} ~ loadError={loadError} @@ -258,7 +296,7 @@ if open || docked RefactorPanel( ~ key={projectId} ~ projectId={projectId} - ~ files={project?.files ?? []} + ~ files={project?.files ?? EMPTY_FILES} ~ file={file} ~ report={report} ~ loadError={loadError} diff --git a/client/ElementsPanel.btsx b/client/ElementsPanel.btsx index 3ad50c9..5452338 100644 --- a/client/ElementsPanel.btsx +++ b/client/ElementsPanel.btsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from 'octane' +import { useEffect, useRef, useState } from 'octane' import { groupAttributes, groupProperties, groupStyles, type ElementSection } from './element-groups.ts' -import { COMMON_STYLES, type ElementInspection, type ElementProperty, type ElementPropertyGroup } from './element-inspector.ts' -import { startLayoutEditor } from './element-layout.ts' +import { COMMON_STYLES, snapshotKey, type ElementInspection, type ElementProperty, type ElementPropertyGroup } from './element-inspector.ts' import { cx } from './util.ts' module @@ -70,44 +69,55 @@ setup const [group, setGroup] = useState('styles'); const [query, setQuery] = useState(''); const [showAll, setShowAll] = useState(false); + const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [newName, setNewName] = useState(''); const [newValue, setNewValue] = useState(''); const [undoCount, setUndoCount] = useState(inspection?.undoCount ?? 0); + const lastKey = useRef(null); const refresh = () => { - setSnapshot(inspection?.capture() ?? null); + setSaving(inspection?.saving ?? false); + setUndoCount(inspection?.undoCount ?? 0); + const next = inspection?.capture() ?? null; + const key = snapshotKey(next); + // Captures are fresh objects every time; only publish when the content + // actually changed, so idle ticks skip the re-render entirely. + if (key === lastKey.current) return; + lastKey.current = key; + setSnapshot(next); setUndoCount(inspection?.undoCount ?? 0); }; useEffect(() => { + // A new inspection starts unmatched, even if its content equals the last one. + lastKey.current = null; refresh(); - setError(null); + setError(inspection?.error ?? 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); + const timer = window.setInterval(() => { if (!document.hidden) refresh(); }, 1000); + const unsubscribe = inspection.subscribe(() => { setError(inspection.error); refresh(); }); + return () => { window.clearInterval(timer); unsubscribe(); }; }, [inspection]); - // Outline the element on the page, with side handles to resize it. - useEffect(() => inspection ? startLayoutEditor(inspection, refresh) : undefined, [inspection]); - const edit = (name: string, value: string | null) => { + const edit = async (name: string, value: string | null) => { if (!inspection) return; try { - inspection.edit(group, name, value); + await inspection.edit(group, name, value); setError(null); refresh(); } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); } }; - const undo = () => { + const undo = async () => { if (!inspection) return; - try { inspection.undo(); setError(null); refresh(); } + try { await 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) => { + const add = async (event: SubmitEvent) => { event.preventDefault(); if (!inspection || !newName.trim()) return; try { - inspection.edit(group, newName.trim(), newValue); + await inspection.edit(group, newName.trim(), newValue); setNewName(''); setNewValue(''); setError(null); refresh(); } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); } }; @@ -132,14 +142,16 @@ section.bdt-pane.bdt-elements-panel(aria-label="Selected 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-element-row + strong.bdt-element-identity #{snapshot.label} + span.bdt-mono.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 : ''} + span.bdt-element-live #{saving ? 'Saving…' : error ? 'Not saved' : 'Saves to source'} + button.bdt-button(type="button" disabled={saving || undoCount === 0} 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.source + p.bdt-element-notice(role="status") This element has no Beast source location. Enable source tagging to save edits. 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 @@ -150,7 +162,7 @@ section.bdt-pane.bdt-elements-panel(aria-label="Selected element") 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. Drag the outline’s sides on the page to resize. Reloading restores the app’s original values. + p.bdt-element-note Edits save to the element’s .btsx file, including layout drags. Changes affect every instance rendered by this source element. The outline stays available when the panel is closed. .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)}) @@ -178,9 +190,9 @@ section.bdt-pane.bdt-elements-panel(aria-label="Selected element") if isOpen(category) div(className={cx('bdt-element-grid', category.columns === 2 && 'is-split')}) each property in category.properties key `${group}:${property.name}` - ElementPropertyRow(property={property} disabled={!snapshot.connected} removable={group === 'attributes' || (group === 'styles' && !!property.inline)} onCommit={edit}) + ElementPropertyRow(property={property} disabled={saving || !snapshot.source || !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 + input.bdt-input(aria-label={group === 'styles' ? 'New CSS property' : 'New attribute'} placeholder={group === 'styles' ? 'CSS property' : 'Attribute name'} value={newName} disabled={saving || !snapshot.source || !snapshot.connected} onInput={(event) => setNewName(event.currentTarget.value)}) + input.bdt-input(aria-label="New property value" placeholder="Value" value={newValue} disabled={saving || !snapshot.source || !snapshot.connected} onInput={(event) => setNewValue(event.currentTarget.value)}) + button.bdt-button(type="submit" disabled={saving || !snapshot.source || !snapshot.connected || !newName.trim()}) Add diff --git a/client/RefactorPanel.btsx b/client/RefactorPanel.btsx index 0418225..5d95c1b 100644 --- a/client/RefactorPanel.btsx +++ b/client/RefactorPanel.btsx @@ -112,6 +112,8 @@ setup const [selecting, setSelecting] = useState(false); const selectionRequest = useRef(0); const suggestionsPane = useRef(null); + let copyTimer: ReturnType; + useEffect(() => () => clearTimeout(copyTimer), []); const manualSuggestion = manual?.hash === current?.hash && manual?.path === file ? manual?.suggestion : null; const suggestions = [...(manualSuggestion ? [manualSuggestion] : []), ...(analysis?.suggestions ?? [])]; const selected = suggestions.find((suggestion) => suggestion.id === selectedId) ?? null; @@ -165,7 +167,8 @@ setup const copy = async (key: string, text: string) => { if (!(await copyText(text))) return; setCopied(key); - window.setTimeout(() => setCopied((value) => (value === key ? null : value)), 1400); + copyTimer = setTimeout(() => setCopied((value) => (value === key ? null : value)), 1400); + clearTimeout(copyTimer); }; useEffect(() => { setSelectedId(null); @@ -217,7 +220,6 @@ setup } }; -FileList(files={files} active={file} metrics={true} onSelect={onSelectFile}) .bdt-pane .bdt-toolbar span.bdt-toolbar-title.bdt-mono @@ -266,148 +268,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/api.ts b/client/api.ts index ea96c95..d53e682 100644 --- a/client/api.ts +++ b/client/api.ts @@ -86,3 +86,8 @@ export function fetchSelection(path: string, line: number, settings: AnalyzerSet export function continueProps(request: ContinuationRequest, project = ''): Promise { return post('/continue-props', request, project) } + +export function saveElementEdit(request: import('../shared/types.ts').ElementEditRequest): Promise { + // Picked DOM elements always belong to the running app, not a browsed project. + return post('/element-edit', request) +} diff --git a/client/devtools.css b/client/devtools.css index d557d49..fbff55d 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); } @@ -1772,10 +1805,12 @@ html:is(.bdt-component-finder-active, .bdt-element-picker-active) :not(#beast-de width: max-content; max-width: min(360px, calc(100vw - 16px)); padding: 12px 15px; - border: 1px solid rgba(255,255,255,.14); + border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 12px; - background: rgba(22,24,23,.96); - box-shadow: 0 8px 30px #0003, 0 1px 3px #0004; + background: rgba(22, 24, 23, 0.96); + box-shadow: + 0 8px 30px #0003, + 0 1px 3px #0004; backdrop-filter: blur(18px); white-space: nowrap; } @@ -1785,15 +1820,26 @@ html:is(.bdt-component-finder-active, .bdt-element-picker-active) :not(#beast-de color: #d9eadf; font-weight: 500; } -.bdt-element-picker-card > span { color: #a4a5a0; font-size: 11px; } +.bdt-element-picker-card > span { + color: #a4a5a0; + font-size: 11px; +} .bdt-element-picker-card small { grid-column: 1 / -1; color: #838b86; - font: 10px/1.4 ui-sans-serif, system-ui, sans-serif; - letter-spacing: .02em; + font: + 10px/1.4 ui-sans-serif, + system-ui, + sans-serif; + letter-spacing: 0.02em; +} +.bdt-element-picker-card.is-visible { + visibility: visible; +} +.bdt-element-picker-highlight { + border-color: #81b49b; + background: #81b49b0c; } -.bdt-element-picker-card.is-visible { visibility: visible; } -.bdt-element-picker-highlight { border-color: #81b49b; background: #81b49b0c; } /* Direct layout editing: the open element's outline and side handles. Only the handles take pointer events. */ .bdt-layout-frame { @@ -1806,17 +1852,49 @@ html:is(.bdt-component-finder-active, .bdt-element-picker-active) :not(#beast-de border: 1px solid #81b49b; pointer-events: none; } -.bdt-layout-frame.is-visible { display: block; } -html:is(.bdt-component-finder-active, .bdt-element-picker-active) .bdt-layout-frame { display: none; } -.bdt-layout-frame.is-fixed { border-style: dashed; } -.bdt-layout-frame.is-fixed .bdt-layout-handle { display: none; } -.bdt-layout-handle { position: absolute; pointer-events: auto; touch-action: none; } -.bdt-layout-handle.is-top, .bdt-layout-handle.is-bottom { left: 6px; right: 6px; height: 9px; cursor: ns-resize; } -.bdt-layout-handle.is-left, .bdt-layout-handle.is-right { top: 6px; bottom: 6px; width: 9px; cursor: ew-resize; } -.bdt-layout-handle.is-top { top: -5px; } -.bdt-layout-handle.is-bottom { bottom: -5px; } -.bdt-layout-handle.is-left { left: -5px; } -.bdt-layout-handle.is-right { right: -5px; } +.bdt-layout-frame.is-visible { + display: block; +} +html:is(.bdt-component-finder-active, .bdt-element-picker-active) .bdt-layout-frame { + display: none; +} +.bdt-layout-frame.is-fixed { + border-style: dashed; +} +.bdt-layout-frame.is-fixed .bdt-layout-handle { + display: none; +} +.bdt-layout-handle { + position: absolute; + pointer-events: auto; + touch-action: none; +} +.bdt-layout-handle.is-top, +.bdt-layout-handle.is-bottom { + left: 6px; + right: 6px; + height: 9px; + cursor: ns-resize; +} +.bdt-layout-handle.is-left, +.bdt-layout-handle.is-right { + top: 6px; + bottom: 6px; + width: 9px; + cursor: ew-resize; +} +.bdt-layout-handle.is-top { + top: -5px; +} +.bdt-layout-handle.is-bottom { + bottom: -5px; +} +.bdt-layout-handle.is-left { + left: -5px; +} +.bdt-layout-handle.is-right { + right: -5px; +} /* A small grip marks the middle of each side. */ .bdt-layout-handle::after { content: ''; @@ -1829,9 +1907,18 @@ html:is(.bdt-component-finder-active, .bdt-element-picker-active) .bdt-layout-fr transform: translate(-50%, -50%); transition: background-color 120ms ease; } -.bdt-layout-handle:is(.is-top, .is-bottom)::after { width: 16px; height: 5px; } -.bdt-layout-handle:is(.is-left, .is-right)::after { width: 5px; height: 16px; } -.bdt-layout-handle:hover::after, .bdt-layout-frame.is-dragging .bdt-layout-handle::after { background: #b8f0d2; } +.bdt-layout-handle:is(.is-top, .is-bottom)::after { + width: 16px; + height: 5px; +} +.bdt-layout-handle:is(.is-left, .is-right)::after { + width: 5px; + height: 16px; +} +.bdt-layout-handle:hover::after, +.bdt-layout-frame.is-dragging .bdt-layout-handle::after { + background: #b8f0d2; +} .bdt-layout-size { position: absolute; bottom: calc(100% + 6px); @@ -1841,71 +1928,315 @@ html:is(.bdt-component-finder-active, .bdt-element-picker-active) .bdt-layout-fr border-radius: 4px; background: #121312; color: #d9eadf; - font: 11px/1.4 ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + font: + 11px/1.4 ui-monospace, + SFMono-Regular, + 'SF Mono', + Menlo, + Consolas, + monospace; letter-spacing: normal; white-space: nowrap; } -.bdt-layout-frame.is-dragging .bdt-layout-size { display: block; } -html.bdt-layout-dragging, html.bdt-layout-dragging * { user-select: none !important; } -html.bdt-layout-dragging:is([data-bdt-layout-edge='left'], [data-bdt-layout-edge='right']) * { cursor: ew-resize !important; } -html.bdt-layout-dragging:is([data-bdt-layout-edge='top'], [data-bdt-layout-edge='bottom']) * { cursor: ns-resize !important; } - -.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; container-type: inline-size; } -.bdt-element-section + .bdt-element-section { margin-top: 6px; } -.bdt-element-section-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 0 6px; cursor: pointer; color: var(--bdt-muted); font-size: 11px; font-weight: 600; text-align: left; } -.bdt-element-section-head:hover:not(:disabled) { color: var(--bdt-text); } -.bdt-element-section-head:disabled { cursor: default; } -.bdt-element-section-head:focus-visible { outline: 1px solid var(--bdt-focus); outline-offset: 2px; border-radius: 4px; } -.bdt-element-section-head.is-editable { color: var(--bdt-ok); } -.bdt-element-section-chevron { display: inline-block; width: 8px; transition: transform 160ms var(--bdt-ease-out); } -.bdt-element-section-head[aria-expanded='true'] .bdt-element-section-chevron { transform: rotate(90deg); } -.bdt-element-section-head > .bdt-dim { font-weight: 400; } -.bdt-element-grid { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 28px; } -.bdt-element-grid.is-split { grid-template-columns: repeat(2, minmax(0, 1fr)); } -@container (max-width: 560px) { .bdt-element-grid.is-split { grid-template-columns: minmax(0, 1fr); } } -.bdt-element-property { display: grid; grid-template-columns: minmax(90px, min(40%, 190px)) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 37px; border-bottom: 1px solid var(--bdt-line); } -.bdt-element-grid:not(.is-split) .bdt-element-property { grid-template-columns: minmax(130px, 32%) minmax(0, 1fr) auto; } -.bdt-switch { position: relative; justify-self: start; width: 28px; height: 16px; flex: none; border-radius: 999px; background: var(--bdt-bg-4); cursor: pointer; transition: background-color 160ms var(--bdt-ease-out); } -.bdt-switch.is-on { background: var(--bdt-ok); } -.bdt-switch:disabled { cursor: default; opacity: 0.45; } -.bdt-switch:focus-visible { outline: 1px solid var(--bdt-focus); outline-offset: 2px; } -.bdt-switch-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--bdt-text); transition: transform 160ms var(--bdt-ease-out); } -.bdt-switch.is-on .bdt-switch-thumb { transform: translateX(12px); } +.bdt-layout-frame.is-dragging .bdt-layout-size { + display: block; +} +html.bdt-layout-dragging, +html.bdt-layout-dragging * { + user-select: none !important; +} +html.bdt-layout-dragging:is([data-bdt-layout-edge='left'], [data-bdt-layout-edge='right']) * { + cursor: ew-resize !important; +} +html.bdt-layout-dragging:is([data-bdt-layout-edge='top'], [data-bdt-layout-edge='bottom']) * { + cursor: ns-resize !important; +} + +.bdt-element-toolbar { + padding: 10px; + gap: 10px; +} + +.bdt-element-row { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; + padding: 0 8px; + color: var(--lime); +} +.bdt-element-identity { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; + font-size: 16px; +} +.bdt-element-identity strong { + gap: 10px; + 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; + container-type: inline-size; +} +.bdt-element-section + .bdt-element-section { + margin-top: 6px; +} +.bdt-element-section-head { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 0 6px; + cursor: pointer; + color: var(--bdt-muted); + font-size: 11px; + font-weight: 600; + text-align: left; +} +.bdt-element-section-head:hover:not(:disabled) { + color: var(--bdt-text); +} +.bdt-element-section-head:disabled { + cursor: default; +} +.bdt-element-section-head:focus-visible { + outline: 1px solid var(--bdt-focus); + outline-offset: 2px; + border-radius: 4px; +} +.bdt-element-section-head.is-editable { + color: var(--bdt-ok); +} +.bdt-element-section-chevron { + display: inline-block; + width: 8px; + transition: transform 160ms var(--bdt-ease-out); +} +.bdt-element-section-head[aria-expanded='true'] .bdt-element-section-chevron { + transform: rotate(90deg); +} +.bdt-element-section-head > .bdt-dim { + font-weight: 400; +} +.bdt-element-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + column-gap: 28px; +} +.bdt-element-grid.is-split { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +@container (max-width: 560px) { + .bdt-element-grid.is-split { + grid-template-columns: minmax(0, 1fr); + } +} +.bdt-element-property { + display: grid; + grid-template-columns: minmax(90px, min(40%, 190px)) minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 37px; + border-bottom: 1px solid var(--bdt-line); +} +.bdt-element-grid:not(.is-split) .bdt-element-property { + grid-template-columns: minmax(130px, 32%) minmax(0, 1fr) auto; +} +.bdt-switch { + position: relative; + justify-self: start; + width: 28px; + height: 16px; + flex: none; + border-radius: 999px; + background: var(--bdt-bg-4); + cursor: pointer; + transition: background-color 160ms var(--bdt-ease-out); +} +.bdt-switch.is-on { + background: var(--bdt-ok); +} +.bdt-switch:disabled { + cursor: default; + opacity: 0.45; +} +.bdt-switch:focus-visible { + outline: 1px solid var(--bdt-focus); + outline-offset: 2px; +} +.bdt-switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--bdt-text); + transition: transform 160ms var(--bdt-ease-out); +} +.bdt-switch.is-on .bdt-switch-thumb { + transform: translateX(12px); +} @media (prefers-reduced-motion: reduce) { - .bdt-switch, .bdt-switch-thumb, .bdt-element-section-chevron { transition: none; } -} -.bdt-element-property-name { color: var(--bdt-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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); } + .bdt-switch, + .bdt-switch-thumb, + .bdt-element-section-chevron { + transition: none; + } +} +.bdt-element-property-name { + color: var(--bdt-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + 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, .bdt-element-grid:not(.is-split) .bdt-element-property { grid-template-columns: minmax(85px, 34%) minmax(0, 1fr) auto; gap: 5px; } - .bdt-element-inline { display: none; } + .bdt-element-nav { + width: 135px; + padding: 6px; + } + .bdt-element-toolbar { + flex-wrap: wrap; + } + .bdt-element-filter { + flex-wrap: wrap; + gap: 8px; + } + .bdt-element-property, + .bdt-element-grid:not(.is-split) .bdt-element-property { + grid-template-columns: minmax(85px, 34%) minmax(0, 1fr) auto; + gap: 5px; + } + .bdt-element-inline { + display: none; + } } .bdt-project-picker { diff --git a/client/element-inspector.test.ts b/client/element-inspector.test.ts new file mode 100644 index 0000000..0f62cbe --- /dev/null +++ b/client/element-inspector.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'bun:test' +import { snapshotKey, type ElementSnapshot } from './element-inspector.ts' + +const snapshot = (extra: Partial = {}): ElementSnapshot => ({ + label: 'div#card', + component: 'Card', + source: 'src/Card.btsx:10:3', + connected: true, + width: 200, + height: 100, + attributes: [{ name: 'id', value: 'card', editable: true }], + styles: [{ name: 'display', value: 'block', editable: true, inline: false }], + properties: [{ name: 'id', value: 'card', editable: true, type: 'string' }], + ...extra, +}) + +test('null snapshots share one key', () => { + expect(snapshotKey(null)).toBe(snapshotKey(null)) +}) + +test('structurally identical snapshots share a key, so idle ticks skip setState', () => { + expect(snapshotKey(snapshot())).toBe(snapshotKey(snapshot())) +}) + +test('any visible change produces a new key', () => { + const before = snapshotKey(snapshot()) + expect(snapshotKey(snapshot({ width: 201 }))).not.toBe(before) + expect(snapshotKey(snapshot({ connected: false }))).not.toBe(before) + expect(snapshotKey(snapshot({ attributes: [] }))).not.toBe(before) + expect(snapshotKey(snapshot({ styles: [] }))).not.toBe(before) + expect(snapshotKey(snapshot({ properties: [] }))).not.toBe(before) + expect(snapshotKey(null)).not.toBe(before) +}) diff --git a/client/element-inspector.ts b/client/element-inspector.ts index 0625929..e7d66f9 100644 --- a/client/element-inspector.ts +++ b/client/element-inspector.ts @@ -1,4 +1,5 @@ -import { COMPONENT_ATTRIBUTE, SOURCE_ATTRIBUTE } from '../shared/types.ts' +import { fetchFile, saveElementEdit, undoRefactor } from './api.ts' +import { DEFAULT_SETTINGS, type ElementEditRequest, COMPONENT_ATTRIBUTE, SOURCE_ATTRIBUTE } from '../shared/types.ts' export type ElementPropertyGroup = 'styles' | 'attributes' | 'properties' export interface ElementProperty { @@ -11,8 +12,8 @@ export interface ElementProperty { export interface StyleEdit { /** Applies CSS declarations to the element right away. */ set(declarations: Readonly>): void - /** Keeps the changes as one undo step. */ - commit(): void + /** Saves the changes to source as one undo step. */ + commit(): Promise /** Restores the inline style from before the edit. */ cancel(): void } @@ -30,7 +31,7 @@ export interface ElementSnapshot { const EDITABLE_PROPERTIES = new Set([ 'value', 'checked', 'selected', 'disabled', 'hidden', 'readOnly', 'required', 'multiple', 'tabIndex', 'title', 'id', 'className', 'placeholder', 'textContent', - 'innerText', 'lang', 'dir', 'draggable', 'spellcheck', 'contentEditable', 'inert', 'autofocus', 'translate', 'accessKey', 'open', 'indeterminate', + 'innerText', 'lang', 'dir', 'draggable', 'spellcheck', 'contentEditable', 'inert', 'autofocus', 'translate', 'accessKey', 'open', 'name', 'type', 'href', 'src', 'alt', 'min', 'max', 'step', 'pattern', 'maxLength', 'minLength', ]) // Replacing these drops element children, so only offer them on text-only elements. @@ -42,6 +43,15 @@ export function elementLabel(element: Element): string { return `${element.localName}${element.id ? `#${element.id}` : ''}` } +/** + * Stable identity for a capture: captures hold only plain data in a fixed + * shape, so identical elements stringify identically. The Elements panel + * compares this key to skip re-rendering on idle refresh ticks. + */ +export function snapshotKey(snapshot: ElementSnapshot | null): string { + return snapshot === null ? 'null' : JSON.stringify(snapshot) +} + 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 @@ -101,15 +111,89 @@ export function captureElement(element: Element): ElementSnapshot { } } -/** Live edits belong to the selected DOM node, and survive panel/tab changes. */ +/** Source-backed edits survive reloads; the selected node is rebound after HMR. */ +const MAX_UNDO_STEPS = 50 + export class ElementInspection { - readonly element: Element + #element: Element + readonly #source: string | null + readonly #index: number + readonly #ready: Promise + #hash = '' + #version = 0 + #saved: Array<{ id: string; hash: string }> = [] + #listeners = new Set<() => void>() + saving = false + error: string | null = null + subscribe(listener: () => void): () => void { + this.#listeners.add(listener) + return () => { this.#listeners.delete(listener) } + } + #notify(): void { for (const listener of this.#listeners) listener() } + get element(): Element { + if (!this.#element.isConnected && this.#source) { + const matches = [...document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`)].filter((el) => el.getAttribute(SOURCE_ATTRIBUTE) === this.#source) + this.#element = matches[this.#index] ?? this.#element + } + return this.#element + } readonly #undo: Array<() => void> = [] - constructor(element: Element) { this.element = element } - get undoCount(): number { return this.#undo.length } + constructor(element: Element) { + this.#element = element + this.#source = element.getAttribute(SOURCE_ATTRIBUTE) + this.#index = [...document.querySelectorAll(`[${SOURCE_ATTRIBUTE}]`)].filter((el) => el.getAttribute(SOURCE_ATTRIBUTE) === this.#source).indexOf(element) + const location = this.#location() + this.#ready = location ? fetchFile(location.path, DEFAULT_SETTINGS).then((report) => { this.#hash = report.hash }).catch((error) => { + this.error = error instanceof Error ? error.message : String(error) + this.#notify() + }) : Promise.resolve() + } + #location(): { path: string; line: number; column: number } | null { + const match = /^(.+):(\d+):(\d+)$/.exec(this.#source ?? '') + return match ? { path: match[1]!, line: Number(match[2]), column: Number(match[3]) } : null + } + #check(): void { + if (this.saving) throw new Error('Wait for the current edit to finish saving.') + if (!this.#source) throw new Error('This element has no Beast source location. Enable source tagging to save edits.') + } + async #save(change: Pick): Promise { + await this.#ready + const location = this.#location() + if (!location || !this.#hash) throw new Error(this.error ?? 'The source file could not be loaded. Pick the element again.') + const previous = this.#hash + const result = await saveElementEdit({ ...location, hash: previous, tag: this.element.localName, ...change }) + this.#hash = result.hash + this.#saved.push({ id: result.undoId, hash: previous }) + if (this.#saved.length > MAX_UNDO_STEPS) this.#saved.shift() + if (this.#undo.length > MAX_UNDO_STEPS) this.#undo.shift() + } + async edit(group: ElementPropertyGroup, name: string, value: string | null): Promise { + this.#check() + const version = this.#version + this.#editLive(group, name, value) + if (this.#version === version) return + this.saving = true + this.error = null + this.#notify() + try { + await this.#save({ group, name, value: group === 'properties' ? Reflect.get(this.element, name) as string | number | boolean : value, + ...(group === 'styles' ? { declarations: { [name]: value }, cssText: inlineStyle(this.element).cssText } : {}) }) + } catch (error) { + this.#undo.pop()?.() + this.error = error instanceof Error ? error.message : String(error) + throw error + } finally { this.saving = false; this.#notify() } + } + get canSave(): boolean { return this.#source !== null } + get undoCount(): number { return this.#saved.length } capture(): ElementSnapshot { return captureElement(this.element) } - edit(group: ElementPropertyGroup, name: string, value: string | null): void { + #pushUndo(step: () => void): void { + this.#version++ + this.#undo.push(step) + } + + #editLive(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') { @@ -123,7 +207,7 @@ export class ElementInspection { style.setProperty(name, next, important ? 'important' : '') } if (element.getAttribute('style') === oldStyle) return - this.#undo.push(() => { + this.#pushUndo(() => { // Restore the complete declaration, including longhands affected by a shorthand. if (oldStyle === null) element.removeAttribute('style') else element.setAttribute('style', oldStyle) @@ -134,7 +218,7 @@ export class ElementInspection { 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) }) + this.#pushUndo(() => { 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.') @@ -144,12 +228,14 @@ export class ElementInspection { 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) }) + this.#pushUndo(() => { Reflect.set(element, name, before) }) } } /** Live inline-style changes, such as a drag, that land as one undo step. */ beginStyleEdit(): StyleEdit { + this.#check() + const declarations: Record = {} const element = this.element if (!element.isConnected) throw new Error('This element is no longer on the page. Pick it again.') const style = inlineStyle(element) @@ -160,13 +246,25 @@ export class ElementInspection { } let done = false return { - set: (declarations) => { - if (!done) for (const [name, value] of Object.entries(declarations)) style.setProperty(name, value) + set: (values) => { + if (!done) for (const [name, value] of Object.entries(values)) { declarations[name] = value; style.setProperty(name, value) } }, - commit: () => { + commit: async () => { if (done) return done = true - if (element.getAttribute('style') !== before) this.#undo.push(restore) + if (element.getAttribute('style') === before) return + this.saving = true + this.error = null + this.#notify() + try { + await this.#save({ group: 'styles', name: '', value: null, declarations, cssText: style.cssText }) + this.#pushUndo(restore) + if (this.#undo.length > MAX_UNDO_STEPS) this.#undo.shift() + } catch (error) { + restore() + this.error = error instanceof Error ? error.message : String(error) + throw error + } finally { this.saving = false; this.#notify() } }, cancel: () => { if (done) return @@ -176,9 +274,21 @@ export class ElementInspection { } } - 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() } + async undo(): Promise { + this.#check() + const saved = this.#saved.at(-1) + if (!saved) return + this.saving = true + this.error = null + this.#notify() + try { + await undoRefactor(saved.id) + this.#hash = saved.hash + this.#saved.pop() + this.#undo.pop()?.() + } catch (error) { + this.error = error instanceof Error ? error.message : String(error) + throw error + } finally { this.saving = false; this.#notify() } } } diff --git a/client/element-layout.ts b/client/element-layout.ts index 94fa0d7..9afe872 100644 --- a/client/element-layout.ts +++ b/client/element-layout.ts @@ -59,7 +59,7 @@ export function canResize(element: Pick, /** Shows the outline and side handles until the returned cleanup runs. */ export function startLayoutEditor(inspection: ElementInspection, onCommit: () => void): () => void { - const element = inspection.element + if (!inspection.canSave) return () => {} const frame = document.createElement('div') frame.className = 'bdt-layout-frame' const size = document.createElement('span') @@ -80,6 +80,7 @@ export function startLayoutEditor(inspection: ElementInspection, onCommit: () => const place = () => { frameRequest = requestAnimationFrame(place) + const element = inspection.element const rect = element.getBoundingClientRect() const display = element.isConnected ? getComputedStyle(element).display : 'none' const visible = element.isConnected && display !== 'none' @@ -89,10 +90,10 @@ export function startLayoutEditor(inspection: ElementInspection, onCommit: () => frame.style.transform = `translate(${rect.left}px, ${rect.top}px)` frame.style.width = `${rect.width}px` frame.style.height = `${rect.height}px` - size.textContent = `${Number(rect.width.toFixed(1))} × ${Number(rect.height.toFixed(1))}` + size.textContent = inspection.error ?? (inspection.saving ? 'Saving…' : `${Number(rect.width.toFixed(1))} × ${Number(rect.height.toFixed(1))}`) } - const finish = (keep: boolean) => { + const finish = (keep: boolean, notify = true) => { if (drag === null) return const { edit, handle, pointer } = drag drag = null @@ -101,9 +102,9 @@ export function startLayoutEditor(inspection: ElementInspection, onCommit: () => frame.classList.remove('is-dragging') delete document.documentElement.dataset.bdtLayoutEdge window.removeEventListener('keydown', onKeyDown, true) - if (keep) edit.commit() + if (keep) void edit.commit().catch(() => {}).finally(onCommit) else edit.cancel() - onCommit() + if (notify) onCommit() } const onKeyDown = (event: KeyboardEvent) => { @@ -114,6 +115,7 @@ export function startLayoutEditor(inspection: ElementInspection, onCommit: () => } const begin = (event: PointerEvent, edge: Edge, handle: HTMLElement) => { + const element = inspection.element if (event.button !== 0 || drag !== null || !element.isConnected) return const computed = getComputedStyle(element) if (!canResize(element, computed.display)) return @@ -149,7 +151,10 @@ export function startLayoutEditor(inspection: ElementInspection, onCommit: () => place() return () => { cancelAnimationFrame(frameRequest) - finish(true) + // Tearing down (unmount or element switch) reverts a partial drag instead + // of committing it, and stays silent: the panel is gone, so notifying it + // would setState on an unmounted component. + finish(false, false) frame.remove() } } 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, }, } diff --git a/server/devtools.test.ts b/server/devtools.test.ts index c3a72a3..366cda7 100644 --- a/server/devtools.test.ts +++ b/server/devtools.test.ts @@ -170,3 +170,21 @@ test('refactor continuation suggestions honor settings and support preview, appl expect((await post('/undo', { id: undoId })).status).toBe(200) expect(readFileSync(running.appPath, 'utf8')).toBe(source) }) + +test('element edits persist through the JSON endpoint with conflict checks and undo', async () => { + const { running, get, post } = await fixture() + writeFileSync(running.appPath, 'div Hello\n') + const file = await (await get('/file?path=src/App.btsx')).json() as FileReport + const request = { path: file.path, hash: file.hash, line: 1, column: 1, tag: 'div', group: 'attributes', name: 'title', value: 'Saved' } + expect((await post('/element-edit', request, { Origin: 'http://other.example' })).status).toBe(403) + expect((await post('/element-edit', { ...request, group: 'unknown' })).status).toBe(422) + expect((await post('/element-edit', { ...request, declarations: { width: {} } })).status).toBe(422) + const result = await post('/element-edit', request) + expect(result.status).toBe(200) + const { hash, undoId } = await result.json() + expect(hash).not.toBe(file.hash) + expect(readFileSync(running.appPath, 'utf8')).toContain('title={"Saved"}') + expect((await post('/element-edit', request)).status).toBe(409) + expect((await post('/undo', { id: undoId })).status).toBe(200) + expect(readFileSync(running.appPath, 'utf8')).toBe('div Hello\n') +}) diff --git a/server/devtools.ts b/server/devtools.ts index 54d9aa4..1a4b051 100644 --- a/server/devtools.ts +++ b/server/devtools.ts @@ -184,6 +184,21 @@ export function createDevtoolsServer(options: DevtoolsServerOptions): DevtoolsSe line: Number(body.line), dryRun: body.dryRun !== false, })) } + if (url.pathname === '/element-edit') { + if (!['styles', 'attributes', 'properties'].includes(String(body.group)) || + !Number.isSafeInteger(body.line) || Number(body.line) < 1 || !Number.isSafeInteger(body.column) || Number(body.column) < 1 || + !['string', 'number', 'boolean'].includes(typeof body.value) && body.value !== null || + body.declarations !== undefined && (body.declarations === null || typeof body.declarations !== 'object' || Array.isArray(body.declarations) || Object.values(body.declarations).some((value) => value !== null && typeof value !== 'string'))) { + return send(422, { error: 'Invalid element edit.' }) + } + return send(200, selected.project.editElement({ + path: String(body.path ?? ''), hash: String(body.hash ?? ''), line: Number(body.line), column: Number(body.column), + tag: String(body.tag ?? ''), group: body.group as 'styles' | 'attributes' | 'properties', + name: String(body.name ?? ''), value: body.value as string | number | boolean | null, + declarations: body.declarations as Record | undefined, + cssText: typeof body.cssText === 'string' ? body.cssText : undefined, + })) + } if (url.pathname === '/undo') return send(200, selected.project.undo(String(body.id ?? ''))) return send(404, { error: `Unknown endpoint ${url.pathname}` }) } catch (error) { diff --git a/server/element-edits.test.ts b/server/element-edits.test.ts new file mode 100644 index 0000000..50acf26 --- /dev/null +++ b/server/element-edits.test.ts @@ -0,0 +1,110 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parse } from 'beast-tsrx' +import { DEFAULT_SETTINGS, type ElementEditRequest } from '../shared/types.ts' +import { BeastProject } from './project.ts' +import { editElement } from './element-edits.ts' + +const roots: string[] = [] +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) +function fixture(source: string) { + const root = mkdtempSync(join(tmpdir(), 'beast-element-edit-')) + roots.push(root) + mkdirSync(join(root, 'src')) + const filename = join(root, 'src/App.btsx') + writeFileSync(filename, source) + const project = new BeastProject({ root, include: ['src'], exclude: [] }) + const request: ElementEditRequest = { path: 'src/App.btsx', hash: project.file('src/App.btsx', DEFAULT_SETTINGS)!.hash, line: 1, column: 1, tag: 'div', group: 'attributes', name: 'title', value: 'Changed' } + return { project, filename, request } +} + +test('attribute edits save to disk, preserve handlers and interpolation, and undo restores exact bytes', () => { + const source = 'setup\n const name = "world";\n const click = () => {};\ndiv.foo(title="Hello #{name}" onClick={click}) Hello #{name}\n' + const { project, filename, request } = fixture(source) + const result = project.editElement({ ...request, line: 4, name: 'id', value: 'card' }) + const after = readFileSync(filename, 'utf8') + expect(after).toContain('title="Hello #{name}"') + expect(after).toContain('onClick={click}') + expect(after).toContain('id={"card"}') + expect(after).toContain('#{name}') + project.undo(result.undoId) + expect(readFileSync(filename, 'utf8')).toBe(source) +}) + +test('layout dimensions preserve dynamic styles and continuation source line positions', () => { + const source = 'setup\n const color = "red";\ndiv(\n ~ style={{ color }}\n ~ )\n span Child\n' + const { project, filename, request } = fixture(source) + project.editElement({ ...request, line: 3, group: 'styles', name: '', value: null, declarations: { width: '320px', 'margin-left': '12px' } }) + const after = readFileSync(filename, 'utf8') + expect(after).toContain('...({ color })') + expect(after).toContain('"width": "320px"') + expect(after).toContain('"margin-left": "12px"') + const node = parse(after).children[0]! + expect(node.kind === 'element' && node.children[0]!.span.start.line).toBe(6) +}) + +test('class and id edits replace selector shorthand and attribute values safely', () => { + const { project, filename, request } = fixture('div.old#old(className="extra") Hi\n') + const result = project.editElement({ ...request, name: 'class', value: 'new #{literal}' }) + expect(readFileSync(filename, 'utf8')).toContain('div#old(class={"new #{literal}"})') + project.editElement({ ...request, hash: result.hash, name: 'id', value: null }) + expect(readFileSync(filename, 'utf8')).not.toContain('#old') +}) + +test('property edits retain primitive types and text edits replace text children only', () => { + const { project, filename, request } = fixture('div\n | Old\n') + const result = project.editElement({ ...request, group: 'properties', name: 'hidden', value: true }) + expect(readFileSync(filename, 'utf8')).toContain('hidden={true}') + project.editElement({ ...request, hash: result.hash, group: 'properties', name: 'textContent', value: 'New "text" #{literal}' }) + expect(readFileSync(filename, 'utf8')).not.toContain('| Old') + expect(readFileSync(filename, 'utf8')).toContain('New \\"text\\" #{literal}') +}) + +test('stale writes and undo conflicts leave external edits untouched', () => { + const { project, filename, request } = fixture('div Original\n') + const result = project.editElement(request) + expect(() => project.editElement(request)).toThrow('changed') + writeFileSync(filename, 'div External edit\n') + expect(() => project.undo(result.undoId)).toThrow('edited after') + expect(readFileSync(filename, 'utf8')).toBe('div External edit\n') +}) + +test('reject missing elements, injected attributes, non-project paths and replacing child elements', () => { + const { project, request } = fixture('div\n span Child\n') + expect(() => project.editElement({ ...request, tag: 'span' })).toThrow('changed') + expect(() => project.editElement({ ...request, name: 'onclick' })).toThrow('cannot be saved') + expect(() => project.editElement({ ...request, path: '../outside.btsx' })).toThrow('Unknown') + expect(() => project.editElement({ ...request, group: 'properties', name: 'textContent' })).toThrow('child elements') +}) + +test('literal styles use browser-normalized CSS and preserve unrelated attributes', () => { + const source = 'div(style="color: red; width: 10px" title="hi")\n' + const { project, filename, request } = fixture(source) + project.editElement({ ...request, group: 'styles', name: 'width', value: null, declarations: { width: null }, cssText: 'color: red;' }) + expect(readFileSync(filename, 'utf8')).toContain('style={"color: red;"}') + expect(readFileSync(filename, 'utf8')).toContain('title="hi"') +}) + +test('elements in local components and control flow can be located', () => { + const source = 'component Card\n if true\n div Hello\nCard\n' + const request: ElementEditRequest = { path: 'src/App.btsx', hash: '', line: 3, column: 5, tag: 'div', group: 'attributes', name: 'title', value: 'Saved' } + expect(editElement(parse(source), source, request)).toContain('title={"Saved"}') +}) + +test('boolean attributes and reflected properties preserve DOM semantics', () => { + const { project, filename, request } = fixture('input(readonly)\n') + const result = project.editElement({ ...request, tag: 'input', group: 'properties', name: 'readOnly', value: false }) + expect(readFileSync(filename, 'utf8')).toContain('readOnly={false}') + expect(readFileSync(filename, 'utf8')).not.toContain('(readonly ') + project.editElement({ ...request, hash: result.hash, tag: 'input', name: 'disabled', value: '' }) + expect(readFileSync(filename, 'utf8')).toContain('disabled={true}') +}) + +test('computed style strings are refused rather than losing expressions', () => { + const source = 'setup\n const color = "red";\ndiv(style={`color: ${color}`})\n' + const { project, filename, request } = fixture(source) + expect(() => project.editElement({ ...request, line: 3, group: 'styles', declarations: { width: '10px' }, cssText: 'color: red; width: 10px;' })).toThrow('computed outside') + expect(readFileSync(filename, 'utf8')).toBe(source) +}) diff --git a/server/element-edits.ts b/server/element-edits.ts new file mode 100644 index 0000000..ea01e5f --- /dev/null +++ b/server/element-edits.ts @@ -0,0 +1,77 @@ +import type { BeastDocument, BeastNode, ElementNode } from 'beast-tsrx' +import type { ElementEditRequest } from '../shared/types.js' +import { RefactorError } from './refactor.js' + +function elements(nodes: readonly BeastNode[]): ElementNode[] { + return nodes.flatMap((node): ElementNode[] => { + switch (node.kind) { + case 'element': return [node, ...elements(node.children)] + case 'fragment': case 'scope': return elements(node.children) + case 'if': case 'switch': return node.branches.flatMap((branch) => elements(branch.children)) + case 'each': return [...elements(node.children), ...elements(node.emptyChildren ?? [])] + case 'try': return [...elements(node.children), ...elements(node.pendingBranch?.children ?? []), ...elements(node.catchBranch?.children ?? [])] + default: return [] + } + }) +} + +const BOOLEAN_ATTRIBUTES = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected']) + +const literal = (value: unknown) => JSON.stringify(value).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029') + +/** Rewrite only the selected element header; retain line positions for source tags. */ +export function editElement(document: BeastDocument, source: string, request: ElementEditRequest): string { + const all = [...elements(document.children), ...document.declarations.flatMap((d) => d.kind === 'component' ? elements(d.children) : [])] + const node = all.find((n) => n.span.start.line === request.line && n.span.start.column === request.column) + if (!node || node.isComponent || node.tag !== request.tag) throw new RefactorError('The source element changed. Pick it again.', 409) + let attrs = node.attrs.map((attr) => source.slice(attr.span.start.offset, attr.span.end.offset).replace(/\r?\n[ \t]*~ ?/g, ' ')) + let classes = node.classes + let id = node.id + let text = node.inlineSpans?.map((span) => span.type === 'expr' ? `#{${span.code}}` : `#{${literal(span.text)}}`).join('') ?? '' + const patches: Array<{ start: number; end: number; value: string }> = [] + const replaceAttr = (names: string[], code: string | null) => { + attrs = attrs.filter((_, i) => { const a = node.attrs[i]; return !a || a.kind !== 'attribute' || !names.includes(a.name) }) + if (code !== null) attrs.push(code) + } + if (request.group === 'styles') { + const attr = node.attrs.find((a) => a.kind === 'attribute' && a.name === 'style') + let base = '{}' + if (attr?.kind === 'attribute' && attr.value.type !== 'bool') { + if (attr.value.type === 'string') { + // A literal CSS string can be replaced with the browser-normalized declaration. + if (attr.value.value.includes('#{')) throw new RefactorError('This style string contains expressions. Edit the full style attribute or use an object literal to preserve them.', 422) + base = '' + } else { + const code = attr.value.code.trim() + if (/^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')$/.test(code)) base = '' + else if (code.startsWith('{') && code.endsWith('}')) base = code + else throw new RefactorError('This style is computed outside the element. Edit the full style attribute or use an object literal to preserve its behavior.', 422) + } + } + const declarations = request.declarations ?? {} + if (Object.keys(declarations).length === 0) throw new RefactorError('No style changes supplied.', 422) + for (const name of Object.keys(declarations)) if (!/^--[\w-]+$|^[a-zA-Z][\w-]*$/.test(name)) throw new RefactorError('Invalid CSS property.', 422) + const overrides = Object.entries(declarations).map(([name, value]) => `${literal(name)}: ${literal(value ?? '')}`).join(', ') + replaceAttr(['style'], base === '' ? `style={${literal(request.cssText ?? '')}}` : `style={{ ...(${base}), ${overrides} }}`) + } else { + const { name, value } = request + if (!/^[a-zA-Z_][\w:.-]*$/.test(name) || /^on/i.test(name) || name.startsWith('data-beast-') || ['innerHTML', 'outerHTML', 'srcdoc'].includes(name)) throw new RefactorError('This property cannot be saved from the Elements panel.', 422) + if (request.group === 'properties' && ['textContent', 'innerText'].includes(name)) { + if (node.children.some((child) => child.kind !== 'text')) throw new RefactorError('Text edits cannot replace child elements.', 422) + text = `#{${literal(value ?? '')}}` + for (const child of node.children) patches.push({ start: child.span.start.offset, end: child.span.end.offset, value: (source.includes('\r\n') ? '\r\n' : '\n').repeat((source.slice(child.span.start.offset, child.span.end.offset).match(/\n/g) ?? []).length) }) + } else { + const names = name === 'class' || name === 'className' ? ['class', 'className'] : [name, name.toLowerCase()] + if (names.includes('class')) classes = [] + if (name === 'id') id = null + const savedValue = request.group === 'attributes' && BOOLEAN_ATTRIBUTES.has(name.toLowerCase()) ? true : name === 'translate' && typeof value === 'boolean' ? (value ? 'yes' : 'no') : value + replaceAttr(names, value === null ? null : `${name}={${literal(savedValue)}}`) + } + } + const selector = `${node.tag}${id ? '#' + id : ''}${classes.map((name) => '.' + name).join('')}` + const header = `${selector}${attrs.length ? '(' + attrs.join(' ') + ')' : ''}${text ? ' ' + text : ''}` + const old = source.slice(node.span.start.offset, node.span.end.offset) + patches.push({ start: node.span.start.offset, end: node.span.end.offset, value: header + (source.includes('\r\n') ? '\r\n' : '\n').repeat((old.match(/\n/g) ?? []).length) }) + for (const patch of patches.sort((a, b) => b.start - a.start)) source = source.slice(0, patch.start) + patch.value + source.slice(patch.end) + return source +} diff --git a/server/project.ts b/server/project.ts index 59a4dd5..9508b96 100644 --- a/server/project.ts +++ b/server/project.ts @@ -13,6 +13,8 @@ import { import { createOctaneCompiler } from 'octane/compiler/bundler' import type { AnalyzerSettings, + ElementEditRequest, + ElementEditResult, ApplyRequest, ContinuationRequest, ApplyResult, @@ -25,6 +27,7 @@ import type { RefactorSuggestion, UndoResult, } from '../shared/types.js' +import { editElement } from './element-edits.js' import { continuationEdits, continueProps } from './continuation.js' import { entryComponents } from './entry.js' import { analyzeDocument, renameSuggestion, type AnalyzeOptions } from './analyze.js' @@ -232,6 +235,23 @@ export class BeastProject { return { undoId, component: '', summary, files } } + editElement(request: ElementEditRequest): ElementEditResult { + 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 source file changed. Pick the element again before editing.', 409) + if (entry.result === null) throw new RefactorError('The source file does not compile.', 422) + const after = editElement(entry.result.ast, entry.source, request) + const change = { absolutePath, before: entry.source, after } + this.#validate(change) + this.#write([change]) + const undoId = randomUUID() + this.#applied.set(undoId, { summary: 'Element edit', changes: [change] }) + if (this.#applied.size > UNDO_LIMIT) this.#applied.delete(this.#applied.keys().next().value!) + return { hash: contentHash(after), undoId } + } + /** 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/shared/types.ts b/shared/types.ts index 4598756..c270e10 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -259,3 +259,21 @@ export interface ApplyResult { export interface UndoResult { summary: string } + +/** A source-backed edit made by the Elements panel or layout handles. */ +export interface ElementEditRequest { + path: string + hash: string + line: number + column: number + tag: string + group: 'styles' | 'attributes' | 'properties' + name: string + value: string | number | boolean | null + declarations?: Record + cssText?: string +} +export interface ElementEditResult { + hash: string + undoId: string +}