diff --git a/docs/proposals/single-editor-hierarchy.md b/docs/proposals/single-editor-hierarchy.md index 658765a..ab3a99e 100644 --- a/docs/proposals/single-editor-hierarchy.md +++ b/docs/proposals/single-editor-hierarchy.md @@ -1,6 +1,6 @@ # Proposal: one editor for a whole workflow hierarchy -**Status:** proposal. Nothing here is implemented. +**Status:** phases 1 and 2 are implemented (in-place navigation, the root's run, and the view in the chat's context). The GLSP-MCP root/view split, phases 3 to 5 and the outline are not. **Affects:** dialogram (most of it), wfpy (a hierarchy export), wfpy-ide (one profile flag). @@ -197,13 +197,25 @@ comes from the same export, and is the cheap part of it: names, files and sizes. ## Phasing -1. **In-place navigation.** The profile flag, drill-down / breadcrumb / "Used - By" in place, the host's per-editor trail and refresh context, and "Open in - its own editor". -2. **The root runs, the chat knows the view.** Root fields in the diagram - context, and Run, Rerun from Here and ⟲ from the root. The run driver and - refresh keyed by the editor. The trail in the chat's turn context, and the - GLSP-MCP root/view split. +1. **In-place navigation.** *Done.* `clientBehavior.nestedNavigation` + (`'in-place'` / `'new-editor'`, default `'new-editor'`). Drill-down and the + breadcrumb stay in the editor for any file. "Used By" navigates in place to + an ancestor in the trail or to a workflow of the editor's own document, and + opens a caller in another file as a new root with no trail. The client's + navigation stack is keyed by the editor's root. The host keys the refresh + context by the editor (from the request's client) and remembers the shown + file, so a save, an external change or a live preview refreshes the view + shown. For another file it reloads from disk, since live-preview content is + the root's text. A node menu item opens a nested workflow's file in its own + editor. +2. **The root runs, the chat knows the view.** *Done, except the GLSP-MCP + split.* The diagram context carries `rootSourceUri`, `rootWorkflowName` and + the trail. ▶ Run, ⟲ and "Rerun from Here" use the root, and inside a nested + view "Rerun from Here" reruns the root-level instance the view is in. The + refresh during and after a run reloads the editor's current view (file, + workflow, trail) rather than jumping to the root. The chat panel sends the + view's trail with the selection and each message, and the turn context names + the nested view and the file defining it. 3. **Editing nested views.** Secondary documents saved after undo/redo, the root editor watching every file in its trail, and the concurrency behaviour tested. diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index 2e9eeb7..83d395c 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -160,6 +160,20 @@ interface CommandEntry { * Communication with the extension host happens over the GLSP vscode-messenger * channel (the diagram webview's raw postMessage is owned by GLSP). */ + +/** + * The view on screen, as a trail from the editor's root, when it is not the + * root itself. + * + * The chat is the root's for the whole hierarchy, so without this the agent + * cannot tell that the diagram shows a nested workflow -- nor which instance + * the selected nodes belong to. + */ +export function currentViewTrail(): Array<{ sourceUri: string; workflowName: string; workflowInstanceName?: string }> | undefined { + const trail = (globalThis as any).__calDiagramContext?.trail; + return Array.isArray(trail) && trail.length > 1 ? trail : undefined; +} + @injectable() export class ChatPanel implements IDiagramStartup, ISelectionListener { /** Run after the diagram model is ready so the webview DOM exists. */ @@ -258,7 +272,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { */ selectionChanged(_root: unknown, selectedElements: string[]): void { this.selectedNodeIds = selectedElements ?? []; - this.sendToHost('chat.selection', { selectedNodeIds: this.selectedNodeIds }); + this.sendToHost('chat.selection', { selectedNodeIds: this.selectedNodeIds, viewTrail: currentViewTrail() }); if (this.initialized) this.update(); } @@ -811,6 +825,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { sessionId: this.currentSessionId, mode: this.currentMode, selectedNodeIds: this.selectedNodeIds, + viewTrail: currentViewTrail(), }); if (!ok) { diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts index b266962..1f25fb9 100644 --- a/packages/diagram-client/src/editing-action-handlers.ts +++ b/packages/diagram-client/src/editing-action-handlers.ts @@ -1,8 +1,10 @@ import { inject, injectable, optional } from 'inversify'; import { EditorContextService, IActionDispatcher, IGridManager, TYPES } from '@eclipse-glsp/client'; -import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler } from '@eclipse-glsp/sprotty'; +import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler, NavigateToExternalTargetAction } 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 { EXECUTION_OVERLAY_ACTION_KIND, type ExecutionOverlayActionPayload } from '@dialogram/shared'; export type WorkflowWorkspaceEntity = { @@ -87,6 +89,21 @@ export namespace WorkflowRerunFromHereAction { } } +export namespace WorkflowOpenInOwnEditorAction { + export const KIND = 'dialogram.openInOwnEditor'; + + export interface Action extends GlspAction { + kind: typeof KIND; + uri: string; + workflowName: string; + } + + export function is(action: unknown): action is Action { + return !!action && typeof action === 'object' && (action as any).kind === KIND + && typeof (action as any).uri === 'string' && typeof (action as any).workflowName === 'string'; + } +} + export namespace WorkflowRenameEntityOperation { export const KIND = 'dialogram.renameEntity'; @@ -1198,7 +1215,14 @@ export class WorkflowRerunFromHereActionHandler implements IActionHandler { return; } const context = (globalThis as any).__calDiagramContext as - | { sourceUri?: string; workflowName?: string; runDir?: string } + | { + sourceUri?: string; + workflowName?: string; + runDir?: string; + rootSourceUri?: string; + rootWorkflowName?: string; + trail?: Array<{ workflowInstanceName?: string }>; + } | undefined; // The menu offers it only for a run that can be resumed; anything the // run driver then finds wrong with it, the driver reports. @@ -1206,11 +1230,39 @@ export class WorkflowRerunFromHereActionHandler implements IActionHandler { console.warn('[workflow] Rerun from Here: no resumable run is shown'); return; } + // The root's run, at the root's step: inside a nested view, the node is + // rerun by rerunning the instance it is in. void VscodeUi.instance.executeCommand(commandId('runWorkflow'), [{ - sourceUri: context.sourceUri, - workflowName: context.workflowName, + sourceUri: context.rootSourceUri ?? context.sourceUri, + workflowName: context.rootWorkflowName ?? context.workflowName, resumeFrom: context.runDir, - resumeAtActor: action.entityName + resumeAtActor: rerunActorFor(action.entityName, context.trail ?? []) }]); } } + + +/** + * "Open in Its Own Editor": open a nested workflow's file as a root of its own. + * + * No trail goes with it, on purpose: the new editor's root is that file, with + * its own chat and runs, not a view of this editor's hierarchy. + */ +@injectable() +export class WorkflowOpenInOwnEditorActionHandler implements IActionHandler { + @inject(TYPES.IActionDispatcher) + protected readonly actionDispatcher!: IActionDispatcher; + + handle(action: GlspAction): ICommand | GlspAction | void { + if (!WorkflowOpenInOwnEditorAction.is(action)) { + return; + } + void this.actionDispatcher.dispatch(NavigateToExternalTargetAction.create({ + uri: action.uri, + args: { + [OPEN_DIAGRAM_ARG]: true, + [NETWORK_NAME_ARG]: action.workflowName + } + }) as never); + } +} diff --git a/packages/diagram-client/src/navigation-ui.ts b/packages/diagram-client/src/navigation-ui.ts index 6546b16..21ae160 100644 --- a/packages/diagram-client/src/navigation-ui.ts +++ b/packages/diagram-client/src/navigation-ui.ts @@ -135,6 +135,51 @@ function isQueueTraceVisible(): boolean { } } +/** Whether a nested workflow in another file opens in this editor, not its own. */ +export function navigatesInPlace(): boolean { + return clientBehavior().nestedNavigation === 'in-place'; +} + +/** + * The workflow this editor runs: the root of its hierarchy, not the view. + * + * Navigating in place, the root is the first crumb, the editor's own document. + * Otherwise a stack can start in another editor's file (a drill-down passes its + * trail along), so the root is the first crumb in this editor's document -- the + * top of what this editor shows. Without a stack, the view is the root. + */ +export function rootOfStack( + stack: Array<{ sourceUri: string; workflowName: string }>, + editorSourceUri: string | undefined, + inPlace: boolean, + shown: { sourceUri: string; workflowName: string } +): { sourceUri: string; workflowName: string } { + if (stack.length === 0) { + return shown; + } + if (inPlace) { + return { sourceUri: stack[0].sourceUri, workflowName: stack[0].workflowName }; + } + const editorKey = editorSourceUri ? normalizeSourceUriKey(editorSourceUri) : shown.sourceUri; + const first = stack.find(crumb => crumb.sourceUri === editorKey); + return first ? { sourceUri: first.sourceUri, workflowName: first.workflowName } : shown; +} + +/** + * The node a "Rerun from Here" reruns, as the run's trace names it. + * + * The trace has a step per firing of the root workflow's own actors. A node + * inside a nested view is not one of them: the instance the view is inside, + * at the root's level, is -- rerunning it reruns the node. + */ +export function rerunActorFor( + entityName: string, + stack: Array<{ workflowInstanceName?: string }> +): string { + const top = stack.slice(1).find(crumb => typeof crumb.workflowInstanceName === 'string' && crumb.workflowInstanceName.trim() !== ''); + return top?.workflowInstanceName?.trim() ?? entityName; +} + /** The body class that shows the queue-size badges on the edges. */ export const DEBUG_EXPANDED_CLASS = 'workflow-debug-expanded'; @@ -215,6 +260,27 @@ export class WorkflowNavigationUi { @inject(EditorContextService) private readonly editorContext: EditorContextService ) {} + /** + * The navigation stack a view belongs to. + * + * Navigating in place, every view in this editor -- in any file -- is a view + * of one hierarchy, rooted at the editor's own document, so they share its + * stack. Opening a nested file in its own editor, the stack is the shown + * file's, as it always was. + */ + private stackKey(sourceUri: string): string { + const root = this.editorContext.sourceUri; + return normalizeSourceUriKey(navigatesInPlace() && root ? root : sourceUri); + } + + private getStack(sourceUri: string): NavigationCrumb[] | undefined { + return this.stacksBySourceUri.get(this.stackKey(sourceUri)); + } + + private setStack(sourceUri: string, stack: NavigationCrumb[]): void { + this.stacksBySourceUri.set(this.stackKey(sourceUri), stack); + } + buildNavigationTrail( currentSourceUri: string | undefined, targetSourceUri: string, @@ -223,7 +289,7 @@ export class WorkflowNavigationUi { ): NavigationCrumb[] { const currentSourceKey = currentSourceUri ? normalizeSourceUriKey(currentSourceUri) : undefined; const targetSourceKey = normalizeSourceUriKey(targetSourceUri); - const fromCurrent = currentSourceKey ? (this.stacksBySourceUri.get(currentSourceKey) ?? []) : []; + const fromCurrent = currentSourceKey ? (this.getStack(currentSourceKey) ?? []) : []; const trail = [...fromCurrent]; if (trail.length === 0 && currentSourceKey) { @@ -252,20 +318,20 @@ export class WorkflowNavigationUi { const sourceKey = normalizeSourceUriKey(sourceUri); if (Array.isArray(trail) && trail.length > 0) { - this.stacksBySourceUri.set(sourceKey, this.normalizeTrail(trail)); + this.setStack(sourceKey, this.normalizeTrail(trail)); return; } - const stack = this.stacksBySourceUri.get(sourceKey) ?? []; + const stack = this.getStack(sourceKey) ?? []; if (stack.length === 0) { - this.stacksBySourceUri.set(sourceKey, [{ sourceUri: sourceKey, workflowName }]); + this.setStack(sourceKey, [{ sourceUri: sourceKey, workflowName }]); return; } const last = stack[stack.length - 1]; if (last?.sourceUri === sourceKey && last?.workflowName === workflowName) { return; } - this.stacksBySourceUri.set(sourceKey, [...stack, { sourceUri: sourceKey, workflowName }]); + this.setStack(sourceKey, [...stack, { sourceUri: sourceKey, workflowName }]); } onModelChanged(root: unknown): void { @@ -327,7 +393,20 @@ export class WorkflowNavigationUi { const incomingTrail = this.parseTrail(args[NAV_TRAIL_ARG]); const nextStack = this.reconcileStack(sourceUri, selected, entryWorkflows, incomingTrail); - this.stacksBySourceUri.set(sourceUri, nextStack); + this.setStack(sourceUri, nextStack); + + // What ▶ Run, "Rerun from Here" and the stepper's ⟲ act on: the root of + // this editor's hierarchy, whatever view is on screen. + const hierarchyRoot = rootOfStack(nextStack, this.editorContext.sourceUri, navigatesInPlace(), { sourceUri, workflowName: selected }); + try { + Object.assign((globalThis as any).__calDiagramContext ?? {}, { + rootSourceUri: hierarchyRoot.sourceUri, + rootWorkflowName: hierarchyRoot.workflowName, + trail: nextStack + }); + } catch { + // ignore + } const runtimeProfile = typeof args['wf:runtimeProfile'] === 'string' ? args['wf:runtimeProfile'] : undefined; @@ -362,7 +441,7 @@ export class WorkflowNavigationUi { } } - const current = this.stacksBySourceUri.get(sourceUri) ?? []; + const current = this.getStack(sourceUri) ?? []; if (current.length === 0) { return [{ sourceUri, workflowName: selected }]; } @@ -418,7 +497,7 @@ export class WorkflowNavigationUi { } this.setStoredEntryWorkflow(sourceUri, picked); const nextTrail = [{ sourceUri, workflowName: picked }]; - this.stacksBySourceUri.set(sourceUri, nextTrail); + this.setStack(sourceUri, nextTrail); void this.actionDispatcher.dispatch(this.requestModel(sourceUri, picked, undefined, nextTrail)); }); } @@ -508,7 +587,7 @@ export class WorkflowNavigationUi { btn.className = 'workflow-wf-breadcrumb'; const displayName = entry.workflowInstanceName || entry.workflowName; btn.textContent = displayName; - const isCrossFile = entry.sourceUri !== currentSourceKey; + const isCrossFile = entry.sourceUri !== currentSourceKey && !navigatesInPlace(); btn.title = idx === stack.length - 1 ? `Current: ${displayName}` : (isCrossFile ? `Open ${displayName} in referenced file` : `Go back to ${displayName}`); @@ -516,8 +595,8 @@ export class WorkflowNavigationUi { btn.addEventListener('click', () => { const nextTrail = stack.slice(0, idx + 1); const next = nextTrail[nextTrail.length - 1]; - this.stacksBySourceUri.set(next.sourceUri, nextTrail); - if (next.sourceUri === currentSourceKey) { + this.setStack(next.sourceUri, nextTrail); + if (next.sourceUri === currentSourceKey || navigatesInPlace()) { void this.actionDispatcher.dispatch(this.requestModel(next.sourceUri, next.workflowName, undefined, nextTrail, selectedRunId)); return; } @@ -801,7 +880,7 @@ export class WorkflowNavigationUi { } this.setStoredEntryWorkflow(sourceUri, picked); const nextTrail = [{ sourceUri, workflowName: picked }]; - this.stacksBySourceUri.set(sourceUri, nextTrail); + this.setStack(sourceUri, nextTrail); void this.actionDispatcher.dispatch(this.requestModel(sourceUri, picked, undefined, nextTrail, meta.selectedRunId)); } @@ -827,7 +906,29 @@ export class WorkflowNavigationUi { const nextTrail = existingParentIndex >= 0 ? stack.slice(0, existingParentIndex + 1) : [{ sourceUri: selectedParent.sourceUri, workflowName: selectedParent.workflowName }]; - this.stacksBySourceUri.set(selectedParent.sourceUri, nextTrail); + if (navigatesInPlace()) { + const editorRoot = this.editorContext.sourceUri ? normalizeSourceUriKey(this.editorContext.sourceUri) : sourceUri; + // An ancestor already in this editor's trail, or a workflow of the + // editor's own document: a view of this editor, shown in place. + if (existingParentIndex >= 0 || selectedParent.sourceUri === editorRoot) { + this.setStack(sourceUri, nextTrail); + void this.actionDispatcher.dispatch(this.requestModel(selectedParent.sourceUri, selectedParent.workflowName, undefined, nextTrail, meta.selectedRunId)); + return; + } + // A caller in another file is a root of its own: its own editor, and + // no trail of this one, which keeps its root, chat and run. + void this.actionDispatcher.dispatch( + NavigateToExternalTargetAction.create({ + uri: selectedParent.sourceUri, + args: { + [OPEN_DIAGRAM_ARG]: true, + [NETWORK_NAME_ARG]: selectedParent.workflowName + } + }) + ); + return; + } + this.setStack(selectedParent.sourceUri, nextTrail); if (selectedParent.sourceUri === sourceUri) { void this.actionDispatcher.dispatch(this.requestModel(sourceUri, selectedParent.workflowName, undefined, nextTrail, meta.selectedRunId)); return; @@ -912,11 +1013,13 @@ export class WorkflowNavigationUi { container.appendChild(status); container.appendChild(next); + // The run is the root's, so a resume is too, whatever view is shown. + const context = (globalThis as any).__calDiagramContext; const resumeRequest = stepperResumeRequest( queueTrace, clientBehavior().resumeAtStep === true, - sourceUri, - meta.selected + typeof context?.rootSourceUri === 'string' ? context.rootSourceUri : sourceUri, + typeof context?.rootWorkflowName === 'string' ? context.rootWorkflowName : meta.selected ); if (resumeRequest) { const resume = document.createElement('button'); diff --git a/packages/diagram-client/src/network-navigation-mouse-listener.ts b/packages/diagram-client/src/network-navigation-mouse-listener.ts index fc6675e..c8ba4fb 100644 --- a/packages/diagram-client/src/network-navigation-mouse-listener.ts +++ b/packages/diagram-client/src/network-navigation-mouse-listener.ts @@ -9,7 +9,7 @@ import { } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; import { WorkflowDiagramTypes, WorkflowDiagramMetadata } from '@dialogram/shared'; -import { WorkflowNavigationUi } from './navigation-ui'; +import { navigatesInPlace, WorkflowNavigationUi } from './navigation-ui'; import { clientBehavior } from './profile'; import { buildCrossFileNavigationTarget, @@ -164,8 +164,13 @@ export class WorkflowNetworkNavigationMouseListener extends MouseListener implem ? navTrail[0]?.workflowName?.trim() || currentWorkflowName : undefined; - // Same-file: switch diagram model within the current editor. - if (normalizedCurrentSourceUri && normalizedReferencedUri === normalizedCurrentSourceUri) { + // Same file, or any file when navigating in place: switch the diagram + // model within the current editor. The request names the defining file; + // the trail keeps the editor's root at its head. + if ( + (normalizedCurrentSourceUri && normalizedReferencedUri === normalizedCurrentSourceUri) + || navigatesInPlace() + ) { this.workflowNavUi.noteNavigate(referencedUri, targetNetworkName, navTrail); const includeGraphSourceFallback = useGraphSourceNavigation && !!normalizedCurrentSourceUri diff --git a/packages/diagram-client/src/profile.ts b/packages/diagram-client/src/profile.ts index 2c252db..63e5236 100644 --- a/packages/diagram-client/src/profile.ts +++ b/packages/diagram-client/src/profile.ts @@ -29,6 +29,10 @@ type DiagramClientBehavior = { acpConnectors?: AcpConnectorInfo[]; /** Whether a run can be resumed at a step of its queue trace. */ resumeAtStep?: boolean; + /** Where a nested workflow defined in another file opens: `in-place` shows it in + * this editor, as a view of the editor's root (one editor per hierarchy); + * `new-editor` (the default) opens the file in its own editor. */ + nestedNavigation?: 'in-place' | 'new-editor'; }; export type AcpConnectorInfo = { diff --git a/packages/diagram-client/src/stock-features.module.ts b/packages/diagram-client/src/stock-features.module.ts index cfdbbda..194580e 100644 --- a/packages/diagram-client/src/stock-features.module.ts +++ b/packages/diagram-client/src/stock-features.module.ts @@ -33,6 +33,8 @@ import { WorkflowPromptRenameEntityActionHandler, WorkflowRerunFromHereAction, WorkflowRerunFromHereActionHandler, + WorkflowOpenInOwnEditorAction, + WorkflowOpenInOwnEditorActionHandler, WorkflowEditParametersAction, WorkflowEditParametersActionHandler, WorkflowEditAnnotationsAction, @@ -102,6 +104,7 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound configureActionHandler(context, WorkflowToggleGridAction.KIND, WorkflowToggleGridActionHandler); configureActionHandler(context, WorkflowPromptRenameEntityAction.KIND, WorkflowPromptRenameEntityActionHandler); configureActionHandler(context, WorkflowRerunFromHereAction.KIND, WorkflowRerunFromHereActionHandler); + configureActionHandler(context, WorkflowOpenInOwnEditorAction.KIND, WorkflowOpenInOwnEditorActionHandler); configureActionHandler(context, WorkflowEditParametersAction.KIND, WorkflowEditParametersActionHandler); configureActionHandler(context, WorkflowEditAnnotationsAction.KIND, WorkflowEditAnnotationsActionHandler); configureActionHandler(context, WorkflowShowWorkspaceEntitiesAction.KIND, WorkflowShowWorkspaceEntitiesActionHandler); diff --git a/packages/diagram-client/src/tool-palette-header-controls.ts b/packages/diagram-client/src/tool-palette-header-controls.ts index f18fb90..2d96478 100644 --- a/packages/diagram-client/src/tool-palette-header-controls.ts +++ b/packages/diagram-client/src/tool-palette-header-controls.ts @@ -8,6 +8,9 @@ type DiagramContext = { runtimeProfile?: string; namespaceName?: string; queueTraceVisible?: boolean; + /** The root of the editor's hierarchy: what runs (navigation-ui `rootOfStack`). */ + rootSourceUri?: string; + rootWorkflowName?: string; }; const QUEUE_TRACE_VISIBLE_STORAGE_KEY = queueTraceVisibleStorageKey(); @@ -186,7 +189,12 @@ function patchHeaderTools(headerTools: HTMLElement): void { void (async () => { const ctx = getDiagramContext(); if (ctx?.sourceUri && ctx?.workflowName) { - await exec(commandId('runWorkflow'), [ctx]); + // The root of the hierarchy runs, not the nested view on screen. + await exec(commandId('runWorkflow'), [{ + ...ctx, + sourceUri: ctx.rootSourceUri ?? ctx.sourceUri, + workflowName: ctx.rootWorkflowName ?? ctx.workflowName + }]); } else { // Fallback: host command uses active diagram tab. await exec(commandId('runWorkflow')); diff --git a/packages/diagram-client/test/container-parity.test.ts b/packages/diagram-client/test/container-parity.test.ts index 9ff263d..3f25731 100644 --- a/packages/diagram-client/test/container-parity.test.ts +++ b/packages/diagram-client/test/container-parity.test.ts @@ -15,11 +15,12 @@ * * 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 - * four additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live + * five 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), and the `dialogram.rerunFromHere` action handler - * (resumes the shown run before a node's last firing) — plus one change: `label:boundary:type` is bound to + * 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) — 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. diff --git a/packages/diagram-client/test/fixtures/container-parity.baseline.json b/packages/diagram-client/test/fixtures/container-parity.baseline.json index a20b690..9bb2fc0 100644 --- a/packages/diagram-client/test/fixtures/container-parity.baseline.json +++ b/packages/diagram-client/test/fixtures/container-parity.baseline.json @@ -34,6 +34,11 @@ "kind": "dialogram.rerunFromHere", "op": "actionHandler" }, + { + "handler": "WorkflowOpenInOwnEditorActionHandler", + "kind": "dialogram.openInOwnEditor", + "op": "actionHandler" + }, { "handler": "WorkflowShowAgentSkillsActionHandler", "kind": "dialogram.showAgentSkills", diff --git a/packages/diagram-client/test/in-place-navigation.test.ts b/packages/diagram-client/test/in-place-navigation.test.ts new file mode 100644 index 0000000..f354f9c --- /dev/null +++ b/packages/diagram-client/test/in-place-navigation.test.ts @@ -0,0 +1,104 @@ +/** + * Navigating a hierarchy in place: one editor, anchored to its root. + * + * Whatever view is shown -- a nested workflow, in any file -- the editor's + * root is what runs, what a resume resumes, and what "Rerun from Here" reruns + * in. A nested workflow can still be opened as a root of its own, on purpose. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { VscodeUi } from '../src/vscode-ui'; +import { navigatesInPlace, rerunActorFor, rootOfStack } from '../src/navigation-ui'; +import { + WorkflowOpenInOwnEditorActionHandler, + WorkflowRerunFromHereActionHandler +} from '../src/editing-action-handlers'; + +const TOP = 'file:///w/top.py'; +const CHILD = 'file:///w/layers/block.py'; +const stack = [ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: CHILD, workflowName: 'block', workflowInstanceName: 'b2' }, + { sourceUri: CHILD, workflowName: 'leaf', workflowInstanceName: 'l1' } +]; + +afterEach(() => { + (VscodeUi as any)._instance = undefined; + delete (globalThis as any).diagramIdentifier; + delete (globalThis as any).__calDiagramContext; +}); + +describe('whether the editor navigates in place', () => { + it('is the product’s choice, off by default', () => { + expect(navigatesInPlace()).toBe(false); + (globalThis as any).diagramIdentifier = { clientBehavior: { nestedNavigation: 'in-place' } }; + expect(navigatesInPlace()).toBe(true); + }); +}); + +describe('the root of an editor’s hierarchy', () => { + const shown = { sourceUri: CHILD, workflowName: 'leaf' }; + + it('is the first crumb, navigating in place', () => { + expect(rootOfStack(stack, TOP, true, shown)).toEqual({ sourceUri: TOP, workflowName: 'top' }); + }); + + it('is the first crumb in the editor’s own file otherwise', () => { + // An editor opened on block.py from top.py carries top.py's trail: its + // own top is the first crumb in block.py. + expect(rootOfStack(stack, CHILD, false, shown)).toEqual({ sourceUri: CHILD, workflowName: 'block' }); + }); + + it('is the view when there is no stack', () => { + expect(rootOfStack([], TOP, true, shown)).toEqual(shown); + }); +}); + +describe('what "Rerun from Here" reruns', () => { + it('is the node itself at the root’s level', () => { + expect(rerunActorFor('review', [{ sourceUri: TOP, workflowName: 'top' } as any])).toBe('review'); + }); + + it('is the root-level instance a nested view is inside', () => { + expect(rerunActorFor('leafNode', stack)).toBe('b2'); + }); + + it('resumes the root’s run from a nested view', () => { + const executeCommand = vi.fn(async () => undefined); + (VscodeUi as any)._instance = { executeCommand }; + (globalThis as any).diagramIdentifier = { + commandIds: { runWorkflow: 'product.runWorkflow' }, + clientBehavior: { resumeAtStep: true } + }; + (globalThis as any).__calDiagramContext = { + sourceUri: CHILD, + workflowName: 'leaf', + runDir: '/w/wf-out/r1', + rootSourceUri: TOP, + rootWorkflowName: 'top', + trail: stack + }; + + new WorkflowRerunFromHereActionHandler().handle({ kind: 'dialogram.rerunFromHere', entityName: 'leafNode' } as any); + + expect(executeCommand).toHaveBeenCalledWith('product.runWorkflow', [{ + sourceUri: TOP, + workflowName: 'top', + resumeFrom: '/w/wf-out/r1', + resumeAtActor: 'b2' + }]); + }); +}); + +describe('Open in Its Own Editor', () => { + it('opens the defining file as a root of its own: no trail goes with it', () => { + const handler = new WorkflowOpenInOwnEditorActionHandler(); + const dispatch = vi.fn(); + (handler as any).actionDispatcher = { dispatch }; + + handler.handle({ kind: 'dialogram.openInOwnEditor', uri: CHILD, workflowName: 'block' } as any); + + const navigate = dispatch.mock.calls[0][0]; + expect(navigate.uri).toBe(CHILD); + expect(navigate.args).toEqual({ 'cal:openDiagram': true, 'cal:networkName': 'block' }); + }); +}); diff --git a/packages/diagram-server/src/server/context-menu-item-provider.ts b/packages/diagram-server/src/server/context-menu-item-provider.ts index 03451da..5476102 100644 --- a/packages/diagram-server/src/server/context-menu-item-provider.ts +++ b/packages/diagram-server/src/server/context-menu-item-provider.ts @@ -20,6 +20,7 @@ const PROMPT_LABEL_EDIT_KIND = 'dialogram.promptLabelEdit'; 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 RESET_EDGE_ROUTES_KIND = 'dialogram.resetEdgeRoutes'; const REROUTE_EDGES_AVOID_OVERLAPS_KIND = 'dialogram.rerouteEdgesAvoidOverlaps'; const LAYOUT_BOUNDARY_FLOW_KIND = 'dialogram.layoutBoundaryFlow'; @@ -136,6 +137,12 @@ function workflowDefinitionTargetFromRoot(rootElement: unknown, fallbackSourceUr return undefined; } +/** Whether two source URIs name the same file, ignoring query and fragment. */ +function sameSourceFile(a: string, b: string): boolean { + const strip = (uri: string): string => uri.trim().replace(/[?#].*$/, ''); + return strip(a) === strip(b); +} + function displayNameFromQualifiedName(qualifiedName: string): string { const afterDots = qualifiedName.split('.').pop() ?? qualifiedName; const parts = afterDots.split('__'); @@ -289,6 +296,30 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider { elementType === WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR ) { const entityName = elementArgs?.[WorkflowDiagramMetadata.ENTITY_NAME]; + // 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 + // behaviour on purpose. + const referencedUri = elementArgs?.[WorkflowDiagramMetadata.REFERENCED_URI]; + const referencedName = elementArgs?.[WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME] + ?? elementArgs?.[WorkflowDiagramMetadata.ENTITY_TYPE]; + if ( + elementArgs?.[WorkflowDiagramMetadata.IS_NETWORK_INSTANCE] === true + && typeof referencedUri === 'string' && referencedUri.trim() !== '' + && typeof referencedName === 'string' && referencedName.trim() !== '' + && !sameSourceFile(referencedUri, sourceUri) + ) { + items.push({ + id: 'dialogram.openInOwnEditor', + label: 'Open in Its Own Editor', + sortString: 'a8', + actions: [{ + kind: OPEN_IN_OWN_EDITOR_KIND, + uri: referencedUri, + workflowName: displayNameFromQualifiedName(referencedName.trim()) + } as any] + }); + } // The run the overlay shows can be resumed at a step: offer to // resume it just before this node's last firing, so it fires again. const rootArgs = (this.modelState.root as any)?.args as Args | undefined; diff --git a/packages/diagram-server/test/open-in-own-editor.test.ts b/packages/diagram-server/test/open-in-own-editor.test.ts new file mode 100644 index 0000000..fc856f8 --- /dev/null +++ b/packages/diagram-server/test/open-in-own-editor.test.ts @@ -0,0 +1,52 @@ +/** + * "Open in Its Own Editor" on a nested workflow defined in another file. + * + * Navigating in place, a double-click shows a nested workflow inside this + * editor. This item is the way to open its file as a root of its own instead. + * It is offered only where that means something: a workflow instance whose + * definition is in a different file from the one shown. + */ +import { describe, expect, it } from 'vitest'; +import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; +import { WorkflowContextMenuItemProvider } from '../src/server/context-menu-item-provider'; + +const SHOWN = 'file:///w/top.py'; + +function itemsFor(args: Record): any[] { + const root: any = { id: 'root', type: 'graph', args: {} }; + const node: any = { id: 'n', type: WorkflowDiagramTypes.NODE_NETWORK, args, parent: root }; + const provider: any = new WorkflowContextMenuItemProvider(); + provider.modelState = { sourceUri: SHOWN, root, index: { find: () => node } }; + return provider.getItems(['n'], { x: 0, y: 0 }); +} + +const instance = (referencedUri: string) => ({ + [WorkflowDiagramMetadata.ENTITY_NAME]: 'b2', + [WorkflowDiagramMetadata.IS_NETWORK_INSTANCE]: true, + [WorkflowDiagramMetadata.REFERENCED_URI]: referencedUri, + [WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME]: 'layers.block.block' +}); + +describe('Open in Its Own Editor', () => { + it('opens a workflow defined in another file, by its name', () => { + const item = itemsFor(instance('file:///w/layers/block.py')).find(i => i.id === 'dialogram.openInOwnEditor'); + + expect(item).toBeDefined(); + expect(item.actions).toEqual([{ + kind: 'dialogram.openInOwnEditor', + uri: 'file:///w/layers/block.py', + workflowName: 'block' + }]); + }); + + it('is not offered for a workflow defined in the file shown', () => { + const ids = itemsFor(instance(SHOWN)).map(i => i.id); + expect(ids).not.toContain('dialogram.openInOwnEditor'); + }); + + it('is not offered for a node that is not a workflow instance', () => { + const ids = itemsFor({ ...instance('file:///w/layers/block.py'), [WorkflowDiagramMetadata.IS_NETWORK_INSTANCE]: false }) + .map(i => i.id); + expect(ids).not.toContain('dialogram.openInOwnEditor'); + }); +}); diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index 0323c43..36dd0d2 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -295,6 +295,10 @@ export interface DiagramClientBehavior { */ paletteIcons?: Record; nodeFamilies?: NodeFamilySpec[]; + /** Where a nested workflow defined in another file opens: `in-place` shows it in + * this editor, as a view of the editor's root (one editor per hierarchy); + * `new-editor` (the default) opens the file in its own editor. */ + nestedNavigation?: 'in-place' | 'new-editor'; /** * Whether the host has a chat backend behind this diagram. * diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts index 79b4c27..cdad878 100644 --- a/packages/extension-core/src/extension/chat/chat-runtime.ts +++ b/packages/extension-core/src/extension/chat/chat-runtime.ts @@ -91,6 +91,41 @@ export interface ChatRuntimeConfig { postTurnHook?: (file: string, text: string) => Promise; } +/** One step of the trail from the root workflow down to the view on screen. */ +export interface ViewCrumb { + sourceUri?: string; + workflowName: string; + workflowInstanceName?: string; +} + +/** + * Tell the agent which nested workflow the diagram is showing. + * + * The chat session is the root's for the whole hierarchy: the agent reads the + * root file and its graph. Navigating in place, the diagram can show a nested + * workflow, possibly defined in another file -- and the selected node ids are + * that view's. Nothing when the root itself is on screen. + */ +export function viewContextText(trail: ViewCrumb[] | undefined): string | undefined { + if (!trail || trail.length < 2) { + return undefined; + } + const path = trail + .map((crumb, i) => + i === 0 + ? crumb.workflowName + : `${crumb.workflowInstanceName ?? crumb.workflowName} (${crumb.workflowName})`, + ) + .join(" › "); + const shown = trail[trail.length - 1]; + const file = shown.sourceUri ? decodeURIComponent(shown.sourceUri.replace(/^file:\/\//, "")) : undefined; + return ( + `The diagram is showing a nested workflow, not the root: ${path}.` + + (file ? ` It is defined in ${file}.` : "") + + " Selected nodes belong to this view." + ); +} + export class ChatRuntime { private readonly acp = new ACPClientService(); private readonly sessions: SessionManager; @@ -110,6 +145,8 @@ export class ChatRuntime { private pendingModel: string | undefined; /** Latest diagram selection (node ids) per file, fed to the turn context. */ private readonly selectionByFile = new Map(); + /** The nested view on screen per file, when it is not the root (see `viewContextText`). */ + private readonly viewTrailByFile = new Map(); private readonly output: vscode.OutputChannel; /** Tears down the ACP -> webview event forwarding registered in the ctor. */ private readonly acpForwardingDisposer: () => void; @@ -151,6 +188,10 @@ export class ChatRuntime { } this.acp.setTurnContextBlocksProvider(async (file) => { const blocks: any[] = []; + const view = file ? viewContextText(this.viewTrailByFile.get(file)) : undefined; + if (view) { + blocks.push({ type: "text", text: view }); + } if (file && this.config.selectionContext !== false) { const selected = this.selectionByFile.get(file) ?? []; if (selected.length > 0) { @@ -467,6 +508,17 @@ export class ChatRuntime { await this.sendModels(uri); } + private setViewTrail(file: string, raw: unknown): void { + const trail = Array.isArray(raw) + ? raw.filter((c: any) => c && typeof c.workflowName === "string") + : []; + if (trail.length > 1) { + this.viewTrailByFile.set(file, trail as ViewCrumb[]); + } else { + this.viewTrailByFile.delete(file); + } + } + private fileFor(uri: string): string { return vscode.Uri.parse(uri).fsPath; } @@ -650,11 +702,13 @@ export class ChatRuntime { ? data.selectedNodeIds.map(String) : [], ); + this.setViewTrail(file, data?.viewTrail); return; case "chat.sendMessage": { if (Array.isArray(data?.selectedNodeIds)) { this.selectionByFile.set(file, data.selectedNodeIds.map(String)); } + this.setViewTrail(file, data?.viewTrail); await this.ensureStarted(cwd); // Fall back to the current session if the panel didn't pass one. let sessionId: string | undefined = diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index d4b65a3..5b365f4 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -54,7 +54,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { private uriToClientId = new Map(); /** Per-URI webview handles, for the profile's raw `postToWebview` channel. */ private uriToWebview = new Map(); - private uriToRefreshContext = new Map(); + private uriToRefreshContext = new Map(); private uriToLiveOverlaySignature = new Map(); /** @@ -566,17 +566,28 @@ export class WorkflowEditorProvider extends GlspEditorProvider { ): void { const refreshContext = this.getRefreshContext(sourceUri); + // The editor refreshes the view it shows. Navigating in place that can be + // a workflow in another file than the editor's own document: refresh that + // file, from disk -- live-preview `content` is the editor document's text, + // which is not the shown file's. + const shownSourceUri = refreshContext?.shownSourceUri; + const showsOtherFile = typeof shownSourceUri === 'string' + && this.canonicalizeUriString(shownSourceUri) !== this.canonicalizeUriString(sourceUri); + const effectiveOptions = showsOtherFile && options.content !== undefined + ? { forceReloadFromDisk: true } + : options; + // Create a RequestModelAction to refresh the diagram const action = RequestModelAction.create({ requestId: 'refresh-' + Date.now(), options: { - sourceUri, + sourceUri: showsOtherFile ? shownSourceUri : sourceUri, diagramType: this.diagramType, ...(typeof refreshContext?.queueTraceVisible === 'boolean' ? { queueTraceVisible: refreshContext.queueTraceVisible } : {}), ...(refreshContext?.networkName ? { networkName: refreshContext.networkName } : {}), ...(refreshContext?.navTrail ? { 'wf:navTrail': refreshContext.navTrail } : {}), ...(refreshContext?.runId ? { [RUN_ID_ARG]: refreshContext.runId } : {}), - ...options + ...effectiveOptions } }); @@ -587,7 +598,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { setRefreshContext( documentUri: vscode.Uri | string, - context: { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string } + context: { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string; shownSourceUri?: string } ): void { const key = this.canonicalizeUriString(documentUri); const next = { @@ -595,7 +606,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { ...context }; - if (!next.networkName && !next.navTrail && typeof next.queueTraceVisible !== 'boolean' && !next.runId) { + if (!next.networkName && !next.navTrail && typeof next.queueTraceVisible !== 'boolean' && !next.runId && !next.shownSourceUri) { this.uriToRefreshContext.delete(key); return; } @@ -603,7 +614,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { this.uriToRefreshContext.set(key, next); } - getRefreshContext(documentUri: vscode.Uri | string): { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string } | undefined { + getRefreshContext(documentUri: vscode.Uri | string): { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string; shownSourceUri?: string } | undefined { return this.uriToRefreshContext.get(this.canonicalizeUriString(documentUri)); } @@ -1024,6 +1035,16 @@ export class WorkflowEditorProvider extends GlspEditorProvider { * Resolve the GLSP client/session id for a given CAL document URI. * Useful for dispatching actions when the diagram tab is not the active webview. */ + /** The document an editor's client was opened on: its root. */ + getDocumentUriForClientId(clientId: string): string | undefined { + for (const [uri, id] of this.uriToClientId) { + if (id === clientId) { + return uri; + } + } + return undefined; + } + getClientIdForDocumentUri(uri: vscode.Uri): string | undefined { return this.uriToClientId.get(this.canonicalizeUriString(uri)); } diff --git a/packages/extension-core/src/extension/diagram/glsp-activation.ts b/packages/extension-core/src/extension/diagram/glsp-activation.ts index 5d935af..f8db054 100644 --- a/packages/extension-core/src/extension/diagram/glsp-activation.ts +++ b/packages/extension-core/src/extension/diagram/glsp-activation.ts @@ -863,7 +863,14 @@ export async function activateGlspIntegration( }; if (typeof sourceUri === 'string') { const finalOptions = (patched.action.options as any) ?? {}; - editorProvider?.setRefreshContext(sourceUri, { + // Keyed by the editor the request came from, not the file it + // asks for: navigating in place, an editor shows workflows of + // other files, and refreshes them as views of its own root. + const editorDocumentUri = typeof (message as any).clientId === 'string' + ? editorProvider?.getDocumentUriForClientId((message as any).clientId) + : undefined; + editorProvider?.setRefreshContext(editorDocumentUri ?? sourceUri, { + shownSourceUri: sourceUri, ...(typeof finalOptions.networkName === 'string' && finalOptions.networkName.trim() !== '' ? { networkName: finalOptions.networkName.trim() } : {}), @@ -1422,14 +1429,21 @@ function registerCalDiagramCommands( runOutput.appendLine(`[wf-lang live] SKIP ${kind === 'agentContextOnly' ? 'agent-ctx' : 'full'} refresh: provider=true clientId=undefined`); return; } + // The run is the root's; the view may be a nested workflow under it, + // in another file. Refresh the view the editor shows -- its file, + // workflow and trail -- or a run would pull it back to the root. + const view = editorProvider.getRefreshContext(uri); const refreshAction = RequestModelAction.create({ requestId: (kind === 'agentContextOnly' ? 'refresh-agent-ctx-' : 'refresh-during-run-') + Date.now(), options: { - sourceUri: uri.toString(), + sourceUri: view?.shownSourceUri ?? uri.toString(), diagramType: WORKFLOW_DIAGRAM_TYPE, ...(kind === 'agentContextOnly' ? { agentContextOnly: true } : {}), queueTraceVisible: getQueueTraceVisible(), - ...(networkName ? { networkName } : {}) + ...(view?.networkName + ? { networkName: view.networkName } + : (networkName ? { networkName } : {})), + ...(view?.navTrail ? { [NAV_TRAIL_ARG]: view.navTrail } : {}) } }); runOutput.appendLine(`[wf-lang live] dispatching ${kind === 'agentContextOnly' ? 'agent-ctx-only refresh' : 'full refresh'}`); diff --git a/packages/extension-core/test/chat-view-context.test.ts b/packages/extension-core/test/chat-view-context.test.ts new file mode 100644 index 0000000..fe97cb9 --- /dev/null +++ b/packages/extension-core/test/chat-view-context.test.ts @@ -0,0 +1,25 @@ +/** + * The chat is the root's for the whole hierarchy; the agent is told which + * nested workflow the diagram shows, so "this" and the selected nodes mean the + * view on screen, not the root. + */ +import { describe, expect, it } from 'vitest'; +import { viewContextText } from '../src/extension/chat/chat-runtime'; + +describe('the view on screen, in the chat’s turn context', () => { + it('says nothing when the root is on screen', () => { + expect(viewContextText(undefined)).toBeUndefined(); + expect(viewContextText([{ workflowName: 'top' }])).toBeUndefined(); + }); + + it('names the path from the root, the instance, and the file defining it', () => { + const text = viewContextText([ + { sourceUri: 'file:///w/top.py', workflowName: 'top' }, + { sourceUri: 'file:///w/layers/block.py', workflowName: 'block', workflowInstanceName: 'b2' } + ]); + + expect(text).toContain('top › b2 (block)'); + expect(text).toContain('/w/layers/block.py'); + expect(text).toContain('Selected nodes belong to this view'); + }); +}); diff --git a/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts b/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts new file mode 100644 index 0000000..0a87b67 --- /dev/null +++ b/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts @@ -0,0 +1,87 @@ +/** + * An editor refreshes the view it shows, which navigating in place can be a + * workflow in another file than its own document. + * + * The refresh context is kept per editor (its own document) and remembers the + * shown file. A refresh -- after a save, an external change, a live preview -- + * reloads that file at the view's workflow and trail. Live-preview content is + * the editor document's text, so for another file the view reloads from disk. + */ +import { describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { DiagramProfile } from '../src/api'; + +vi.mock('@eclipse-glsp/vscode-integration', () => ({ + GlspEditorProvider: class { + onDidChangeCustomDocument: unknown; + constructor(protected readonly glspVscodeConnector: any) { + this.onDidChangeCustomDocument = glspVscodeConnector?.onDidChangeCustomDocument; + } + }, + GlspVscodeConnector: class {} +})); + +const { WorkflowEditorProvider } = await import('../src/extension/diagram/diagram-editor-provider'); + +const ROOT = vscode.Uri.file('/w/top.py'); +const CHILD = vscode.Uri.file('/w/layers/block.py'); + +function makeProvider() { + const dispatched: Array<{ action: any; clientId: string }> = []; + const connector = { + onDidChangeCustomDocument: undefined, + dispatchAction: (action: any, clientId: string) => dispatched.push({ action, clientId }) + } as any; + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + const provider: any = new WorkflowEditorProvider(context, connector, {} as unknown as DiagramProfile); + provider.uriToClientId.set(provider.canonicalizeUriString(ROOT), 'client-top'); + return { provider, dispatched }; +} + +const trail = JSON.stringify([ + { sourceUri: ROOT.toString(), workflowName: 'top' }, + { sourceUri: CHILD.toString(), workflowName: 'block', workflowInstanceName: 'b2' } +]); + +describe('refreshing an editor that navigates in place', () => { + it('finds the editor’s document from its client', () => { + const { provider } = makeProvider(); + expect(provider.getDocumentUriForClientId('client-top')).toBe(provider.canonicalizeUriString(ROOT)); + expect(provider.getDocumentUriForClientId('nobody')).toBeUndefined(); + }); + + it('reloads the shown file at the view’s workflow and trail', () => { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: CHILD.toString(), networkName: 'block', navTrail: trail }); + + provider.dispatchModelRefresh('client-top', provider.canonicalizeUriString(ROOT), { forceReloadFromDisk: true }); + + const options = dispatched[0].action.options; + expect(dispatched[0].clientId).toBe('client-top'); + expect(options.sourceUri).toBe(CHILD.toString()); + expect(options.networkName).toBe('block'); + expect(options['wf:navTrail']).toBe(trail); + }); + + it('reloads another file from disk rather than with the editor document’s text', () => { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: CHILD.toString(), networkName: 'block' }); + + provider.dispatchModelRefresh('client-top', provider.canonicalizeUriString(ROOT), { content: 'top.py text' }); + + const options = dispatched[0].action.options; + expect(options.content).toBeUndefined(); + expect(options.forceReloadFromDisk).toBe(true); + }); + + it('refreshes its own document as before when that is what it shows', () => { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: ROOT.toString(), networkName: 'top' }); + + provider.dispatchModelRefresh('client-top', provider.canonicalizeUriString(ROOT), { content: 'top.py text' }); + + const options = dispatched[0].action.options; + expect(options.sourceUri).toBe(provider.canonicalizeUriString(ROOT)); + expect(options.content).toBe('top.py text'); + }); +}); diff --git a/packages/extension-core/test/network-navigation-mouse-listener.test.ts b/packages/extension-core/test/network-navigation-mouse-listener.test.ts index 5e1c67f..2fb435b 100644 --- a/packages/extension-core/test/network-navigation-mouse-listener.test.ts +++ b/packages/extension-core/test/network-navigation-mouse-listener.test.ts @@ -17,7 +17,8 @@ vi.mock('@eclipse-glsp/client', () => ({ })); vi.mock('../../diagram-client/src/navigation-ui', () => ({ - WorkflowNavigationUi: class {} + WorkflowNavigationUi: class {}, + navigatesInPlace: () => (globalThis as any).diagramIdentifier?.clientBehavior?.nestedNavigation === 'in-place' })); vi.mock('@eclipse-glsp/sprotty', () => ({ @@ -142,4 +143,54 @@ describe('WorkflowNetworkNavigationMouseListener', () => { const actions = listener.doubleClick(target, {} as MouseEvent); expect(actions).toEqual([]); }); -}); \ No newline at end of file +}); + +/** + * A nested workflow defined in another file: a new editor by default, a view in + * this editor when the product navigates in place. + */ +describe('cross-file drill-down', () => { + const root = 'file:///workspace/top.py'; + const child = 'file:///workspace/layers/block.py'; + const navTrail = [ + { sourceUri: root, workflowName: 'top' }, + { sourceUri: child, workflowName: 'block', workflowInstanceName: 'b2' } + ]; + + function drillDown(nestedNavigation?: string) { + const listener = new WorkflowNetworkNavigationMouseListener(); + (listener as any).editorContext = { sourceUri: root, diagramType: 'workflow-diagram' }; + (listener as any).workflowNavUi = { buildNavigationTrail: () => navTrail, noteNavigate: () => {} }; + (globalThis as any).diagramIdentifier = { + clientBehavior: nestedNavigation ? { nestedNavigation } : {} + }; + (globalThis as any).__calDiagramContext = { workflowName: 'top' }; + return listener.doubleClick({ + type: WorkflowDiagramTypes.NODE_NETWORK, + args: { + [WorkflowDiagramMetadata.IS_NETWORK_INSTANCE]: true, + [WorkflowDiagramMetadata.REFERENCED_URI]: child, + [WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME]: 'block', + 'wf:entityInstanceName': 'b2' + } + } as any, {} as MouseEvent); + } + + it('opens a new editor by default', () => { + const actions = drillDown(); + expect(actions).toHaveLength(1); + expect((actions[0] as any).kind).toBe('navigateToExternalTarget'); + }); + + it('shows the nested workflow in this editor when navigating in place', () => { + const actions = drillDown('in-place'); + + expect(actions).toHaveLength(1); + expect(RequestModelAction.is(actions[0] as any)).toBe(true); + const request = actions[0] as any; + // The defining file is what is rendered; the trail keeps the root first. + expect(request.options.sourceUri).toBe(child); + expect(request.options.networkName).toBe('block'); + expect(JSON.parse(request.options[NAV_TRAIL_ARG])[0]).toEqual({ sourceUri: root, workflowName: 'top' }); + }); +}); diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index ed6d98d..fb7b37e 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -57,6 +57,10 @@ export interface SidecarClientBehavior { networkNavigationLabels?: boolean; noneSentinel?: string; scriptInterpreterCommands?: string[]; + /** Where a nested workflow defined in another file opens: `in-place` shows it in + * this editor, as a view of the editor's root (one editor per hierarchy); + * `new-editor` (the default) opens the file in its own editor. */ + nestedNavigation?: 'in-place' | 'new-editor'; } /** The 24 consumer-owned command ids a sidecar diagram profile carries. */