From eca3f3ea3743311a4ae7b0ef8f129754893817b8 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 27 Oct 2026 12:17:10 +0000 Subject: [PATCH 1/2] Collapse the unrelated methods when focusing on a state error and mark the ways to fix it Focus mode dimmed the methods unrelated to a state error but kept their text, so the layout did not move. On a large machine like DatagramSocket the dimmed label stacks still took the space, and the state the error is about was below the fold. Focus mode now collapses the dimmed methods of each label into a count named in its tooltip ("+9 more", "2 methods"), merges the constructors of each state, and lays the diagram out again. A tall focused diagram is shrunk to fit the panel, but never below 60%. Show all is unchanged. Only the failed method and the ways to fix the error stay bright: a shortest path along the diagram's transitions from each state that rejects the call to a state that accepts it, drawn dashed in the colour of the expected states. When no transition leads there, the constructors of the expected states are the way, as the verifier's hint says. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 154 +++++++++++++++++------ client/src/webview/diagram-focus.ts | 74 +++++++++++ client/src/webview/diagram.ts | 142 ++++++++++++++++----- client/src/webview/styles.ts | 15 +++ client/src/webview/views/fsm/fsm.spec.ts | 15 ++- client/src/webview/views/fsm/fsm.ts | 21 +++- 6 files changed, 347 insertions(+), 74 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index ededfa6..1c7e30f 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm'; import { createMermaidDiagram, renderMermaidDiagram } from './diagram'; -import { getRelatedStates, getStateErrorFocus, getStateErrorFocuses, orderStateMachines } from './diagram-focus'; +import { getConstructorFixStates, getRelatedStates, getStateErrorFocus, getStateErrorFocuses, getStateFixes, orderStateMachines } from './diagram-focus'; // DefaultMutableTreeNode from the state error focus issue: // root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error @@ -62,11 +62,10 @@ function lines(diagram: string): string[] { } const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:')); +const fixTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-fix-transitions:')); const calledMethod = (label: string) => `${label}`; const dimmedMethod = (label: string) => `${label}`; -// removes the hooks that only style the focused diagram -const withoutDimming = (diagram: string) => lines(diagram.replace(/(.*?)<\/span>/g, '$1')) - .filter(line => !line.startsWith('%%') && !line.endsWith('ljStateDimmed')); +const collapsedMethods = (names: string[], count: string) => `${count}`; describe('state error focus', () => { it('splits the possible states into valid and invalid ones', () => { @@ -93,6 +92,7 @@ describe('state error focus', () => { 'class closed ljStateInvalid', ])); const send = focusOnError ? dimmedMethod('send') : 'send'; + // a single dimmed method keeps its name for (const state of ['unbound', 'bound', 'connected']) { expect(diagram).toContain(`${state} --> ${state} : ${calledMethod('receive')}
${send}`); } @@ -103,16 +103,14 @@ describe('state error focus', () => { } }); - it('dims the states and transitions unrelated to the error when focused, without changing the labels', () => { + it('dims the states unrelated to the error and the transitions that do not help to fix it when focused', () => { const focused = createMermaidDiagram(datagramSocket(), 'TB', false, true); - const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); expect(lines(focused)).toContain('class broken ljStateDimmed'); - // both initial transitions, then bind, disconnect, close from unbound and connected, repair, and discard - expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge11 edge12']); - // focusing only adds styling hooks, so the layout stays the same - expect(withoutDimming(focused)).toEqual(lines(full)); - expect(full).not.toMatch(/ljStateDimmed|lj-dimmed/); + // the constructor without arguments is the way to fix closed, which no call leaves + expect(fixTransitions(focused)).toEqual(['%% lj-fix-transitions: edge0']); + // the other constructor, then bind, connect, disconnect, every close, repair, and discard + expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12']); }); it('draws entry transitions from the initial pseudo state, dimmed when focused', () => { @@ -126,35 +124,89 @@ describe('state error focus', () => { expect(dimmedTransitions(createMermaidDiagram(sm, 'TB', false, true))[0]).toMatch(/^%% lj-dimmed-transitions: edge0 edge1\b/); }); - it('only keeps the failed method and the ways out of the possible states bright', () => { - const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true)); + it('merges the entry transitions of each state into one collapsed transition when focused', () => { + const sm = { ...treeNode(), entryTransitions: [ + { to: 'maybeHasChildren', label: 'isLeaf', toCondition: '!return' }, + { to: 'maybeHasChildren', label: 'getChildCount', toCondition: 'return > 0' }, + ] }; - expect(diagram).toEqual(expect.arrayContaining([ - // leaving a possible state for another state + expect(lines(createMermaidDiagram(sm, 'TB', true, true))) + .toContain(`[*] --> maybeHasChildren : ${collapsedMethods(['isLeaf', 'getChildCount'], '2 methods')}`); + }); + + it('leaves the full diagram unchanged when showing all', () => { + const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); + + expect(full).not.toMatch(/ljStateDimmed|lj-dimmed|lj-fix|lj-collapsed/); + expect(lines(full)).toEqual(expect.arrayContaining([ + '[*] --> bound : new DatagramSocket()', + '[*] --> broken', 'bound --> connected : connect', - 'bound --> closed : close', - // self loops and transitions from other states - `bound --> bound : ${calledMethod('receive')}
${dimmedMethod('send')}`, - `unbound --> bound : ${dimmedMethod('bind')}`, - `connected --> bound : ${dimmedMethod('disconnect')}`, - `broken --> closed : ${dimmedMethod('discard')}`, ])); }); - it('dims each method of a merged label, and the transition only when all of them are dimmed', () => { + it('collapses the dimmed methods of a label into a count, named in its tooltip', () => { const sm = datagramSocket(); sm.transitions.push( - { from: 'broken', to: 'broken', label: 'receive' }, - { from: 'bound', to: 'connected', label: 'reconnect' }, + { from: 'bound', to: 'bound', label: 'getPort' }, + { from: 'bound', to: 'bound', label: 'getOption' }, + { from: 'unbound', to: 'bound', label: 'bindAll' }, + // an overload is counted once + { from: 'unbound', to: 'bound', label: 'bind' }, + ); + + expect(lines(createMermaidDiagram(sm, 'TB', false, true))).toEqual(expect.arrayContaining([ + `bound --> bound : ${calledMethod('receive')}
${collapsedMethods(['send', 'getPort', 'getOption'], '+3 more')}`, + `unbound --> bound : ${collapsedMethods(['bind', 'bindAll'], '2 methods')}`, + ])); + }); + + it('merges the constructors of each state into one collapsed transition when focused', () => { + const sm = treeNode(); + sm.initialTransitions.push({ to: 'childrenAllowed', constructorSignature: 'DefaultMutableTreeNode()' }); + + const diagram = lines(createMermaidDiagram(sm, 'TB', false, true)); + expect(diagram).toContain(`[*] --> childrenAllowed : ${collapsedMethods(['DefaultMutableTreeNode(Object)', 'DefaultMutableTreeNode()'], '2 constructors')}`); + expect(diagram.filter(line => line.startsWith('[*]'))).toHaveLength(1); + }); + + it('keeps a shortest way from each invalid state to an accepted state bright', () => { + const sm = datagramSocket(); + sm.transitions.push( + { from: 'closed', to: 'broken', label: 'fail' }, + { from: 'broken', to: 'unbound', label: 'reset' }, + { from: 'broken', to: 'unbound', label: 'restart' }, ); const diagram = createMermaidDiagram(sm, 'TB', false, true); expect(lines(diagram)).toEqual(expect.arrayContaining([ - `broken --> broken : ${dimmedMethod('repair')}
${calledMethod('receive')}`, - 'bound --> connected : connect
reconnect', + 'closed --> broken : fail', + // both methods of a step are a way + 'broken --> unbound : reset
restart', + // a constructor is no fix when the invalid state can reach an accepted one + `[*] --> bound : ${dimmedMethod('new DatagramSocket()')}`, ])); - // the self loop of broken keeps its path for the failed method - expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge12']); + expect(fixTransitions(diagram)).toEqual(['%% lj-fix-transitions: edge13 edge14']); + }); + + it('finds a shortest way to an accepted state for each invalid state, or none', () => { + const sm = datagramSocket({ possibleStates: ['closed', 'broken'], validStates: [], invalidStates: ['closed', 'broken'] }); + const focus = getStateErrorFocus(sm)!; + + const fixes = getStateFixes(sm, focus); + expect(fixes).toEqual([ + { from: 'closed', steps: [] }, + { from: 'broken', steps: [] }, + ]); + expect(getConstructorFixStates(sm, focus, fixes)).toEqual(['bound']); + + sm.transitions.push({ from: 'closed', to: 'unbound', label: 'reopen' }, { from: 'closed', to: 'broken', label: 'fail' }); + expect(getStateFixes(sm, focus)).toEqual([ + { from: 'closed', steps: [{ from: 'closed', to: 'unbound', methods: ['reopen'] }] }, + // through closed, which the call does not accept either + { from: 'broken', steps: [{ from: 'broken', to: 'closed', methods: ['discard'] }, { from: 'closed', to: 'unbound', methods: ['reopen'] }] }, + ]); + expect(getConstructorFixStates(sm, focus, getStateFixes(sm, focus))).toEqual([]); }); it('dims the conditions of a dimmed method with it', () => { @@ -170,7 +222,7 @@ describe('state error focus', () => { sm.transitions.push({ from: 'bound', to: 'bound', label: 'receiveAll' }); expect(lines(createMermaidDiagram(sm, 'TB', false, true))) - .toContain(`bound --> bound : ${calledMethod('receive')}
${dimmedMethod('send')}
${dimmedMethod('receiveAll')}`); + .toContain(`bound --> bound : ${calledMethod('receive')}
${collapsedMethods(['send', 'receiveAll'], '+2 more')}`); }); it('does not focus without accepted states', () => { @@ -194,8 +246,9 @@ describe('state error focus', () => { const diagram = createMermaidDiagram(sm, 'TB', false, true); expect(lines(diagram)).toContain('class unbound,bound,connected ljStateAccepted'); expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /); - // every state is possible, so only the self loops other than the failed method are dimmed - expect(lines(diagram)).toEqual(expect.arrayContaining(['unbound --> bound : bind', `broken --> broken : ${dimmedMethod('repair')}`])); + // no state is known to reject the call, so there is no way to mark and only the failed method stays bright + expect(fixTransitions(diagram)).toEqual([]); + expect(lines(diagram)).toEqual(expect.arrayContaining([`unbound --> bound : ${dimmedMethod('bind')}`, `broken --> broken : ${dimmedMethod('repair')}`])); }); it('marks the tree node example without an allowed state', () => { @@ -206,13 +259,14 @@ describe('state error focus', () => { 'class childrenAllowed ljStateInvalid', 'class maybeHasChildren ljStateAccepted', `maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`, - // add leaves childrenAllowed, removeAllChildren does not start from a possible state + // add is the way to the accepted state 'childrenAllowed --> maybeHasChildren : add', `maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`, ])); expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/); // the constructor is dimmed although it leads to a possible state expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge2']); + expect(fixTransitions(diagram.join('\n'))).toEqual(['%% lj-fix-transitions: edge1']); }); it('only highlights the possible states when the accepted states are unknown', () => { @@ -243,7 +297,7 @@ describe('rendered state error focus', () => { document.body.innerHTML = ''; }); - it('dims the rendered path and label of the listed transitions', async () => { + it('dims the rendered path and label of the listed transitions, and marks the ways to fix the error', async () => { const diagram = createMermaidDiagram(datagramSocket(), 'TB', false, true); document.body.innerHTML = '
'; const pre = document.querySelector('.mermaid')!; @@ -257,8 +311,38 @@ describe('rendered state error focus', () => { await renderMermaidDiagram(document, { mermaid: { run } }); const dimmed = (selector: string) => Array.from(document.querySelectorAll(selector), node => node.getAttribute('data-id')); - expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']); - expect(dimmed('g.label.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']); + expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge1', 'edge12']); + expect(dimmed('g.label.lj-dimmed')).toEqual(['edge1', 'edge12']); + expect(dimmed('path.transition.lj-fix-transition')).toEqual(['edge0']); + }); +}); + +describe('fitting a focused diagram', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + async function render(focused: boolean, height: number) { + document.body.innerHTML = `
x
`; + const pre = document.querySelector('.mermaid')!; + const run = vi.fn(async () => { + pre.innerHTML = ''; + Object.defineProperty(pre.querySelector('svg')!, 'clientHeight', { value: height }); + }); + document.querySelector('.diagram-viewport')!.getBoundingClientRect = () => ({ top: 200 }) as DOMRect; + await renderMermaidDiagram(document, { mermaid: { run }, innerHeight: 816 }); + return (document.querySelector('.mermaid svg') as SVGSVGElement).style.maxHeight; + } + + it('shrinks a tall diagram to the panel, but not below a readable scale', async () => { + // 816 - 200 - 16 = 600 pixels are left below the summary + expect(await render(true, 900)).toBe('600px'); + expect(await render(true, 2000)).toBe('1200px'); + expect(await render(true, 500)).toBe(''); + }); + + it('leaves the diagram as it is when showing all', async () => { + expect(await render(false, 900)).toBe(''); }); }); diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index 624ce45..95f7a4d 100644 --- a/client/src/webview/diagram-focus.ts +++ b/client/src/webview/diagram-focus.ts @@ -79,3 +79,77 @@ export function orderStateMachines(sms: LJStateMachine[]): LJStateMachine[] { export function getStateErrorFocuses(sms: LJStateMachine[]): StateErrorFocus[] { return sms.map(getStateErrorFocus).filter((focus): focus is StateErrorFocus => !!focus); } + +/** + * A transition of a way to fix a state error, with every method that makes it + */ +export type StateFixStep = { + from: string; + to: string; + methods: string[]; +}; + +/** + * A way to bring the receiver from a state that rejects the call to a state that accepts it, read from the diagram + * The steps are empty when no transition leads from the state to an accepted one + */ +export type StateFix = { + from: string; + steps: StateFixStep[]; +}; + +/** + * Gets, for each possible state that rejects the call, a shortest way along the diagram's transitions to an accepted state + * The diagram only has the transitions of the specification, so a way may need specific arguments, and other ways may exist + * @param sm the state machine + * @param focus the state error on it + * @returns one fix per invalid state, in the order of the states + */ +export function getStateFixes(sm: LJStateMachine, focus: StateErrorFocus): StateFix[] { + const methodsByEdge = new Map(); + sm.transitions.forEach(({ from, to, label }) => { + if (from === to) return; + const key = `${from}|${to}`; + const methods = methodsByEdge.get(key) ?? []; + if (!methods.includes(label)) methods.push(label); + methodsByEdge.set(key, methods); + }); + const edges = [...methodsByEdge].map(([key, methods]) => { + const [from, to] = key.split('|'); + return { from, to, methods }; + }); + + return sm.states.filter(state => focus.invalidStates.includes(state)).map(start => { + // breadth-first, so the first accepted state reached is one of the closest + const previous = new Map([[start, null]]); + const queue = [start]; + while (queue.length > 0) { + const state = queue.shift()!; + if (focus.acceptedStates.includes(state)) return { from: start, steps: getStepsTo(state, previous) }; + edges.filter(edge => edge.from === state && !previous.has(edge.to)).forEach(edge => { + previous.set(edge.to, edge); + queue.push(edge.to); + }); + } + return { from: start, steps: [] }; + }); +} + +function getStepsTo(state: string, previous: Map): StateFixStep[] { + const steps: StateFixStep[] = []; + for (let step = previous.get(state); step; step = previous.get(step.from)) steps.unshift(step); + return steps; +} + +/** + * Gets the states whose constructors fix a state error, which are the accepted states when an invalid state cannot reach one + * @param sm the state machine + * @param focus the state error on it + * @param fixes the fixes of the state error + * @returns the accepted states a constructor leads to, or none when every invalid state can reach an accepted one + */ +export function getConstructorFixStates(sm: LJStateMachine, focus: StateErrorFocus, fixes: StateFix[]): string[] { + if (!fixes.some(fix => fix.steps.length === 0)) return []; + return [...new Set(sm.initialTransitions.map(transition => transition.to))] + .filter(state => focus.acceptedStates.includes(state)); +} diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index d205ea1..25b338f 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -1,6 +1,6 @@ import type { LJStateMachine } from "../types/fsm"; import { copyToClipboard } from "./clipboard"; -import { INVALID_STATE_ICON, VALID_STATE_ICON, getRelatedStates, getStateErrorFocus } from "./diagram-focus"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getConstructorFixStates, getRelatedStates, getStateErrorFocus, getStateFixes } from "./diagram-focus"; // constants const MIN_ZOOM = 0.2; @@ -8,8 +8,13 @@ const MAX_ZOOM = 5; const ZOOM_BUTTON_FACTOR = 1.5; const SCROLL_ZOOM_IN_FACTOR = 1.05; const SCROLL_ZOOM_OUT_FACTOR = 0.95; -// Mermaid comment listing the transitions to dim once rendered, which leaves the layout unchanged +// Mermaid comment listing the transitions to dim once rendered const DIMMED_TRANSITIONS_COMMENT = '%% lj-dimmed-transitions:'; +// Mermaid comment listing the transitions on a way from a rejecting state to an accepting one +const FIX_TRANSITIONS_COMMENT = '%% lj-fix-transitions:'; +// a focused diagram is shrunk to fit the panel, but never below this scale so that its labels stay readable +const MIN_FOCUS_SCALE = 0.6; +const FOCUS_BOTTOM_MARGIN = 16; // state variables let zoomLevel = 1; @@ -24,7 +29,7 @@ let startY = 0; * @param sm * @param orientation * @param showConditions - * @param focusOnError whether to dim the states and transitions unrelated to the selected state error + * @param focusOnError whether to dim the states unrelated to the selected state error and collapse the unrelated methods * @returns Mermaid diagram string */ export function createMermaidDiagram( @@ -39,11 +44,19 @@ export function createMermaidDiagram( const errorFocus = getStateErrorFocus(sm); const relatedStates = focusOnError && errorFocus ? getRelatedStates(errorFocus) : []; const isDimmed = (state: string) => relatedStates.length > 0 && !relatedStates.includes(state); + // focusing collapses the dimmed methods of each label, so the diagram is laid out again around the error + const collapse = relatedStates.length > 0; + // the ways from the invalid states to an accepted state stay bright and are marked + const fixes = collapse ? getStateFixes(sm, errorFocus!) : []; + const fixEdges = new Set(fixes.flatMap(fix => fix.steps.map(step => `${step.from}|${step.to}`))); + const constructorFixStates = collapse ? getConstructorFixStates(sm, errorFocus!, fixes) : []; // Mermaid numbers the rendered transitions in the order they are declared const dimmedTransitions: string[] = []; + const fixTransitions: string[] = []; let transitionCount = 0; - const addTransition = (line: string, dimmed: boolean) => { + const addTransition = (line: string, dimmed: boolean, fix = false) => { if (dimmed) dimmedTransitions.push(`edge${transitionCount}`); + if (fix) fixTransitions.push(`edge${transitionCount}`); transitionCount++; lines.push(line); }; @@ -51,26 +64,54 @@ export function createMermaidDiagram( lines.push('stateDiagram-v2'); lines.push(` direction ${orientation}`); - // initial transitions, dimmed in focus mode since the receiver already exists at the call - sm.initialTransitions.forEach(transition => { - const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, relatedStates.length > 0); - }); + // initial transitions, dimmed in focus mode since the receiver already exists at the call, + // unless creating a new object is the only fix + if (collapse) { + const targets = [...new Set(sm.initialTransitions.map(transition => transition.to))]; + targets.forEach(to => { + const signatures = sm.initialTransitions.filter(transition => transition.to === to) + .map(transition => transition.constructorSignature ? escapeMermaidLabel(transition.constructorSignature) : '') + .filter(Boolean); + const fix = constructorFixStates.includes(to); + const label = fix + ? signatures.join('
') + : getCollapsedLabel([], signatures.map(signature => ({ name: signature, label: signature })), 'constructors'); + addTransition(` [*] --> ${to}${label ? ` : ${label}` : ''}`, !fix, fix); + }); + } else { + sm.initialTransitions.forEach(transition => { + const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); + addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, false); + }); + } - // entry transitions of objects in an unknown state, also from the initial pseudo state and dimmed in focus mode - sm.entryTransitions?.forEach(transition => { - const label = getTransitionLabel(transition.label, null, transition.toCondition, showConditions); - addTransition(` [*] --> ${transition.to} : ${label}`, relatedStates.length > 0); - }); - - // in focus mode, only the failed method and the ways out of the possible states stay bright - const isUsefulTransition = (from: string, to: string) => !!errorFocus?.possibleStates.includes(from) && from !== to; + // entry transitions of objects in an unknown state, also from the initial pseudo state, + // dimmed and collapsed in focus mode like the constructors + const entryTransitions = sm.entryTransitions ?? []; + if (collapse) { + [...new Set(entryTransitions.map(transition => transition.to))].forEach(to => { + const methods: CollapsibleLabel[] = []; + entryTransitions.filter(transition => transition.to === to).forEach(transition => { + const name = escapeMermaidLabel(transition.label); + const label = getTransitionLabel(transition.label, null, transition.toCondition, showConditions); + if (!methods.some(method => method.name === name)) methods.push({ name, label }); + }); + addTransition(` [*] --> ${to} : ${getCollapsedLabel([], methods, 'methods')}`, true); + }); + } else { + entryTransitions.forEach(transition => { + const label = getTransitionLabel(transition.label, null, transition.toCondition, showConditions); + addTransition(` [*] --> ${transition.to} : ${label}`, false); + }); + } // group transitions by from/to states and merge labels - const transitionMap = new Map(); + const transitionMap = new Map(); sm.transitions.forEach(transition => { + const key = `${transition.from}|${transition.to}`; const isCalledMethod = transition.label === sm.errorContext?.calledMethod; - const dimmed = relatedStates.length > 0 && !isCalledMethod && !isUsefulTransition(transition.from, transition.to); + // in focus mode, only the failed method and the ways to an accepted state stay bright + const dimmed = collapse && !isCalledMethod && !fixEdges.has(key); const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -78,18 +119,18 @@ export function createMermaidDiagram( showConditions, isCalledMethod, ); - const key = `${transition.from}|${transition.to}`; - if (!transitionMap.has(key)) transitionMap.set(key, { labels: [], dimmed: true }); + if (!transitionMap.has(key)) transitionMap.set(key, { bright: [], dimmed: [] }); const group = transitionMap.get(key)!; - // wrapping the label keeps its text, so the layout stays the same - group.labels.push(dimmed ? `${label}` : label); - group.dimmed &&= dimmed; + const name = escapeMermaidLabel(transition.label); + // overloads share a name, so it is counted once + if (!dimmed) group.bright.push(label); + else if (!group.dimmed.some(method => method.name === name)) group.dimmed.push({ name, label }); }); // add transitions, dimming those whose methods are all dimmed - transitionMap.forEach(({ labels, dimmed }, key) => { + transitionMap.forEach(({ bright, dimmed }, key) => { const [from, to] = key.split('|'); - addTransition(` ${from} --> ${to} : ${labels.join('
')}`, dimmed); + addTransition(` ${from} --> ${to} : ${getCollapsedLabel(bright, dimmed, 'methods')}`, bright.length === 0, fixEdges.has(key)); }); if (!errorFocus) { @@ -114,10 +155,27 @@ export function createMermaidDiagram( if (states.length > 0) lines.push(` class ${states.join(',')} ${className}`); }); if (dimmedTransitions.length > 0) lines.push(`${DIMMED_TRANSITIONS_COMMENT} ${dimmedTransitions.join(' ')}`); + if (fixTransitions.length > 0) lines.push(`${FIX_TRANSITIONS_COMMENT} ${fixTransitions.join(' ')}`); return lines.join('\n'); } +// a method of a transition label that focus mode may collapse, by its name and its full label +type CollapsibleLabel = { name: string; label: string }; + +/** + * Gets the label of a transition whose dimmed methods are collapsed into a count, named in its tooltip + * A single dimmed method keeps its full label, since the count would be no shorter + */ +function getCollapsedLabel(bright: string[], dimmed: CollapsibleLabel[], noun: 'methods' | 'constructors'): string { + if (dimmed.length === 0) return bright.join('
'); + if (dimmed.length === 1) return [...bright, `${dimmed[0].label}`].join('
'); + const count = bright.length > 0 ? `+${dimmed.length} more` : `${dimmed.length} ${noun}`; + // the names are already escaped, and a tooltip shows them on hover + const names = dimmed.map(method => method.name).join(', ').replace(/'/g, '''); + return [...bright, `${count}`].join('
'); +} + function getTransitionLabel( label: string, fromCondition?: string | null, @@ -168,10 +226,15 @@ export async function renderMermaidDiagram(document: any, window: any) { if (mermaidElements.length === 0) return; try { - const dimmedTransitions = Array.from(mermaidElements, (element: any) => getDimmedTransitions(element.textContent)); + const dimmedTransitions = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, DIMMED_TRANSITIONS_COMMENT)); + const fixTransitions = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, FIX_TRANSITIONS_COMMENT)); await mermaid.run({ nodes: mermaidElements }); highlightErrorTransitions(document); - mermaidElements.forEach((element: any, index: number) => dimTransitions(element, dimmedTransitions[index])); + mermaidElements.forEach((element: any, index: number) => { + markTransitions(element, dimmedTransitions[index], 'lj-dimmed'); + markTransitions(element, fixTransitions[index], 'lj-fix-transition'); + }); + fitFocusedDiagrams(document, window); applyTransform(document); registerPanListeners(document); const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null; @@ -195,15 +258,28 @@ function highlightErrorTransitions(document: any) { }); } -function getDimmedTransitions(diagram: string | null): string[] { - const comment = diagram?.split('\n').find(line => line.startsWith(DIMMED_TRANSITIONS_COMMENT)); - return comment?.slice(DIMMED_TRANSITIONS_COMMENT.length).trim().split(/\s+/) ?? []; +function getListedTransitions(diagram: string | null, prefix: string): string[] { + const comment = diagram?.split('\n').find(line => line.startsWith(prefix)); + return comment?.slice(prefix.length).trim().split(/\s+/) ?? []; } -function dimTransitions(element: any, edgeIds: string[]) { +function markTransitions(element: any, edgeIds: string[], className: string) { edgeIds.forEach(edgeId => { element.querySelectorAll(`path.transition[data-id="${edgeId}"], g.label[data-id="${edgeId}"]`) - .forEach((part: any) => part.classList.add('lj-dimmed')); + .forEach((part: any) => part.classList.add(className)); + }); +} + +// shrinks the focused diagrams with a state error so that they fit below the summary, keeping them readable +function fitFocusedDiagrams(document: any, window: any) { + const viewport = document.querySelector('.diagram-viewport.diagram-focused') as HTMLElement | null; + if (!viewport) return; + const available = window.innerHeight - viewport.getBoundingClientRect().top - FOCUS_BOTTOM_MARGIN; + viewport.querySelectorAll('.mermaid svg').forEach((svg: any) => { + if (!svg.querySelector('.ljStateInvalid, .ljStateValid, .ljStateAccepted')) return; + const height = svg.clientHeight; + if (!height || height <= available) return; + svg.style.maxHeight = `${Math.max(available, height * MIN_FOCUS_SCALE)}px`; }); } diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index 2c99e1c..24d2688 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1135,6 +1135,15 @@ export function getStyles(): string { stroke-width: 1px !important; stroke-dasharray: 4 3; } + /* a way from a rejecting state to an expected one, dashed like the expected states */ + .diagram-container .mermaid svg path.lj-fix-transition { + stroke: var(--lj-state-accepted) !important; + stroke-width: 1.5px !important; + stroke-dasharray: 6 4 !important; + } + .diagram-container .mermaid .edgeLabel .lj-collapsed-methods { + font-style: italic; + } .diagram-container .mermaid svg .ljStateDimmed, .diagram-container .mermaid svg .lj-dimmed, .diagram-container .mermaid .edgeLabel .lj-dimmed-method { @@ -1228,5 +1237,11 @@ export function getStyles(): string { border: 1px dashed; border-color: var(--lj-state-accepted); } + .diagram-error-legend-fix { + height: 0; + border-radius: 0; + border-top: 2px dashed; + border-color: var(--lj-state-accepted); + } `; } diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index 039f66d..99eb6ce 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -358,7 +358,20 @@ describe('state machine view', () => { .toBe('Invalid method call param.write() \u00B7 expected state tilingSet and compressionExplicit \u00B7 actual state startTiling and compressionExplicit'); expect(document.querySelector('.diagram-state-set-dimmed')).toBeNull(); expect(Array.from(document.querySelectorAll('.diagram-error-legend-item'), node => node.textContent)) - .toEqual(['\u2715 Actual state, not expected', '\u2713 Actual state, expected', 'Expected state']); + .toEqual(['\u2715 Actual state, not expected', '\u2713 Actual state, expected', 'Expected state', 'Way to an expected state']); + }); + + it('only explains the ways to an expected state when focusing on the error', () => { + const sm = { ...tiling, errorContext: { calledMethod: 'write', receiver: 'param', possibleStates: ['startTiling'], acceptedStates: ['tilingSet'], validStates: [], invalidStates: ['startTiling'] } }; + const legend = () => Array.from(document.querySelectorAll('.diagram-error-legend-item'), node => node.textContent); + + renderStateMachineView(document.body, [sm], ['tiling diagram'], 'TB', false, true); + expect(legend()).toContain('Way to an expected state'); + expect(document.querySelector('.diagram-viewport.diagram-focused')).not.toBeNull(); + + renderStateMachineView(document.body, [sm], ['tiling diagram'], 'TB', false, false); + expect(legend()).not.toContain('Way to an expected state'); + expect(document.querySelector('.diagram-focused')).toBeNull(); }); it('names the call of the state set with the error when it cannot be marked', () => { diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index 8cd7ac2..d04180b 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,5 +1,5 @@ import type { LJStateMachine } from "../../../types/fsm"; -import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectedCallText, getStateErrorFocuses, type StateErrorFocus } from "../../diagram-focus"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getConstructorFixStates, getRejectedCallText, getStateErrorFocus, getStateErrorFocuses, getStateFixes, type StateErrorFocus } from "../../diagram-focus"; import { renderCodiconButton } from "../../icons"; import { escapeHtml } from "../../utils"; import { renderMainHeader, type NavBadges } from "../sections"; @@ -57,7 +57,7 @@ function renderStateMachineViewHtml(sms: LJStateMachine[], diagrams: string[], o ${errorFocuses.length > 0 - ? renderStateErrorSummary(getStateErrorSummary(errorFocuses), stateErrors) + renderStateErrorLegend(errorFocuses, focusOnError) + ? renderStateErrorSummary(getStateErrorSummary(errorFocuses), stateErrors) + renderStateErrorLegend(errorFocuses, focusOnError && hasStateFixes(sms), focusOnError) : isStateErrorShown(stateErrors) ? renderStateErrorSummary(getUnmarkedStateErrorSummary(sms, stateErrors), stateErrors) : ''} ${showConditions && hasConditionExpansions ? /*html*/`
@@ -65,7 +65,7 @@ function renderStateMachineViewHtml(sms: LJStateMachine[], diagrams: string[], o Postcondition
` : ''} -
+
${sms.length === 1 ? `
${diagrams[0]}
` @@ -139,14 +139,25 @@ function renderStateErrorStepper({ index, count }: StateErrorNavigation): string `; } -// explains the state marks with the same outlines, fills, and icons as the diagram, next to the toggle that dims the rest -function renderStateErrorLegend(focuses: StateErrorFocus[], focusOnError: boolean): string { +// whether a diagram marks a way from a rejecting state to an accepting one +function hasStateFixes(sms: LJStateMachine[]): boolean { + return sms.some(sm => { + const focus = getStateErrorFocus(sm); + if (!focus) return false; + const fixes = getStateFixes(sm, focus); + return fixes.some(fix => fix.steps.length > 0) || getConstructorFixStates(sm, focus, fixes).length > 0; + }); +} + +// explains the state marks with the same outlines, fills, and icons as the diagram, next to the toggle that collapses the rest +function renderStateErrorLegend(focuses: StateErrorFocus[], hasFixes: boolean, focusOnError: boolean): string { const items = [ focuses.some(focus => focus.invalidStates.length > 0) ? renderLegendItem('invalid', `${INVALID_STATE_ICON} Actual state, not expected`) : '', focuses.some(focus => focus.validStates.length > 0) ? renderLegendItem('valid', `${VALID_STATE_ICON} Actual state, expected`) : '', focuses.some(focus => focus.acceptedStates.some(state => !focus.validStates.includes(state))) ? renderLegendItem('accepted', 'Expected state') : '', + hasFixes ? renderLegendItem('fix', 'Way to an expected state') : '', ].filter(Boolean); return /*html*/`
From 782e902cd6099598e8ada6710b05fd67e05583ea Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 27 Oct 2026 17:12:00 +0000 Subject: [PATCH 2/2] Drop the diagram's ways to fix and list collapsed methods on hover The shortest way along the diagram ignored the transitions' conditions and the code, and fell back to "create a new object" for a dead-end state. Only the failed method stays bright now; the verifier's hints are the ways to fix. Hovering "+N more" or "N methods" lists the methods, with their conditions, next to the label, and a click keeps the list until the next click, a pan, or a zoom. The diagram is not laid out again, so the list shows at once. The list goes right, left, above, or below the label, or beside the diagram, wherever it covers the fewest states, labels, and transition lines. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 151 +++++++++++-------- client/src/webview/diagram-focus.ts | 74 --------- client/src/webview/diagram.ts | 145 ++++++++++++++---- client/src/webview/popover-placement.spec.ts | 39 +++++ client/src/webview/popover-placement.ts | 76 ++++++++++ client/src/webview/styles.ts | 54 +++++-- client/src/webview/views/fsm/fsm.spec.ts | 7 +- client/src/webview/views/fsm/fsm.ts | 17 +-- 8 files changed, 369 insertions(+), 194 deletions(-) create mode 100644 client/src/webview/popover-placement.spec.ts create mode 100644 client/src/webview/popover-placement.ts diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 1c7e30f..7f9a2a0 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm'; import { createMermaidDiagram, renderMermaidDiagram } from './diagram'; -import { getConstructorFixStates, getRelatedStates, getStateErrorFocus, getStateErrorFocuses, getStateFixes, orderStateMachines } from './diagram-focus'; +import { getRelatedStates, getStateErrorFocus, getStateErrorFocuses, orderStateMachines } from './diagram-focus'; // DefaultMutableTreeNode from the state error focus issue: // root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error @@ -62,10 +62,10 @@ function lines(diagram: string): string[] { } const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:')); -const fixTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-fix-transitions:')); const calledMethod = (label: string) => `${label}`; const dimmedMethod = (label: string) => `${label}`; -const collapsedMethods = (names: string[], count: string) => `${count}`; +const collapsedMethods = (labels: string[], count: string) => + `${count}${labels.map(label => `${label}`).join('')}`; describe('state error focus', () => { it('splits the possible states into valid and invalid ones', () => { @@ -103,14 +103,13 @@ describe('state error focus', () => { } }); - it('dims the states unrelated to the error and the transitions that do not help to fix it when focused', () => { + it('dims the states unrelated to the error and the transitions without the failed method when focused', () => { const focused = createMermaidDiagram(datagramSocket(), 'TB', false, true); expect(lines(focused)).toContain('class broken ljStateDimmed'); - // the constructor without arguments is the way to fix closed, which no call leaves - expect(fixTransitions(focused)).toEqual(['%% lj-fix-transitions: edge0']); - // the other constructor, then bind, connect, disconnect, every close, repair, and discard - expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12']); + // both constructors, then bind, connect, disconnect, every close, repair, and discard + expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge3 edge4 edge8 edge9 edge10 edge11 edge12']); + expect(focused).not.toContain('lj-fix'); }); it('draws entry transitions from the initial pseudo state, dimmed when focused', () => { @@ -130,8 +129,10 @@ describe('state error focus', () => { { to: 'maybeHasChildren', label: 'getChildCount', toCondition: 'return > 0' }, ] }; - expect(lines(createMermaidDiagram(sm, 'TB', true, true))) - .toContain(`[*] --> maybeHasChildren : ${collapsedMethods(['isLeaf', 'getChildCount'], '2 methods')}`); + expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(`[*] --> maybeHasChildren : ${collapsedMethods([ + 'isLeaf
!return', + 'getChildCount
return > 0', + ], '2 methods')}`); }); it('leaves the full diagram unchanged when showing all', () => { @@ -145,7 +146,7 @@ describe('state error focus', () => { ])); }); - it('collapses the dimmed methods of a label into a count, named in its tooltip', () => { + it('collapses the dimmed methods of a label into a count, keeping them for the list on hover', () => { const sm = datagramSocket(); sm.transitions.push( { from: 'bound', to: 'bound', label: 'getPort' }, @@ -170,45 +171,6 @@ describe('state error focus', () => { expect(diagram.filter(line => line.startsWith('[*]'))).toHaveLength(1); }); - it('keeps a shortest way from each invalid state to an accepted state bright', () => { - const sm = datagramSocket(); - sm.transitions.push( - { from: 'closed', to: 'broken', label: 'fail' }, - { from: 'broken', to: 'unbound', label: 'reset' }, - { from: 'broken', to: 'unbound', label: 'restart' }, - ); - - const diagram = createMermaidDiagram(sm, 'TB', false, true); - expect(lines(diagram)).toEqual(expect.arrayContaining([ - 'closed --> broken : fail', - // both methods of a step are a way - 'broken --> unbound : reset
restart', - // a constructor is no fix when the invalid state can reach an accepted one - `[*] --> bound : ${dimmedMethod('new DatagramSocket()')}`, - ])); - expect(fixTransitions(diagram)).toEqual(['%% lj-fix-transitions: edge13 edge14']); - }); - - it('finds a shortest way to an accepted state for each invalid state, or none', () => { - const sm = datagramSocket({ possibleStates: ['closed', 'broken'], validStates: [], invalidStates: ['closed', 'broken'] }); - const focus = getStateErrorFocus(sm)!; - - const fixes = getStateFixes(sm, focus); - expect(fixes).toEqual([ - { from: 'closed', steps: [] }, - { from: 'broken', steps: [] }, - ]); - expect(getConstructorFixStates(sm, focus, fixes)).toEqual(['bound']); - - sm.transitions.push({ from: 'closed', to: 'unbound', label: 'reopen' }, { from: 'closed', to: 'broken', label: 'fail' }); - expect(getStateFixes(sm, focus)).toEqual([ - { from: 'closed', steps: [{ from: 'closed', to: 'unbound', methods: ['reopen'] }] }, - // through closed, which the call does not accept either - { from: 'broken', steps: [{ from: 'broken', to: 'closed', methods: ['discard'] }, { from: 'closed', to: 'unbound', methods: ['reopen'] }] }, - ]); - expect(getConstructorFixStates(sm, focus, getStateFixes(sm, focus))).toEqual([]); - }); - it('dims the conditions of a dimmed method with it', () => { const sm = datagramSocket(); sm.transitions[0] = { ...sm.transitions[0], fromCondition: 'unbound(this)' }; @@ -246,8 +208,7 @@ describe('state error focus', () => { const diagram = createMermaidDiagram(sm, 'TB', false, true); expect(lines(diagram)).toContain('class unbound,bound,connected ljStateAccepted'); expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /); - // no state is known to reject the call, so there is no way to mark and only the failed method stays bright - expect(fixTransitions(diagram)).toEqual([]); + // only the failed method stays bright expect(lines(diagram)).toEqual(expect.arrayContaining([`unbound --> bound : ${dimmedMethod('bind')}`, `broken --> broken : ${dimmedMethod('repair')}`])); }); @@ -259,14 +220,12 @@ describe('state error focus', () => { 'class childrenAllowed ljStateInvalid', 'class maybeHasChildren ljStateAccepted', `maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`, - // add is the way to the accepted state - 'childrenAllowed --> maybeHasChildren : add', + `childrenAllowed --> maybeHasChildren : ${dimmedMethod('add')}`, `maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`, ])); expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/); // the constructor is dimmed although it leads to a possible state - expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge2']); - expect(fixTransitions(diagram.join('\n'))).toEqual(['%% lj-fix-transitions: edge1']); + expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2']); }); it('only highlights the possible states when the accepted states are unknown', () => { @@ -297,7 +256,7 @@ describe('rendered state error focus', () => { document.body.innerHTML = ''; }); - it('dims the rendered path and label of the listed transitions, and marks the ways to fix the error', async () => { + it('dims the rendered path and label of the listed transitions', async () => { const diagram = createMermaidDiagram(datagramSocket(), 'TB', false, true); document.body.innerHTML = '
'; const pre = document.querySelector('.mermaid')!; @@ -311,9 +270,81 @@ describe('rendered state error focus', () => { await renderMermaidDiagram(document, { mermaid: { run } }); const dimmed = (selector: string) => Array.from(document.querySelectorAll(selector), node => node.getAttribute('data-id')); - expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge1', 'edge12']); - expect(dimmed('g.label.lj-dimmed')).toEqual(['edge1', 'edge12']); - expect(dimmed('path.transition.lj-fix-transition')).toEqual(['edge0']); + expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']); + expect(dimmed('g.label.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']); + }); +}); + +describe('collapsed labels', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + async function render() { + const sm = datagramSocket(); + sm.transitions.push({ from: 'bound', to: 'bound', label: 'getPort' }); + const label = lines(createMermaidDiagram(sm, 'TB', false, true)).find(line => line.startsWith('bound --> bound'))!.split(' : ')[1]; + document.body.innerHTML = '
x
'; + const pre = document.querySelector('.mermaid')!; + const run = vi.fn(async () => { + pre.innerHTML = `
${label}
`; + }); + await renderMermaidDiagram(document, { mermaid: { run } }); + return document.querySelector('.lj-collapsed-methods')!; + } + const popover = () => document.querySelector('.lj-methods-popover'); + const items = () => Array.from(document.querySelectorAll('.lj-methods-popover .lj-collapsed-item'), node => node.textContent); + + it('lists the methods on hover without rendering the diagram again', async () => { + const label = await render(); + expect(popover()).toBeNull(); + + label.dispatchEvent(new MouseEvent('mouseenter')); + expect(items()).toEqual(['send', 'getPort']); + label.dispatchEvent(new MouseEvent('mouseleave')); + expect(popover()).toBeNull(); + }); + + it('places the list beside the label, off the states', async () => { + const label = await render(); + const box = (left: number, top: number, width: number, height: number) => ({ left, top, right: left + width, bottom: top + height }) as DOMRect; + label.getBoundingClientRect = () => box(100, 100, 60, 20); + // a state right of the label + document.querySelector('svg')!.insertAdjacentHTML('beforeend', ''); + document.querySelector('g.node')!.getBoundingClientRect = () => box(170, 50, 200, 120); + const size = (name: 'offsetWidth' | 'offsetHeight', value: number) => vi.spyOn(HTMLElement.prototype, name, 'get').mockReturnValue(value); + const spies = [size('offsetWidth', 80), size('offsetHeight', 40)]; + // a 1000 x 800 page, where fixed elements land where they are put + vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(1000); + vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(800); + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLDivElement) { + return box(parseFloat(this.style.left) || 0, parseFloat(this.style.top) || 0, 80, 40); + }); + + label.dispatchEvent(new MouseEvent('mouseenter')); + expect((popover() as HTMLElement).style.left).toBe(`${100 - 6 - 80}px`); + expect((popover() as HTMLElement).style.top).toBe('90px'); + spies.forEach(spy => spy.mockRestore()); + vi.restoreAllMocks(); + }); + + it('keeps the list after a click until the next click, a pan, or a zoom', async () => { + const label = await render(); + + label.dispatchEvent(new MouseEvent('mouseenter')); + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + label.dispatchEvent(new MouseEvent('mouseleave')); + expect(items()).toEqual(['send', 'getPort']); + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(popover()).toBeNull(); + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + document.querySelector('svg')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(popover()).toBeNull(); + + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + document.querySelector('.diagram-container')!.dispatchEvent(new WheelEvent('wheel')); + expect(popover()).toBeNull(); }); }); diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index 95f7a4d..624ce45 100644 --- a/client/src/webview/diagram-focus.ts +++ b/client/src/webview/diagram-focus.ts @@ -79,77 +79,3 @@ export function orderStateMachines(sms: LJStateMachine[]): LJStateMachine[] { export function getStateErrorFocuses(sms: LJStateMachine[]): StateErrorFocus[] { return sms.map(getStateErrorFocus).filter((focus): focus is StateErrorFocus => !!focus); } - -/** - * A transition of a way to fix a state error, with every method that makes it - */ -export type StateFixStep = { - from: string; - to: string; - methods: string[]; -}; - -/** - * A way to bring the receiver from a state that rejects the call to a state that accepts it, read from the diagram - * The steps are empty when no transition leads from the state to an accepted one - */ -export type StateFix = { - from: string; - steps: StateFixStep[]; -}; - -/** - * Gets, for each possible state that rejects the call, a shortest way along the diagram's transitions to an accepted state - * The diagram only has the transitions of the specification, so a way may need specific arguments, and other ways may exist - * @param sm the state machine - * @param focus the state error on it - * @returns one fix per invalid state, in the order of the states - */ -export function getStateFixes(sm: LJStateMachine, focus: StateErrorFocus): StateFix[] { - const methodsByEdge = new Map(); - sm.transitions.forEach(({ from, to, label }) => { - if (from === to) return; - const key = `${from}|${to}`; - const methods = methodsByEdge.get(key) ?? []; - if (!methods.includes(label)) methods.push(label); - methodsByEdge.set(key, methods); - }); - const edges = [...methodsByEdge].map(([key, methods]) => { - const [from, to] = key.split('|'); - return { from, to, methods }; - }); - - return sm.states.filter(state => focus.invalidStates.includes(state)).map(start => { - // breadth-first, so the first accepted state reached is one of the closest - const previous = new Map([[start, null]]); - const queue = [start]; - while (queue.length > 0) { - const state = queue.shift()!; - if (focus.acceptedStates.includes(state)) return { from: start, steps: getStepsTo(state, previous) }; - edges.filter(edge => edge.from === state && !previous.has(edge.to)).forEach(edge => { - previous.set(edge.to, edge); - queue.push(edge.to); - }); - } - return { from: start, steps: [] }; - }); -} - -function getStepsTo(state: string, previous: Map): StateFixStep[] { - const steps: StateFixStep[] = []; - for (let step = previous.get(state); step; step = previous.get(step.from)) steps.unshift(step); - return steps; -} - -/** - * Gets the states whose constructors fix a state error, which are the accepted states when an invalid state cannot reach one - * @param sm the state machine - * @param focus the state error on it - * @param fixes the fixes of the state error - * @returns the accepted states a constructor leads to, or none when every invalid state can reach an accepted one - */ -export function getConstructorFixStates(sm: LJStateMachine, focus: StateErrorFocus, fixes: StateFix[]): string[] { - if (!fixes.some(fix => fix.steps.length === 0)) return []; - return [...new Set(sm.initialTransitions.map(transition => transition.to))] - .filter(state => focus.acceptedStates.includes(state)); -} diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index 25b338f..16e5a51 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -1,6 +1,7 @@ import type { LJStateMachine } from "../types/fsm"; import { copyToClipboard } from "./clipboard"; -import { INVALID_STATE_ICON, VALID_STATE_ICON, getConstructorFixStates, getRelatedStates, getStateErrorFocus, getStateFixes } from "./diagram-focus"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getRelatedStates, getStateErrorFocus } from "./diagram-focus"; +import { placePopover, type Box, type Point } from "./popover-placement"; // constants const MIN_ZOOM = 0.2; @@ -10,11 +11,14 @@ const SCROLL_ZOOM_IN_FACTOR = 1.05; const SCROLL_ZOOM_OUT_FACTOR = 0.95; // Mermaid comment listing the transitions to dim once rendered const DIMMED_TRANSITIONS_COMMENT = '%% lj-dimmed-transitions:'; -// Mermaid comment listing the transitions on a way from a rejecting state to an accepting one -const FIX_TRANSITIONS_COMMENT = '%% lj-fix-transitions:'; // a focused diagram is shrunk to fit the panel, but never below this scale so that its labels stay readable const MIN_FOCUS_SCALE = 0.6; const FOCUS_BOTTOM_MARGIN = 16; +// the list of a collapsed label's methods keeps this distance from the label and from the window's edges +const POPOVER_GAP = 6; +const POPOVER_MARGIN = 8; +// the step between the points of a transition line the list avoids +const LINE_SAMPLE_STEP = 8; // state variables let zoomLevel = 1; @@ -46,17 +50,11 @@ export function createMermaidDiagram( const isDimmed = (state: string) => relatedStates.length > 0 && !relatedStates.includes(state); // focusing collapses the dimmed methods of each label, so the diagram is laid out again around the error const collapse = relatedStates.length > 0; - // the ways from the invalid states to an accepted state stay bright and are marked - const fixes = collapse ? getStateFixes(sm, errorFocus!) : []; - const fixEdges = new Set(fixes.flatMap(fix => fix.steps.map(step => `${step.from}|${step.to}`))); - const constructorFixStates = collapse ? getConstructorFixStates(sm, errorFocus!, fixes) : []; // Mermaid numbers the rendered transitions in the order they are declared const dimmedTransitions: string[] = []; - const fixTransitions: string[] = []; let transitionCount = 0; - const addTransition = (line: string, dimmed: boolean, fix = false) => { + const addTransition = (line: string, dimmed: boolean) => { if (dimmed) dimmedTransitions.push(`edge${transitionCount}`); - if (fix) fixTransitions.push(`edge${transitionCount}`); transitionCount++; lines.push(line); }; @@ -64,19 +62,15 @@ export function createMermaidDiagram( lines.push('stateDiagram-v2'); lines.push(` direction ${orientation}`); - // initial transitions, dimmed in focus mode since the receiver already exists at the call, - // unless creating a new object is the only fix + // initial transitions, dimmed in focus mode since the receiver already exists at the call if (collapse) { const targets = [...new Set(sm.initialTransitions.map(transition => transition.to))]; targets.forEach(to => { const signatures = sm.initialTransitions.filter(transition => transition.to === to) .map(transition => transition.constructorSignature ? escapeMermaidLabel(transition.constructorSignature) : '') .filter(Boolean); - const fix = constructorFixStates.includes(to); - const label = fix - ? signatures.join('
') - : getCollapsedLabel([], signatures.map(signature => ({ name: signature, label: signature })), 'constructors'); - addTransition(` [*] --> ${to}${label ? ` : ${label}` : ''}`, !fix, fix); + const label = getCollapsedLabel([], signatures.map(signature => ({ name: signature, label: signature })), 'constructors'); + addTransition(` [*] --> ${to}${label ? ` : ${label}` : ''}`, true); }); } else { sm.initialTransitions.forEach(transition => { @@ -110,8 +104,8 @@ export function createMermaidDiagram( sm.transitions.forEach(transition => { const key = `${transition.from}|${transition.to}`; const isCalledMethod = transition.label === sm.errorContext?.calledMethod; - // in focus mode, only the failed method and the ways to an accepted state stay bright - const dimmed = collapse && !isCalledMethod && !fixEdges.has(key); + // in focus mode, only the failed method stays bright + const dimmed = collapse && !isCalledMethod; const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -130,7 +124,7 @@ export function createMermaidDiagram( // add transitions, dimming those whose methods are all dimmed transitionMap.forEach(({ bright, dimmed }, key) => { const [from, to] = key.split('|'); - addTransition(` ${from} --> ${to} : ${getCollapsedLabel(bright, dimmed, 'methods')}`, bright.length === 0, fixEdges.has(key)); + addTransition(` ${from} --> ${to} : ${getCollapsedLabel(bright, dimmed, 'methods')}`, bright.length === 0); }); if (!errorFocus) { @@ -155,7 +149,6 @@ export function createMermaidDiagram( if (states.length > 0) lines.push(` class ${states.join(',')} ${className}`); }); if (dimmedTransitions.length > 0) lines.push(`${DIMMED_TRANSITIONS_COMMENT} ${dimmedTransitions.join(' ')}`); - if (fixTransitions.length > 0) lines.push(`${FIX_TRANSITIONS_COMMENT} ${fixTransitions.join(' ')}`); return lines.join('\n'); } @@ -164,16 +157,16 @@ export function createMermaidDiagram( type CollapsibleLabel = { name: string; label: string }; /** - * Gets the label of a transition whose dimmed methods are collapsed into a count, named in its tooltip + * Gets the label of a transition whose dimmed methods are collapsed into a count * A single dimmed method keeps its full label, since the count would be no shorter + * The full labels are kept hidden in the count, for the list shown on hover */ function getCollapsedLabel(bright: string[], dimmed: CollapsibleLabel[], noun: 'methods' | 'constructors'): string { if (dimmed.length === 0) return bright.join('
'); if (dimmed.length === 1) return [...bright, `${dimmed[0].label}`].join('
'); const count = bright.length > 0 ? `+${dimmed.length} more` : `${dimmed.length} ${noun}`; - // the names are already escaped, and a tooltip shows them on hover - const names = dimmed.map(method => method.name).join(', ').replace(/'/g, '''); - return [...bright, `${count}`].join('
'); + const methods = `${dimmed.map(method => `${method.label}`).join('')}`; + return [...bright, `${count}${methods}`].join('
'); } function getTransitionLabel( @@ -227,14 +220,13 @@ export async function renderMermaidDiagram(document: any, window: any) { try { const dimmedTransitions = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, DIMMED_TRANSITIONS_COMMENT)); - const fixTransitions = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, FIX_TRANSITIONS_COMMENT)); await mermaid.run({ nodes: mermaidElements }); highlightErrorTransitions(document); mermaidElements.forEach((element: any, index: number) => { markTransitions(element, dimmedTransitions[index], 'lj-dimmed'); - markTransitions(element, fixTransitions[index], 'lj-fix-transition'); }); fitFocusedDiagrams(document, window); + registerCollapsedLabels(document); applyTransform(document); registerPanListeners(document); const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null; @@ -283,6 +275,101 @@ function fitFocusedDiagrams(document: any, window: any) { }); } +/** + * Shows the methods of a collapsed label in a list next to it, without laying out the diagram again + * Hovering shows the list, and a click pins it until the next click, a pan, or a zoom + */ +function registerCollapsedLabels(document: any) { + const container = document.querySelector('.diagram-container') as HTMLElement | null; + if (!container) return; + let popover: HTMLElement | null = null; + let pinned: Element | null = null; + + const hide = () => { + popover?.remove(); + popover = null; + pinned = null; + }; + const show = (label: Element) => { + popover?.remove(); + popover = document.createElement('div') as HTMLElement; + popover.className = 'lj-methods-popover'; + popover.innerHTML = label.querySelector('.lj-collapsed-list')?.innerHTML ?? ''; + container.appendChild(popover); + placeMethodsPopover(document, popover, label); + }; + + container.querySelectorAll('.lj-collapsed-methods').forEach(label => { + label.addEventListener('mouseenter', () => { + if (!pinned) show(label); + }); + label.addEventListener('mouseleave', () => { + if (!pinned) hide(); + }); + label.addEventListener('click', (e: Event) => { + e.stopPropagation(); + if (pinned === label) return hide(); + show(label); + pinned = label; + }); + }); + // the list would no longer be next to its label + container.addEventListener('mousedown', (e: Event) => { + if (!(e.target as Element).closest?.('.lj-collapsed-methods, .lj-methods-popover')) hide(); + }); + container.addEventListener('wheel', hide); +} + +// places the list of a collapsed label's methods where it covers the least of the label's diagram +function placeMethodsPopover(document: any, popover: HTMLElement, label: Element) { + // the visible area, without the scrollbars + const page = document.documentElement; + const bounds = { + left: POPOVER_MARGIN, + top: POPOVER_MARGIN, + right: page.clientWidth - POPOVER_MARGIN, + bottom: page.clientHeight - POPOVER_MARGIN, + }; + popover.style.maxHeight = `${Math.max(bounds.bottom - bounds.top, 0)}px`; + const svg = label.closest('svg'); + const anchor = toBox(label.getBoundingClientRect()); + const boxes = svg ? Array.from(svg.querySelectorAll('g.node, .edgeLabel'), (element: Element) => toBox(element.getBoundingClientRect())) : []; + const position = placePopover( + anchor, + { width: popover.offsetWidth, height: popover.offsetHeight }, + bounds, + svg ? toBox(svg.getBoundingClientRect()) : anchor, + boxes, + svg ? getLinePoints(svg) : [], + POPOVER_GAP, + ); + popover.style.left = `${position.x}px`; + popover.style.top = `${position.y}px`; + // the webview may offset fixed elements, so the popover is moved by what it missed + const placed = popover.getBoundingClientRect(); + popover.style.left = `${2 * position.x - placed.left}px`; + popover.style.top = `${2 * position.y - placed.top}px`; +} + +function toBox(rect: DOMRect): Box { + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; +} + +// points along the transition lines of a diagram, in window coordinates +function getLinePoints(svg: Element): Point[] { + return Array.from(svg.querySelectorAll('path.transition')).flatMap((path: any) => { + const matrix = path.getScreenCTM?.(); + if (!matrix || typeof path.getTotalLength !== 'function') return []; + const length = path.getTotalLength(); + const points: Point[] = []; + for (let at = 0; at <= length; at += LINE_SAMPLE_STEP) { + const { x, y } = path.getPointAtLength(at).matrixTransform(matrix); + points.push({ x, y }); + } + return points; + }); +} + function highlightTransitionMarker(svg: any, transition: any, edgeId: string) { const markerReference = transition.getAttribute('marker-end'); const markerId = markerReference?.match(/^url\(#(.+)\)$/)?.[1]; @@ -378,8 +465,8 @@ export function registerPanListeners(document: any) { const onMouseDown = (e: any) => { const target = e.target as any; if (target.tagName === 'A' || target.tagName === 'BUTTON') return; - // keep the error summary text selectable - if (target.closest?.('.diagram-error-summary')) return; + // keep the error summary text selectable, and the collapsed labels clickable + if (target.closest?.('.diagram-error-summary, .lj-collapsed-methods, .lj-methods-popover')) return; e.preventDefault(); isPanning = true; diff --git a/client/src/webview/popover-placement.spec.ts b/client/src/webview/popover-placement.spec.ts new file mode 100644 index 0000000..76de823 --- /dev/null +++ b/client/src/webview/popover-placement.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { placePopover, type Box } from './popover-placement'; + +const box = (left: number, top: number, width: number, height: number): Box => ({ left, top, right: left + width, bottom: top + height }); +const window = box(0, 0, 1000, 800); +const diagram = box(200, 100, 400, 500); +// a 40 x 20 label in the middle of the diagram +const anchor = box(380, 300, 40, 20); +const size = { width: 100, height: 60 }; +const place = (boxes: Box[], points: { x: number; y: number }[] = [], bounds = window) => + placePopover(anchor, size, bounds, diagram, boxes, points, 6); + +describe('popover placement', () => { + it('places the popover right of its label when nothing is there', () => { + expect(place([])).toEqual({ x: 426, y: 280 }); + }); + + it('moves left of the label when a state is right of it', () => { + expect(place([box(430, 270, 120, 80)])).toEqual({ x: 274, y: 280 }); + }); + + it('avoids the transition lines', () => { + // a vertical line right of the label, and one left of it + const line = (x: number) => Array.from({ length: 20 }, (_, i) => ({ x, y: 250 + i * 8 })); + expect(place([], [...line(470), ...line(330)])).toEqual({ x: 350, y: 234 }); + }); + + it('goes beside the diagram when the label is surrounded', () => { + const around = [box(426, 250, 120, 120), box(254, 250, 120, 120), box(300, 200, 200, 94), box(300, 326, 200, 100)]; + expect(place(around)).toEqual({ x: 606, y: 280 }); + }); + + it('stays inside its bounds, off its label', () => { + // no room right of the label + expect(place([], [], box(0, 0, 450, 800))).toEqual({ x: 274, y: 280 }); + // no room either side, so it is pushed against the edge + expect(place([], [], box(0, 0, 300, 800)).x).toBe(200); + }); +}); diff --git a/client/src/webview/popover-placement.ts b/client/src/webview/popover-placement.ts new file mode 100644 index 0000000..daed698 --- /dev/null +++ b/client/src/webview/popover-placement.ts @@ -0,0 +1,76 @@ +export type Box = { left: number; top: number; right: number; bottom: number }; +export type Point = { x: number; y: number }; + +// covering a point of a transition's line costs as much as covering this many square pixels of a state or label +const LINE_POINT_COST = 60; +// moving the popover one pixel further from its label costs as much as covering this many square pixels +const DISTANCE_COST = 2; + +/** + * Places a popover next to its anchor where it covers the least of the diagram + * It tries the right, left, top, and bottom of the anchor, then beside the diagram at the anchor's height, + * keeps each inside the window, and picks the one that covers the fewest states, labels, and transition lines, + * the closest one on a tie + * @param anchor the box of the label the popover lists the methods of + * @param size the size of the popover + * @param bounds the box the popover must stay in, usually the window less a margin + * @param diagram the box of the diagram, to place the popover beside it + * @param boxes the boxes of the states and labels the popover should not cover + * @param points points along the transition lines the popover should not cover + * @param gap the space between the popover and its anchor or the diagram + * @returns the top-left corner of the popover + */ +export function placePopover( + anchor: Box, + size: { width: number; height: number }, + bounds: Box, + diagram: Box, + boxes: Box[], + points: Point[], + gap: number, +): Point { + const { width, height } = size; + const middle = (anchor.top + anchor.bottom - height) / 2; + const center = (anchor.left + anchor.right - width) / 2; + const candidates: Point[] = [ + { x: anchor.right + gap, y: middle }, + { x: anchor.left - gap - width, y: middle }, + { x: center, y: anchor.top - gap - height }, + { x: center, y: anchor.bottom + gap }, + { x: diagram.right + gap, y: middle }, + { x: diagram.left - gap - width, y: middle }, + ].map(({ x, y }) => ({ + x: clamp(x, bounds.left, bounds.right - width), + y: clamp(y, bounds.top, bounds.bottom - height), + })); + + const cost = ({ x, y }: Point) => { + const box = { left: x, top: y, right: x + width, bottom: y + height }; + const covered = boxes.reduce((sum, other) => sum + overlap(box, other), 0) + + overlap(box, anchor) * 10 + + points.filter(point => contains(box, point)).length * LINE_POINT_COST; + return covered + distance(box, anchor) * DISTANCE_COST; + }; + return candidates.reduce((best, candidate) => cost(candidate) < cost(best) ? candidate : best); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)); +} + +function overlap(a: Box, b: Box): number { + const width = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return width > 0 && height > 0 ? width * height : 0; +} + +function contains(box: Box, { x, y }: Point): boolean { + return x > box.left && x < box.right && y > box.top && y < box.bottom; +} + +// the gap between two boxes, 0 when they touch or overlap +function distance(a: Box, b: Box): number { + const dx = Math.max(b.left - a.right, a.left - b.right, 0); + const dy = Math.max(b.top - a.bottom, a.top - b.bottom, 0); + return Math.hypot(dx, dy); +} diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index 24d2688..e682ed1 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1135,15 +1135,51 @@ export function getStyles(): string { stroke-width: 1px !important; stroke-dasharray: 4 3; } - /* a way from a rejecting state to an expected one, dashed like the expected states */ - .diagram-container .mermaid svg path.lj-fix-transition { - stroke: var(--lj-state-accepted) !important; - stroke-width: 1.5px !important; - stroke-dasharray: 6 4 !important; - } .diagram-container .mermaid .edgeLabel .lj-collapsed-methods { font-style: italic; } + /* hovering shows the collapsed methods, and a click keeps them shown */ + .diagram-container .mermaid .edgeLabel .lj-collapsed-methods { + cursor: pointer; + } + .diagram-container .mermaid .edgeLabel .lj-collapsed-methods:hover { + opacity: 1; + text-decoration: underline; + } + .lj-collapsed-list { + display: none; + } + .lj-methods-popover { + position: fixed; + z-index: 10; + overflow-y: auto; + padding: 0.35rem 0.6rem; + font-size: 0.8rem; + line-height: 1.4; + color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); + background: var(--vscode-editorHoverWidget-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-panel-border)); + border-radius: 4px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36)); + cursor: default; + user-select: text; + } + .lj-methods-popover .lj-collapsed-item { + display: block; + } + .lj-methods-popover .lj-collapsed-item + .lj-collapsed-item { + margin-top: 0.2rem; + } + .lj-methods-popover .state-cond { + display: block; + font-size: 0.9em; + } + .lj-methods-popover .state-cond-pre { + color: var(--lj-state-cond-pre); + } + .lj-methods-popover .state-cond-post { + color: var(--lj-state-cond-post); + } .diagram-container .mermaid svg .ljStateDimmed, .diagram-container .mermaid svg .lj-dimmed, .diagram-container .mermaid .edgeLabel .lj-dimmed-method { @@ -1237,11 +1273,5 @@ export function getStyles(): string { border: 1px dashed; border-color: var(--lj-state-accepted); } - .diagram-error-legend-fix { - height: 0; - border-radius: 0; - border-top: 2px dashed; - border-color: var(--lj-state-accepted); - } `; } diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index 99eb6ce..24e186d 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -358,19 +358,16 @@ describe('state machine view', () => { .toBe('Invalid method call param.write() \u00B7 expected state tilingSet and compressionExplicit \u00B7 actual state startTiling and compressionExplicit'); expect(document.querySelector('.diagram-state-set-dimmed')).toBeNull(); expect(Array.from(document.querySelectorAll('.diagram-error-legend-item'), node => node.textContent)) - .toEqual(['\u2715 Actual state, not expected', '\u2713 Actual state, expected', 'Expected state', 'Way to an expected state']); + .toEqual(['\u2715 Actual state, not expected', '\u2713 Actual state, expected', 'Expected state']); }); - it('only explains the ways to an expected state when focusing on the error', () => { + it('fits the diagram to the panel only when focusing on the error', () => { const sm = { ...tiling, errorContext: { calledMethod: 'write', receiver: 'param', possibleStates: ['startTiling'], acceptedStates: ['tilingSet'], validStates: [], invalidStates: ['startTiling'] } }; - const legend = () => Array.from(document.querySelectorAll('.diagram-error-legend-item'), node => node.textContent); renderStateMachineView(document.body, [sm], ['tiling diagram'], 'TB', false, true); - expect(legend()).toContain('Way to an expected state'); expect(document.querySelector('.diagram-viewport.diagram-focused')).not.toBeNull(); renderStateMachineView(document.body, [sm], ['tiling diagram'], 'TB', false, false); - expect(legend()).not.toContain('Way to an expected state'); expect(document.querySelector('.diagram-focused')).toBeNull(); }); diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index d04180b..bac2ae6 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,5 +1,5 @@ import type { LJStateMachine } from "../../../types/fsm"; -import { INVALID_STATE_ICON, VALID_STATE_ICON, getConstructorFixStates, getRejectedCallText, getStateErrorFocus, getStateErrorFocuses, getStateFixes, type StateErrorFocus } from "../../diagram-focus"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectedCallText, getStateErrorFocuses, type StateErrorFocus } from "../../diagram-focus"; import { renderCodiconButton } from "../../icons"; import { escapeHtml } from "../../utils"; import { renderMainHeader, type NavBadges } from "../sections"; @@ -57,7 +57,7 @@ function renderStateMachineViewHtml(sms: LJStateMachine[], diagrams: string[], o
${errorFocuses.length > 0 - ? renderStateErrorSummary(getStateErrorSummary(errorFocuses), stateErrors) + renderStateErrorLegend(errorFocuses, focusOnError && hasStateFixes(sms), focusOnError) + ? renderStateErrorSummary(getStateErrorSummary(errorFocuses), stateErrors) + renderStateErrorLegend(errorFocuses, focusOnError) : isStateErrorShown(stateErrors) ? renderStateErrorSummary(getUnmarkedStateErrorSummary(sms, stateErrors), stateErrors) : ''} ${showConditions && hasConditionExpansions ? /*html*/`
@@ -139,25 +139,14 @@ function renderStateErrorStepper({ index, count }: StateErrorNavigation): string `; } -// whether a diagram marks a way from a rejecting state to an accepting one -function hasStateFixes(sms: LJStateMachine[]): boolean { - return sms.some(sm => { - const focus = getStateErrorFocus(sm); - if (!focus) return false; - const fixes = getStateFixes(sm, focus); - return fixes.some(fix => fix.steps.length > 0) || getConstructorFixStates(sm, focus, fixes).length > 0; - }); -} - // explains the state marks with the same outlines, fills, and icons as the diagram, next to the toggle that collapses the rest -function renderStateErrorLegend(focuses: StateErrorFocus[], hasFixes: boolean, focusOnError: boolean): string { +function renderStateErrorLegend(focuses: StateErrorFocus[], focusOnError: boolean): string { const items = [ focuses.some(focus => focus.invalidStates.length > 0) ? renderLegendItem('invalid', `${INVALID_STATE_ICON} Actual state, not expected`) : '', focuses.some(focus => focus.validStates.length > 0) ? renderLegendItem('valid', `${VALID_STATE_ICON} Actual state, expected`) : '', focuses.some(focus => focus.acceptedStates.some(state => !focus.validStates.includes(state))) ? renderLegendItem('accepted', 'Expected state') : '', - hasFixes ? renderLegendItem('fix', 'Way to an expected state') : '', ].filter(Boolean); return /*html*/`