From 1f450c3fa64b8b496d84ee6a1930b9903dbdc99e Mon Sep 17 00:00:00 2001 From: mdp28 <212812676+mdp28@users.noreply.github.com> Date: Fri, 15 May 2026 16:11:16 +1000 Subject: [PATCH] Fix Flyde imports after file moves --- vscode/src/extension.ts | 9 + vscode/src/flowFileReferences.ts | 178 +++++++++++++ vscode/src/test/flowFileReferences.test.ts | 279 +++++++++++++++++++++ vscode/src/updateImportsOnFileMove.ts | 82 ++++++ 4 files changed, 548 insertions(+) create mode 100644 vscode/src/flowFileReferences.ts create mode 100644 vscode/src/test/flowFileReferences.test.ts create mode 100644 vscode/src/updateImportsOnFileMove.ts diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..76cc9760 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 { updateImportsOnFileMove } from "./updateImportsOnFileMove"; // the application insights key (also known as instrumentation key) @@ -106,6 +107,14 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("flyde.openAsText", openAsTextHandler) ); + context.subscriptions.push( + vscode.workspace.onDidRenameFiles((event) => { + void updateImportsOnFileMove(event).catch((error) => { + console.warn("Failed to update Flyde imports after file rename", error); + }); + }) + ); + function getWorkspaceRootPath(): vscode.Uri | undefined { // Check if there is an open workspace if ( diff --git a/vscode/src/flowFileReferences.ts b/vscode/src/flowFileReferences.ts new file mode 100644 index 00000000..dc355c1f --- /dev/null +++ b/vscode/src/flowFileReferences.ts @@ -0,0 +1,178 @@ +import * as path from "path"; + +import { FlydeFlow, NodeInstance, VisualNode } from "@flyde/core"; + +export interface RenamePath { + oldPath: string; + newPath: string; +} + +const normalizePath = (filePath: string) => path.resolve(filePath); + +const isSameOrInside = (candidatePath: string, rootPath: string) => { + const relativePath = path.relative(rootPath, candidatePath); + + return ( + relativePath === "" || + (!!relativePath && + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath)) + ); +}; + +const applyRenameMapping = ( + filePath: string, + renames: readonly RenamePath[] +) => { + const normalizedPath = normalizePath(filePath); + let bestMatch: RenamePath | undefined; + let bestMatchLength = -1; + + for (const rename of renames) { + const oldPath = normalizePath(rename.oldPath); + + if ( + oldPath.length > bestMatchLength && + isSameOrInside(normalizedPath, oldPath) + ) { + bestMatch = rename; + bestMatchLength = oldPath.length; + } + } + + if (!bestMatch) { + return normalizedPath; + } + + const relativeChildPath = path.relative( + normalizePath(bestMatch.oldPath), + normalizedPath + ); + + return path.resolve(normalizePath(bestMatch.newPath), relativeChildPath); +}; + +const getPathBeforeRename = ( + currentPath: string, + renames: readonly RenamePath[] +) => { + const reversedRenames = renames.map((rename) => ({ + oldPath: rename.newPath, + newPath: rename.oldPath, + })); + + return applyRenameMapping(currentPath, reversedRenames); +}; + +export const toFlydeFileSourcePath = ( + fromFlowPath: string, + targetPath: string +) => { + const relativePath = path.relative(path.dirname(fromFlowPath), targetPath); + const normalizedRelativePath = relativePath.replace(/\\/g, "/"); + + if (path.isAbsolute(relativePath)) { + return normalizedRelativePath; + } + + if ( + normalizedRelativePath === ".." || + normalizedRelativePath.startsWith("../") || + normalizedRelativePath.startsWith("./") + ) { + return normalizedRelativePath; + } + + return `./${normalizedRelativePath}`; +}; + +const hasFileSource = ( + instance: NodeInstance +): instance is NodeInstance & { source: { type: "file"; data: string } } => { + return ( + instance.source?.type === "file" && typeof instance.source.data === "string" + ); +}; + +const updateInstanceFileReferences = ( + instance: NodeInstance, + previousFlowPath: string, + currentFlowPath: string, + renames: readonly RenamePath[] +): boolean => { + let changed = false; + + if (hasFileSource(instance)) { + const previousTargetPath = path.resolve( + path.dirname(previousFlowPath), + instance.source.data + ); + const currentTargetPath = applyRenameMapping(previousTargetPath, renames); + const nextSourcePath = toFlydeFileSourcePath( + currentFlowPath, + currentTargetPath + ); + + if ( + (previousFlowPath !== currentFlowPath || + previousTargetPath !== currentTargetPath) && + instance.source.data !== nextSourcePath + ) { + instance.source.data = nextSourcePath; + changed = true; + } + } + + if ( + instance.source?.type === "inline" && + instance.source.data && + typeof instance.source.data === "object" + ) { + changed = + updateVisualNodeFileReferences( + instance.source.data, + previousFlowPath, + currentFlowPath, + renames + ) || changed; + } + + return changed; +}; + +const updateVisualNodeFileReferences = ( + node: VisualNode, + previousFlowPath: string, + currentFlowPath: string, + renames: readonly RenamePath[] +): boolean => { + return node.instances.reduce((changed, instance) => { + return ( + updateInstanceFileReferences( + instance, + previousFlowPath, + currentFlowPath, + renames + ) || changed + ); + }, false); +}; + +export const updateFlowFileReferences = ( + flow: FlydeFlow, + currentFlowPath: string, + renames: readonly RenamePath[] +): boolean => { + const normalizedCurrentFlowPath = normalizePath(currentFlowPath); + const previousFlowPath = getPathBeforeRename( + normalizedCurrentFlowPath, + renames + ); + + return updateVisualNodeFileReferences( + flow.node, + previousFlowPath, + normalizedCurrentFlowPath, + renames + ); +}; diff --git a/vscode/src/test/flowFileReferences.test.ts b/vscode/src/test/flowFileReferences.test.ts new file mode 100644 index 00000000..9dd17019 --- /dev/null +++ b/vscode/src/test/flowFileReferences.test.ts @@ -0,0 +1,279 @@ +import * as path from "path"; +import assert = require("assert"); + +import { FlydeFlow } from "@flyde/core"; +import { deserializeFlow } from "@flyde/loader/dist/server"; +import { + RenamePath, + updateFlowFileReferences, +} from "../flowFileReferences"; + +const createFlow = (instances: any[]): FlydeFlow => ({ + node: { + id: "ParentFlow", + inputs: {}, + outputs: {}, + inputsPosition: {}, + outputsPosition: {}, + connections: [], + instances, + }, +}); + +const visualFileInstance = (sourcePath: string) => ({ + id: "visual-child", + nodeId: "ChildFlow", + type: "visual", + inputConfig: {}, + pos: { x: 0, y: 0 }, + source: { + type: "file", + data: sourcePath, + }, +}); + +const codeFileInstance = (sourcePath: string) => ({ + id: "code-child", + nodeId: "CodeChild", + type: "code", + inputConfig: {}, + config: {}, + pos: { x: 0, y: 0 }, + source: { + type: "file", + data: sourcePath, + }, +}); + +const updateFlow = ( + flow: FlydeFlow, + currentFlowPath: string, + renames: readonly RenamePath[] +) => { + const changed = updateFlowFileReferences(flow, currentFlowPath, renames); + assert.strictEqual(changed, true); +}; + +suite("flowFileReferences", () => { + test("updates flows that import a moved visual flow", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = createFlow([visualFileInstance("./ChildFlow.flyde")]); + + updateFlow(flow, parentPath, [ + { + oldPath: path.join(root, "ChildFlow.flyde"), + newPath: path.join(root, "nested", "ChildFlow.flyde"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./nested/ChildFlow.flyde" + ); + }); + + test("updates file imports inside a moved flow", () => { + const root = path.resolve("workspace"); + const flow = createFlow([visualFileInstance("./ChildFlow.flyde")]); + + updateFlow(flow, path.join(root, "nested", "ParentFlow.flyde"), [ + { + oldPath: path.join(root, "ParentFlow.flyde"), + newPath: path.join(root, "nested", "ParentFlow.flyde"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "../ChildFlow.flyde" + ); + }); + + test("handles importer and imported flow moving in the same operation", () => { + const root = path.resolve("workspace"); + const flow = createFlow([visualFileInstance("./ChildFlow.flyde")]); + + updateFlow(flow, path.join(root, "flows", "ParentFlow.flyde"), [ + { + oldPath: path.join(root, "ParentFlow.flyde"), + newPath: path.join(root, "flows", "ParentFlow.flyde"), + }, + { + oldPath: path.join(root, "ChildFlow.flyde"), + newPath: path.join(root, "flows", "nested", "ChildFlow.flyde"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./nested/ChildFlow.flyde" + ); + }); + + test("updates file imports recursively in inline visual nodes", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = createFlow([ + { + id: "inline-wrapper", + nodeId: "InlineWrapper", + type: "visual", + inputConfig: {}, + pos: { x: 0, y: 0 }, + source: { + type: "inline", + data: createFlow([visualFileInstance("./ChildFlow.flyde")]).node, + }, + }, + ]); + + updateFlow(flow, parentPath, [ + { + oldPath: path.join(root, "ChildFlow.flyde"), + newPath: path.join(root, "nested", "ChildFlow.flyde"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data.instances[0].source.data, + "./nested/ChildFlow.flyde" + ); + }); + + test("updates moved code-node file imports", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = createFlow([codeFileInstance("./CodeChild.flyde.ts")]); + + updateFlow(flow, parentPath, [ + { + oldPath: path.join(root, "CodeChild.flyde.ts"), + newPath: path.join(root, "nodes", "CodeChild.flyde.ts"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./nodes/CodeChild.flyde.ts" + ); + }); + + test("updates imports created from legacy top-level import declarations", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = deserializeFlow( + `imports: + "./ChildFlow.flyde": ChildFlow +node: + id: ParentFlow + inputs: {} + outputs: {} + instances: + - id: visual-child + nodeId: ChildFlow + inputConfig: {} + pos: + x: 0 + y: 0 + connections: [] + inputsPosition: {} + outputsPosition: {} +`, + parentPath + ); + + updateFlow(flow, parentPath, [ + { + oldPath: path.join(root, "ChildFlow.flyde"), + newPath: path.join(root, "nested", "ChildFlow.flyde"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./nested/ChildFlow.flyde" + ); + }); + + test("updates references to a flow moved as part of a folder move", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = createFlow([visualFileInstance("./old/ChildFlow.flyde")]); + + updateFlow(flow, parentPath, [ + { + oldPath: path.join(root, "old"), + newPath: path.join(root, "new"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./new/ChildFlow.flyde" + ); + }); + + test("keeps references unchanged when a moved folder contains both files", () => { + const root = path.resolve("workspace"); + const flow = createFlow([visualFileInstance("./ChildFlow.flyde")]); + + const changed = updateFlowFileReferences( + flow, + path.join(root, "new", "ParentFlow.flyde"), + [ + { + oldPath: path.join(root, "old"), + newPath: path.join(root, "new"), + }, + ] + ); + + assert.strictEqual(changed, false); + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./ChildFlow.flyde" + ); + }); + + test("uses the most specific rename when nested renames overlap", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = createFlow([visualFileInstance("./old/ChildFlow.flyde")]); + + updateFlow(flow, parentPath, [ + { + oldPath: path.join(root, "old"), + newPath: path.join(root, "new"), + }, + { + oldPath: path.join(root, "old", "ChildFlow.flyde"), + newPath: path.join(root, "special", "ChildFlow.flyde"), + }, + ]); + + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./special/ChildFlow.flyde" + ); + }); + + test("does not treat sibling folder prefixes as descendants", () => { + const root = path.resolve("workspace"); + const parentPath = path.join(root, "ParentFlow.flyde"); + const flow = createFlow([visualFileInstance("./oldish/ChildFlow.flyde")]); + + const changed = updateFlowFileReferences(flow, parentPath, [ + { + oldPath: path.join(root, "old"), + newPath: path.join(root, "new"), + }, + ]); + + assert.strictEqual(changed, false); + assert.strictEqual( + (flow.node.instances[0] as any).source.data, + "./oldish/ChildFlow.flyde" + ); + }); +}); diff --git a/vscode/src/updateImportsOnFileMove.ts b/vscode/src/updateImportsOnFileMove.ts new file mode 100644 index 00000000..8001cc4d --- /dev/null +++ b/vscode/src/updateImportsOnFileMove.ts @@ -0,0 +1,82 @@ +import * as vscode from "vscode"; + +import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server"; +import { updateFlowFileReferences } from "./flowFileReferences"; + +const FLYDE_FILE_GLOB = "**/*.flyde"; +const FLYDE_EXCLUDE_GLOB = "**/{node_modules,.git,out,dist,webview-dist}/**"; + +const getOpenDocument = (uri: vscode.Uri) => { + return vscode.workspace.textDocuments.find( + (document) => document.uri.toString() === uri.toString() + ); +}; + +const replaceDocumentText = async ( + document: vscode.TextDocument, + nextText: string +) => { + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + + edit.replace(document.uri, fullRange, nextText); + await vscode.workspace.applyEdit(edit); +}; + +export const updateImportsOnFileMove = async ( + event: vscode.FileRenameEvent +) => { + const renames = event.files.map((file) => ({ + oldPath: file.oldUri.fsPath, + newPath: file.newUri.fsPath, + })); + + if (renames.length === 0) { + return; + } + + const flowUris = await vscode.workspace.findFiles( + FLYDE_FILE_GLOB, + FLYDE_EXCLUDE_GLOB + ); + + let updatedCount = 0; + + for (const flowUri of flowUris) { + try { + const openDocument = getOpenDocument(flowUri); + const raw = + openDocument?.getText() ?? + new TextDecoder().decode(await vscode.workspace.fs.readFile(flowUri)); + const flow = deserializeFlow(raw, flowUri.fsPath); + + if (!updateFlowFileReferences(flow, flowUri.fsPath, renames)) { + continue; + } + + const serialized = serializeFlow(flow); + + if (openDocument) { + await replaceDocumentText(openDocument, serialized); + } else { + await vscode.workspace.fs.writeFile( + flowUri, + new TextEncoder().encode(serialized) + ); + } + updatedCount += 1; + } catch (error) { + console.warn(`Failed to update Flyde imports in ${flowUri.fsPath}`, error); + } + } + + if (updatedCount > 0) { + const suffix = updatedCount === 1 ? "file" : "files"; + vscode.window.showInformationMessage( + `Updated Flyde imports in ${updatedCount} ${suffix}.` + ); + } +};