From 964556b45f764c1502a5b19ae684519835c3c83e Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Fri, 2 Oct 2026 11:30:39 +0200 Subject: [PATCH] A nested workflow's file, edited unsaved, previews in the views showing it Navigating in place, an editor can show a nested workflow defined in another file. Most of editing such a view already held: undo and redo save whatever file they change, and a save or an on-disk change refreshes every editor, which since in-place navigation reloads the view shown. Only live preview did not reach it -- it fired for an editor's own document alone -- so typing in the nested file's text editor changed nothing until a save. Now every editor showing that file is previewed with that file's text. The chat's turn context also says the diagram's tools act on the view's file, which is what the user sees. The proposal's section on editing nested views is corrected: an earlier draft said undo left such a file unsaved. Phase 3 of docs/proposals/single-editor-hierarchy.md. Claude-Session: https://claude.ai/code/session_015VK7fH1c4aKbexnq2QcuKU --- docs/proposals/single-editor-hierarchy.md | 43 ++++++------ .../src/extension/chat/chat-runtime.ts | 2 +- .../diagram/diagram-editor-provider.ts | 47 +++++++++++-- ...m-editor-provider-in-place-refresh.test.ts | 67 +++++++++++++++++++ 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/docs/proposals/single-editor-hierarchy.md b/docs/proposals/single-editor-hierarchy.md index ab3a99e..4fba5e9 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 and 2 are implemented (in-place navigation, the root's run, and the view in the chat's context). The GLSP-MCP root/view split, phases 3 to 5 and the outline are not. +**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. **Affects:** dialogram (most of it), wfpy (a hierarchy export), wfpy-ide (one profile flag). @@ -117,21 +117,22 @@ Editable, in the file that defines it, which the edit handlers already do. What the host has to add: - **Secondary documents never stay dirty.** The sidecar writes an edit straight - to disk. Undo and redo instead apply a `WorkspaceEdit` to the file's - `TextDocument` and leave it unsaved. That is fine for the root, whose custom - editor saves it, and wrong for a nested file: the next sidecar edit and the - next `wfpy plan` read the stale file on disk. After an undo or redo applies to - a document other than the root, the host saves that document. The rule is - simple: a file shown inside another file's editor is never left dirty. -- **The root editor refreshes when any file in its trail changes.** Today the - external-change and live-preview refreshes fire only for a document that has - its own client. The root editor watches every file in its current trail, and - refreshes its view at the trail when one changes, whether by an edit made here, - in a text editor, or by git. + to disk, and undo and redo save the document they change, whatever file it is + (`ReversibleWorkspaceEditCommand.persist`). A nested file is therefore never + left dirty by the diagram. This needed nothing new: an earlier draft of this + section said undo left it unsaved, which was wrong. +- **The root editor refreshes when a file it shows changes.** A save, or a + change on disk from git, a formatter or the agent, already refreshed every + open diagram. Since phase 1 that refresh reloads the view an editor shows, at + its trail. Only unsaved typing in a nested file's text editor previewed + nothing, because live preview fired only for an editor's own document. It now + also previews in every editor showing that file, with that file's text + (`previewInViewsShowing`). - **Concurrent editors.** The same nested file may also be open in its own editor ("Open in its own editor", or because someone opened it directly). Both - edit through the sidecar's optimistic concurrency (content-hash revisions), so - a stale edit is refused rather than lost, and each editor refreshes from disk. + refresh from disk on every save. Undo and redo refuse when the document no + longer holds the text they left, so an edit made in one editor is not undone + over one made in the other. ### 5. Layout per instance, owned by the root @@ -208,17 +209,19 @@ comes from the same export, and is the cheap part of it: names, files and sizes. shown. For another file it reloads from disk, since live-preview content is the root's text. A node menu item opens a nested workflow's file in its own editor. -2. **The root runs, the chat knows the view.** *Done, except the GLSP-MCP - split.* The diagram context carries `rootSourceUri`, `rootWorkflowName` and +2. **The root runs, the chat knows the view.** *Done.* The diagram context carries `rootSourceUri`, `rootWorkflowName` and the trail. ▶ Run, ⟲ and "Rerun from Here" use the root, and inside a nested view "Rerun from Here" reruns the root-level instance the view is in. The refresh during and after a run reloads the editor's current view (file, workflow, trail) rather than jumping to the root. The chat panel sends the view's trail with the selection and each message, and the turn context names - the nested view and the file defining it. -3. **Editing nested views.** Secondary documents saved after undo/redo, the root - editor watching every file in its trail, and the concurrency behaviour - tested. + the nested view and the file defining it. The diagram's GLSP-MCP tools act on + the view's file, which is what the user sees. The turn context says so, rather + than splitting the tools between root and view. +3. **Editing nested views.** *Done.* Undo and redo already saved any file they + 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. 5. **The hierarchy export, the cache and the outline.** wfpy `--hierarchy`, diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts index cdad878..426f171 100644 --- a/packages/extension-core/src/extension/chat/chat-runtime.ts +++ b/packages/extension-core/src/extension/chat/chat-runtime.ts @@ -122,7 +122,7 @@ export function viewContextText(trail: ViewCrumb[] | undefined): string | undefi return ( `The diagram is showing a nested workflow, not the root: ${path}.` + (file ? ` It is defined in ${file}.` : "") + - " Selected nodes belong to this view." + " Selected nodes belong to this view, and the diagram tools act on its file." ); } diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index 5b365f4..57ce851 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -520,9 +520,11 @@ export class WorkflowEditorProvider extends GlspEditorProvider { const uriString = this.canonicalizeUriString(event.document.uri); const clientId = this.uriToClientId.get(uriString); - - // Only process if this document has an open diagram + + // Not an editor's own document, but possibly a nested workflow's file + // shown in another editor's view: preview it there. if (!clientId) { + this.previewInViewsShowing(event); return; } @@ -552,6 +554,36 @@ export class WorkflowEditorProvider extends GlspEditorProvider { this.changeDebounceTimers.set(uriString, timer); } + /** + * Live preview for a file that is not an editor's document but is shown in + * one: a nested workflow navigated into in place. Each editor showing it is + * refreshed with that file's current text, debounced as its own document is. + */ + protected previewInViewsShowing(event: vscode.TextDocumentChangeEvent): void { + if (event.contentChanges.length === 0) { + return; + } + const changed = this.canonicalizeUriString(event.document.uri); + for (const [editorUri, editorClientId] of this.uriToClientId) { + const shown = this.uriToRefreshContext.get(editorUri)?.shownSourceUri; + if (!shown || this.canonicalizeUriString(shown) !== changed) { + continue; + } + const timerKey = `${editorUri}::${changed}`; + const existing = this.changeDebounceTimers.get(timerKey); + if (existing) { + clearTimeout(existing); + } + this.changeDebounceTimers.set(timerKey, setTimeout(() => { + this.changeDebounceTimers.delete(timerKey); + this.dispatchModelRefresh(editorClientId, editorUri, { + content: event.document.getText(), + contentUri: changed + }); + }, WorkflowEditorProvider.CHANGE_DEBOUNCE_MS)); + } + } + /** * Dispatch a RequestModelAction to refresh the diagram. * @@ -562,7 +594,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { protected dispatchModelRefresh( clientId: string, sourceUri: string, - options: { content?: string; forceReloadFromDisk?: boolean } = {} + options: { content?: string; forceReloadFromDisk?: boolean; contentUri?: string } = {} ): void { const refreshContext = this.getRefreshContext(sourceUri); @@ -573,9 +605,14 @@ export class WorkflowEditorProvider extends GlspEditorProvider { const shownSourceUri = refreshContext?.shownSourceUri; const showsOtherFile = typeof shownSourceUri === 'string' && this.canonicalizeUriString(shownSourceUri) !== this.canonicalizeUriString(sourceUri); - const effectiveOptions = showsOtherFile && options.content !== undefined + // `content` is the text of `contentUri` when given, of the editor's + // document otherwise. It previews the view only if it is the shown file's. + const { contentUri, ...rest } = options; + const contentFile = this.canonicalizeUriString(contentUri ?? sourceUri); + const shownFile = this.canonicalizeUriString(showsOtherFile ? shownSourceUri! : sourceUri); + const effectiveOptions = rest.content !== undefined && contentFile !== shownFile ? { forceReloadFromDisk: true } - : options; + : rest; // Create a RequestModelAction to refresh the diagram const action = RequestModelAction.create({ diff --git a/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts b/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts index 0a87b67..e226337 100644 --- a/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts +++ b/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts @@ -85,3 +85,70 @@ describe('refreshing an editor that navigates in place', () => { expect(options.content).toBe('top.py text'); }); }); + +/** + * A nested workflow's file, edited in a text editor without saving: the views + * showing it preview the change, as an editor's own document always did. + */ +describe('live preview of a file shown in another editor', () => { + const change = (uri: vscode.Uri, text: string) => ({ + document: { uri, getText: () => text }, + contentChanges: [{ text: 'x' }] + }); + + it('refreshes the view showing it, with that file’s text', () => { + vi.useFakeTimers(); + try { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: CHILD.toString(), networkName: 'block' }); + + provider.handleDocumentChange(change(CHILD, 'block.py, unsaved')); + vi.runAllTimers(); + + expect(dispatched).toHaveLength(1); + const options = dispatched[0].action.options; + expect(dispatched[0].clientId).toBe('client-top'); + expect(options.sourceUri).toBe(CHILD.toString()); + expect(options.content).toBe('block.py, unsaved'); + expect(options.forceReloadFromDisk).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('leaves alone an editor that is not showing it', () => { + vi.useFakeTimers(); + try { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: ROOT.toString(), networkName: 'top' }); + + provider.handleDocumentChange(change(CHILD, 'block.py, unsaved')); + vi.runAllTimers(); + + expect(dispatched).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('a nested workflow’s file changed on disk', () => { + it('reloads the view showing it, at its trail', () => { + vi.useFakeTimers(); + try { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: CHILD.toString(), networkName: 'block', navTrail: trail }); + + provider.handleExternalFileChange(CHILD); + vi.runAllTimers(); + + expect(dispatched).toHaveLength(1); + const options = dispatched[0].action.options; + expect(options.sourceUri).toBe(CHILD.toString()); + expect(options['wf:navTrail']).toBe(trail); + expect(options.forceReloadFromDisk).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +});