diff --git a/loader/src/resolver/server/updateMovedFlydeImports.spec.ts b/loader/src/resolver/server/updateMovedFlydeImports.spec.ts new file mode 100644 index 00000000..bb9d2aa2 --- /dev/null +++ b/loader/src/resolver/server/updateMovedFlydeImports.spec.ts @@ -0,0 +1,170 @@ +import { assert } from "chai"; +import { updateFlydeImportsAfterMove } from "./updateMovedFlydeImports"; + +const parentFlow = `imports: {} +node: + instances: + - pos: + x: 300 + y: 200 + id: nested-instance + inputConfig: {} + nodeId: ChildFlow + config: {} + type: visual + source: + type: file + data: ./ChildFlow.flyde + - pos: + x: 100 + y: 100 + id: stdlib-add + inputConfig: {} + nodeId: Add + config: {} + type: code + source: + type: package + data: "@flyde/nodes" + connections: [] + id: ParentFlow + inputs: {} + outputs: {} +`; + +describe("updateFlydeImportsAfterMove", () => { + it("updates the import path when an imported .flyde file is moved to another folder", () => { + const updated = updateFlydeImportsAfterMove( + parentFlow, + "/workspace/ParentFlow.flyde", + "/workspace/ParentFlow.flyde", + { + oldPath: "/workspace/ChildFlow.flyde", + newPath: "/workspace/shared/ChildFlow.flyde", + } + ); + + assert.isNotNull(updated); + assert.include(updated!, "data: ./shared/ChildFlow.flyde"); + assert.notInclude(updated!, "data: ./ChildFlow.flyde\n"); + }); + + it("re-relativizes imports when the importing flow itself is moved", () => { + const updated = updateFlydeImportsAfterMove( + parentFlow, + "/workspace/ParentFlow.flyde", + "/workspace/flows/ParentFlow.flyde", + { + oldPath: "/workspace/ParentFlow.flyde", + newPath: "/workspace/flows/ParentFlow.flyde", + } + ); + + assert.isNotNull(updated); + assert.include(updated!, "data: ../ChildFlow.flyde"); + }); + + it("keeps relative paths stable when a flow and its import are moved together", () => { + const afterFlowMove = updateFlydeImportsAfterMove( + parentFlow, + "/workspace/ParentFlow.flyde", + "/workspace/sub/ParentFlow.flyde", + { + oldPath: "/workspace/ParentFlow.flyde", + newPath: "/workspace/sub/ParentFlow.flyde", + } + ); + assert.isNotNull(afterFlowMove); + + // simulate the extension applying each move of the rename event in turn + const afterBothMoves = updateFlydeImportsAfterMove( + afterFlowMove!, + "/workspace/sub/ParentFlow.flyde", + "/workspace/sub/ParentFlow.flyde", + { + oldPath: "/workspace/ChildFlow.flyde", + newPath: "/workspace/sub/ChildFlow.flyde", + } + ); + + assert.isNotNull(afterBothMoves); + assert.include(afterBothMoves!, "data: ./ChildFlow.flyde"); + }); + + it("returns null for moves that do not affect the flow", () => { + const updated = updateFlydeImportsAfterMove( + parentFlow, + "/workspace/ParentFlow.flyde", + "/workspace/ParentFlow.flyde", + { + oldPath: "/workspace/unrelated/Other.flyde", + newPath: "/workspace/other/Other.flyde", + } + ); + + assert.isNull(updated); + }); + + it("leaves package and inline sources untouched", () => { + const updated = updateFlydeImportsAfterMove( + parentFlow, + "/workspace/ParentFlow.flyde", + "/workspace/ParentFlow.flyde", + { + oldPath: "/workspace/ChildFlow.flyde", + newPath: "/workspace/shared/ChildFlow.flyde", + } + ); + + assert.include(updated!, 'data: "@flyde/nodes"'); + }); + + it("updates code node file imports (.flyde.ts) as well", () => { + const flowWithCodeImport = parentFlow.replace( + "data: ./ChildFlow.flyde", + "data: ./Add.flyde.ts" + ); + + const updated = updateFlydeImportsAfterMove( + flowWithCodeImport, + "/workspace/ParentFlow.flyde", + "/workspace/ParentFlow.flyde", + { + oldPath: "/workspace/Add.flyde.ts", + newPath: "/workspace/lib/Add.flyde.ts", + } + ); + + assert.isNotNull(updated); + assert.include(updated!, "data: ./lib/Add.flyde.ts"); + }); + + it("preserves unrelated formatting and content", () => { + const updated = updateFlydeImportsAfterMove( + parentFlow, + "/workspace/ParentFlow.flyde", + "/workspace/ParentFlow.flyde", + { + oldPath: "/workspace/ChildFlow.flyde", + newPath: "/workspace/shared/ChildFlow.flyde", + } + ); + + assert.include(updated!, "id: stdlib-add"); + assert.include(updated!, "x: 300"); + }); + + it("returns null for flows that fail to parse", () => { + const updated = updateFlydeImportsAfterMove( + "node: [unclosed", + "/workspace/ParentFlow.flyde", + "/workspace/ParentFlow.flyde", + { + oldPath: "/workspace/ChildFlow.flyde", + newPath: "/workspace/shared/ChildFlow.flyde", + } + ); + + assert.isNull(updated); + }); +}); diff --git a/loader/src/resolver/server/updateMovedFlydeImports.ts b/loader/src/resolver/server/updateMovedFlydeImports.ts new file mode 100644 index 00000000..427ef5e6 --- /dev/null +++ b/loader/src/resolver/server/updateMovedFlydeImports.ts @@ -0,0 +1,78 @@ +import { dirname, relative, resolve } from "path"; +import { parseDocument, isMap, isSeq } from "yaml"; + +export interface MovedFile { + /** Absolute path of the file before the move/rename */ + oldPath: string; + /** Absolute path of the file after the move/rename */ + newPath: string; +} + +const toPosixPath = (p: string) => p.split("\\").join("/"); + +/** + * Rewrites the relative `source.data` file references inside a .flyde flow + * after a file in the workspace was moved or renamed. + * + * Handles both directions: + * - an imported file was moved: references resolving to `move.oldPath` are + * re-pointed at `move.newPath` + * - the flow file itself was moved (`flowPathBefore` !== `flowPathAfter`): + * every relative file reference is re-relativized against the new location + * + * Edits are applied through the YAML document AST so unrelated formatting and + * content are preserved. Returns the updated contents, or `null` when no + * reference needed updating. + */ +export function updateFlydeImportsAfterMove( + flowContents: string, + flowPathBefore: string, + flowPathAfter: string, + move: MovedFile +): string | null { + const doc = parseDocument(flowContents); + if (doc.errors.length > 0) { + return null; + } + + const instances = doc.getIn(["node", "instances"], true); + if (!isSeq(instances)) { + return null; + } + + const oldTarget = resolve(move.oldPath); + let changed = false; + + for (const instance of instances.items) { + if (!isMap(instance)) { + continue; + } + const source = instance.get("source", true); + if (!isMap(source) || source.get("type") !== "file") { + continue; + } + const data = source.get("data"); + if (typeof data !== "string" || data === "") { + continue; + } + + const targetBefore = resolve(dirname(flowPathBefore), data); + const targetAfter = targetBefore === oldTarget ? resolve(move.newPath) : targetBefore; + + if (targetAfter === targetBefore && flowPathBefore === flowPathAfter) { + continue; + } + + let newData = toPosixPath(relative(dirname(flowPathAfter), targetAfter)); + if (!newData.startsWith(".")) { + newData = `./${newData}`; + } + + if (newData !== data) { + source.set("data", newData); + changed = true; + } + } + + return changed ? String(doc) : null; +} diff --git a/loader/src/server.ts b/loader/src/server.ts index cfb3376b..28ad9e55 100644 --- a/loader/src/server.ts +++ b/loader/src/server.ts @@ -8,4 +8,5 @@ export * from "./run-flow"; export { createServerReferencedNodeFinder } from "./resolver/server/findReferencedNodeServer"; export * from "./resolver/server/resolveImportablePaths"; export { resolveFlowByPath } from "./resolver/server/resolveFlowServer"; -export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils"; \ No newline at end of file +export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils"; +export { updateFlydeImportsAfterMove, MovedFile } from "./resolver/server/updateMovedFlydeImports"; \ No newline at end of file diff --git a/vscode/package.json b/vscode/package.json index 3f1678cc..1d3b09eb 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -45,7 +45,9 @@ "release": "vsce package --no-yarn && vsce publish minor" }, "main": "./out/extension.js", - "activationEvents": [], + "activationEvents": [ + "workspaceContains:**/*.flyde" + ], "contributes": { "customEditors": [ { diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..9ca3cf77 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 { registerFlydeImportMoveUpdater } from "./update-imports-on-rename"; // the application insights key (also known as instrumentation key) @@ -106,6 +107,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("flyde.openAsText", openAsTextHandler) ); + registerFlydeImportMoveUpdater(context); + function getWorkspaceRootPath(): vscode.Uri | undefined { // Check if there is an open workspace if ( diff --git a/vscode/src/update-imports-on-rename.ts b/vscode/src/update-imports-on-rename.ts new file mode 100644 index 00000000..c191f0c3 --- /dev/null +++ b/vscode/src/update-imports-on-rename.ts @@ -0,0 +1,85 @@ +import * as vscode from "vscode"; +import { + updateFlydeImportsAfterMove, + MovedFile, +} from "@flyde/loader/dist/server"; + +/** + * Keeps .flyde imports working when files are moved or renamed in the + * workspace (fixes https://github.com/flydelabs/flyde/issues/112). + * + * Flow files reference imported nodes through workspace-relative paths, so + * moving an imported .flyde (or .flyde.ts) file used to break the import. + * This listener rewrites the affected `source.data` references in every flow + * file after a rename - both when an imported file moved and when a flow + * file itself moved (in which case its relative imports are re-relativized). + */ +export function registerFlydeImportMoveUpdater( + context: vscode.ExtensionContext +) { + context.subscriptions.push( + vscode.workspace.onDidRenameFiles((event) => { + handleRenamedFiles(event.files).catch((e) => { + console.error("Flyde: failed to update imports after file move", e); + }); + }) + ); +} + +async function handleRenamedFiles( + files: readonly { oldUri: vscode.Uri; newUri: vscode.Uri }[] +) { + const moves: MovedFile[] = files + .map(({ oldUri, newUri }) => ({ + oldPath: oldUri.fsPath, + newPath: newUri.fsPath, + })) + .filter(({ oldPath, newPath }) => oldPath !== newPath); + + if (moves.length === 0) { + return; + } + + const previousPathByNewPath = new Map( + moves.map(({ oldPath, newPath }) => [newPath, oldPath]) + ); + + const flowUris = await vscode.workspace.findFiles( + "**/*.flyde", + "**/node_modules/**" + ); + + await Promise.all( + flowUris.map(async (uri) => { + try { + const flowPathAfter = uri.fsPath; + const flowPathBefore = + previousPathByNewPath.get(flowPathAfter) ?? flowPathAfter; + + const raw = await vscode.workspace.fs.readFile(uri); + const original = Buffer.from(raw).toString("utf8"); + + let contents = original; + let baseBefore = flowPathBefore; + for (const move of moves) { + const updated = updateFlydeImportsAfterMove( + contents, + baseBefore, + flowPathAfter, + move + ); + if (updated !== null) { + contents = updated; + } + baseBefore = flowPathAfter; + } + + if (contents !== original) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(contents, "utf8")); + } + } catch (e) { + console.error(`Flyde: failed to update imports in ${uri.fsPath}`, e); + } + }) + ); +}