// Active-canvas state, selected-element tracking, and HTML injection // (inspector overlay + canvas runtime). See plan Task 7 + DDR-007. import path from 'node:path'; import type { Context } from './context.ts'; import { normalizeSessionKey, sessionFile } from './session-scope.ts'; export interface SelectedElement { file: string; /** CSS-selector path (v1 anchor). Always present for backwards-compat + legacy HTML canvases. */ selector: string; /** Occurrence index among `querySelectorAll(selector)` — disambiguates a * component repeated within one artboard. Absent → first match. */ index?: number; tag: string; classes: string; text: string; dom_path: string[]; bounds: { x: number; y: number; w: number; h: number } | null; html: string; ts: string; /** * Schema version. v2 = TSX canvas with a `data-cd-id` anchor at click target * (or any ancestor — script walks via `closest()`); v1 = no `data-cd-id` * anywhere (legacy `.html` canvases, or click on shell chrome of a TSX * canvas). Readers must accept both during the grace window. */ v: 1 | 2; /** Stable per-element id from canvas-pipeline two-pass transform. Present only when v === 2. */ id?: string; /** * Canvas slug — POSIX, extension-less, relative to designRoot. Matches * `_locator.json` top-level keys. Present only when v === 2. The inspector * derives it server-side from `file` (stripping `/` prefix + `.tsx`). */ canvas?: string; /** * Canvas-file mtime (ms) at capture — the drift-gate stamp * (feature-acp-context-hardening). `data-cd-id` is POSITIONAL, not content * identity, so a selection restored after another agent edited the canvas * must not be trusted blindly. 0 = mtime unavailable. */ canvas_mtime?: number; /** * Set on restore-from-`selections` when the canvas changed since capture * (mtime mismatch). Consumers re-anchor via `data-dc-element`/selector or * degrade to canvas-wide — never trust the positional id when stale. */ stale?: boolean; /** * feature-photo-editor (Task 14) — which photo-editing context this * selection is (an artboard `` vs. an annotation `ImageStroke`), and * the resolved `assets/.` source. Client-derived (dom-selection.ts * re-reads the live DOM, including a `data-photo-asset` tag stamped after an * edit bakes) — round-tripped here (not dropped like the OTHER client-only * fields such as `authored`/`computed`/`attrs`) because, unlike those, there * is no cheap way to re-derive it from a plain server-side restore: losing it * silently drops the Inspector's Photo tab on every canvas switch / reconnect * until a fresh click re-selects the element. */ photoKind?: 'artboard-img' | 'annotation-image'; photoAsset?: string; } /** * Phase 4.1: `selected` widens from `SelectedElement | null` to * `SelectedElement | SelectedElement[] | null` for multi-select via canvas-shell * input router. Readers must accept all three shapes. Writer below emits a * single object when cardinality is 1 (back-compat with `/design:edit` + * downstream tools that read the legacy shape) and an array for N > 1. */ export type SelectedValue = SelectedElement | SelectedElement[] | null; export interface ActiveState { active: string | null; open_tabs: string[]; selected: SelectedValue; /** * Per-canvas selection memory, keyed by canvas slug * (feature-acp-context-hardening). Additive: `selected` above stays the * ACTIVE canvas's mirror, so every legacy reader (prep.sh SEL_VALID, * /design:edit step 3, handoff tooling) keeps working unchanged — the same * back-compat philosophy as the Phase 4.1 obj→arr widening. Non-active * entries carry `html: ''` (size cap — locators survive, the 4000-char * payload doesn't multiply across N canvases). */ selections: Record; last_change: string | null; session_started: string; active_comments?: unknown[]; } type SetSelectedInput = | Omit | Array> | null; export interface Inspect { /** Whose state this is — `''` for the shared singleton (D3). */ sessionKey: string; state: ActiveState; load(): Promise; setActive(file: string): void; setOpenTabs(tabs: string[]): void; setSelected(sel: SetSelectedInput): void; /** * feature-file-tree-drag-drop-folders (Task 3) — a canvas moved server-side * (`moveCanvas`). Retarget every reference to the OLD designRel-prefixed * file path (`active`, `open_tabs[]`, the active `selected`) to the new * one, and re-key the parked `selections` map entry from the old slug to * the new one. Returns whether anything actually changed (so the caller * can skip an unnecessary `canvas-list-update` detail). Idempotent no-op * when the moved canvas wasn't referenced anywhere in this state. */ retarget(fromFile: string, toFile: string): boolean; /** Forget a deleted canvas without moving its selection onto another file. */ remove(file: string): boolean; save(): Promise; injectInspector(html: string): string; } const NEW = (): ActiveState => ({ active: null, open_tabs: [], selected: null, selections: {}, last_change: null, session_started: new Date().toISOString(), }); export function createInspect( ctx: Context, loadActiveComments: (file: string) => Promise, /** * Whose state this is — Cloud Phase 27 D3. `''` is the shared singleton every * desktop has always had; a non-empty key gives one member of a cell their * own selection, their own open tab, and their own `_active..json`. */ sessionKey = '' ): Inspect { const state: ActiveState = NEW(); const activeFile = sessionFile(ctx.paths.activeFile, sessionKey); let saveQueued = false; async function save() { saveQueued = false; try { // Bun.write creates parent dirs automatically — no .keep poke needed. let active_comments: unknown[] = []; if (state.active) { try { active_comments = await loadActiveComments(state.active); } catch { /* ignore */ } } const enriched = { ...state, active_comments }; await Bun.write(activeFile, JSON.stringify(enriched, null, 2)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error(' warn: failed to save _active.json:', msg); } } function scheduleSave() { if (saveQueued) return; saveQueued = true; queueMicrotask(save); } async function load() { try { const raw = await Bun.file(activeFile).text(); const prev = JSON.parse(raw); Object.assign(state, prev, { session_started: new Date().toISOString() }); // Pre-selections _active.json (or a hand-edited one) → keep the invariant. if (!state.selections || typeof state.selections !== 'object') state.selections = {}; } catch { // first boot } } /** Canvas-file mtime for the drift-gate stamp. `file` is repoRoot-relative * (designRel-prefixed, as the iframe reports it). Best-effort 0. Clamp to * designRoot: `file` originates in the (untrusted, DDR-054) canvas via the * origin-checked postMessage relay, and we `stat` it — a `../../../etc/…` * must not turn this into a filesystem existence/mtime oracle (defender S1; * mirrors localDepsFromSource's clamp). */ function mtimeFor(file: string): number { try { const rel = (file || '').replace(/^\/+/, ''); if (!rel) return 0; const abs = path.resolve(ctx.paths.designRoot, path.relative(ctx.paths.designRel, rel)); const root = path.resolve(ctx.paths.designRoot); if (abs !== root && !abs.startsWith(root + path.sep)) return 0; const mt = Bun.file(abs).lastModified; return Number.isFinite(mt) ? mt : 0; } catch { return 0; } } /** Size cap for parked (non-active) selections — locators survive, the * 4000-char outerHTML doesn't multiply across N canvases. */ function stripHtml(sel: SelectedValue): SelectedValue { if (sel == null) return sel; const strip = (e: SelectedElement): SelectedElement => ({ ...e, html: '' }); return Array.isArray(sel) ? sel.map(strip) : strip(sel); } /** Restore the incoming canvas's parked selection, drift-gated: a canvas * edited since capture gets `stale: true` on every element (positional * data-cd-id must not be trusted across another writer's edit). */ function restoreFor(file: string): SelectedValue { const parked = state.selections[deriveCanvasSlug(file)]; if (parked == null) return null; const current = mtimeFor(file); const gate = (e: SelectedElement): SelectedElement => e.canvas_mtime && current && e.canvas_mtime !== current ? { ...e, stale: true } : e; return Array.isArray(parked) ? parked.map(gate) : gate(parked); } function setActive(file: string) { if (typeof file !== 'string') return; if (state.active === file) return; // Park the outgoing canvas's selection (html-stripped) instead of losing // it — the root fix for "switch canvas → agent loses my selection" // (feature-acp-context-hardening). if (state.active && state.selected != null) { state.selections[deriveCanvasSlug(state.active)] = stripHtml(state.selected); } state.active = file || null; state.selected = file ? restoreFor(file) : null; state.last_change = new Date().toISOString(); scheduleSave(); ctx.bus.emit('active', state.active, { session: sessionKey }); // Clients (StatusBar, shell halo, chat context chip) must see the restored // selection, not assume the pre-switch null. ctx.bus.emit('selected', state.selected, { session: sessionKey }); } function setOpenTabs(tabs: string[]) { if (!Array.isArray(tabs)) return; state.open_tabs = tabs.filter((t): t is string => typeof t === 'string'); // GC selection memory for closed canvases — a closed tab's parked // selection has no consumer and would otherwise accrete forever. Keep the // CURRENT active canvas too: the single-canvas shell sends `tabs` with only // the incoming canvas BEFORE `active` parks the outgoing one, so without // this the outgoing canvas's memory would depend on message ordering. const keep = new Set(state.open_tabs.map((t) => deriveCanvasSlug(t))); if (state.active) keep.add(deriveCanvasSlug(state.active)); for (const slug of Object.keys(state.selections)) { if (!keep.has(slug)) delete state.selections[slug]; } state.last_change = new Date().toISOString(); scheduleSave(); } function enrich(sel: Omit): SelectedElement { const file = typeof sel.file === 'string' ? sel.file : (state.active ?? ''); const id = typeof sel.id === 'string' && sel.id ? sel.id : undefined; const v: 1 | 2 = id ? 2 : 1; return { file, selector: String(sel.selector || ''), index: typeof sel.index === 'number' ? sel.index : undefined, tag: String(sel.tag || ''), classes: String(sel.classes || ''), text: String(sel.text || '').slice(0, 240), dom_path: Array.isArray(sel.dom_path) ? sel.dom_path.slice(0, 16) : [], bounds: sel.bounds ?? null, html: String(sel.html || '').slice(0, 4000), ts: new Date().toISOString(), v, canvas_mtime: mtimeFor(file), ...(id ? { id, canvas: deriveCanvasSlug(file) } : {}), ...(() => { if (sel.photoKind !== 'artboard-img' && sel.photoKind !== 'annotation-image') return {}; // `photoAsset` traces back to client-derived DOM state (a // `data-photo-asset` attribute inside the untrusted canvas iframe, // DDR-054) — unlike the sibling `text` field it had no shape/length // constraint before persisting to `_active.json` and broadcasting to // every connected WS peer (security review finding). It's always a // fixed-shape `assets/.` reference, so an unshaped value // is dropped outright rather than merely truncated. const asset = String(sel.photoAsset || ''); if (!/^assets\/[0-9a-f]{8}\.[a-z0-9]+$/i.test(asset)) return {}; return { photoKind: sel.photoKind, photoAsset: asset }; })(), }; } function setSelected(sel: SetSelectedInput) { if (sel == null) { state.selected = null; // Explicit deselect clears the active canvas's parked memory too — a // deliberate act, not a context loss. if (state.active) delete state.selections[deriveCanvasSlug(state.active)]; } else if (Array.isArray(sel)) { const enriched = sel .filter( (s): s is Omit => !!s && typeof s === 'object' ) .map(enrich); // Writer back-compat: collapse single-entry array to a bare object so // legacy readers (`/design:edit`, handoff tooling) keep working without // schema awareness. N>1 stays as an array. if (enriched.length === 0) state.selected = null; else if (enriched.length === 1) state.selected = enriched[0] ?? null; else state.selected = enriched; } else if (typeof sel === 'object') { state.selected = enrich(sel); } else { state.selected = null; } // Write-through into the per-canvas memory (full payload incl. html — this // IS the active canvas's rich copy). Keyed by the ACTIVE canvas, and only // when the selection's own file matches it: the client gates select posts to // `e.source === activeWin` (app.jsx), but an ACTIVE untrusted canvas (a peer's // canvas reviewed in hub mode, DDR-054) could still claim `file: ` and plant it into that canvas's slot for later delivery to // the auto-approving agent. A selection can only legitimately belong to the // canvas the user is looking at, so a mismatched `file` is a cross-canvas // plant — drop the write-through (attacker Finding 2 residual). if (state.selected != null && state.active) { const first = Array.isArray(state.selected) ? state.selected[0] : state.selected; const activeSlug = deriveCanvasSlug(state.active); if (first?.file && deriveCanvasSlug(first.file) === activeSlug) { state.selections[activeSlug] = state.selected; } } state.last_change = new Date().toISOString(); scheduleSave(); ctx.bus.emit('selected', state.selected, { session: sessionKey }); } function remove(file: string): boolean { const slug = deriveCanvasSlug(file); const active = state.active === file; const referenced = active || state.open_tabs.includes(file) || Object.hasOwn(state.selections, slug); if (!referenced) return false; // setActive parks the outgoing selection; remove that memory afterwards. if (active) setActive(''); state.open_tabs = state.open_tabs.filter((tab) => tab !== file); delete state.selections[slug]; if (active) state.active_comments = []; state.last_change = new Date().toISOString(); scheduleSave(); return true; } function retarget(fromFile: string, toFile: string): boolean { const fromSlug = deriveCanvasSlug(fromFile); const toSlug = deriveCanvasSlug(toFile); let changed = false; if (state.active === fromFile) { state.active = toFile; changed = true; } if (state.open_tabs.includes(fromFile)) { state.open_tabs = state.open_tabs.map((t) => (t === fromFile ? toFile : t)); changed = true; } if (Object.hasOwn(state.selections, fromSlug)) { state.selections[toSlug] = state.selections[fromSlug] as SelectedValue; delete state.selections[fromSlug]; changed = true; } if (state.selected != null) { const list = Array.isArray(state.selected) ? state.selected : [state.selected]; let selChanged = false; const next = list.map((e) => { if (e.canvas !== fromSlug) return e; selChanged = true; return { ...e, canvas: toSlug, file: e.file === fromFile ? toFile : e.file }; }); if (selChanged) { state.selected = Array.isArray(state.selected) ? next : (next[0] ?? null); changed = true; } } if (!changed) return false; state.last_change = new Date().toISOString(); scheduleSave(); return true; } /** * Canvas slug for v2 selections. Mirrors `canvasSlug()` from locator.ts but * accepts a designRoot-relative `file` path (which is what the iframe reports) * rather than an absolute one. Strips a leading `/` if * present and strips the final extension. */ function deriveCanvasSlug(file: string): string { let s = (file || '').replace(/^\/+/, ''); // Strip a leading designRoot prefix if it's part of the file path. The // iframe's pathname includes the design root (e.g. `.design/ui/Foo.tsx`); // locator.ts strips it via path.relative — mirror that here. const dr = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, ''); if (dr && s.startsWith(`${dr}/`)) s = s.slice(dr.length + 1); const dot = s.lastIndexOf('.'); return dot > 0 ? s.slice(0, dot) : s; } return { sessionKey, state, load, setActive, setOpenTabs, setSelected, retarget, remove, save, injectInspector, }; } /** * One `Inspect` per member — Cloud Phase 27 D3. * * A desktop asks for `for('')` forever and gets the single instance it always * had. A cell asks with the proxy's vouched session key and gets one per * member, so an owner and a viewer stop overwriting each other's open tab and * selection. * * Instances are created on demand and kept: they are small (one state object), * a member reconnects to the same key across reloads, and evicting one would * lose exactly the state this exists to preserve. The key space is bounded by * the project's membership, not by anything a client can invent — * `normalizeSessionKey` rejects a value that is not proxy-shaped, and the proxy * strips inbound `x-maude-*` before injecting its own. */ export interface InspectRegistry { /** The instance for this session key, created on first use. */ for(sessionKey?: string | null): Inspect; /** Every live instance. */ all(): Inspect[]; } export function createInspectRegistry( ctx: Context, loadActiveComments: (file: string) => Promise ): InspectRegistry { const instances = new Map(); function get(sessionKey?: string | null): Inspect { const key = normalizeSessionKey(sessionKey); let found = instances.get(key); if (!found) { found = createInspect(ctx, loadActiveComments, key); instances.set(key, found); // A member returning after a reload picks their own place back up. Fire // and forget: `load()` is a best-effort read of a file that usually does // not exist yet, and blocking a request on it would trade a correctness // nicety for latency on every first touch. if (key) void found.load(); } return found; } return { for: get, all: () => [...instances.values()], }; } // ---------- Inspector script injection ---------- function injectInspector(html: string): string { const idx = html.lastIndexOf(''); if (idx === -1) return html + INSPECTOR_SCRIPT; return html.slice(0, idx) + INSPECTOR_SCRIPT + html.slice(idx); } // Comment-pin rendering overlay injected into every served HTML page under // designRoot. Pin layer is the ONLY responsibility — hover/click selection is // owned by canvas-shell.tsx (TSX canvases) and isn't applicable to legacy // `.html` mocks since the broader migration to TSX. Pin layer keeps working // in both, because pins are positioned by selector and updated via the // `comments-set` postMessage channel from the shell. const INSPECTOR_SCRIPT = ` `;