diff --git a/packages/diagram-client/src/diagram-client.css b/packages/diagram-client/src/diagram-client.css index 85a9f3f..e875b71 100644 --- a/packages/diagram-client/src/diagram-client.css +++ b/packages/diagram-client/src/diagram-client.css @@ -2936,7 +2936,8 @@ details[open] > summary .agent-chat-expand-arrow { .workflow-fab-btn[hidden] { display: none; } -.workflow-debug-toggle-btn.active { +.workflow-debug-toggle-btn.active, +.workflow-hierarchy-outline-toggle.active { color: var(--vscode-focusBorder, #4daafc); border-color: var(--vscode-focusBorder, #4daafc); } @@ -3273,23 +3274,13 @@ details[open] > summary .agent-chat-expand-arrow { /* ── Hierarchy outline (hierarchy-outline.ts) ─────────────────────────── */ -.workflow-hierarchy-outline-toggle { +/* The toggle lives in the floating button stack, under the chat button. Until + the stack exists it floats on its own, where the stack will be. */ +.workflow-hierarchy-outline-toggle.floating { position: fixed; - top: 44px; - left: 8px; - z-index: 30; - width: 28px; - height: 28px; - border: 1px solid var(--vscode-editorWidget-border, #454545); - border-radius: 4px; - background: var(--vscode-editorWidget-background, #252526); - color: var(--vscode-foreground, #cccccc); - cursor: pointer; -} - -.workflow-hierarchy-outline-toggle.active, -.workflow-hierarchy-outline-toggle:hover { - background: var(--vscode-toolbar-hoverBackground, #2a2d2e); + bottom: calc(var(--workflow-wf-nav-height, 0px) + 16px); + right: 14px; + z-index: 1002; } .workflow-hierarchy-outline { diff --git a/packages/diagram-client/src/hierarchy-outline.ts b/packages/diagram-client/src/hierarchy-outline.ts index f9092ac..f383fe8 100644 --- a/packages/diagram-client/src/hierarchy-outline.ts +++ b/packages/diagram-client/src/hierarchy-outline.ts @@ -121,7 +121,7 @@ export class HierarchyOutlinePanel { placeholder="Filter instances…" aria-label="Filter instances" spellcheck="false" />
- `; @@ -168,6 +168,10 @@ export class HierarchyOutlinePanel { const panel = document.getElementById(PANEL_ID); if (toggle) { toggle.hidden = !available; + placeInButtonStack(toggle); + // The stack and the chat button may be put in place after this + // listener runs; place it again once they are. + requestAnimationFrame(() => placeInButtonStack(toggle)); } if (panel) { panel.hidden = !available || !this.open; @@ -358,6 +362,32 @@ export class HierarchyOutlinePanel { } } +/** + * Put the hierarchy button in the floating button stack, directly under the + * chat button. The stack is a reversed column -- what comes first in it sits + * lowest -- so "under chat" is just before it. Without a stack yet, the button + * floats on its own until one exists. + */ +export function placeInButtonStack( + toggle: HTMLElement, + doc: Pick = document +): void { + const stack = doc.querySelector('.workflow-fab-stack'); + if (!stack) { + toggle.classList.add('floating'); + return; + } + toggle.classList.remove('floating'); + const chat = doc.getElementById('workflow-chat-toggle-btn'); + if (chat && chat.parentElement === stack) { + if (toggle.nextElementSibling !== chat) { + stack.insertBefore(toggle, chat); + } + } else if (toggle.parentElement !== stack) { + stack.appendChild(toggle); + } +} + /** The navigation trail a model root carries (`wf:navTrail`, JSON or a list). */ export function trailOfRoot(root: unknown): Array<{ workflowName: string; workflowInstanceName?: string }> { const raw = (root as { args?: Record })?.args?.['wf:navTrail']; diff --git a/packages/diagram-client/src/initial-viewport-service.ts b/packages/diagram-client/src/initial-viewport-service.ts index 7b9b470..9914a08 100644 --- a/packages/diagram-client/src/initial-viewport-service.ts +++ b/packages/diagram-client/src/initial-viewport-service.ts @@ -1,43 +1,27 @@ import { CenterAction } from '@eclipse-glsp/protocol'; -import { TYPES, type IActionDispatcher, EditorContextService } from '@eclipse-glsp/client'; +import { TYPES, type IActionDispatcher } from '@eclipse-glsp/client'; import { inject, injectable } from 'inversify'; -type RootArgs = { - sourceUri?: string; - 'wf:selectedWorkflow'?: string; - 'cal:networkName'?: string; - 'wf:workflowName'?: string; -}; - +/** + * Centers the diagram when a view is shown for the first time. + * + * Which views that is, is decided by `ViewportPreservingSetModelCommand`: a view + * never shown in this editor is centered, a view navigated back to returns to + * where it was left, and a refresh of the view shown keeps its viewport. + */ @injectable() export class InitialViewportService { - private readonly centeredKeys = new Set(); - constructor( - @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher, - @inject(EditorContextService) private readonly editorContext: EditorContextService + @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher ) {} /** - * Center the diagram once when a workflow model is first shown. + * Center the diagram once it has rendered. * - * Uses a double-rAF so we run after the first paint/bounds computation. + * Uses a double-rAF so it runs after the first paint/bounds computation, + * when the viewport has a real size. */ - maybeCenterOnInitialModel(root: unknown): void { - const args = ((root as any)?.args ?? {}) as RootArgs; - const sourceUri = args.sourceUri ?? this.editorContext.sourceUri; - const workflowName = args['wf:selectedWorkflow'] ?? args['cal:networkName'] ?? args['wf:workflowName']; - if (!sourceUri || !workflowName) { - return; - } - - const key = `${sourceUri}::${workflowName}`; - if (this.centeredKeys.has(key)) { - return; - } - this.centeredKeys.add(key); - - // Run after initial render so viewport has a real size. + centerSoon(): void { requestAnimationFrame(() => { requestAnimationFrame(() => { void this.actionDispatcher.dispatch(CenterAction.create([], { animate: false })); diff --git a/packages/diagram-client/src/viewport-preserving-set-model-command.ts b/packages/diagram-client/src/viewport-preserving-set-model-command.ts index d11e140..9294c71 100644 --- a/packages/diagram-client/src/viewport-preserving-set-model-command.ts +++ b/packages/diagram-client/src/viewport-preserving-set-model-command.ts @@ -17,8 +17,13 @@ type RootArgs = { /** One-shot guard for the always-on `firstSetModel` webview breadcrumb (see below). */ let firstSetModelLogged = false; -function modelViewportKey(root: unknown): string | undefined { - const args = ((root as any)?.args ?? {}) as RootArgs; +/** + * Which view a model root is: its file, its workflow, and -- for a nested + * workflow shown in place -- the instance path that reached it, so two + * instances of one workflow are two views. + */ +export function modelViewportKey(root: unknown): string | undefined { + const args = ((root as any)?.args ?? {}) as RootArgs & { 'wf:navTrail'?: unknown }; const sourceUri = typeof args.sourceUri === 'string' ? args.sourceUri : undefined; const workflowName = (typeof args['wf:selectedWorkflow'] === 'string' ? args['wf:selectedWorkflow'] : undefined) @@ -27,7 +32,68 @@ function modelViewportKey(root: unknown): string | undefined { if (!sourceUri || !workflowName) { return undefined; } - return `${sourceUri}::${workflowName}`; + return `${sourceUri}::${workflowName}${instancePathKey(args['wf:navTrail'])}`; +} + +function instancePathKey(rawTrail: unknown): string { + try { + const trail = typeof rawTrail === 'string' ? JSON.parse(rawTrail) : rawTrail; + if (!Array.isArray(trail) || trail.length < 2) { + return ''; + } + return '::' + trail.slice(1).map((crumb: any) => crumb?.workflowInstanceName ?? crumb?.workflowName ?? '').join('/'); + } catch { + return ''; + } +} + +/** + * The viewport each view was last left at, so navigating back to one returns + * to where it was -- as a browser's back does -- rather than to the default + * scroll, which puts the graph off-center. Per webview: one editor's views. + */ +const viewportsByView = new Map(); + +type Viewport = { zoom: number; scroll: { x: number; y: number } }; +type ViewportRoot = { zoom?: number; scroll?: { x: number; y: number } }; + +/** + * Where the new model's viewport should be, decided across a `SetModelAction`: + * + * - the same view refreshed (a save, a run, a live preview): kept as it is; + * - a view navigated to that this editor showed before: back where it was left; + * - a view never shown here: centered (returns `'center'`). + * + * The view left is remembered in `store` first. Sets `newRoot`'s zoom and + * scroll in place for the first two cases. + */ +export function settleViewport( + previousRoot: ViewportRoot, + newRoot: ViewportRoot, + store: Map +): 'center' | undefined { + const previousKey = modelViewportKey(previousRoot); + const nextKey = modelViewportKey(newRoot); + const sameView = !!previousKey && !!nextKey && previousKey === nextKey; + const hasViewport = (root: ViewportRoot): root is Viewport => typeof root.zoom === 'number' && !!root.scroll; + + if (sameView) { + if (hasViewport(previousRoot) && hasViewport(newRoot)) { + newRoot.zoom = previousRoot.zoom; + newRoot.scroll = { ...previousRoot.scroll }; + } + return undefined; + } + if (previousKey && hasViewport(previousRoot)) { + store.set(previousKey, { zoom: previousRoot.zoom, scroll: { ...previousRoot.scroll } }); + } + const remembered = nextKey ? store.get(nextKey) : undefined; + if (remembered) { + newRoot.zoom = remembered.zoom; + newRoot.scroll = { ...remembered.scroll }; + return undefined; + } + return 'center'; } /** @@ -83,24 +149,9 @@ export class ViewportPreservingSetModelCommand extends FeedbackAwareSetModelComm }; this.workflowNavUi?.onModelChanged(newRoot); - this.initialViewport.maybeCenterOnInitialModel(newRoot); - - const previousKey = modelViewportKey(previousRoot); - const nextKey = modelViewportKey(newRoot); - const sameWorkflowModel = !!previousKey && !!nextKey && previousKey === nextKey; - - // Preserve viewport (scroll/zoom) if present on both roots. - // Important: do NOT carry viewport across workflow switches (e.g. parent -> child), - // otherwise nested workflows inherit stale parent viewport framing. - if ( - sameWorkflowModel && - typeof previousRoot.zoom === 'number' && - previousRoot.scroll && - typeof newRoot.zoom === 'number' && - newRoot.scroll - ) { - newRoot.zoom = previousRoot.zoom; - newRoot.scroll = { ...previousRoot.scroll }; + + if (settleViewport(previousRoot, newRoot, viewportsByView) === 'center') { + this.initialViewport.centerSoon(); } const idsToSelect = this.postEditSelection.consumeMatchingIds(newRoot); diff --git a/packages/diagram-client/test/outline-button-and-viewport.test.ts b/packages/diagram-client/test/outline-button-and-viewport.test.ts new file mode 100644 index 0000000..969b777 --- /dev/null +++ b/packages/diagram-client/test/outline-button-and-viewport.test.ts @@ -0,0 +1,114 @@ +/** + * The hierarchy button sits in the floating button stack, under the chat + * button; and navigating back to a view returns it to where it was left + * instead of to the default scroll, which put the graph off-center. + */ +import { describe, expect, it } from 'vitest'; +import { placeInButtonStack } from '../src/hierarchy-outline'; +import { modelViewportKey, settleViewport } from '../src/viewport-preserving-set-model-command'; + +/** A minimal element tree: enough for parent/sibling bookkeeping. */ +class FakeElement { + parentElement: FakeElement | null = null; + children: FakeElement[] = []; + readonly classes = new Set(); + constructor(readonly id = '') {} + get nextElementSibling(): FakeElement | null { + const siblings = this.parentElement?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + get classList() { + const classes = this.classes; + return { add: (c: string) => classes.add(c), remove: (c: string) => classes.delete(c) }; + } + private detach(child: FakeElement): void { + const from = child.parentElement; + if (from) { + from.children.splice(from.children.indexOf(child), 1); + } + } + appendChild(child: FakeElement): void { + this.detach(child); + child.parentElement = this; + this.children.push(child); + } + insertBefore(child: FakeElement, before: FakeElement): void { + this.detach(child); + child.parentElement = this; + this.children.splice(this.children.indexOf(before), 0, child); + } +} + +function docWith(stack: FakeElement | null, chat?: FakeElement): any { + return { querySelector: () => stack, getElementById: (id: string) => (chat && chat.id === id ? chat : null) }; +} + +describe('the hierarchy button', () => { + it('goes directly under the chat button: just before it in the reversed stack', () => { + const stack = new FakeElement(); + const runs = new FakeElement('runs'); + const chat = new FakeElement('workflow-chat-toggle-btn'); + stack.appendChild(runs); + stack.appendChild(chat); + const toggle = new FakeElement('hierarchy'); + + placeInButtonStack(toggle as any, docWith(stack, chat)); + + expect(stack.children.map(c => c.id)).toEqual(['runs', 'hierarchy', 'workflow-chat-toggle-btn']); + expect(toggle.classes.has('floating')).toBe(false); + }); + + it('stays put when placed again', () => { + const stack = new FakeElement(); + const chat = new FakeElement('workflow-chat-toggle-btn'); + stack.appendChild(chat); + const toggle = new FakeElement('hierarchy'); + placeInButtonStack(toggle as any, docWith(stack, chat)); + placeInButtonStack(toggle as any, docWith(stack, chat)); + expect(stack.children.map(c => c.id)).toEqual(['hierarchy', 'workflow-chat-toggle-btn']); + }); + + it('floats on its own until there is a stack', () => { + const toggle = new FakeElement('hierarchy'); + placeInButtonStack(toggle as any, docWith(null)); + expect(toggle.classes.has('floating')).toBe(true); + }); +}); + +const view = (workflow: string, instance?: string, viewport?: { zoom: number; scroll: { x: number; y: number } }) => ({ + args: { + sourceUri: 'file:///w/top.py', + 'wf:selectedWorkflow': workflow, + ...(instance ? { 'wf:navTrail': JSON.stringify([{ workflowName: 'top' }, { workflowName: workflow, workflowInstanceName: instance }]) } : {}) + }, + zoom: viewport?.zoom ?? 1, + scroll: viewport?.scroll ?? { x: 0, y: 0 } +}); + +describe('the viewport across a model change', () => { + it('tells two instances of one workflow apart', () => { + expect(modelViewportKey(view('block', 'b1'))).not.toBe(modelViewportKey(view('block', 'b2'))); + }); + + it('centers a view never shown', () => { + const store = new Map(); + expect(settleViewport(view('top', undefined, { zoom: 2, scroll: { x: 50, y: 60 } }), view('block', 'b1'), store)).toBe('center'); + }); + + it('returns to where a view was left when navigating back to it', () => { + const store = new Map(); + const leftAt = { zoom: 0.8, scroll: { x: 120, y: -40 } }; + // Down from the root into b1, then back up. + settleViewport(view('top', undefined, leftAt), view('block', 'b1'), store); + const back = view('top'); + + expect(settleViewport(view('block', 'b1', { zoom: 1.5, scroll: { x: 9, y: 9 } }), back, store)).toBeUndefined(); + expect({ zoom: back.zoom, scroll: back.scroll }).toEqual(leftAt); + }); + + it('keeps the viewport of the view refreshed', () => { + const refreshed = view('top'); + expect(settleViewport(view('top', undefined, { zoom: 1.2, scroll: { x: 5, y: 6 } }), refreshed, new Map())).toBeUndefined(); + expect({ zoom: refreshed.zoom, scroll: refreshed.scroll }).toEqual({ zoom: 1.2, scroll: { x: 5, y: 6 } }); + }); +});