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
55 changes: 54 additions & 1 deletion packages/diagram-client/src/editing-action-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { inject, injectable, optional } from 'inversify';
import { EditorContextService, IActionDispatcher, IGridManager, TYPES } from '@eclipse-glsp/client';
import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler, NavigateToExternalTargetAction } from '@eclipse-glsp/sprotty';
import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler, NavigateToExternalTargetAction, RequestModelAction } from '@eclipse-glsp/sprotty';
import { VscodeUi, type VscodeQuickPickItem } from './vscode-ui';
import { clientBehavior, commandId, settingsNamespace } from './profile';
import { rerunActorFor } from './navigation-ui';
import { NETWORK_NAME_ARG, OPEN_DIAGRAM_ARG } from './network-navigation-target';
import { FocusAfterLoadService } from './focus-after-load';
import { EXECUTION_OVERLAY_ACTION_KIND, type ExecutionOverlayActionPayload } from '@dialogram/shared';

export type WorkflowWorkspaceEntity = {
Expand Down Expand Up @@ -104,6 +105,24 @@ export namespace WorkflowOpenInOwnEditorAction {
}
}

export namespace WorkflowGoToErrorAction {
export const KIND = 'dialogram.goToError';

export interface Action extends GlspAction {
kind: typeof KIND;
/** From the editor's root to the view the failure is in. */
trail: Array<{ sourceUri: string; workflowName: string; workflowInstanceName?: string }>;
/** The node in that view that failed. */
nodeName: string;
}

export function is(action: unknown): action is Action {
return !!action && typeof action === 'object' && (action as any).kind === KIND
&& Array.isArray((action as any).trail) && (action as any).trail.length > 0
&& typeof (action as any).nodeName === 'string';
}
}

export namespace WorkflowRenameEntityOperation {
export const KIND = 'dialogram.renameEntity';

Expand Down Expand Up @@ -1266,3 +1285,37 @@ export class WorkflowOpenInOwnEditorActionHandler implements IActionHandler {
}) as never);
}
}


/**
* "Go to Error": open the view where the shown run failed, at the trail a
* drill-down would give it, and select the node that failed once it loads.
*/
@injectable()
export class WorkflowGoToErrorActionHandler implements IActionHandler {
@inject(TYPES.IActionDispatcher)
protected readonly actionDispatcher!: IActionDispatcher;

@inject(EditorContextService)
protected readonly editorContext!: EditorContextService;

@inject(FocusAfterLoadService)
protected readonly focusAfterLoad!: FocusAfterLoadService;

handle(action: GlspAction): ICommand | GlspAction | void {
if (!WorkflowGoToErrorAction.is(action)) {
return;
}
const view = action.trail[action.trail.length - 1];
this.focusAfterLoad.focusWhenLoaded(action.nodeName);
void this.actionDispatcher.dispatch(RequestModelAction.create({
requestId: `go-to-error-${Date.now()}`,
options: {
sourceUri: view.sourceUri,
diagramType: this.editorContext.diagramType,
networkName: view.workflowName,
'wf:navTrail': JSON.stringify(action.trail)
}
}) as never);
}
}
40 changes: 40 additions & 0 deletions packages/diagram-client/src/focus-after-load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Select and center a node once the view it is in has loaded.
*
* Opening another view is a model request; the node exists only when the new
* model arrives. "Go to Error" asks for the failing node here before opening
* its view, and this listener selects it when the view comes in.
*/
import { inject, injectable } from 'inversify';
import { TYPES, type IActionDispatcher } from '@eclipse-glsp/client';
import { CenterAction, SelectAction } from '@eclipse-glsp/protocol';
import { findNodeIdByEntityName } from './hierarchy-outline';

@injectable()
export class FocusAfterLoadService {
@inject(TYPES.IActionDispatcher) protected readonly dispatcher!: IActionDispatcher;

private pending: string | undefined;

/** Select the node named `entityName` in the next view that has it. */
focusWhenLoaded(entityName: string): void {
this.pending = entityName;
}

modelRootChanged(root: unknown): void {
const name = this.pending;
if (!name) {
return;
}
const id = findNodeIdByEntityName(root, name);
if (!id) {
return; // not this view: keep waiting for the one that has it
}
this.pending = undefined;
// After the view has rendered it.
setTimeout(() => {
void this.dispatcher.dispatch(SelectAction.create({ selectedElementsIDs: [id] }) as never);
void this.dispatcher.dispatch(CenterAction.create([id], { animate: true, retainZoom: true }) as never);
}, 0);
}
}
28 changes: 6 additions & 22 deletions packages/diagram-client/src/hierarchy-outline-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* testable headlessly (the panel imports GLSP values and cannot load without a
* DOM) -- the split mlir-viewer's outline uses, for the same reason.
*/
import type { HierarchyOutlineEntry } from '@dialogram/shared';
import { hierarchyEntryAt, hierarchyTrailTo, type HierarchyOutlineEntry } from '@dialogram/shared';

