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(),