From f9c365c6124273ad94e251fae563f75bc246df6b Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Fri, 2 Oct 2026 11:41:28 +0200 Subject: [PATCH] A nested view's layout is the root's, per instance, in the root file Navigating in place, a nested workflow is shown as an instance of the editor's root. Its layout was still the workflow's own: one per workflow, in the file that defines it, shared by every parent and every instance. With `nestedNavigation: 'in-place'` (now also in the storage options), a view at a trail reads and saves its layout in the root file's store under `root//`, so two uses of a workflow can be laid out differently; until it has one it starts from the workflow's standalone layout, so earlier layouts carry over. The layout target is now recorded on the diagram model at load and read by every handler that saves a layout (`layoutTargetOf`). Eight of them -- moving a node, rerouting, resetting routes, the layout commands, placing new nodes -- recomputed the key from the shown workflow's name, so a hierarchical runtime's per-instance layouts were read per instance and saved per workflow. A rename also moves the node in the instance layout. Phase 4 of docs/proposals/single-editor-hierarchy.md. Claude-Session: https://claude.ai/code/session_015VK7fH1c4aKbexnq2QcuKU --- docs/proposals/single-editor-hierarchy.md | 15 +- .../src/operations/change-bounds-handler.ts | 13 +- .../change-routing-points-handler.ts | 8 +- .../layout-boundary-flow-handler.ts | 8 +- .../layout-serpentine-mesh-handler.ts | 8 +- .../reroute-edges-avoid-overlaps-handler.ts | 8 +- .../operations/reset-edge-routes-handler.ts | 8 +- .../src/server/diagram-action-handlers.ts | 8 +- .../src/server/layout-target.ts | 82 +++++++++++ .../src/server/source-model-storage.ts | 29 +++- .../src/server/storage-runtime-options.ts | 6 + .../test/per-instance-layout.test.ts | 135 ++++++++++++++++++ packages/extension-core/src/api.ts | 3 + .../operations/rename-entity-handler.ts | 17 ++- .../src/sidecar-diagram-profile.ts | 3 +- 15 files changed, 297 insertions(+), 54 deletions(-) create mode 100644 packages/diagram-server/src/server/layout-target.ts create mode 100644 packages/diagram-server/test/per-instance-layout.test.ts diff --git a/docs/proposals/single-editor-hierarchy.md b/docs/proposals/single-editor-hierarchy.md index 4fba5e9..1ed9535 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:** phases 1 to 3 are implemented (in-place navigation, the root's run, the view in the chat's context, and editing nested views). Phases 4 and 5 (per-instance layout, the hierarchy export and the outline) are not. +**Status:** phases 1 to 4 are implemented (in-place navigation, the root's run, the view in the chat's context, editing nested views, and per-instance layout). Phase 5 (the hierarchy export and the outline) is not. **Affects:** dialogram (most of it), wfpy (a hierarchy export), wfpy-ide (one profile flag). @@ -222,8 +222,17 @@ comes from the same export, and is the cheap part of it: names, files and sizes. changed. A save or an on-disk change already refreshed every editor, and now reaches the view shown. Unsaved edits to a nested file preview in the views showing it. -4. **Layout per instance**, in the root's layout file, falling back to the - defining file's. +4. **Layout per instance.** *Done.* With `nestedNavigation: 'in-place'` + (now also in the storage options), a view at a trail of two or more crumbs + reads its layout from the root file's store, keyed + `root//` (`layout-target.ts`). Until it has one, it + starts from the workflow's standalone layout. The target is recorded on the + diagram model at load (`layoutTarget`), and every handler that saves a + layout uses it (`layoutTargetOf`): moving a node, rerouting, resetting + routes, the layout commands, placing new nodes. Eight of them had each + recomputed the key from the shown workflow's name, so a hierarchical + runtime's per-instance layouts were read per instance and saved per + workflow. A rename also moves the node in the instance's layout. 5. **The hierarchy export, the cache and the outline.** wfpy `--hierarchy`, instance-elaborated nested graphs, the server cache, then the outline panel. diff --git a/packages/diagram-server/src/operations/change-bounds-handler.ts b/packages/diagram-server/src/operations/change-bounds-handler.ts index a2dafdc..e14bad9 100644 --- a/packages/diagram-server/src/operations/change-bounds-handler.ts +++ b/packages/diagram-server/src/operations/change-bounds-handler.ts @@ -10,7 +10,6 @@ import { GEdge, GNode, GPort } from '@eclipse-glsp/graph'; import { ChangeBoundsOperation } from '@eclipse-glsp/protocol'; import { GModelChangeBoundsOperationHandler, GModelSerializer, GModelRecordingCommand, type Command, type MaybePromise } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { URI } from 'vscode-uri'; import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; @@ -20,6 +19,7 @@ import { WORKFLOW_REROUTE_EDGES_AVOID_OVERLAPS_OPERATION_KIND, WorkflowRerouteEdgesAvoidOverlapsOperationHandler } from './reroute-edges-avoid-overlaps-handler'; +import { layoutTargetOf } from '../server/layout-target'; @injectable() export class WorkflowChangeBoundsOperationHandler extends GModelChangeBoundsOperationHandler { @@ -66,8 +66,7 @@ export class WorkflowChangeBoundsOperationHandler extends GModelChangeBoundsOper return; } - const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath; - const networkId = this.getNetworkId(diagramModel); + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); void (async () => { const positions = (await this.layoutPersistence.loadLayout(workflowFilePath, networkId)) ?? new Map(); @@ -197,12 +196,4 @@ export class WorkflowChangeBoundsOperationHandler extends GModelChangeBoundsOper } return undefined; } - - private getNetworkId(diagramModel: WorkflowDiagramModel): string { - const explicit = (diagramModel as any)?.workflowName as string | undefined; - if (typeof explicit === 'string' && explicit.trim() !== '') { - return explicit.trim(); - } - return 'unknown'; - } } diff --git a/packages/diagram-server/src/operations/change-routing-points-handler.ts b/packages/diagram-server/src/operations/change-routing-points-handler.ts index 5df8fed..67bb8c5 100644 --- a/packages/diagram-server/src/operations/change-routing-points-handler.ts +++ b/packages/diagram-server/src/operations/change-routing-points-handler.ts @@ -10,7 +10,6 @@ import { type MaybePromise } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { URI } from 'vscode-uri'; import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; @@ -18,6 +17,7 @@ import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@di import { WorkflowDiagramConstants } from '@dialogram/shared'; import type { WorkflowDiagramModel } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; +import { layoutTargetOf } from '../server/layout-target'; @injectable() export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandler { @@ -280,11 +280,7 @@ export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandle return; } - const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath; - const explicitName = (diagramModel as any).workflowName as string | undefined; - const networkId = explicitName && explicitName.trim() !== '' - ? explicitName.trim() - : 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); // Prefer persisted positions (stable keys) and only update routes. const persistedPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId); diff --git a/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts b/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts index 675cafd..9538569 100644 --- a/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts +++ b/packages/diagram-server/src/operations/layout-boundary-flow-handler.ts @@ -11,7 +11,6 @@ import { GModelSerializer } from '@eclipse-glsp/server'; import { inject, injectable, optional } from 'inversify'; -import { URI } from 'vscode-uri'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; import type { WorkflowDiagramModel } from '@dialogram/shared'; @@ -22,6 +21,7 @@ import { WORKFLOW_REROUTE_EDGES_AVOID_OVERLAPS_OPERATION_KIND, WorkflowRerouteEdgesAvoidOverlapsOperationHandler } from './reroute-edges-avoid-overlaps-handler'; +import { layoutTargetOf } from '../server/layout-target'; export const WORKFLOW_LAYOUT_BOUNDARY_FLOW_OPERATION_KIND = 'dialogram.layoutBoundaryFlow' as const; @@ -94,11 +94,7 @@ export class WorkflowLayoutBoundaryFlowOperationHandler extends OperationHandler return; } - const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath; - const explicitName = (diagramModel as any).workflowName as string | undefined; - const networkId = explicitName && explicitName.trim() !== '' - ? explicitName.trim() - : 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); const positions = this.collectNodePositions(this.modelState.root); const routes = this.collectEdgeRoutes(this.modelState.root); await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); diff --git a/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts b/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts index bf0702b..a568187 100644 --- a/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts +++ b/packages/diagram-server/src/operations/layout-serpentine-mesh-handler.ts @@ -14,7 +14,6 @@ import { GModelSerializer } from '@eclipse-glsp/server'; import { inject, injectable, optional } from 'inversify'; -import { URI } from 'vscode-uri'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; import { WorkflowRerouteEdgesAvoidOverlapsOperationHandler, @@ -24,6 +23,7 @@ import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@di import type { WorkflowDiagramModel } from '@dialogram/shared'; import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; import { WorkflowDiagramConstants } from '@dialogram/shared'; +import { layoutTargetOf } from '../server/layout-target'; export const WORKFLOW_LAYOUT_SERPENTINE_MESH_OPERATION_KIND = 'dialogram.layoutSerpentineMesh' as const; @@ -95,11 +95,7 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl return; } - const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath; - const explicitName = (diagramModel as any).workflowName as string | undefined; - const networkId = explicitName && explicitName.trim() !== '' - ? explicitName.trim() - : 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); const positions = this.collectNodePositions(this.modelState.root); const routes = this.collectEdgeRoutes(this.modelState.root); await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); diff --git a/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts b/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts index e39016f..cc653c8 100644 --- a/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts +++ b/packages/diagram-server/src/operations/reroute-edges-avoid-overlaps-handler.ts @@ -11,7 +11,6 @@ import { type MaybePromise } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { URI } from 'vscode-uri'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; import { WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; import type { WorkflowDiagramModel } from '@dialogram/shared'; @@ -19,6 +18,7 @@ import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { WorkflowDiagramConstants, portAnchor } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; import { routeOrthogonal, type RouterConnector, type RouterObstacle } from '../routing/libavoid-router'; +import { layoutTargetOf } from '../server/layout-target'; /** Every segment axis-aligned? A diagonal means the polyline is not a valid route. */ export function isOrthogonalPolyline(points: readonly { x: number; y: number }[]): boolean { @@ -1029,8 +1029,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation if (!operation.preview) { const dm = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; if (dm) { - const workflowFilePath = URI.parse(dm.documentUri).fsPath; - const networkId = (dm as any).workflowName ?? 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(dm as any); const positions = this.collectNodePositions(root); const routes = this.collectEdgeRoutes(root); await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); @@ -1232,8 +1231,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation const dm = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined; if (!dm) { return; } - const workflowFilePath = URI.parse(dm.documentUri).fsPath; - const networkId = (dm as any).workflowName ?? 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(dm as any); const positions = this.collectNodePositions(root); const routes = this.collectEdgeRoutes(root); diff --git a/packages/diagram-server/src/operations/reset-edge-routes-handler.ts b/packages/diagram-server/src/operations/reset-edge-routes-handler.ts index ae4d33f..8103dcd 100644 --- a/packages/diagram-server/src/operations/reset-edge-routes-handler.ts +++ b/packages/diagram-server/src/operations/reset-edge-routes-handler.ts @@ -8,12 +8,12 @@ import { type MaybePromise } from '@eclipse-glsp/server'; import { inject, injectable } from 'inversify'; -import { URI } from 'vscode-uri'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; import { WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; import type { WorkflowDiagramModel } from '@dialogram/shared'; import { WorkflowDiagramMetadata } from '@dialogram/shared'; import { GModelSerializer } from '@eclipse-glsp/server'; +import { layoutTargetOf } from '../server/layout-target'; export const WORKFLOW_RESET_EDGE_ROUTES_OPERATION_KIND = 'dialogram.resetEdgeRoutes' as const; @@ -119,11 +119,7 @@ export class WorkflowResetEdgeRoutesOperationHandler extends OperationHandler { return; } - const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath; - const explicitName = (diagramModel as any).workflowName as string | undefined; - const networkId = explicitName && explicitName.trim() !== '' - ? explicitName.trim() - : 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); // Prefer the already-persisted node map (it uses stable keys), falling back to the // current model snapshot if no layout file exists yet. diff --git a/packages/diagram-server/src/server/diagram-action-handlers.ts b/packages/diagram-server/src/server/diagram-action-handlers.ts index 7668783..cbb1669 100644 --- a/packages/diagram-server/src/server/diagram-action-handlers.ts +++ b/packages/diagram-server/src/server/diagram-action-handlers.ts @@ -41,7 +41,6 @@ import { UpdateModelAction } from '@eclipse-glsp/protocol'; import { inject, injectable, optional } from 'inversify'; -import { URI } from 'vscode-uri'; import { WorkflowDiagramMetadata, type WorkflowDiagramModel, @@ -59,6 +58,7 @@ import { import { clearAllEdgeRoutingPoints } from '../routing/clear-edge-routes'; import { releaseBoundaryNodeConstraints, restoreNodeLayoutOptions, runBoundaryFlowLayout } from '../operations/boundary-flow-layout'; import { perfNow } from './graph-load-perf'; +import { layoutTargetOf } from './layout-target'; /** * Custom ModelSubmissionHandler - minimal logging version. @@ -1747,11 +1747,7 @@ export class WorkflowLayoutOperationHandler extends OperationHandler { return; } - const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath; - const explicitName = (diagramModel as any)?.workflowName as string | undefined; - const networkId = explicitName && explicitName.trim() !== '' - ? explicitName.trim() - : 'unknown'; + const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any); const positions = this.collectNodePositions(this.modelState.root); const routes = this.collectEdgeRoutes(this.modelState.root); await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes); diff --git a/packages/diagram-server/src/server/layout-target.ts b/packages/diagram-server/src/server/layout-target.ts new file mode 100644 index 0000000..1d86aed --- /dev/null +++ b/packages/diagram-server/src/server/layout-target.ts @@ -0,0 +1,82 @@ +/** + * Where a view's layout lives, and under which key. + * + * A workflow shown on its own keeps its layout in its file's layout store, + * keyed by its name. A nested workflow shown in place, as a view of an editor's + * root, is an *instance*: its layout belongs to the root, in the root file's + * store, keyed by the instance path from the root (`top/b2/block`). Two uses of + * one workflow can then be arranged differently, and the root owns the layout + * of everything it shows. + * + * An instance with no layout of its own yet starts from the workflow's + * standalone layout, so every layout made before keeps applying. + */ + +import { URI } from 'vscode-uri'; + +export interface LayoutTarget { + /** The source file whose layout store holds it. */ + filePath: string; + /** The key inside that store. */ + networkId: string; +} + +export interface InstanceLayout { + target: LayoutTarget; + /** Read when the target holds nothing yet: the workflow's standalone layout. */ + fallback: LayoutTarget; +} + +export interface TrailCrumb { + sourceUri: string; + workflowName: string; + workflowInstanceName?: string; +} + +/** + * The instance layout for a view at `trail`, or nothing when the view is a + * root (a trail of one, or none) or the trail does not end at it. + */ +export function instanceLayoutFor( + trail: TrailCrumb[], + shown: { filePath: string; workflowName: string }, + toFilePath: (sourceUri: string) => string +): InstanceLayout | undefined { + if (trail.length < 2) { + return undefined; + } + const root = trail[0]; + const last = trail[trail.length - 1]; + if (last.workflowName !== shown.workflowName || toFilePath(last.sourceUri) !== shown.filePath) { + return undefined; + } + const instancePath = trail.slice(1).map(crumb => crumb.workflowInstanceName?.trim() || crumb.workflowName); + return { + target: { + filePath: toFilePath(root.sourceUri), + // The shape a hierarchical runtime's layouts already use: + // root, instance path, then the workflow shown. + networkId: [root.workflowName, ...instancePath, shown.workflowName].join('/') + }, + fallback: { filePath: shown.filePath, networkId: shown.workflowName } + }; +} + +/** + * The layout target of the loaded view: where its layout is read from and + * saved to. Recorded on the diagram model at load (`layoutTarget`) -- the + * instance target for a nested view in place, or a hierarchical runtime's -- + * so every handler that saves a layout saves it where it was read. Without one, + * the shown file and workflow, as handlers computed it before. + */ +export function layoutTargetOf( + diagramModel: { documentUri: string; workflowName?: unknown; layoutTarget?: LayoutTarget } +): LayoutTarget { + if (diagramModel.layoutTarget) { + return diagramModel.layoutTarget; + } + const name = typeof diagramModel.workflowName === 'string' && diagramModel.workflowName.trim() !== '' + ? diagramModel.workflowName.trim() + : 'unknown'; + return { filePath: URI.parse(diagramModel.documentUri).fsPath, networkId: name }; +} diff --git a/packages/diagram-server/src/server/source-model-storage.ts b/packages/diagram-server/src/server/source-model-storage.ts index 58e46fd..a2d4292 100644 --- a/packages/diagram-server/src/server/source-model-storage.ts +++ b/packages/diagram-server/src/server/source-model-storage.ts @@ -17,6 +17,7 @@ import { import { RequestModelAction, SaveModelAction } from '@eclipse-glsp/protocol'; import { inject, injectable, optional } from 'inversify'; import { URI } from 'vscode-uri'; +import { instanceLayoutFor, type LayoutTarget } from './layout-target'; import * as path from 'node:path'; import { promises as fs } from 'node:fs'; import { @@ -440,7 +441,7 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { (diagramModel as any).workflowName = workflowName; (diagramModel as any).documentUri = sourceUri; - const workflowFilePath = filePath; + let workflowFilePath = filePath; // Qualify hierarchical network IDs so each instance-path gets its own layout // (e.g. "qwen35_model/decoder_block/decoder_layer"). Only a hierarchical runtime ever // supplies the root-workflow arg, so gating on its presence (rather than the settings @@ -449,13 +450,32 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { const hierarchicalRoot = typeof opts[ROOT_WORKFLOW_ARG] === 'string' && String(opts[ROOT_WORKFLOW_ARG]).trim() !== '' ? String(opts[ROOT_WORKFLOW_ARG]).trim() : undefined; + let fallbackLayout: LayoutTarget | undefined; if (hierarchicalRoot) { const hierarchicalInstancePath = parseStringListArg(opts[INSTANCE_PATH_ARG]); const parts = [hierarchicalRoot, ...hierarchicalInstancePath, networkId]; networkId = parts.join('/'); + } else if (this.storageOptions?.nestedNavigation === 'in-place') { + // A nested workflow shown in place is an instance of the editor's + // root: its layout is the root's, per instance, falling back to the + // workflow's standalone layout until it has one of its own. + const instance = instanceLayoutFor( + this.parseNavigationTrailArg(opts[NAV_TRAIL_ARG]), + { filePath: workflowFilePath, workflowName: networkId }, + uri => URI.parse(uri).fsPath + ); + if (instance) { + workflowFilePath = instance.target.filePath; + networkId = instance.target.networkId; + fallbackLayout = instance.fallback; + } + } + let loadedLayoutPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId); + let edgeRoutes = await this.layoutPersistence.loadEdgeRoutes(workflowFilePath, networkId); + if (!loadedLayoutPositions && fallbackLayout) { + loadedLayoutPositions = await this.layoutPersistence.loadLayout(fallbackLayout.filePath, fallbackLayout.networkId); + edgeRoutes = await this.layoutPersistence.loadEdgeRoutes(fallbackLayout.filePath, fallbackLayout.networkId); } - const loadedLayoutPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId); - const edgeRoutes = await this.layoutPersistence.loadEdgeRoutes(workflowFilePath, networkId); const hasPersistedEdgeRoutes = edgeRoutes !== undefined; perf?.mark('layoutIO'); @@ -519,6 +539,9 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { ); const hasPersistedEdgeRoutesEffective = hasPersistedLayout && hasPersistedEdgeRoutes; + // Where every handler that saves this view's layout saves it. + (diagramModel as any).layoutTarget = { filePath: workflowFilePath, networkId }; + this.modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, { workflowFilePath, networkId, diff --git a/packages/diagram-server/src/server/storage-runtime-options.ts b/packages/diagram-server/src/server/storage-runtime-options.ts index 5865e3b..26609bf 100644 --- a/packages/diagram-server/src/server/storage-runtime-options.ts +++ b/packages/diagram-server/src/server/storage-runtime-options.ts @@ -31,6 +31,12 @@ export interface StorageRuntimeOptions { * The class is derived from each annotation, so core names none. */ nodeFamilies?: NodeFamilySpec[]; + /** + * How the product navigates nested workflows (`clientBehavior.nestedNavigation`). + * In place, a nested view's layout is the root's, stored per instance in the + * root file's layout (see `layout-target.ts`). + */ + nestedNavigation?: 'in-place' | 'new-editor'; /** * Whether the tool palette offers anything to create. Default `true`. * diff --git a/packages/diagram-server/test/per-instance-layout.test.ts b/packages/diagram-server/test/per-instance-layout.test.ts new file mode 100644 index 0000000..f254284 --- /dev/null +++ b/packages/diagram-server/test/per-instance-layout.test.ts @@ -0,0 +1,135 @@ +/** + * A nested workflow shown in place has its layout per instance, in the root. + * + * Its key is the instance path from the editor's root and it lives in the root + * file's layout store, so two uses of one workflow can be arranged + * differently. An instance with no layout yet starts from the workflow's + * standalone layout. A product opening nested files in their own editors + * keeps one layout per workflow, as before. + */ +import { promises as fs } from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { URI } from 'vscode-uri'; +import { instanceLayoutFor, layoutTargetOf } from '../src/server/layout-target'; +import { WorkflowSourceModelStorage } from '../src/server/source-model-storage'; +import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared'; + +const toPath = (uri: string) => URI.parse(uri).fsPath; +const TOP = 'file:///w/top.py'; +const BLOCK = 'file:///w/layers/block.py'; +const trail = [ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: BLOCK, workflowName: 'block', workflowInstanceName: 'b2' } +]; + +describe('the layout of a view', () => { + it('is the root’s, per instance, for a nested view', () => { + expect(instanceLayoutFor(trail, { filePath: '/w/layers/block.py', workflowName: 'block' }, toPath)).toEqual({ + target: { filePath: '/w/top.py', networkId: 'top/b2/block' }, + fallback: { filePath: '/w/layers/block.py', networkId: 'block' } + }); + }); + + it('tells two instances of one workflow apart', () => { + const other = [trail[0], { ...trail[1], workflowInstanceName: 'b3' }]; + expect(instanceLayoutFor(other, { filePath: '/w/layers/block.py', workflowName: 'block' }, toPath)?.target.networkId) + .toBe('top/b3/block'); + }); + + it('is the workflow’s own for a root, or a trail that does not end at the view', () => { + expect(instanceLayoutFor([trail[0]], { filePath: '/w/top.py', workflowName: 'top' }, toPath)).toBeUndefined(); + expect(instanceLayoutFor(trail, { filePath: '/w/layers/other.py', workflowName: 'other' }, toPath)).toBeUndefined(); + }); +}); + +/** Load a nested view through the storage and record which layouts it asks for. */ +async function loadNested(nestedNavigation: 'in-place' | 'new-editor', saved: Record) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'per-instance-layout-')); + const blockFile = path.join(dir, 'block.py'); + await fs.writeFile(blockFile, '# block\n'); + const topFile = path.join(dir, 'top.py'); + const blockUri = URI.file(blockFile).toString(); + const asked: string[] = []; + const state = new Map(); + + const storage: any = new (WorkflowSourceModelStorage as any)(); + storage.storageOptions = { settingsNamespace: 'wf', operationPrefix: 'wf', nestedNavigation }; + storage.ensureModelSource = () => undefined; + storage.modelState = { set: (k: string, v: unknown) => state.set(k, v), get: (k: string) => state.get(k), updateRoot: () => {} }; + const key = (file: string, id: string) => `${path.basename(file)}#${id}`; + storage.layoutPersistence = { + loadLayout: async (file: string, id: string) => { + asked.push(key(file, id)); + const nodes = saved[key(file, id)]; + return nodes ? new Map(nodes.map(n => [n, { x: 1, y: 1 }])) : undefined; + }, + loadEdgeRoutes: async () => undefined + }; + + const doc = { version: '1', graph: { id: 'wf:block', nodes: [], edges: [] } }; + const opts = { + sourceUri: blockUri, + networkName: 'block', + 'wf:navTrail': JSON.stringify([ + { sourceUri: URI.file(topFile).toString(), workflowName: 'top' }, + { sourceUri: blockUri, workflowName: 'block', workflowInstanceName: 'b2' } + ]) + }; + try { + await storage.finishLoadFromDoc(doc, blockUri, blockFile, opts, undefined); + } catch { + // Past the layout lookup the load needs a live session; what it asked + // for and recorded is what this tests. + } + await fs.rm(dir, { recursive: true, force: true }); + return { + asked, + persistence: state.get(WORKFLOW_LAYOUT_PERSISTENCE_KEY) as any, + model: state.get(WORKFLOW_NETWORK_MODEL_KEY) as any + }; +} + +describe('loading a nested view', () => { + it('reads and saves its layout in the root file, under the instance path', async () => { + const { asked, persistence, model } = await loadNested('in-place', { 'top.py#top/b2/block': ['n'] }); + + expect(asked).toEqual(['top.py#top/b2/block']); + expect(path.basename(persistence.workflowFilePath)).toBe('top.py'); + expect(persistence.networkId).toBe('top/b2/block'); + // Every handler that saves a layout (moving a node, rerouting, a layout + // command) reads it from here, so it saves where the view was read. + expect(path.basename(layoutTargetOf(model).filePath)).toBe('top.py'); + expect(layoutTargetOf(model).networkId).toBe('top/b2/block'); + }); + + it('starts from the workflow’s standalone layout until it has its own, still saving per instance', async () => { + const { asked, persistence } = await loadNested('in-place', { 'block.py#block': ['n'] }); + + expect(asked).toEqual(['top.py#top/b2/block', 'block.py#block']); + expect(persistence.networkId).toBe('top/b2/block'); + }); + + it('keeps one layout per workflow when nested files open in their own editors', async () => { + const { asked, persistence } = await loadNested('new-editor', {}); + + expect(asked).toEqual(['block.py#block']); + expect(persistence.networkId).toBe('block'); + }); +}); + +describe('where a handler saves a layout', () => { + it('is the target recorded at load', () => { + expect(layoutTargetOf({ + documentUri: BLOCK, + workflowName: 'block', + layoutTarget: { filePath: '/w/top.py', networkId: 'top/b2/block' } + })).toEqual({ filePath: '/w/top.py', networkId: 'top/b2/block' }); + }); + + it('is the shown file and workflow without one, as before', () => { + expect(layoutTargetOf({ documentUri: BLOCK, workflowName: 'block' })).toEqual({ filePath: '/w/layers/block.py', networkId: 'block' }); + expect(layoutTargetOf({ documentUri: BLOCK }).networkId).toBe('unknown'); + }); +}); diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index 36dd0d2..5b8020c 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -98,6 +98,9 @@ export interface DiagramStorageOptions { * platform builds the actual palette item. */ entityPaletteItems?: EntityPaletteItemSpec[]; nodeFamilies?: NodeFamilySpec[]; + /** How nested workflows are navigated; in place, a nested view's layout is + * the root's, per instance (diagram-server `layout-target.ts`). */ + nestedNavigation?: 'in-place' | 'new-editor'; } /** diff --git a/packages/sidecar-toolkit/src/server/operations/rename-entity-handler.ts b/packages/sidecar-toolkit/src/server/operations/rename-entity-handler.ts index e97c852..98c2535 100644 --- a/packages/sidecar-toolkit/src/server/operations/rename-entity-handler.ts +++ b/packages/sidecar-toolkit/src/server/operations/rename-entity-handler.ts @@ -2,7 +2,7 @@ import { Action, Command, ModelState, OperationHandler } from '@eclipse-glsp/ser import { inject, injectable } from 'inversify'; import { URI } from 'vscode-uri'; import * as vscode from 'vscode'; -import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; +import { WORKFLOW_NETWORK_MODEL_KEY, WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; import { LayoutPersistenceService } from '@dialogram/diagram-server/services/layout-persistence-service'; import { ReversibleMultiWorkspaceEditCommand } from '@dialogram/diagram-server/operations/reversible-multi-workspace-edit-command'; @@ -93,6 +93,21 @@ export class RenameEntityOperationHandler extends OperationHandler { renameInfo.oldName, renameInfo.newName ); + // A nested view in place keeps its layout per instance, + // in the root's store: the node is renamed there too. + const diagramModel = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as any; + const target = diagramModel?.layoutTarget as { filePath: string; networkId: string } | undefined; + if ( + target + && (target.filePath !== URI.parse(renameInfo.sourceUri).fsPath || target.networkId !== networkId) + ) { + await this.layoutPersistence.renameNode( + target.filePath, + target.networkId, + renameInfo.oldName, + renameInfo.newName + ); + } } catch { // Ignore layout migration errors; rename already applied. } diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index fb7b37e..fdebaf2 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -414,7 +414,8 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { operationPrefix: input.sidecarOperationPrefix, useAlternateEntityPalette: input.useAlternateEntityPalette, entityPaletteItems: input.entityPaletteItems, - nodeFamilies: input.nodeFamilies + nodeFamilies: input.nodeFamilies, + nestedNavigation: input.clientBehavior?.nestedNavigation }, watch: { globs: input.watchGlobs ?? [`**/*${input.sourceExtension}`] }, navigation: createPythonNavigationProvider(),