export interface OutlineCrumb {
sourceUri: string;
Expand Down Expand Up @@ -37,30 +37,14 @@ export function outlineMatches(entry: HierarchyOutlineEntry, query: string): boo
}

/** The entry at `path`, if the outline has it. */
export function entryAt(root: HierarchyOutlineEntry, path: string[]): HierarchyOutlineEntry | undefined {
let current: HierarchyOutlineEntry | undefined = root;
for (const name of path) {
current = current?.children.find(child => child.path[child.path.length - 1] === name);
}
return current;
}
export const entryAt = hierarchyEntryAt;

/**
* The navigation trail to the instance at `path`: the root, then one crumb per
* instance on the way. What a breadcrumb and a drill-down produce, so a view
* opened from the outline is the same view either way would open.
* The navigation trail to the instance at `path` -- see `hierarchyTrailTo`,
* shared with the server's "Go to Error".
*/
export function trailTo(root: HierarchyOutlineEntry, rootSourceUri: string, path: string[]): OutlineCrumb[] | undefined {
const trail: OutlineCrumb[] = [{ sourceUri: root.sourceUri ?? rootSourceUri, workflowName: root.workflowName }];
for (let i = 1; i <= path.length; i++) {
const entry = entryAt(root, path.slice(0, i));
if (!entry?.sourceUri) {
return undefined;
}
trail.push({ sourceUri: entry.sourceUri, workflowName: entry.workflowName, workflowInstanceName: path[i - 1] });
}
return trail;
}
export const trailTo: (root: HierarchyOutlineEntry, rootSourceUri: string, path: string[]) => OutlineCrumb[] | undefined =
hierarchyTrailTo;

