From 93862db18db3bebe9ff3d502020c1ca44b4defb5 Mon Sep 17 00:00:00 2001 From: DAPPWEB Date: Tue, 13 Oct 2026 17:55:40 +0800 Subject: [PATCH] fix(vscode): preserve Flyde imports across file moves --- .../src/serdes/file-reference-renames.spec.ts | 68 ++++++ loader/src/serdes/file-reference-renames.ts | 102 +++++++++ loader/src/serdes/index.ts | 1 + vscode/src/editor/open-flyde-panel.ts | 3 + vscode/src/extension.ts | 2 + vscode/src/fileRenameParticipant.ts | 44 ++++ vscode/src/flydeEditor.ts | 95 +++++++- vscode/src/test/fileMoveImports.test.ts | 209 ++++++++++++++++++ vscode/src/test/testUtils.ts | 8 +- vscode/src/webview/VSCodeFlowEditor.tsx | 7 +- vscode/src/webview/bootstrap.ts | 3 +- vscode/src/webview/vscode-ports.ts | 4 +- 12 files changed, 528 insertions(+), 18 deletions(-) create mode 100644 loader/src/serdes/file-reference-renames.spec.ts create mode 100644 loader/src/serdes/file-reference-renames.ts create mode 100644 vscode/src/fileRenameParticipant.ts create mode 100644 vscode/src/test/fileMoveImports.test.ts diff --git a/loader/src/serdes/file-reference-renames.spec.ts b/loader/src/serdes/file-reference-renames.spec.ts new file mode 100644 index 000000000..d9e214204 --- /dev/null +++ b/loader/src/serdes/file-reference-renames.spec.ts @@ -0,0 +1,68 @@ +import { strict as assert } from "assert"; +import { resolve } from "path"; +import { parse } from "yaml"; +import { fileReferenceEditsForRenames, FileReferenceRename } from "./file-reference-renames"; + +const root = resolve("rename-fixture"); +const at = (path: string) => resolve(root, path); +const text = (reference: string, type = "file") => `# keep comment\nnode:\n instances:\n - source: { type: ${type}, data: '${reference}' } # import\n config: { note: '${reference}' }\n`; +const rewrite = (input: string, file: string, renames: FileReferenceRename[]) => + fileReferenceEditsForRenames(input, at(file), renames).sort((a, b) => b.start - a.start) + .reduce((content, edit) => content.slice(0, edit.start) + edit.text + content.slice(edit.end), input); +const move = (oldPath: string, newPath: string) => ({ oldPath: at(oldPath), newPath: at(newPath) }); +const source = (input: string) => parse(input).node.instances[0].source.data; + +describe("preserving file references across rename batches", () => { + it("replaces only a moved producer's YAML scalar", () => { + const before = text("./Child.flyde"); + const after = rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]); + assert.equal(source(after), "./parts/Child.flyde"); + assert.equal(after, before.replace("data: './Child.flyde'", 'data: "./parts/Child.flyde"')); + }); + it("rebases a moved importing flow", () => { + assert.equal(source(rewrite(text("./Child.flyde"), "Main.flyde", [move("Main.flyde", "flows/Main.flyde")])), "../Child.flyde"); + }); + it("uses both original paths for independently moved producer and consumer", () => { + assert.equal(source(rewrite(text("./Child.flyde"), "Main.flyde", [move("Main.flyde", "flows/Main.flyde"), move("Child.flyde", "parts/Child.flyde")])), "../parts/Child.flyde"); + }); + it("keeps a reference unchanged when its entire folder moves", () => { + const before = text("./Child.flyde"); + assert.equal(rewrite(before, "components/Main.flyde", [move("components", "parts")]), before); + }); + it("does not confuse a containing folder with a similarly named sibling", () => { + const before = text("./components-extra/Child.flyde"); + assert.equal(rewrite(before, "Main.flyde", [move("components", "parts")]), before); + }); + it("maps a batch once and chooses the most specific original source", () => { + assert.equal(source(rewrite(text("./components/Child.flyde"), "Main.flyde", [move("components", "parts"), move("components/Child.flyde", "Child2.flyde"), move("Child2.flyde", "Child3.flyde")])), "./Child2.flyde"); + }); + it("traverses nested inline flows while leaving package/custom references alone", () => { + const before = `node:\n instances:\n - source:\n type: inline\n data:\n instances:\n - source: { type: file, data: ./Child.flyde }\n - source: { type: package, data: ./Child.flyde }\n - source: { type: custom, data: ./Child.flyde }\n`; + const after = parse(rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")])); + assert.deepEqual(after.node.instances[0].source.data.instances.map((instance: any) => instance.source.data), ["./parts/Child.flyde", "./Child.flyde", "./Child.flyde"]); + }); + it("updates relative legacy import-map keys only", () => { + const before = "imports:\n './Child.flyde': [Child]\n '@flyde/nodes': [Value]\nnode: {}\n"; + assert.deepEqual(parse(rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")])).imports, { "./parts/Child.flyde": ["Child"], "@flyde/nodes": ["Value"] }); + }); + it("preserves absolute imports and safely quotes YAML punctuation in new names", () => { + const next = at("parts/Child: #2.flyde"); + assert.equal(source(rewrite(text(at("Child.flyde")), "Main.flyde", [move("Child.flyde", "parts/Child: #2.flyde")])), next); + }); + it("rejects malformed YAML instead of guessing at source ranges", () => { + assert.throws(() => rewrite("node: [", "Main.flyde", [move("Child.flyde", "parts/Child.flyde")])); + }); + for (const style of [">-", "|-"]) { + it(`preserves the next property and header comment after a ${style} block scalar`, () => { + const before = `node:\n instances:\n - source:\n type: file\n data: ${style} # path comment\n ./Child.flyde\n inputConfig: {}\n`; + const after = rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]); + assert.equal(source(after), "./parts/Child.flyde"); + assert.deepEqual(parse(after).node.instances[0].inputConfig, {}); + assert(after.includes('# path comment\n inputConfig: {}')); + }); + } + it("does not rewrite anchors whose aliases may be used by unrelated data", () => { + const before = "node:\n instances:\n - source: {type: file, data: &path ./Child.flyde}\n config: {note: *path}\n"; + assert.equal(rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]), before); + }); +}); diff --git a/loader/src/serdes/file-reference-renames.ts b/loader/src/serdes/file-reference-renames.ts new file mode 100644 index 000000000..44430278c --- /dev/null +++ b/loader/src/serdes/file-reference-renames.ts @@ -0,0 +1,102 @@ +import { dirname, isAbsolute, relative, resolve, sep } from "path"; +import { isMap, isScalar, isSeq, parseDocument } from "yaml"; + +export interface FileReferenceRename { + oldPath: string; + newPath: string; +} + +export interface FileReferenceEdit { + start: number; + end: number; + text: string; +} + +/** Map original paths once: a rename batch is simultaneous, not a chain. */ +export function renameFileReferencePath(file: string, renames: readonly FileReferenceRename[]): string { + const rename = [...renames].sort((a, b) => b.oldPath.length - a.oldPath.length).find(({ oldPath }) => { + const suffix = relative(oldPath, file); + return suffix === "" || (suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)); + }); + return rename ? resolve(rename.newPath, relative(rename.oldPath, file)) : file; +} + +/** Change only file-source scalars, preserving comments and unrelated YAML. */ +export function fileReferenceEditsForRenames( + text: string, + flowPath: string, + renames: readonly FileReferenceRename[] +): FileReferenceEdit[] { + const document = parseDocument(text); + if (document.errors.length) { + throw new Error(document.errors[0].message); + } + const edits: FileReferenceEdit[] = []; + const nextFlowPath = renameFileReferencePath(flowPath, renames); + + const update = (scalar: unknown) => { + if (!isScalar(scalar) || typeof scalar.value !== "string" || !scalar.range || scalar.anchor) { + return; + } + const original = scalar.value; + const target = resolve(dirname(flowPath), original); + const nextTarget = renameFileReferencePath(target, renames); + if (resolve(dirname(nextFlowPath), original) === nextTarget) { + return; + } + const next = isAbsolute(original) + ? nextTarget + : relative(dirname(nextFlowPath), nextTarget).split(sep).join("/"); + const value = !isAbsolute(original) && original.startsWith("./") && !next.startsWith(".") ? `./${next}` : next; + let replacement = JSON.stringify(value); + if (scalar.type === "BLOCK_FOLDED" || scalar.type === "BLOCK_LITERAL") { + const originalText = text.slice(scalar.range[0], scalar.range[1]); + const header = originalText.split(/\r?\n/, 1)[0]; + const comment = header.indexOf("#"); + if (comment >= 0) { replacement += ` ${header.slice(comment)}`; } + // Block scalar ranges consume the final newline before the next property. + if (originalText.endsWith("\r\n")) { replacement += "\r\n"; } + else if (originalText.endsWith("\n")) { replacement += "\n"; } + } + edits.push({ start: scalar.range[0], end: scalar.range[1], text: replacement }); + }; + + const visitNode = (node: unknown) => { + if (!isMap(node)) { + return; + } + const instances = node.get("instances", true); + if (!isSeq(instances)) { + return; + } + for (const instance of instances.items) { + if (!isMap(instance)) { + continue; + } + const source = instance.get("source", true); + if (!isMap(source)) { + continue; + } + if (source.get("type") === "file") { + update(source.get("data", true)); + } else if (source.get("type") === "inline") { + visitNode(source.get("data", true)); + } + } + }; + + if (isMap(document.contents)) { + visitNode(document.contents.get("node", true)); + // Older flows retain relative paths as import-map keys until migration. + const imports = document.contents.get("imports", true); + if (isMap(imports)) { + for (const pair of imports.items) { + if (isScalar(pair.key) && typeof pair.key.value === "string" && + (pair.key.value.startsWith(".") || isAbsolute(pair.key.value))) { + update(pair.key); + } + } + } + } + return edits; +} diff --git a/loader/src/serdes/index.ts b/loader/src/serdes/index.ts index 5e1098cb0..f71dcb1f1 100644 --- a/loader/src/serdes/index.ts +++ b/loader/src/serdes/index.ts @@ -1,3 +1,4 @@ export * from "./serialize"; export * from "./deserialize"; export * from "./migrations"; +export * from "./file-reference-renames"; diff --git a/vscode/src/editor/open-flyde-panel.ts b/vscode/src/editor/open-flyde-panel.ts index fc48b7093..f6ee725af 100644 --- a/vscode/src/editor/open-flyde-panel.ts +++ b/vscode/src/editor/open-flyde-panel.ts @@ -10,6 +10,7 @@ export type WebviewContentParams = { initialNode: EditorVisualNode; webviewId: string; darkMode: boolean; + renderGeneration?: string; }; const isDev = process.env.MODE === "dev"; @@ -71,6 +72,7 @@ export async function getWebviewContent(params: WebviewContentParams) { initialNode, executionId, darkMode, + renderGeneration, } = params; const stylePath = vscode.Uri.joinPath(extensionUri, "media", "style.css"); @@ -96,6 +98,7 @@ export async function getWebviewContent(params: WebviewContentParams) { executionId, hasOpenAiToken, darkMode, + renderGeneration, }; const serializedBootstrapData = Buffer.from( JSON.stringify(bootstrapData) diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727f..37de59cf1 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -15,6 +15,7 @@ import { activateReporter, reportEvent, analytics } from "./analytics"; import { showFirstRunPrivacyNotice, showPrivacySettings } from "./privacyNotice"; import { Template, getTemplates, scaffoldTemplate } from "./templateUtils"; +import { registerFileRenameParticipant } from "./fileRenameParticipant"; // the application insights key (also known as instrumentation key) @@ -54,6 +55,7 @@ export function activate(context: vscode.ExtensionContext) { const mainOutputChannel = vscode.window.createOutputChannel("Flyde"); const debugOutputChannel = vscode.window.createOutputChannel("Flyde (Debug)"); + context.subscriptions.push(registerFileRenameParticipant(mainOutputChannel)); let currentTheme = vscode.window.activeColorTheme; diff --git a/vscode/src/fileRenameParticipant.ts b/vscode/src/fileRenameParticipant.ts new file mode 100644 index 000000000..0d9a69a57 --- /dev/null +++ b/vscode/src/fileRenameParticipant.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; +import { fileReferenceEditsForRenames } from "@flyde/loader/dist/server"; + +export function registerFileRenameParticipant(output: Pick): vscode.Disposable { + return vscode.workspace.onWillRenameFiles(event => { + event.waitUntil((async () => { + const uris = new Map((await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**")) + .map(uri => [uri.toString(), uri])); + for (const document of vscode.workspace.textDocuments) { + if (!document.isUntitled && document.uri.path.endsWith(".flyde")) { + uris.set(document.uri.toString(), document.uri); + } + } + const edit = new vscode.WorkspaceEdit(); + const snapshots = new Map(); + for (const uri of uris.values()) { + if (event.token.isCancellationRequested) { + return; + } + const renames = event.files.filter(({ oldUri, newUri }) => + oldUri.scheme === uri.scheme && newUri.scheme === uri.scheme && + oldUri.authority === uri.authority && newUri.authority === uri.authority + ).map(({ oldUri, newUri }) => ({ oldPath: oldUri.fsPath, newPath: newUri.fsPath })); + if (!renames.length) { + continue; + } + try { + const document = await vscode.workspace.openTextDocument(uri); + snapshots.set(document, document.version); + for (const replacement of fileReferenceEditsForRenames(document.getText(), uri.fsPath, renames)) { + edit.replace(uri, new vscode.Range(document.positionAt(replacement.start), document.positionAt(replacement.end)), replacement.text); + } + } catch (error) { + output.appendLine(`Unable to update imports in ${uri.fsPath}: ${error}`); + } + } + if (event.token.isCancellationRequested || [...snapshots].some(([document, version]) => document.version !== version)) { + output.appendLine("Skipped Flyde import updates because documents changed during the rename scan."); + return; + } + return edit; + })()); + }); +} diff --git a/vscode/src/flydeEditor.ts b/vscode/src/flydeEditor.ts index c0ab2496a..092d0a2fd 100644 --- a/vscode/src/flydeEditor.ts +++ b/vscode/src/flydeEditor.ts @@ -11,7 +11,7 @@ import { resolveEditorInstance, resolveEditorNode, } from "@flyde/loader"; -import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server"; +import { deserializeFlow, serializeFlow, fileReferenceEditsForRenames, renameFileReferencePath } from "@flyde/loader/dist/server"; import { FlydeFlow, @@ -132,7 +132,7 @@ export class FlydeEditorEditorProvider vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; const fileRoot = firstWorkspace ? firstWorkspace.uri.fsPath : ""; - const relative = path.relative(fileRoot, document.fileName); + let relative = path.relative(fileRoot, document.fileName); let lastSaveBy = ""; @@ -171,10 +171,21 @@ export class FlydeEditorEditorProvider const webviewId = `wv-${(Date.now() + randomInt(999)).toString(32)}`; - const fullDocumentPath = document.uri.fsPath; - const executionId = fullDocumentPath; + let fullDocumentPath = document.uri.fsPath; + let executionId = fullDocumentPath; + let renderNumber = 0; + let activeRenderGeneration = ""; + let renameInProgress = false; + let renamedDocumentUri = document.uri; + let renameCancellation: vscode.Disposable | undefined; const renderWebview = async () => { + const renderGeneration = `${webviewId}-${++renderNumber}`; + activeRenderGeneration = renderGeneration; + fullDocumentPath = document.uri.fsPath; + relative = path.relative(fileRoot, fullDocumentPath); + const snapshotDocument = document; + const snapshotVersion = document.version; const raw = document.getText(); const fileName = ( document.uri.fsPath.split(path.sep).pop() ?? "Default" @@ -219,7 +230,7 @@ export class FlydeEditorEditorProvider } // used to avoid triggering "onChange" of the same webview - webviewPanel.webview.html = await getWebviewContent({ + const html = await getWebviewContent({ extensionUri: this.context.extensionUri, relativeFile: relative, port: this.params.port, @@ -228,7 +239,14 @@ export class FlydeEditorEditorProvider webviewId, executionId, darkMode: this.params.darkMode, + renderGeneration, }); + if (activeRenderGeneration !== renderGeneration) { return; } + if (document !== snapshotDocument || document.version !== snapshotVersion) { + // Edits made while the manifest loads must not become stale editor state. + return renderWebview(); + } + webviewPanel.webview.html = html; lastFlow = initialFlow as FlydeFlow; @@ -240,14 +258,14 @@ export class FlydeEditorEditorProvider vscode.commands.executeCommand("setContext", "flyde.flowLoaded", true); - const _debugger = createEditorClient( + let _debugger = createEditorClient( `http://localhost:${this.params.port}`, executionId ); const flowRunner = new EmbeddedFlowRunner(this.params.port); - _debugger.onBatchedEvents((events) => { + const onDebuggerEvents: Parameters["onBatchedEvents"]>[0] = (events) => { const { mainOutputChannel, debugOutputChannel } = this.params; events.forEach((event) => { debugOutputChannel.appendLine(formatEvent(event)); @@ -257,7 +275,8 @@ export class FlydeEditorEditorProvider } } }); - }); + }; + _debugger.onBatchedEvents(onDebuggerEvents); let didFocusOutput = false; @@ -346,6 +365,9 @@ export class FlydeEditorEditorProvider break; } case "setFlow": { + if (renameInProgress || event.params.renderGeneration !== activeRenderGeneration) { + throw new Error("The flow changed after a file move or editor refresh. Please retry your edit."); + } const { flow } = event.params; const node = flow.node as EditorVisualNode; @@ -389,7 +411,11 @@ export class FlydeEditorEditorProvider const range = new vscode.Range(0, 0, document.lineCount, 0); edit.replace(document.uri, range, serialized); lastSaveBy = webviewId; - await vscode.workspace.applyEdit(edit); + try { + await vscode.workspace.applyEdit(edit); + } finally { + lastSaveBy = ""; + } messageResponse(event, undefined); break; } @@ -742,7 +768,7 @@ export class FlydeEditorEditorProvider webviewPanel.onDidChangeViewState(async (e) => { // when the webview is refocused, it needs to receive a new html to contain the correct initial flow - if (e.webviewPanel.active) { + if (e.webviewPanel.active && !renameInProgress) { await renderWebview(); } }); @@ -751,9 +777,10 @@ export class FlydeEditorEditorProvider (e) => { const isSameUri = e.document.uri.toString() === document.uri.toString(); - if (isSameUri && lastSaveBy !== webviewId) { + if (isSameUri && lastSaveBy !== webviewId && !renameInProgress) { const raw = document.getText(); const flow: FlydeFlow = deserializeFlow(raw, fullDocumentPath); + lastFlow = flow; webviewPanel.webview.postMessage({ type: "onExternalFlowChange", requestId: "TODO-cuid", @@ -764,9 +791,55 @@ export class FlydeEditorEditorProvider } ); + const beforeRenameSubscription = vscode.workspace.onWillRenameFiles(event => { + const renames = event.files.filter(({ oldUri, newUri }) => oldUri.scheme === document.uri.scheme && oldUri.authority === document.uri.authority && + newUri.scheme === document.uri.scheme && newUri.authority === document.uri.authority) + .map(({ oldUri, newUri }) => ({ oldPath: oldUri.fsPath, newPath: newUri.fsPath })); + try { + const renamedPath = renameFileReferencePath(fullDocumentPath, renames); + renamedDocumentUri = document.uri.with({ path: document.uri.scheme === "file" ? vscode.Uri.file(renamedPath).path : renamedPath.split(path.sep).join("/") }); + if (renamedPath !== fullDocumentPath || fileReferenceEditsForRenames(document.getText(), fullDocumentPath, renames).length) { + // Invalidate already-posted saves before the participant changes text. + const previousGeneration = activeRenderGeneration; + activeRenderGeneration = ""; + renameInProgress = true; + renameCancellation?.dispose(); + renameCancellation = event.token.onCancellationRequested(() => { + if (renameInProgress) { + renameInProgress = false; + activeRenderGeneration = previousGeneration; + renamedDocumentUri = document.uri; + } + }); + } + } catch { /* A malformed text buffer cannot be safely refreshed. */ } + }); + const afterRenameSubscription = vscode.workspace.onDidRenameFiles(async () => { + if (renameInProgress) { + renameCancellation?.dispose(); + try { + document = await vscode.workspace.openTextDocument(renamedDocumentUri); + if (executionId !== document.uri.fsPath) { + executionId = document.uri.fsPath; + _debugger.destroy(); + _debugger = createEditorClient(`http://localhost:${this.params.port}`, executionId); + _debugger.onBatchedEvents(onDebuggerEvents); + } + await renderWebview(); + } finally { + lastSaveBy = ""; + renameInProgress = false; + } + } + }); + // Make sure we get rid of the listener when our editor is closed. webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); + beforeRenameSubscription.dispose(); + afterRenameSubscription.dispose(); + renameCancellation?.dispose(); + _debugger.destroy(); }); } diff --git a/vscode/src/test/fileMoveImports.test.ts b/vscode/src/test/fileMoveImports.test.ts new file mode 100644 index 000000000..44babfe02 --- /dev/null +++ b/vscode/src/test/fileMoveImports.test.ts @@ -0,0 +1,209 @@ +import assert = require("assert"); +import * as vscode from "vscode"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { deserializeFlow, createServerReferencedNodeFinder } from "@flyde/loader/dist/server"; +import { eventually } from "@flyde/core"; +import { addNodeFromMenu, getInstances, openFlydeFile, waitForFlowEditor } from "./pageObjects"; +import sinon = require("sinon"); +import * as webviewContent from "../editor/open-flyde-panel"; + +suite("File move import integration", () => { + let root: string; + const fixtureRoots: string[] = []; + const child = "node:\n id: Child\n inputs: {}\n outputs: {}\n instances: []\n connections: []\n"; + const main = (reference: string) => `# preserved\nnode:\n id: Main\n inputs: {}\n outputs: {}\n instances:\n - id: child\n type: visual\n nodeId: Child\n source: {type: file, data: '${reference}'} # source comment\n inputConfig: {}\n pos: {x: 0, y: 0}\n connections: []\n`; + const uri = (name: string) => vscode.Uri.file(join(root, name)); + const write = (name: string, contents: string) => { + mkdirSync(dirname(join(root, name)), { recursive: true }); + writeFileSync(join(root, name), contents); + }; + const rename = async (oldName: string, newName: string) => { + mkdirSync(dirname(join(root, newName)), { recursive: true }); + const edit = new vscode.WorkspaceEdit(); + edit.renameFile(uri(oldName), uri(newName)); + assert(await vscode.workspace.applyEdit(edit), "VS Code rename must succeed"); + }; + const resolves = (document: vscode.TextDocument) => { + const flow = deserializeFlow(document.getText(), document.uri.fsPath); + const node = createServerReferencedNodeFinder(document.uri.fsPath)(flow.node.instances[0]); + assert.equal(node.id, "Child", "real loader must resolve the moved dependency"); + }; + + suiteSetup(async () => { + const extension = vscode.extensions.getExtension("flyde.flyde-vscode"); + assert(extension, "development extension must be installed"); + await extension.activate(); + }); + setup(() => { + root = mkdtempSync(join(tmpdir(), "flyde-move-imports-")); + fixtureRoots.push(root); + }); + teardown(async () => { + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + // Save participants' edits before deleting this isolated fixture. + for (const document of vscode.workspace.textDocuments) { + if (document.uri.fsPath.startsWith(root)) { await document.save(); } + } + }); + suiteTeardown(async () => { + // VS Code local history copies saved fixtures asynchronously. + await new Promise(resolve => setTimeout(resolve, 500)); + for (const directory of fixtureRoots) { rmSync(directory, { recursive: true, force: true }); } + }); + + test("moving an imported flow preserves resolution and YAML comments", async () => { + const before = main("./Child.flyde"); + write("Child.flyde", child); + write("Main.flyde", before); + const document = await vscode.workspace.openTextDocument(uri("Main.flyde")); + resolves(document); + await rename("Child.flyde", "parts/Child.flyde"); + resolves(document); + assert.equal(document.getText(), before.replace("data: './Child.flyde'", 'data: "./parts/Child.flyde"')); + await document.save(); + assert.equal(readFileSync(uri("Main.flyde").fsPath, "utf8"), document.getText()); + }); + test("a dirty importer keeps unsaved edits and is not implicitly saved", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + const document = await vscode.workspace.openTextDocument(uri("Main.flyde")); + const edit = new vscode.WorkspaceEdit(); + edit.insert(document.uri, new vscode.Position(0, 0), "# unsaved note\n"); + assert(await vscode.workspace.applyEdit(edit)); + await rename("Child.flyde", "parts/Child.flyde"); + assert(document.getText().startsWith("# unsaved note\n")); + assert(document.isDirty); + assert.equal(readFileSync(document.uri.fsPath, "utf8"), main("./Child.flyde")); + resolves(document); + }); + test("moving the importer rebases its imports at the original URI", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + await vscode.workspace.openTextDocument(uri("Main.flyde")); + await rename("Main.flyde", "flows/Main.flyde"); + const document = await vscode.workspace.openTextDocument(uri("flows/Main.flyde")); + resolves(document); + assert(document.getText().includes('data: "../Child.flyde"')); + }); + test("folder moves update descendants without matching prefix siblings", async () => { + write("parts/Child.flyde", child); + write("parts-extra/Child.flyde", child); + write("Main.flyde", main("./parts/Child.flyde")); + write("Other.flyde", main("./parts-extra/Child.flyde")); + const mainDocument = await vscode.workspace.openTextDocument(uri("Main.flyde")); + const otherDocument = await vscode.workspace.openTextDocument(uri("Other.flyde")); + const otherBefore = otherDocument.getText(); + await rename("parts", "library"); + resolves(mainDocument); + assert.equal(otherDocument.getText(), otherBefore); + }); + test("one malformed neighbor does not prevent fixing a valid flow", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + write("Invalid.flyde", "node: ["); + const document = await vscode.workspace.openTextDocument(uri("Main.flyde")); + const invalid = await vscode.workspace.openTextDocument(uri("Invalid.flyde")); + await rename("Child.flyde", "parts/Child.flyde"); + resolves(document); + assert.equal(invalid.getText(), "node: ["); + }); + test("an open visual editor keeps renamed imports through another visual save", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + const document = await vscode.workspace.openTextDocument(uri("Main.flyde")); + await openFlydeFile(document.uri.fsPath); + console.log("move regression: opened original panel"); + await waitForFlowEditor(); + console.log("move regression: editor ready, saving first visual edit"); + await addNodeFromMenu("Value"); + console.log("move regression: first edit submitted"); + await eventually(() => assert.equal(deserializeFlow(document.getText(), document.uri.fsPath).node.instances.length, 2), 5000); + await rename("Child.flyde", "parts/Child.flyde"); + console.log("move regression: renamed dependency"); + await waitForFlowEditor(10000); + await eventually(async () => assert((await getInstances()).some(instance => instance.textContent?.includes("Child"))), 5000); + await addNodeFromMenu("Value"); + console.log("move regression: second edit submitted"); + await eventually(() => assert.equal(deserializeFlow(document.getText(), document.uri.fsPath).node.instances.length, 3), 5000); + resolves(document); + const source = deserializeFlow(document.getText(), document.uri.fsPath).node.instances[0].source; + assert(source.type === "file"); + assert.equal(source.data, "./parts/Child.flyde"); + }); + test("an open importing flow can move and save from the same visual panel", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + await openFlydeFile(uri("Main.flyde").fsPath); + await waitForFlowEditor(); + await rename("Main.flyde", "flows/Main.flyde"); + await waitForFlowEditor(10000); + await addNodeFromMenu("Value"); + const document = await vscode.workspace.openTextDocument(uri("flows/Main.flyde")); + await eventually(() => assert.equal(deserializeFlow(document.getText(), document.uri.fsPath).node.instances.length, 2), 5000); + resolves(document); + const source = deserializeFlow(document.getText(), document.uri.fsPath).node.instances[0].source; + assert(source.type === "file"); + assert.equal(source.data, "../Child.flyde"); + }); + test("cancelling the rename token leaves the existing visual editor able to save", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + const document = await vscode.workspace.openTextDocument(uri("Main.flyde")); + const registration = sinon.spy(vscode.workspace, "onWillRenameFiles"); + const cancellation = new vscode.CancellationTokenSource(); + try { + await openFlydeFile(document.uri.fsPath); + await waitForFlowEditor(); + const listener = registration.lastCall.args[0]; + assert.equal(typeof listener, "function"); + listener({files: [{oldUri: uri("Child.flyde"), newUri: uri("parts/Child.flyde")}], token: cancellation.token, waitUntil: () => {}}); + cancellation.cancel(); + await addNodeFromMenu("Value"); + await eventually(() => assert.equal(deserializeFlow(document.getText(), document.uri.fsPath).node.instances.length, 2), 5000); + resolves(document); + } finally { + registration.restore(); + cancellation.dispose(); + } + }); + test("an edit made during rename refresh survives the next visual save", async () => { + write("Child.flyde", child); + write("Main.flyde", main("./Child.flyde")); + const document = await vscode.workspace.openTextDocument(uri("Main.flyde")); + await openFlydeFile(document.uri.fsPath); + await waitForFlowEditor(); + const originalContent = webviewContent.getWebviewContent; + let release: (() => void) | undefined; + let entered: (() => void) | undefined; + const enteredManifest = new Promise(resolve => { entered = resolve; }); + const continueManifest = new Promise(resolve => { release = resolve; }); + let delayed = false; + const manifest = sinon.stub(webviewContent, "getWebviewContent").callsFake(async params => { + const contents = await originalContent(params); + if (!delayed) { + delayed = true; + entered!(); + await continueManifest; + } + return contents; + }); + try { + await rename("Child.flyde", "parts/Child.flyde"); + await enteredManifest; + const edit = new vscode.WorkspaceEdit(); + edit.insert(document.uri, new vscode.Position(3, 0), " description: Edited during refresh\n"); + assert(await vscode.workspace.applyEdit(edit)); + release!(); + await waitForFlowEditor(10000); + await addNodeFromMenu("Value"); + await eventually(() => assert.equal(deserializeFlow(document.getText(), document.uri.fsPath).node.instances.length, 2), 5000); + assert.equal(deserializeFlow(document.getText(), document.uri.fsPath).node.description, "Edited during refresh"); + resolves(document); + } finally { + release!(); + manifest.restore(); + } + }); +}); diff --git a/vscode/src/test/testUtils.ts b/vscode/src/test/testUtils.ts index cff37806a..c7aa686d0 100644 --- a/vscode/src/test/testUtils.ts +++ b/vscode/src/test/testUtils.ts @@ -31,8 +31,14 @@ export async function webviewTestingCommand< ): Promise { const webview = await getLastWebview(); return new Promise((res, rej) => { - webview.onDidReceiveMessage((message) => { + const timeout = setTimeout(() => { + subscription.dispose(); + rej(new Error(`Webview did not respond to ${command}`)); + }, 2000); + const subscription = webview.onDidReceiveMessage((message) => { if (message.type === "__webviewTestingResponse") { + clearTimeout(timeout); + subscription.dispose(); if (message.error) { rej(message.error); } else { diff --git a/vscode/src/webview/VSCodeFlowEditor.tsx b/vscode/src/webview/VSCodeFlowEditor.tsx index db8871760..19a38ae19 100644 --- a/vscode/src/webview/VSCodeFlowEditor.tsx +++ b/vscode/src/webview/VSCodeFlowEditor.tsx @@ -50,7 +50,8 @@ export const VSCodeFlowEditor: React.FC = ({ port, relativeFile, executionId, - darkMode + darkMode, + renderGeneration }) => { // Check if we're in test mode (either dummy data or vscode test environment) const isDummyMode = new URLSearchParams(window.location.search).get('dummy') === 'true'; @@ -78,7 +79,7 @@ export const VSCodeFlowEditor: React.FC = ({ const lastChangeReason = useRef(""); const didMount = useRef(false); - const ports = useMemo(() => createVsCodePorts(), []); + const ports = useMemo(() => createVsCodePorts(renderGeneration), [renderGeneration]); const connectToRemoteDebugger = useCallback((url: string) => { const newClient = createEditorClient(url, executionId); @@ -204,4 +205,4 @@ export const VSCodeFlowEditor: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/vscode/src/webview/bootstrap.ts b/vscode/src/webview/bootstrap.ts index 4d67a7729..d54df211a 100644 --- a/vscode/src/webview/bootstrap.ts +++ b/vscode/src/webview/bootstrap.ts @@ -7,6 +7,7 @@ export interface BootstrapData { executionId: string; hasOpenAiToken: boolean; darkMode: boolean; + renderGeneration?: string; } export const getBootstrapData = (): BootstrapData | null => { @@ -181,4 +182,4 @@ export const getBootstrapData = (): BootstrapData | null => { console.error("Failed to parse bootstrap data:", error); return null; } -}; \ No newline at end of file +}; diff --git a/vscode/src/webview/vscode-ports.ts b/vscode/src/webview/vscode-ports.ts index 9b5d8b86e..df43a9c34 100644 --- a/vscode/src/webview/vscode-ports.ts +++ b/vscode/src/webview/vscode-ports.ts @@ -46,7 +46,7 @@ export const postMessageCallback = ( }); }; -export const createVsCodePorts = (): EditorPorts => { +export const createVsCodePorts = (renderGeneration?: string): EditorPorts => { return { prompt: ({ text, defaultValue }) => { return postMessageCallback("prompt", { text, defaultValue }); @@ -61,7 +61,7 @@ export const createVsCodePorts = (): EditorPorts => { return postMessageCallback("readFlow", dto); }, setFlow: async (dto) => { - return postMessageCallback("setFlow", dto); + return postMessageCallback("setFlow", { ...dto, renderGeneration }); }, generateNodeFromPrompt: async (dto) => { return postMessageCallback("generateNodeFromPrompt", dto);