diff --git a/docs/proposals/single-editor-hierarchy.md b/docs/proposals/single-editor-hierarchy.md index 1ed9535..c21c4ec 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 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. +**Status:** implemented, all five phases: in-place navigation, the root's run and chat context, editing nested views, per-instance layout, and the hierarchy export with its cache and outline. Rows do not yet carry a run's state (see phase 5). **Affects:** dialogram (most of it), wfpy (a hierarchy export), wfpy-ide (one profile flag). @@ -233,8 +233,22 @@ comes from the same export, and is the cheap part of it: names, files and sizes. 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. +5. **The hierarchy export, the cache and the outline.** *Done.* + - wfpy: `wfpy plan --format graph --hierarchy` (huawei-csl/wfpy#45) exports + the root and every nested instance, each elaborated as its parent built + it. + - The cache: a product gives `cliHierarchyArgs`, and `CliGraphModelSource` + reads every view, root or nested, from one export per root + (`hierarchy-cache.ts`). The export is kept while every file it came from is + unchanged on disk. An instance that did not elaborate, or unsaved text + being previewed, still gets a plan of its own. + - The outline: the tree without graphs reaches the root model as + `wf:hierarchy`. The panel (`hierarchy-outline.ts`, helpers in + `hierarchy-outline-tree.ts`) has a toggle and the O key, a filter that + keeps ancestors, a single click that selects and centers the instance in + the view containing it, a double click that opens its view, and the + current row highlighted. + - Not yet: a run's state (running / done / failed, fire counts) on the rows. Each phase is usable on its own. 1 and 2 together are what removes the editor-per-level problem. diff --git a/packages/diagram-client/src/diagram-client.css b/packages/diagram-client/src/diagram-client.css index 7e9e388..85a9f3f 100644 --- a/packages/diagram-client/src/diagram-client.css +++ b/packages/diagram-client/src/diagram-client.css @@ -3270,3 +3270,135 @@ details[open] > summary .agent-chat-expand-arrow { .wf-rab-text { white-space: pre-wrap; word-break: break-word; font-size: 11.5px; line-height: 1.4; } .wf-rab-text.stream { max-height: 160px; overflow-y: auto; } @keyframes wf-rab-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } + +/* ── Hierarchy outline (hierarchy-outline.ts) ─────────────────────────── */ + +.workflow-hierarchy-outline-toggle { + position: fixed; + top: 44px; + left: 8px; + z-index: 30; + width: 28px; + height: 28px; + border: 1px solid var(--vscode-editorWidget-border, #454545); + border-radius: 4px; + background: var(--vscode-editorWidget-background, #252526); + color: var(--vscode-foreground, #cccccc); + cursor: pointer; +} + +.workflow-hierarchy-outline-toggle.active, +.workflow-hierarchy-outline-toggle:hover { + background: var(--vscode-toolbar-hoverBackground, #2a2d2e); +} + +.workflow-hierarchy-outline { + position: fixed; + top: 78px; + left: 8px; + bottom: 48px; + z-index: 30; + width: 300px; + display: flex; + flex-direction: column; + border: 1px solid var(--vscode-editorWidget-border, #454545); + border-radius: 4px; + background: var(--vscode-editorWidget-background, #252526); + color: var(--vscode-foreground, #cccccc); + font-family: var(--vscode-font-family); + font-size: 12px; +} + +.workflow-hierarchy-outline[hidden], +.workflow-hierarchy-outline-toggle[hidden] { + display: none; +} + +.workflow-hierarchy-outline-header { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 6px; + border-bottom: 1px solid var(--vscode-editorWidget-border, #454545); +} + +.workflow-hierarchy-outline-title { + flex: 1; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + opacity: 0.8; +} + +.workflow-hierarchy-outline-btn { + border: none; + background: transparent; + color: inherit; + cursor: pointer; + padding: 2px; +} + +.workflow-hierarchy-outline-search { + margin: 6px; + padding: 3px 6px; + border: 1px solid var(--vscode-input-border, #3c3c3c); + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #cccccc); +} + +.workflow-hierarchy-outline-body { + flex: 1; + overflow: auto; + padding-bottom: 6px; +} + +.workflow-hierarchy-outline-row { + display: flex; + align-items: center; + gap: 4px; + min-height: 22px; + padding-right: 6px; + cursor: pointer; + white-space: nowrap; +} + +.workflow-hierarchy-outline-row:hover { + background: var(--vscode-list-hoverBackground, #2a2d2e); +} + +.workflow-hierarchy-outline-row.current { + background: var(--vscode-list-activeSelectionBackground, #04395e); + color: var(--vscode-list-activeSelectionForeground, #ffffff); +} + +.workflow-hierarchy-outline-twisty { + width: 16px; + flex: none; +} + +.workflow-hierarchy-outline-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.workflow-hierarchy-outline-type, +.workflow-hierarchy-outline-meta { + opacity: 0.6; +} + +.workflow-hierarchy-outline-file { + padding: 0 4px; + border-radius: 3px; + background: var(--vscode-badge-background, #4d4d4d); + color: var(--vscode-badge-foreground, #ffffff); + font-size: 10px; +} + +.workflow-hierarchy-outline-meta { + margin-left: auto; +} + +.workflow-hierarchy-outline-meta.error { + color: var(--vscode-errorForeground, #f48771); + opacity: 1; +} diff --git a/packages/diagram-client/src/hierarchy-outline-tree.ts b/packages/diagram-client/src/hierarchy-outline-tree.ts new file mode 100644 index 0000000..56a1a0c --- /dev/null +++ b/packages/diagram-client/src/hierarchy-outline-tree.ts @@ -0,0 +1,82 @@ +/** + * Pure helpers for the hierarchy outline, kept GLSP-free so they are unit + * testable headlessly (the panel imports GLSP values and cannot load without a + * DOM) -- the split mlir-viewer's outline uses, for the same reason. + */ +import type { HierarchyOutlineEntry } from '@dialogram/shared'; + +export interface OutlineCrumb { + sourceUri: string; + workflowName: string; + workflowInstanceName?: string; +} + +/** A row's stable id: its path of instance names (`''` for the root). */ +export function rowId(entry: { path: string[] }): string { + return entry.path.join('/'); +} + +/** What a row is called: its instance name, or the workflow for the root. */ +export function rowLabel(entry: HierarchyOutlineEntry): string { + return entry.path.length > 0 ? entry.path[entry.path.length - 1] : entry.workflowName; +} + +/** + * A row survives a filter when it matches, or when something beneath it does: + * a filter that hid the ancestors would leave the matches unreachable, since + * the tree is the way to them. `query` is lower-cased and trimmed already. + */ +export function outlineMatches(entry: HierarchyOutlineEntry, query: string): boolean { + if (!query) { + return true; + } + if (rowLabel(entry).toLowerCase().includes(query) || entry.workflowName.toLowerCase().includes(query)) { + return true; + } + return entry.children.some(child => outlineMatches(child, query)); +} + +/** The entry at `path`, if the outline has it. */ +export function entryAt(root: HierarchyOutlineEntry, path: string[]): HierarchyOutlineEntry | undefined { + let current: HierarchyOutlineEntry | undefined = root; + for (const name of path) { + current = current?.children.find(child => child.path[child.path.length - 1] === name); + } + return current; +} + +/** + * The navigation trail to the instance at `path`: the root, then one crumb per + * instance on the way. What a breadcrumb and a drill-down produce, so a view + * opened from the outline is the same view either way would open. + */ +export function trailTo(root: HierarchyOutlineEntry, rootSourceUri: string, path: string[]): OutlineCrumb[] | undefined { + const trail: OutlineCrumb[] = [{ sourceUri: root.sourceUri ?? rootSourceUri, workflowName: root.workflowName }]; + for (let i = 1; i <= path.length; i++) { + const entry = entryAt(root, path.slice(0, i)); + if (!entry?.sourceUri) { + return undefined; + } + trail.push({ sourceUri: entry.sourceUri, workflowName: entry.workflowName, workflowInstanceName: path[i - 1] }); + } + return trail; +} + +/** The path the shown view is at, from its trail. */ +export function pathOfTrail(trail: Array<{ workflowName: string; workflowInstanceName?: string }>): string[] { + return trail.slice(1).map(crumb => crumb.workflowInstanceName ?? crumb.workflowName); +} + +/** The ids of every ancestor of `path`, so the current row can be revealed. */ +export function ancestorIds(path: string[]): string[] { + return path.map((_, i) => path.slice(0, i).join('/')); +} + +/** The file name of an instance defined elsewhere than the root, or nothing. */ +export function foreignFileName(entry: HierarchyOutlineEntry, rootSourceUri: string | undefined): string | undefined { + if (!entry.sourceUri || !rootSourceUri || entry.sourceUri === rootSourceUri) { + return undefined; + } + const name = entry.sourceUri.split('/').pop(); + return name ? decodeURIComponent(name) : undefined; +} diff --git a/packages/diagram-client/src/hierarchy-outline.ts b/packages/diagram-client/src/hierarchy-outline.ts new file mode 100644 index 0000000..f9092ac --- /dev/null +++ b/packages/diagram-client/src/hierarchy-outline.ts @@ -0,0 +1,388 @@ +/** + * The hierarchy outline: a tree of every workflow instance under the editor's + * root, and the way to reach any of them. + * + * Navigating a hierarchy in one editor, double-clicking down one level at a time + * is no way to reach an instance six levels deep, nor to see what is there. + * Modelled on mlir-viewer's file outline, and cheap in the same way: the tree is + * plain data on the root model's args (`wf:hierarchy`), so opening, closing and + * filtering rows lays nothing out. Only choosing a row loads a view. + * + * A single click *looks*: it selects and centers the instance's node in the + * view that contains it, opening that view first if another is shown. A double + * click *goes*: it opens the instance's own view, at the same trail a + * drill-down and the breadcrumb would give it. The current view's row is + * highlighted and its ancestors opened, so the outline and the breadcrumb are + * the same trail. + */ +import { inject, injectable } from 'inversify'; +import { EditorContextService, TYPES, type IActionDispatcher } from '@eclipse-glsp/client'; +import { CenterAction, RequestModelAction, SelectAction } from '@eclipse-glsp/sprotty'; +import { WorkflowDiagramMetadata, type HierarchyOutlineEntry } from '@dialogram/shared'; +import { navigatesInPlace } from './navigation-ui'; +import { + ancestorIds, + entryAt, + foreignFileName, + outlineMatches, + pathOfTrail, + rowId, + rowLabel, + trailTo +} from './hierarchy-outline-tree'; + +const PANEL_ID = 'workflow-hierarchy-outline'; +const BODY_ID = 'workflow-hierarchy-outline-body'; +const SEARCH_ID = 'workflow-hierarchy-outline-search'; +const TOGGLE_ID = 'workflow-hierarchy-outline-toggle'; +const OPEN_STORAGE_KEY = 'dialogram.hierarchyOutline.open'; +/** How long a click waits to find out it was the first half of a double click. */ +const DOUBLE_CLICK_GRACE_MS = 220; + +function storedOpen(): boolean { + try { + return globalThis.localStorage?.getItem(OPEN_STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +function storeOpen(open: boolean): void { + try { + globalThis.localStorage?.setItem(OPEN_STORAGE_KEY, open ? '1' : '0'); + } catch { + // ignore + } +} + +@injectable() +export class HierarchyOutlinePanel { + @inject(TYPES.IActionDispatcher) protected readonly dispatcher!: IActionDispatcher; + @inject(EditorContextService) protected readonly editorContext!: EditorContextService; + + private outline: HierarchyOutlineEntry | undefined; + private renderedJson: string | undefined; + private readonly expanded = new Set(['']); + private query = ''; + private open = storedOpen(); + private currentPath: string[] = []; + private root: unknown; + private wired = false; + private clickTimer: ReturnType | undefined; + /** An instance to select once the view containing it has loaded. */ + private pendingFocus: string | undefined; + + modelRootChanged(root: unknown): void { + this.root = root; + const raw = (root as { args?: Record })?.args?.['wf:hierarchy']; + if (!navigatesInPlace() || typeof raw !== 'string') { + this.setAvailable(false); + return; + } + if (raw !== this.renderedJson) { + try { + this.outline = JSON.parse(raw) as HierarchyOutlineEntry; + this.renderedJson = raw; + } catch { + this.setAvailable(false); + return; + } + } + // The trail the server echoes on the root: this view's own, whichever + // listener the model reaches first. + this.currentPath = pathOfTrail(trailOfRoot(root)); + for (const id of ancestorIds(this.currentPath)) { + this.expanded.add(id); + } + this.setAvailable(true); + this.resolvePendingFocus(); + this.render(); + } + + // ── Chrome ────────────────────────────────────────────────────────── + + private ensureChrome(): void { + if (typeof document === 'undefined' || !document.body || document.getElementById(PANEL_ID)) { + return; + } + const holder = document.createElement('div'); + holder.innerHTML = ` + + `; + while (holder.firstChild) { + document.body.appendChild(holder.firstChild); + } + } + + private wire(): void { + if (this.wired || typeof document === 'undefined') { + return; + } + this.wired = true; + document.getElementById(TOGGLE_ID)?.addEventListener('click', () => this.setOpen(!this.open)); + const panel = document.getElementById(PANEL_ID); + panel?.querySelector('[data-action="close"]')?.addEventListener('click', () => this.setOpen(false)); + panel?.querySelector('[data-action="collapse"]')?.addEventListener('click', () => { + this.expanded.clear(); + this.expanded.add(''); + this.render(); + }); + document.getElementById(SEARCH_ID)?.addEventListener('input', event => { + this.query = (event.target as HTMLInputElement).value.trim().toLowerCase(); + this.render(); + }); + const body = document.getElementById(BODY_ID); + body?.addEventListener('click', event => this.onRowClick(event, false)); + body?.addEventListener('dblclick', event => this.onRowClick(event, true)); + document.addEventListener('keydown', event => { + const target = event.target as HTMLElement | null; + const typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable); + if (!typing && !event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === 'o' + && !document.getElementById(TOGGLE_ID)?.hidden) { + event.preventDefault(); + this.setOpen(!this.open); + } + }); + } + + private setAvailable(available: boolean): void { + this.ensureChrome(); + this.wire(); + const toggle = document.getElementById(TOGGLE_ID); + const panel = document.getElementById(PANEL_ID); + if (toggle) { + toggle.hidden = !available; + } + if (panel) { + panel.hidden = !available || !this.open; + } + } + + private setOpen(open: boolean): void { + this.open = open; + storeOpen(open); + const panel = document.getElementById(PANEL_ID); + if (panel) { + panel.hidden = !open; + } + document.getElementById(TOGGLE_ID)?.classList.toggle('active', open); + if (open) { + this.render(); + } + } + + // ── Rows ──────────────────────────────────────────────────────────── + + private render(): void { + const body = document.getElementById(BODY_ID); + if (!body || !this.outline || !this.open) { + return; + } + body.replaceChildren(...this.renderRows(this.outline, 0)); + } + + private renderRows(entry: HierarchyOutlineEntry, depth: number): HTMLElement[] { + if (!outlineMatches(entry, this.query)) { + return []; + } + const id = rowId(entry); + // A filter opens every row with a match beneath it. + const isOpen = this.query !== '' || this.expanded.has(id); + const row = document.createElement('div'); + row.className = 'workflow-hierarchy-outline-row'; + row.dataset.rowId = id; + row.setAttribute('role', 'treeitem'); + row.style.paddingLeft = `${6 + depth * 14}px`; + row.classList.toggle('current', id === this.currentPath.join('/')); + + const twisty = document.createElement('span'); + twisty.className = 'workflow-hierarchy-outline-twisty codicon'; + if (entry.children.length > 0) { + twisty.classList.add(isOpen ? 'codicon-chevron-down' : 'codicon-chevron-right'); + twisty.dataset.toggle = id; + } + row.appendChild(twisty); + + const label = document.createElement('span'); + label.className = 'workflow-hierarchy-outline-label'; + label.textContent = rowLabel(entry); + row.appendChild(label); + + if (entry.path.length > 0) { + const type = document.createElement('span'); + type.className = 'workflow-hierarchy-outline-type'; + type.textContent = entry.workflowName; + row.appendChild(type); + } + const file = foreignFileName(entry, this.outline?.sourceUri); + if (file) { + const chip = document.createElement('span'); + chip.className = 'workflow-hierarchy-outline-file'; + chip.textContent = file; + row.appendChild(chip); + } + const meta = document.createElement('span'); + meta.className = 'workflow-hierarchy-outline-meta'; + if (entry.error) { + meta.classList.add('error'); + meta.textContent = 'error'; + row.title = entry.error; + } else if (entry.truncated) { + meta.textContent = '…'; + row.title = 'Not expanded: it nests itself, or is too deep'; + } else { + const parts = [ + typeof entry.nodeCount === 'number' ? `${entry.nodeCount} nodes` : undefined, + entry.children.length > 0 ? `${entry.children.length} nested` : undefined + ].filter(Boolean); + meta.textContent = parts.join(' · '); + } + row.appendChild(meta); + + const rows: HTMLElement[] = [row]; + if (isOpen) { + for (const child of entry.children) { + rows.push(...this.renderRows(child, depth + 1)); + } + } + return rows; + } + + private onRowClick(event: MouseEvent, isDouble: boolean): void { + const target = event.target as HTMLElement; + const toggle = target.closest('[data-toggle]'); + if (toggle && !isDouble) { + const id = toggle.dataset.toggle!; + if (this.expanded.has(id)) { + this.expanded.delete(id); + } else { + this.expanded.add(id); + } + this.render(); + return; + } + const row = target.closest('.workflow-hierarchy-outline-row'); + const entry = row && this.outline + ? entryAt(this.outline, row.dataset.rowId ? row.dataset.rowId.split('/') : []) + : undefined; + if (!entry) { + return; + } + if (this.clickTimer) { + clearTimeout(this.clickTimer); + this.clickTimer = undefined; + } + if (isDouble) { + this.openView(entry.path); + return; + } + this.clickTimer = setTimeout(() => { + this.clickTimer = undefined; + this.look(entry); + }, DOUBLE_CLICK_GRACE_MS); + } + + // ── Navigation ────────────────────────────────────────────────────── + + /** Select and center an instance's node in the view containing it. */ + private look(entry: HierarchyOutlineEntry): void { + if (entry.path.length === 0) { + this.openView([]); + return; + } + const parentPath = entry.path.slice(0, -1); + const name = entry.path[entry.path.length - 1]; + if (parentPath.join('/') === this.currentPath.join('/')) { + this.selectAndCenter(name); + return; + } + this.pendingFocus = name; + this.openView(parentPath); + } + + /** Open the view at `path`, at the trail a drill-down would give it. */ + private openView(path: string[]): void { + if (!this.outline) { + return; + } + const rootUri = this.editorContext.sourceUri ?? this.outline.sourceUri ?? ''; + const trail = trailTo(this.outline, rootUri, path); + if (!trail) { + return; + } + const target = trail[trail.length - 1]; + void this.dispatcher.dispatch(RequestModelAction.create({ + requestId: `outline-${Date.now()}`, + options: { + sourceUri: target.sourceUri, + diagramType: this.editorContext.diagramType, + networkName: target.workflowName, + 'wf:navTrail': JSON.stringify(trail) + } + }) as never); + } + + private resolvePendingFocus(): void { + const name = this.pendingFocus; + if (!name) { + return; + } + this.pendingFocus = undefined; + // After the view has rendered it. + setTimeout(() => this.selectAndCenter(name), 0); + } + + private selectAndCenter(instanceName: string): void { + const id = findNodeIdByEntityName(this.root, instanceName); + if (!id) { + return; + } + void this.dispatcher.dispatch(SelectAction.create({ selectedElementsIDs: [id] }) as never); + void this.dispatcher.dispatch(CenterAction.create([id], { animate: true, retainZoom: true }) as never); + } +} + +/** The navigation trail a model root carries (`wf:navTrail`, JSON or a list). */ +export function trailOfRoot(root: unknown): Array<{ workflowName: string; workflowInstanceName?: string }> { + const raw = (root as { args?: Record })?.args?.['wf:navTrail']; + try { + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(parsed) ? parsed.filter(c => c && typeof c.workflowName === 'string') : []; + } catch { + return []; + } +} + +/** The id of the node whose entity (instance) name is `name`, in a model root. */ +export function findNodeIdByEntityName(root: unknown, name: string): string | undefined { + const stack: any[] = [root]; + while (stack.length > 0) { + const element = stack.pop(); + if (!element) { + continue; + } + if (element.args?.[WorkflowDiagramMetadata.ENTITY_NAME] === name) { + return element.id; + } + for (const child of element.children ?? []) { + stack.push(child); + } + } + return undefined; +} diff --git a/packages/diagram-client/src/stock-features.module.ts b/packages/diagram-client/src/stock-features.module.ts index 194580e..d2dd964 100644 --- a/packages/diagram-client/src/stock-features.module.ts +++ b/packages/diagram-client/src/stock-features.module.ts @@ -23,6 +23,7 @@ import { WhitespaceRootPropertiesMouseListener } from './whitespace-root-propert import { WorkflowElkLiveDragRouter } from './elk-live-drag-router'; import { WorkflowChangeBoundsTool } from './change-bounds-drag-threshold'; import { LibavoidEdgeRouter } from './libavoid-edge-router'; +import { HierarchyOutlinePanel } from './hierarchy-outline'; import { preloadLibavoid } from './libavoid-loader'; import { WorkflowPromptLabelEditAction, @@ -64,6 +65,12 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound bind(TYPES.ISelectionListener).toService(PropertyPanel); bind(TYPES.IGModelRootListener).toService(PropertyPanel); + // The hierarchy outline, fed by the root model's `wf:hierarchy`. Shown only + // when the product navigates nested workflows in place and the model + // carries a hierarchy. + bind(HierarchyOutlinePanel).toSelf().inSingletonScope(); + bind(TYPES.IGModelRootListener).toService(HierarchyOutlinePanel); + // Register integrated chat panel (slides up from bottom of diagram). // Bound as an IDiagramStartup so GLSP eagerly instantiates it on diagram // startup — a plain singleton binding is never resolved, so the panel and diff --git a/packages/diagram-client/test/container-parity.test.ts b/packages/diagram-client/test/container-parity.test.ts index 3f25731..e82fbec 100644 --- a/packages/diagram-client/test/container-parity.test.ts +++ b/packages/diagram-client/test/container-parity.test.ts @@ -15,12 +15,13 @@ * * 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 - * five additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live + * six additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live * routing tier), `ChangeBoundsTool -> WorkflowChangeBoundsTool` (the mouse-drag * threshold) and `IDiagramStartup -> WorkflowPaletteIconStartup` (draws the * palette icons a product contributed), the `dialogram.rerunFromHere` action handler * (resumes the shown run before a node's last firing) and the `dialogram.openInOwnEditor` - * one (opens a nested workflow's file as a root of its own) — plus one change: `label:boundary:type` is bound to + * one (opens a nested workflow's file as a root of its own), and the hierarchy outline panel + * (bound to itself and as a model-root listener) — plus one change: `label:boundary:type` is bound to * 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 9bb2fc0..4137239 100644 --- a/packages/diagram-client/test/fixtures/container-parity.baseline.json +++ b/packages/diagram-client/test/fixtures/container-parity.baseline.json @@ -385,5 +385,16 @@ "op": "modelElement", "typeId": "port:output", "view": "WorkflowOutputPortView" + }, + { + "id": "HierarchyOutlinePanel", + "op": "bind", + "scope": "singleton", + "to": "self" + }, + { + "id": "Symbol(stub.IGModelRootListener)", + "op": "bind", + "to": "service:HierarchyOutlinePanel" } ] diff --git a/packages/diagram-client/test/hierarchy-outline.test.ts b/packages/diagram-client/test/hierarchy-outline.test.ts new file mode 100644 index 0000000..4a96d93 --- /dev/null +++ b/packages/diagram-client/test/hierarchy-outline.test.ts @@ -0,0 +1,102 @@ +/** + * The hierarchy outline: a tree of every workflow instance under the editor's + * root, after mlir-viewer's file outline. Its helpers are pure, so the tree, + * the filter and the trail a row opens are tested headlessly. + */ +import { describe, expect, it } from 'vitest'; +import { WorkflowDiagramMetadata, type HierarchyOutlineEntry } from '@dialogram/shared'; +import { + ancestorIds, + entryAt, + foreignFileName, + outlineMatches, + pathOfTrail, + rowId, + rowLabel, + trailTo +} from '../src/hierarchy-outline-tree'; +import { findNodeIdByEntityName, trailOfRoot } from '../src/hierarchy-outline'; + +const TOP = 'file:///w/top.py'; +const BLOCK = 'file:///w/layers/block.py'; +const outline: HierarchyOutlineEntry = { + path: [], workflowName: 'top', sourceUri: TOP, nodeCount: 2, + children: [ + { + path: ['b1'], workflowName: 'block', sourceUri: BLOCK, nodeCount: 3, + children: [{ path: ['b1', 'leaf'], workflowName: 'stage', sourceUri: BLOCK, children: [] }] + }, + { path: ['b2'], workflowName: 'block', sourceUri: BLOCK, error: 'ValueError: no', children: [] } + ] +}; + +describe('a row', () => { + it('is named by its instance, the root by its workflow', () => { + expect(rowLabel(outline)).toBe('top'); + expect(rowLabel(outline.children[0])).toBe('b1'); + expect(rowId(outline.children[0].children[0])).toBe('b1/leaf'); + }); + + it('shows the file defining it only when it is not the root’s', () => { + expect(foreignFileName(outline.children[0], TOP)).toBe('block.py'); + expect(foreignFileName(outline, TOP)).toBeUndefined(); + }); +}); + +describe('filtering the outline', () => { + it('keeps a match and the rows on the way to it', () => { + expect(outlineMatches(outline, 'stage')).toBe(true); + expect(outlineMatches(outline.children[0], 'stage')).toBe(true); + expect(outlineMatches(outline.children[1], 'stage')).toBe(false); + }); + + it('matches instance names and workflow names alike', () => { + expect(outlineMatches(outline.children[1], 'b2')).toBe(true); + expect(outlineMatches(outline.children[1], 'block')).toBe(true); + expect(outlineMatches(outline.children[1], '')).toBe(true); + }); +}); + +describe('the view a row opens', () => { + it('is at the trail a drill-down would give it', () => { + expect(trailTo(outline, TOP, ['b1', 'leaf'])).toEqual([ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: BLOCK, workflowName: 'block', workflowInstanceName: 'b1' }, + { sourceUri: BLOCK, workflowName: 'stage', workflowInstanceName: 'leaf' } + ]); + expect(trailTo(outline, TOP, [])).toEqual([{ sourceUri: TOP, workflowName: 'top' }]); + expect(entryAt(outline, ['b9'])).toBeUndefined(); + }); + + it('is highlighted, with its ancestors opened, when it is the view shown', () => { + const shown = [ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: BLOCK, workflowName: 'block', workflowInstanceName: 'b1' }, + { sourceUri: BLOCK, workflowName: 'stage', workflowInstanceName: 'leaf' } + ]; + expect(pathOfTrail(shown)).toEqual(['b1', 'leaf']); + expect(ancestorIds(['b1', 'leaf'])).toEqual(['', 'b1']); + }); + + it('takes its trail from the model root the server echoes it on', () => { + expect(pathOfTrail(trailOfRoot({ args: { 'wf:navTrail': JSON.stringify([ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: BLOCK, workflowName: 'block', workflowInstanceName: 'b1' } + ]) } }))).toEqual(['b1']); + expect(trailOfRoot({ args: {} })).toEqual([]); + }); +}); + +describe('looking at an instance', () => { + it('finds its node in the view by its entity name', () => { + const root = { + id: 'graph', + children: [ + { id: 'a', args: { [WorkflowDiagramMetadata.ENTITY_NAME]: 'other' } }, + { id: 'scope', children: [{ id: 'b1-node', args: { [WorkflowDiagramMetadata.ENTITY_NAME]: 'b1' } }] } + ] + }; + expect(findNodeIdByEntityName(root, 'b1')).toBe('b1-node'); + expect(findNodeIdByEntityName(root, 'missing')).toBeUndefined(); + }); +}); diff --git a/packages/diagram-server/src/model/graph-gmodel-source.ts b/packages/diagram-server/src/model/graph-gmodel-source.ts index a6e8541..48a89ae 100644 --- a/packages/diagram-server/src/model/graph-gmodel-source.ts +++ b/packages/diagram-server/src/model/graph-gmodel-source.ts @@ -55,6 +55,7 @@ export type PyGraphDocument = { graph: PyGraph; partial?: boolean; errors?: Array<{ message?: string; file?: string; line?: number; column?: number }>; + hierarchy?: unknown; }; export type GModelElement = { @@ -909,6 +910,11 @@ export class GraphGModelSource { if (Array.isArray(doc.errors) && doc.errors.length > 0) { graphArgs['wf:errors'] = doc.errors; } + // The outline of the hierarchy this view is part of, as JSON: plain + // data the client renders without laying anything out. + if (doc.hierarchy && typeof doc.hierarchy === 'object') { + graphArgs['wf:hierarchy'] = JSON.stringify(doc.hierarchy); + } if (workflowDefinitionSource) { const definitionRange = { start: { line: Math.max(0, workflowDefinitionSource.line - 1), character: 0 }, diff --git a/packages/diagram-server/test/hierarchy-root-arg.test.ts b/packages/diagram-server/test/hierarchy-root-arg.test.ts new file mode 100644 index 0000000..7dbde4d --- /dev/null +++ b/packages/diagram-server/test/hierarchy-root-arg.test.ts @@ -0,0 +1,26 @@ +/** + * The hierarchy a view is part of reaches the client on the root model, as + * `wf:hierarchy`: plain JSON the outline renders without laying anything out. + */ +import { describe, expect, it } from 'vitest'; +import { GraphGModelSource, type PyGraphDocument } from '../src/model/graph-gmodel-source'; + +const doc = (hierarchy?: unknown): PyGraphDocument => ({ + version: '1', + graph: { id: 'root', nodes: [], edges: [] }, + ...(hierarchy ? { hierarchy } : {}) +}); + +describe('the hierarchy on the root model', () => { + it('is published as JSON when the graph carries one', () => { + const outline = { path: [], workflowName: 'top', children: [{ path: ['b1'], workflowName: 'block', children: [] }] }; + const root: any = new GraphGModelSource({} as any).transform(doc(outline)).graph; + + expect(JSON.parse(root.args['wf:hierarchy'])).toEqual(outline); + }); + + it('is absent otherwise', () => { + const root: any = new GraphGModelSource({} as any).transform(doc()).graph; + expect(root.args?.['wf:hierarchy']).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/diagram-seams.ts b/packages/shared/src/diagram-seams.ts index ba5f9b0..8058d00 100644 --- a/packages/shared/src/diagram-seams.ts +++ b/packages/shared/src/diagram-seams.ts @@ -80,6 +80,28 @@ export interface GraphDocument { errors?: Array<{ message?: string; file?: string; line?: number; column?: number }>; /** Normalized diagnostics; storage publishes these verbatim (no product-shaped walking). */ diagnostics?: GraphDiagnostic[]; + /** + * The hierarchy this graph is part of, from its root: the outline a client + * shows to navigate it. Published on the root model as `wf:hierarchy`. + */ + hierarchy?: HierarchyOutlineEntry; +} + +/** + * One instance in a workflow hierarchy, for an outline: where it is (its + * `path` of instance names from the root, `[]` for the root), what it + * instantiates, the file defining it, its node in the parent's graph, and how + * big it is. No graph: an outline is light enough to ride on every view. + */ +export interface HierarchyOutlineEntry { + path: string[]; + workflowName: string; + sourceUri?: string; + nodeId?: string; + nodeCount?: number; + error?: string; + truncated?: boolean; + children: HierarchyOutlineEntry[]; } export interface ModelSourceOptions { diff --git a/packages/sidecar-toolkit/src/server/cli-graph-model-source.ts b/packages/sidecar-toolkit/src/server/cli-graph-model-source.ts index f34bd1b..981c481 100644 --- a/packages/sidecar-toolkit/src/server/cli-graph-model-source.ts +++ b/packages/sidecar-toolkit/src/server/cli-graph-model-source.ts @@ -18,7 +18,12 @@ import type { GraphSourceAnalysis, ModelSourceOptions } from '@dialogram/shared'; -import { GRAPH_SOURCE_URI_ARG } from '@dialogram/diagram-server/server/graph-load-request-options'; +import { + GRAPH_SOURCE_URI_ARG, + NAV_TRAIL_ARG, + parseNavigationTrailArg +} from '@dialogram/diagram-server/server/graph-load-request-options'; +import { HierarchyCache, findInstance, outlineOf } from './hierarchy-cache'; import { extractWorkflowDefinitionNames, normalizeSourceUriKey } from './source-analysis'; import { type SidecarRuntimeConfig, @@ -45,6 +50,9 @@ export class CliGraphModelSource implements DiagramModelSource { private readonly planGraphCache = new Map(); private static readonly PLAN_GRAPH_CACHE_MAX = 24; + /** One export per hierarchy root, when the runtime can make one. */ + private readonly hierarchyCache = new HierarchyCache((rootFile, rootWorkflow) => this.exportHierarchy(rootFile, rootWorkflow)); + constructor( private readonly cfg: SidecarRuntimeConfig, private readonly fallback: SidecarModelSource @@ -79,6 +87,11 @@ export class CliGraphModelSource implements DiagramModelSource { : this.fallback.analysis.pickDefaultWorkflowName(workflowFilePath, localWorkflowNames); const selectedWorkflowName = requestedWorkflowName ?? defaultWorkflowName; + const fromHierarchy = await this.viewFromHierarchy(sourceUri, workflowFilePath, selectedWorkflowName, opts); + if (fromHierarchy) { + return this.fallback.attachDiagnostics({ ...fromHierarchy, resolvedOptions: opts }); + } + const args: string[] = [...cliInvocation.argsPrefix, ...this.cfg.cliGraphArgs(workflowFilePath, selectedWorkflowName)]; if (selectedWorkflowName) { args.push('--workflow', selectedWorkflowName); @@ -197,6 +210,73 @@ export class CliGraphModelSource implements DiagramModelSource { return nodeIdentitiesFromDoc(JSON.parse(result.stdout)); } + /** + * The view, read from its hierarchy's export, with the outline attached. + * + * The root is the trail's first crumb, or the view itself without a trail; + * the view is the instance at the trail's instance path. Anything that does + * not line up -- no export, an instance that did not elaborate, unsaved + * text being previewed -- leaves the view to a plan of its own. + */ + private async viewFromHierarchy( + sourceUri: string, + workflowFilePath: string, + workflowName: string | undefined, + opts: Record + ): Promise { + if (!this.cfg.cliHierarchyArgs || typeof opts.content === 'string') { + return undefined; + } + const trail = parseNavigationTrailArg(opts[NAV_TRAIL_ARG]); + const root = trail.length > 0 + ? { file: URI.parse(trail[0].sourceUri).fsPath, workflow: trail[0].workflowName } + : workflowName ? { file: workflowFilePath, workflow: workflowName } : undefined; + if (!root) { + return undefined; + } + const exported = await this.hierarchyCache.get(root.file, root.workflow); + if (!exported) { + return undefined; + } + const path = trail.slice(1).map(crumb => crumb.workflowInstanceName ?? crumb.workflowName); + const outline = outlineOf(exported.hierarchy); + if (path.length === 0) { + if (workflowFilePath !== root.file || (workflowName && workflowName !== root.workflow)) { + return undefined; + } + const { hierarchy: _tree, ...rootDoc } = exported; + return { ...(rootDoc as unknown as GraphDocument), hierarchy: outline }; + } + const instance = findInstance(exported.hierarchy, path); + if ( + !instance?.graph + || (workflowName && instance.workflowName !== workflowName) + || (instance.sourcePath && URI.file(instance.sourcePath).fsPath !== workflowFilePath) + ) { + return undefined; + } + return { ...(instance.graph as GraphDocument), hierarchy: outline }; + } + + /** Runs the runtime's hierarchy export; its stdout, or undefined when it fails. */ + private async exportHierarchy(rootFile: string, rootWorkflow: string): Promise { + if (!this.cfg.cliHierarchyArgs) { + return undefined; + } + const cliInvocation = this.getCliInvocation(); + const result = await runChildProcess( + cliInvocation.cmd, + [...cliInvocation.argsPrefix, ...this.cfg.cliHierarchyArgs(rootFile, rootWorkflow)], + { timeoutMs: this.graphLoadTimeoutMs(rootFile) } + ); + const failure = describeChildFailure(cliInvocation.cmd, result); + if (failure || result.stdout.trim() === '') { + console.warn(`[CliGraphModelSource] Hierarchy export failed for ${rootFile}#${rootWorkflow}: ${failure ?? 'no output'}`); + return undefined; + } + return result.stdout; + } + /** Produce a well-formed failure document when acquisition is impossible; delegates to the fallback. */ createFailureDocument(sourceUri: string, message: string): GraphDocument { return this.fallback.createFailureDocument(sourceUri, message); diff --git a/packages/sidecar-toolkit/src/server/hierarchy-cache.ts b/packages/sidecar-toolkit/src/server/hierarchy-cache.ts new file mode 100644 index 0000000..d6127ec --- /dev/null +++ b/packages/sidecar-toolkit/src/server/hierarchy-cache.ts @@ -0,0 +1,149 @@ +/** + * One export of a whole workflow hierarchy, cached per root. + * + * Navigating a hierarchy in one editor, every view is a workflow somewhere + * under the editor's root. Asking the runtime for each view on its own costs a + * process per drill-down, and elaborates each nested workflow standalone + * rather than as the instance its parent built. A runtime that can export the + * whole hierarchy at once (`cliHierarchyArgs`) is asked once per root; every + * view, and the outline, are read from that export. + * + * The export is kept while every file it came from is unchanged on disk -- the + * root's and each nested workflow's -- and taken again once any of them is not. + */ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import { URI } from 'vscode-uri'; +import type { HierarchyOutlineEntry } from '@dialogram/shared'; + +/** One instance in a runtime's hierarchy export. */ +export interface HierarchyEntry { + path: string[]; + workflowName: string; + sourcePath?: string; + nodeId?: string; + nodeCount?: number; + graph?: unknown; + error?: string; + truncated?: boolean; + children: HierarchyEntry[]; +} + +/** A runtime's hierarchy export: the root's graph document plus the tree. */ +export type HierarchyExport = Record & { graph: unknown; hierarchy: HierarchyEntry }; + +/** The instance at `path` (instance names from the root), if the tree has it. */ +export function findInstance(root: HierarchyEntry, path: string[]): HierarchyEntry | undefined { + let current: HierarchyEntry | undefined = root; + for (const name of path) { + current = current?.children.find(child => child.path[child.path.length - 1] === name); + if (!current) { + return undefined; + } + } + return current; +} + +/** + * The tree without its graphs: what the outline shows. Light enough to ride + * on every view's model, however large the hierarchy's graphs are. + */ +export function outlineOf(entry: HierarchyEntry): HierarchyOutlineEntry { + return { + path: entry.path, + workflowName: entry.workflowName, + ...(entry.sourcePath ? { sourceUri: URI.file(entry.sourcePath).toString() } : {}), + ...(entry.nodeId ? { nodeId: entry.nodeId } : {}), + ...(typeof entry.nodeCount === 'number' ? { nodeCount: entry.nodeCount } : {}), + ...(entry.error ? { error: entry.error } : {}), + ...(entry.truncated ? { truncated: true } : {}), + children: entry.children.map(outlineOf) + }; +} + +/** Every file the hierarchy was elaborated from. */ +export function hierarchyFiles(entry: HierarchyEntry): string[] { + const files = new Set(); + const walk = (node: HierarchyEntry): void => { + if (node.sourcePath) { + files.add(node.sourcePath); + } + node.children.forEach(walk); + }; + walk(entry); + return [...files]; +} + +export async function fileHash(filePath: string): Promise { + try { + return createHash('sha256').update(await fs.readFile(filePath)).digest('hex'); + } catch { + return undefined; + } +} + +interface CachedHierarchy { + exported: HierarchyExport; + hashes: Map; +} + +export class HierarchyCache { + private readonly entries = new Map(); + private static readonly MAX = 8; + + constructor( + /** Runs the runtime's hierarchy export; its stdout, or undefined on failure. */ + private readonly run: (rootFile: string, rootWorkflow: string) => Promise, + private readonly hash: (filePath: string) => Promise = fileHash + ) {} + + /** The hierarchy rooted at `rootWorkflow` in `rootFile`, exported anew if any of its files changed. */ + async get(rootFile: string, rootWorkflow: string): Promise { + const key = `${rootFile}::${rootWorkflow}`; + const cached = this.entries.get(key); + if (cached && (await this.unchanged(cached))) { + this.entries.delete(key); + this.entries.set(key, cached); + return cached.exported; + } + + const stdout = await this.run(rootFile, rootWorkflow); + if (stdout === undefined) { + this.entries.delete(key); + return undefined; + } + let exported: HierarchyExport; + try { + exported = JSON.parse(stdout); + } catch { + return undefined; + } + if (!exported || typeof exported !== 'object' || !exported.graph || !exported.hierarchy) { + return undefined; + } + + const files = new Set([rootFile, ...hierarchyFiles(exported.hierarchy)]); + const hashes = new Map(); + for (const file of files) { + hashes.set(file, await this.hash(file)); + } + this.entries.set(key, { exported, hashes }); + while (this.entries.size > HierarchyCache.MAX) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) { + break; + } + this.entries.delete(oldest); + } + return exported; + } + + private async unchanged(cached: CachedHierarchy): Promise { + for (const [file, hash] of cached.hashes) { + if ((await this.hash(file)) !== hash) { + return false; + } + } + return true; + } +} diff --git a/packages/sidecar-toolkit/src/server/sidecar-runtime-config.ts b/packages/sidecar-toolkit/src/server/sidecar-runtime-config.ts index dfce1d1..6378c6f 100644 --- a/packages/sidecar-toolkit/src/server/sidecar-runtime-config.ts +++ b/packages/sidecar-toolkit/src/server/sidecar-runtime-config.ts @@ -208,6 +208,13 @@ export interface SidecarRuntimeConfig { graphAcquisition: 'sidecar-export' | 'cli-plan'; /** argv builder for the 'cli-plan' path, e.g. (file) => ['plan', file, '--format', 'graph', '--best-effort']. */ cliGraphArgs?: (file: string, requestedWorkflow?: string) => string[]; + /** + * The runtime arguments that export the whole hierarchy rooted at + * `rootWorkflow` in `file` (product syntax). With it, a view of a nested + * workflow, and the outline, are read from one cached export per root + * instead of a plan per view (see `hierarchy-cache.ts`). + */ + cliHierarchyArgs?: (file: string, rootWorkflow: string) => string[]; /** Product-specific label prefix for the sidecar-export failure message (the toolkit carries no * product literal); defaults to a neutral 'Graph export failed' when unset. */ graphExportFailureLabel?: string; diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index fdebaf2..8f4a447 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -130,6 +130,13 @@ export interface SidecarProfileInput { acceptedOperationPrefixes: string[]; graphAcquisition: 'sidecar-export' | 'cli-plan'; cliGraphArgs?: (file: string, requestedWorkflow?: string) => string[]; + /** + * The runtime arguments that export the whole hierarchy rooted at + * `rootWorkflow` in `file` (product syntax). With it, a view of a nested + * workflow, and the outline, are read from one cached export per root + * instead of a plan per view (see `hierarchy-cache.ts`). + */ + cliHierarchyArgs?: (file: string, rootWorkflow: string) => string[]; graphExportFailureLabel?: string; /** * The run arguments that resume the run in `runDir` at `step` of its queue @@ -229,6 +236,7 @@ function sidecarRuntimeConfig(input: SidecarProfileInput): SidecarRuntimeConfig acceptedOperationPrefixes: input.acceptedOperationPrefixes, graphAcquisition: input.graphAcquisition, cliGraphArgs: input.cliGraphArgs, + cliHierarchyArgs: input.cliHierarchyArgs, graphExportFailureLabel: input.graphExportFailureLabel, undoLabelSuffix: input.undoLabelSuffix, createNodeStrings: input.createNodeStrings, diff --git a/packages/sidecar-toolkit/test/hierarchy-cache.test.ts b/packages/sidecar-toolkit/test/hierarchy-cache.test.ts new file mode 100644 index 0000000..2bb2b18 --- /dev/null +++ b/packages/sidecar-toolkit/test/hierarchy-cache.test.ts @@ -0,0 +1,157 @@ +/** + * One export of a workflow hierarchy per root, and every view read from it. + * + * Navigating a hierarchy in one editor asked the runtime for each view on its + * own: a process per drill-down, each nested workflow elaborated standalone. + * With a runtime that exports the whole hierarchy, the root is exported once + * and kept while every file it came from is unchanged. + */ +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 { HierarchyCache, findInstance, outlineOf, type HierarchyEntry } from '../src/server/hierarchy-cache'; +import { CliGraphModelSource } from '../src/server/cli-graph-model-source'; + +const graph = (label: string) => ({ version: '1', graph: { id: `wf:${label}`, nodes: [{ id: label, label }], edges: [] } }); + +function tree(top: string, block: string): HierarchyEntry { + return { + path: [], workflowName: 'top', sourcePath: top, nodeCount: 2, + children: [ + { + path: ['b1'], workflowName: 'block', sourcePath: block, nodeId: 'node:root:b1', nodeCount: 1, + graph: graph('b1-view'), + children: [{ path: ['b1', 'leaf'], workflowName: 'leaf', sourcePath: block, graph: graph('leaf-view'), children: [] }] + }, + { path: ['b2'], workflowName: 'block', sourcePath: block, error: 'ValueError: no', children: [] } + ] + }; +} + +describe('the hierarchy tree', () => { + const root = tree('/w/top.py', '/w/block.py'); + + it('finds an instance by its path of instance names', () => { + expect(findInstance(root, ['b1', 'leaf'])?.workflowName).toBe('leaf'); + expect(findInstance(root, [])).toBe(root); + expect(findInstance(root, ['b9'])).toBeUndefined(); + }); + + it('outlines it without its graphs, files as URIs', () => { + const outline = outlineOf(root); + expect(JSON.stringify(outline)).not.toContain('b1-view'); + expect(outline.children[0]).toMatchObject({ path: ['b1'], nodeId: 'node:root:b1', sourceUri: URI.file('/w/block.py').toString() }); + expect(outline.children[1].error).toBe('ValueError: no'); + }); +}); + +describe('the cache', () => { + function cacheWith(hashes: Record) { + let runs = 0; + const cache = new HierarchyCache( + async () => { + runs += 1; + return JSON.stringify({ ...graph('top-view'), hierarchy: tree('/w/top.py', '/w/block.py') }); + }, + async file => hashes[file] + ); + return { cache, runs: () => runs }; + } + + it('exports a root once while its files are unchanged', async () => { + const { cache, runs } = cacheWith({ '/w/top.py': 'a', '/w/block.py': 'b' }); + + await cache.get('/w/top.py', 'top'); + await cache.get('/w/top.py', 'top'); + + expect(runs()).toBe(1); + }); + + it('exports it again when a nested workflow’s file changes', async () => { + const hashes = { '/w/top.py': 'a', '/w/block.py': 'b' }; + const { cache, runs } = cacheWith(hashes); + + await cache.get('/w/top.py', 'top'); + hashes['/w/block.py'] = 'b2'; + await cache.get('/w/top.py', 'top'); + + expect(runs()).toBe(2); + }); + + it('returns nothing for an export that failed or is not one', async () => { + expect(await new HierarchyCache(async () => undefined).get('/w/top.py', 'top')).toBeUndefined(); + expect(await new HierarchyCache(async () => '{"graph": {}}').get('/w/top.py', 'top')).toBeUndefined(); + }); +}); + +describe('a view read from the hierarchy', () => { + async function source() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'hierarchy-view-')); + const top = path.join(dir, 'top.py'); + const block = path.join(dir, 'block.py'); + await fs.writeFile(top, '@workflow\ndef top():\n pass\n'); + await fs.writeFile(block, '@workflow\ndef block():\n pass\n'); + let exports = 0; + const fallback: any = { + analysis: { pickDefaultWorkflowName: () => 'top' }, + attachDiagnostics: (doc: unknown) => doc + }; + const cfg: any = { + cliCommandDefault: 'runtime', + cliGraphArgs: () => ['plan'], + cliHierarchyArgs: (file: string, wf: string) => ['plan', file, '--workflow', wf, '--hierarchy'] + }; + const src: any = new CliGraphModelSource(cfg, fallback); + src.exportHierarchy = async () => { + exports += 1; + return JSON.stringify({ ...graph('top-view'), hierarchy: tree(top, block) }); + }; + const trail = (...crumbs: object[]) => JSON.stringify(crumbs); + return { src, top, block, trail, exports: () => exports }; + } + + it('serves the root and a nested view from one export, with the outline', async () => { + const { src, top, block, trail, exports } = await source(); + const topUri = URI.file(top).toString(); + const blockUri = URI.file(block).toString(); + + const rootDoc = await src.getGraph(topUri, { requestOptions: { networkName: 'top' } }); + const nestedDoc = await src.getGraph(blockUri, { + requestOptions: { + networkName: 'block', + 'wf:navTrail': trail( + { sourceUri: topUri, workflowName: 'top' }, + { sourceUri: blockUri, workflowName: 'block', workflowInstanceName: 'b1' } + ) + } + }); + + expect(rootDoc.graph.id).toBe('wf:top-view'); + expect(rootDoc.hierarchy.children.map((c: any) => c.path)).toEqual([['b1'], ['b2']]); + expect(nestedDoc.graph.id).toBe('wf:b1-view'); + expect(nestedDoc.hierarchy.workflowName).toBe('top'); + expect(exports()).toBe(1); + }); + + it('leaves an instance that did not elaborate to a plan of its own', async () => { + const { src, top, block, trail } = await source(); + const viewFromHierarchy = src.viewFromHierarchy.bind(src); + + const doc = await viewFromHierarchy(URI.file(block).toString(), block, 'block', { + 'wf:navTrail': trail( + { sourceUri: URI.file(top).toString(), workflowName: 'top' }, + { sourceUri: URI.file(block).toString(), workflowName: 'block', workflowInstanceName: 'b2' } + ) + }); + + expect(doc).toBeUndefined(); + }); + + it('leaves unsaved text being previewed to a plan of its own', async () => { + const { src, top } = await source(); + const doc = await src.viewFromHierarchy(URI.file(top).toString(), top, 'top', { content: 'unsaved' }); + expect(doc).toBeUndefined(); + }); +});