/** The path the shown view is at, from its trail. */
export function pathOfTrail(trail: Array<{ workflowName: string; workflowInstanceName?: string }>): string[] {
Expand Down
3 changes: 2 additions & 1 deletion packages/diagram-client/src/hierarchy-outline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
*/
import { inject, injectable } from 'inversify';
import { EditorContextService, TYPES, type IActionDispatcher } from '@eclipse-glsp/client';
import { CenterAction, RequestModelAction, SelectAction } from '@eclipse-glsp/sprotty';
import { RequestModelAction } from '@eclipse-glsp/sprotty';
import { CenterAction, SelectAction } from '@eclipse-glsp/protocol';
import { WorkflowDiagramMetadata, type HierarchyOutlineEntry } from '@dialogram/shared';
import { navigatesInPlace } from './navigation-ui';
import {
Expand Down
8 changes: 8 additions & 0 deletions packages/diagram-client/src/stock-features.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { WorkflowElkLiveDragRouter } from './elk-live-drag-router';
import { WorkflowChangeBoundsTool } from './change-bounds-drag-threshold';
import { LibavoidEdgeRouter } from './libavoid-edge-router';
import { HierarchyOutlinePanel } from './hierarchy-outline';
import { FocusAfterLoadService } from './focus-after-load';
import { preloadLibavoid } from './libavoid-loader';
import {
WorkflowPromptLabelEditAction,
Expand All @@ -36,6 +37,8 @@ import {
WorkflowRerunFromHereActionHandler,
WorkflowOpenInOwnEditorAction,
WorkflowOpenInOwnEditorActionHandler,
WorkflowGoToErrorAction,
WorkflowGoToErrorActionHandler,
WorkflowEditParametersAction,
WorkflowEditParametersActionHandler,
WorkflowEditAnnotationsAction,
Expand Down Expand Up @@ -71,6 +74,10 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound
bind(HierarchyOutlinePanel).toSelf().inSingletonScope();
bind(TYPES.IGModelRootListener).toService(HierarchyOutlinePanel);

// Selects a node once the view it is in has loaded ("Go to Error").
bind(FocusAfterLoadService).toSelf().inSingletonScope();
bind(TYPES.IGModelRootListener).toService(FocusAfterLoadService);

// Register integrated chat panel (slides up from bottom of diagram).
// Bound as an IDiagramStartup so GLSP eagerly instantiates it on diagram
// startup — a plain singleton binding is never resolved, so the panel and
Expand Down Expand Up @@ -112,6 +119,7 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound
configureActionHandler(context, WorkflowPromptRenameEntityAction.KIND, WorkflowPromptRenameEntityActionHandler);
configureActionHandler(context, WorkflowRerunFromHereAction.KIND, WorkflowRerunFromHereActionHandler);
configureActionHandler(context, WorkflowOpenInOwnEditorAction.KIND, WorkflowOpenInOwnEditorActionHandler);
configureActionHandler(context, WorkflowGoToErrorAction.KIND, WorkflowGoToErrorActionHandler);
configureActionHandler(context, WorkflowEditParametersAction.KIND, WorkflowEditParametersActionHandler);
configureActionHandler(context, WorkflowEditAnnotationsAction.KIND, WorkflowEditAnnotationsActionHandler);
configureActionHandler(context, WorkflowShowWorkspaceEntitiesAction.KIND, WorkflowShowWorkspaceEntitiesActionHandler);
Expand Down
7 changes: 4 additions & 3 deletions packages/diagram-client/test/container-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
*
* The baseline is no longer a pure capture of that commit: features added since
* the split are appended to it deliberately, and the fixture currently carries
* six additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live
* seven additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live
* routing tier), `ChangeBoundsTool -> WorkflowChangeBoundsTool` (the mouse-drag
* threshold) and `IDiagramStartup -> WorkflowPaletteIconStartup` (draws the
* palette icons a product contributed), the `dialogram.rerunFromHere` action handler
* (resumes the shown run before a node's last firing) and the `dialogram.openInOwnEditor`
* one (opens a nested workflow's file as a root of its own), and the hierarchy outline panel
* (bound to itself and as a model-root listener) — plus one change: `label:boundary:type` is bound to
* one (opens a nested workflow's file as a root of its own), the hierarchy outline panel
* (bound to itself and as a model-root listener), and "Go to Error" (its action handler and
* the focus-after-load service, bound the same two ways) — plus one change: `label:boundary:type` is bound to
* the non-editable `BoundaryLabel`, because editing it renamed the port.
* The oracle still does its job: it fails on any binding this composition gains
* or loses, and updating the fixture is the deliberate act of accepting one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,5 +396,21 @@
"id": "Symbol(stub.IGModelRootListener)",
"op": "bind",
"to": "service:HierarchyOutlinePanel"
},
{
"handler": "WorkflowGoToErrorActionHandler",
"kind": "dialogram.goToError",
"op": "actionHandler"
},
{
"id": "FocusAfterLoadService",
"op": "bind",
"scope": "singleton",
"to": "self"
},
{
"id": "Symbol(stub.IGModelRootListener)",
"op": "bind",
"to": "service:FocusAfterLoadService"
}
]
60 changes: 60 additions & 0 deletions packages/diagram-client/test/go-to-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* "Go to Error" on the client: open the view where the run failed, then select
* the node that failed once that view has loaded.
*/
import { describe, expect, it, vi } from 'vitest';
import { WorkflowDiagramMetadata } from '@dialogram/shared';
import { WorkflowGoToErrorActionHandler } from '../src/editing-action-handlers';
import { FocusAfterLoadService } from '../src/focus-after-load';

const trail = [
{ sourceUri: 'file:///w/top.py', workflowName: 'top' },
{ sourceUri: 'file:///w/mid.py', workflowName: 'middle', workflowInstanceName: 'm' }
];

describe('Go to Error', () => {
it('opens the failure’s view at its trail and waits to select the node', () => {
const handler: any = new WorkflowGoToErrorActionHandler();
const dispatch = vi.fn();
const focusWhenLoaded = vi.fn();
handler.actionDispatcher = { dispatch };
handler.editorContext = { diagramType: 'workflow-diagram' };
handler.focusAfterLoad = { focusWhenLoaded };

handler.handle({ kind: 'dialogram.goToError', trail, nodeName: 'x' });

expect(focusWhenLoaded).toHaveBeenCalledWith('x');
const request = dispatch.mock.calls[0][0];
expect(request.options.sourceUri).toBe('file:///w/mid.py');
expect(request.options.networkName).toBe('middle');
expect(JSON.parse(request.options['wf:navTrail'])).toEqual(trail);
});
});

describe('selecting a node once its view loads', () => {
const view = (...names: string[]) => ({
id: 'root',
children: names.map(name => ({ id: `node-${name}`, args: { [WorkflowDiagramMetadata.ENTITY_NAME]: name } }))
});

it('waits for the view that has it, then selects and centers it once', async () => {
const service: any = new FocusAfterLoadService();
const dispatch = vi.fn();
service.dispatcher = { dispatch };

service.focusWhenLoaded('x');
service.modelRootChanged(view('a', 'b')); // the view being left
await new Promise(resolve => setTimeout(resolve, 0));
expect(dispatch).not.toHaveBeenCalled();

service.modelRootChanged(view('x', 'y'));
await new Promise(resolve => setTimeout(resolve, 0));
expect(dispatch.mock.calls.map(c => c[0].kind)).toEqual(['elementSelected', 'center']);
expect(dispatch.mock.calls[0][0].selectedElementsIDs).toEqual(['node-x']);

dispatch.mockClear();
service.modelRootChanged(view('x'));
await new Promise(resolve => setTimeout(resolve, 0));
expect(dispatch).not.toHaveBeenCalled();
});
});
61 changes: 60 additions & 1 deletion packages/diagram-server/src/server/context-menu-item-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import {
NavigateToExternalTargetAction,
Point
} from '@eclipse-glsp/protocol';
import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';
import {
hierarchyTrailTo,
WorkflowDiagramMetadata,
WorkflowDiagramTypes,
type HierarchyCrumb,
type HierarchyOutlineEntry
} from '@dialogram/shared';

const SHOW_OPTIONS_ARG = 'jsonOpenerOptions';
const NAVIGATE_PREFER_DEFINITION_ARG = 'wf:navigatePreferDefinition';
Expand All @@ -21,6 +27,46 @@ const EDIT_PARAMETERS_KIND = 'dialogram.editParameters';
const PROMPT_RENAME_ENTITY_KIND = 'dialogram.promptRenameEntity';
const RERUN_FROM_HERE_KIND = 'dialogram.rerunFromHere';
const OPEN_IN_OWN_EDITOR_KIND = 'dialogram.openInOwnEditor';
const GO_TO_ERROR_KIND = 'dialogram.goToError';

/**
* "Go to Error": the action that opens the view where the shown run failed and
* selects the node that did, built from the hierarchy on the root (`wf:hierarchy`)
* and the failure's instance path (`wf:errorPath`). Nothing when the root does
* not carry both, or the failure is already in the view shown.
*/
export function goToErrorAction(rootArgs: Args | undefined): { kind: string; trail: HierarchyCrumb[]; nodeName: string } | undefined {
const errorPath = rootArgs?.['wf:errorPath'] as unknown;
const rawHierarchy = rootArgs?.['wf:hierarchy'];
if (!Array.isArray(errorPath) || errorPath.length === 0 || typeof rawHierarchy !== 'string') {
return undefined;
}
let outline: HierarchyOutlineEntry;
try {
outline = JSON.parse(rawHierarchy);
} catch {
return undefined;
}
const viewPath = (() => {
try {
const trail = JSON.parse(String(rootArgs?.['wf:navTrail'] ?? '[]'));
return Array.isArray(trail)
? trail.map((c: any) => (typeof c?.workflowInstanceName === 'string' ? c.workflowInstanceName : '')).filter(Boolean)
: [];
} catch {
return [];
}
})();
const parentPath = (errorPath as string[]).slice(0, -1);
if (parentPath.join('/') === viewPath.join('/')) {
return undefined;
}
const trail = hierarchyTrailTo(outline, outline.sourceUri ?? '', parentPath);
if (!trail) {
return undefined;
}
return { kind: GO_TO_ERROR_KIND, trail, nodeName: (errorPath as string[])[errorPath.length - 1] };
}
const RESET_EDGE_ROUTES_KIND = 'dialogram.resetEdgeRoutes';
const REROUTE_EDGES_AVOID_OVERLAPS_KIND = 'dialogram.rerouteEdgesAvoidOverlaps';
const LAYOUT_BOUNDARY_FLOW_KIND = 'dialogram.layoutBoundaryFlow';
Expand Down Expand Up @@ -296,6 +342,14 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider {
elementType === WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR
) {
const entityName = elementArgs?.[WorkflowDiagramMetadata.ENTITY_NAME];
// The node a failure deep down is inside: go to where it failed.
const errorWithin = elementArgs?.['wf:errorWithin'] as unknown;
if (Array.isArray(errorWithin) && errorWithin.length > 0) {
const goToError = goToErrorAction((this.modelState.root as any)?.args as Args | undefined);
if (goToError) {
items.push({ id: 'dialogram.goToError', label: 'Go to Error', sortString: 'a0', actions: [goToError as any] });
}
}
// A nested workflow defined in another file can be opened as a root
// of its own: its own editor, chat and runs. Navigating in place, a
// double-click shows it here instead, so this is the way to the old
Expand Down Expand Up @@ -419,6 +473,11 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider {
}

if (selectedElementIds.length === 0) {
// The shown run failed somewhere this view does not show.
const goToError = goToErrorAction((this.modelState.root as any)?.args as Args | undefined);
if (goToError) {
items.push({ id: 'dialogram.goToError', label: 'Go to Error', sortString: 'a0', actions: [goToError as any] });
}
items.push({
id: 'dialogram.layoutDiagramBoundaryFlow',
label: 'Layout Diagram',
Expand Down
Loading
Loading