Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 8 additions & 17 deletions packages/diagram-client/src/diagram-client.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 31 additions & 1 deletion packages/diagram-client/src/hierarchy-outline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class HierarchyOutlinePanel {
placeholder="Filter instances…" aria-label="Filter instances" spellcheck="false" />
<div id="${BODY_ID}" class="workflow-hierarchy-outline-body" role="tree"></div>
</aside>
<button id="${TOGGLE_ID}" class="workflow-hierarchy-outline-toggle" type="button"
<button id="${TOGGLE_ID}" class="workflow-fab-btn workflow-hierarchy-outline-toggle floating" type="button"
title="Hierarchy (O)" aria-label="Toggle hierarchy outline" hidden>
<span class="codicon codicon-type-hierarchy"></span>
</button>`;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, 'querySelector' | 'getElementById'> = 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<string, unknown> })?.args?.['wf:navTrail'];
Expand Down
42 changes: 13 additions & 29 deletions packages/diagram-client/src/initial-viewport-service.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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 }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<string, { zoom: number; scroll: { x: number; y: number } }>();

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<string, Viewport>
): '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';
}

/**
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 114 additions & 0 deletions packages/diagram-client/test/outline-button-and-viewport.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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 } });
});
});
Loading