From a1d5f6eea4cc6bae057cb1ce7c6caa4ba758eab2 Mon Sep 17 00:00:00 2001 From: kestrel-devagent Date: Tue, 15 Sep 2026 00:01:12 +0000 Subject: [PATCH] fix(vscode): keep local .flyde imports valid after file moves Rewrite relative source:file references when VS Code renames or moves .flyde files or folders, including when the importing flow itself moves. Also rebases nested inline visual nodes and deprecated top-level imports. --- vscode/src/extension.ts | 2 + vscode/src/registerFileRenameHandler.ts | 61 +++++++ vscode/src/test/updateFileReferences.test.ts | 174 +++++++++++++++++++ vscode/src/updateFileReferences.ts | 114 ++++++++++++ 4 files changed, 351 insertions(+) create mode 100644 vscode/src/registerFileRenameHandler.ts create mode 100644 vscode/src/test/updateFileReferences.test.ts create mode 100644 vscode/src/updateFileReferences.ts diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..ba94cc77 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 { registerFileRenameHandler } from "./registerFileRenameHandler"; // 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(registerFileRenameHandler(mainOutputChannel)); let currentTheme = vscode.window.activeColorTheme; diff --git a/vscode/src/registerFileRenameHandler.ts b/vscode/src/registerFileRenameHandler.ts new file mode 100644 index 00000000..f5022e8f --- /dev/null +++ b/vscode/src/registerFileRenameHandler.ts @@ -0,0 +1,61 @@ +import * as vscode from "vscode"; +import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server"; +import { updateFileReferences } from "./updateFileReferences"; + +/** + * Keep local .flyde file references valid when VS Code renames or moves + * files/folders. Uses onWillRenameFiles + WorkspaceEdit so unsaved buffers + * and undo stay under the editor's control. + */ +export function registerFileRenameHandler( + output: Pick +): vscode.Disposable { + return vscode.workspace.onWillRenameFiles((event) => { + event.waitUntil( + (async () => { + const moves = event.files.map(({ oldUri, newUri }) => ({ + oldPath: oldUri.fsPath, + newPath: newUri.fsPath, + })); + + const uris = await vscode.workspace.findFiles( + "**/*.flyde", + "**/node_modules/**" + ); + const documents = new Map(uris.map((uri) => [uri.toString(), uri])); + for (const document of vscode.workspace.textDocuments) { + if ( + !document.isUntitled && + document.uri.path.endsWith(".flyde") + ) { + documents.set(document.uri.toString(), document.uri); + } + } + + const edit = new vscode.WorkspaceEdit(); + for (const uri of documents.values()) { + try { + const document = await vscode.workspace.openTextDocument(uri); + const text = document.getText(); + const flow = deserializeFlow(text, uri.fsPath); + if (updateFileReferences(flow, uri.fsPath, moves)) { + edit.replace( + uri, + new vscode.Range( + document.positionAt(0), + document.positionAt(text.length) + ), + serializeFlow(flow) + ); + } + } catch (error) { + output.appendLine( + `Could not update Flyde imports in ${uri.fsPath}: ${error}` + ); + } + } + return edit; + })() + ); + }); +} diff --git a/vscode/src/test/updateFileReferences.test.ts b/vscode/src/test/updateFileReferences.test.ts new file mode 100644 index 00000000..f631db55 --- /dev/null +++ b/vscode/src/test/updateFileReferences.test.ts @@ -0,0 +1,174 @@ +import assert = require("assert"); +import * as path from "path"; +import type { FlydeFlow } from "@flyde/core"; +import { applyMove, updateFileReferences } from "../updateFileReferences"; + +const root = path.resolve("rename-test-workspace"); +const at = (...parts: string[]) => path.join(root, ...parts); + +const flowWithFileRef = (reference: string): FlydeFlow => ({ + node: { + id: "Main", + inputs: {}, + outputs: {}, + connections: [], + inputsPosition: {}, + outputsPosition: {}, + instances: [ + { + id: "dependency", + type: "code", + nodeId: "Dependency", + source: { type: "file", data: reference }, + config: {}, + inputConfig: {}, + pos: { x: 0, y: 0 }, + }, + ], + }, +}); + +const fileRef = (value: FlydeFlow): string => { + const source = value.node.instances[0].source; + assert.strictEqual(source.type, "file"); + return (source as { type: "file"; data: string }).data; +}; + +suite("updateFileReferences", () => { + test("rewrites a moved file dependency", () => { + const value = flowWithFileRef("./Dependency.flyde.ts"); + assert.ok( + updateFileReferences(value, at("Main.flyde"), [ + { + oldPath: at("Dependency.flyde.ts"), + newPath: at("nodes", "Dependency.flyde.ts"), + }, + ]) + ); + assert.strictEqual(fileRef(value), "./nodes/Dependency.flyde.ts"); + }); + + test("rebases imports when the importing flow moves", () => { + const value = flowWithFileRef("./Dependency.flyde.ts"); + assert.ok( + updateFileReferences(value, at("Main.flyde"), [ + { oldPath: at("Main.flyde"), newPath: at("flows", "Main.flyde") }, + ]) + ); + assert.strictEqual(fileRef(value), "../Dependency.flyde.ts"); + }); + + test("rewrites references into a renamed folder", () => { + const value = flowWithFileRef("./nodes/Dependency.flyde"); + assert.ok( + updateFileReferences(value, at("Main.flyde"), [ + { oldPath: at("nodes"), newPath: at("lib") }, + ]) + ); + assert.strictEqual(fileRef(value), "./lib/Dependency.flyde"); + }); + + test("leaves relative paths unchanged when both files move together", () => { + const value = flowWithFileRef("Dependency.flyde.ts"); + assert.strictEqual( + updateFileReferences(value, at("nodes", "Main.flyde"), [ + { oldPath: at("nodes"), newPath: at("lib") }, + ]), + false + ); + assert.strictEqual(fileRef(value), "Dependency.flyde.ts"); + }); + + test("does not match a directory that only shares a name prefix", () => { + const value = flowWithFileRef("./nodes-extra/Dependency.flyde.ts"); + assert.strictEqual( + updateFileReferences(value, at("Main.flyde"), [ + { oldPath: at("nodes"), newPath: at("lib") }, + ]), + false + ); + }); + + test("updates references nested inside inline visual nodes", () => { + const value = flowWithFileRef("./Dependency.flyde.ts"); + const nested = flowWithFileRef("./Dependency.flyde.ts").node; + value.node.instances[0] = { + id: "inline", + type: "visual", + nodeId: "Nested", + source: { type: "inline", data: nested }, + inputConfig: {}, + pos: { x: 0, y: 0 }, + }; + assert.ok( + updateFileReferences(value, at("Main.flyde"), [ + { + oldPath: at("Dependency.flyde.ts"), + newPath: at("Renamed.flyde.ts"), + }, + ]) + ); + assert.strictEqual(fileRef({ node: nested }), "./Renamed.flyde.ts"); + }); + + test("leaves package and custom sources untouched", () => { + for (const type of ["package", "custom"] as const) { + const value = flowWithFileRef("./Dependency.flyde.ts"); + value.node.instances[0].source = { + type, + data: "./Dependency.flyde.ts", + } as any; + assert.strictEqual( + updateFileReferences(value, at("Main.flyde"), [ + { + oldPath: at("Dependency.flyde.ts"), + newPath: at("Renamed.flyde.ts"), + }, + ]), + false + ); + } + }); + + test("treats a batch as simultaneous moves (no chaining)", () => { + const value = flowWithFileRef("./A.flyde"); + updateFileReferences(value, at("Main.flyde"), [ + { oldPath: at("A.flyde"), newPath: at("B.flyde") }, + { oldPath: at("B.flyde"), newPath: at("C.flyde") }, + ]); + assert.strictEqual(fileRef(value), "./B.flyde"); + }); + + test("applyMove prefers the most specific move in a batch", () => { + assert.strictEqual( + applyMove(at("nodes", "A.flyde"), [ + { oldPath: at("nodes"), newPath: at("lib") }, + { oldPath: at("nodes", "A.flyde"), newPath: at("B.flyde") }, + ]), + at("B.flyde") + ); + }); + + test("rewrites deprecated top-level imports map keys", () => { + const value: FlydeFlow = { + imports: { "./Child.flyde": ["Child"] }, + node: { + id: "Main", + inputs: {}, + outputs: {}, + connections: [], + inputsPosition: {}, + outputsPosition: {}, + instances: [], + }, + }; + assert.ok( + updateFileReferences(value, at("Main.flyde"), [ + { oldPath: at("Child.flyde"), newPath: at("shared", "Child.flyde") }, + ]) + ); + assert.deepStrictEqual(value.imports, { + "./shared/Child.flyde": ["Child"], + }); + }); +}); diff --git a/vscode/src/updateFileReferences.ts b/vscode/src/updateFileReferences.ts new file mode 100644 index 00000000..40d2a58c --- /dev/null +++ b/vscode/src/updateFileReferences.ts @@ -0,0 +1,114 @@ +import type { FlydeFlow, VisualNode } from "@flyde/core"; +import * as path from "path"; + +export interface FileMove { + oldPath: string; + newPath: string; +} + +/** + * Apply a batch of simultaneous renames/moves to an absolute path, + * choosing the most specific (longest) matching source path. + */ +export function applyMove( + absolutePath: string, + moves: readonly FileMove[] +): string { + const match = [...moves] + .sort((a, b) => b.oldPath.length - a.oldPath.length) + .find(({ oldPath }) => { + const rel = path.relative(oldPath, absolutePath); + return ( + rel === "" || + (rel !== ".." && + !rel.startsWith(`..${path.sep}`) && + !path.isAbsolute(rel)) + ); + }); + + if (!match) { + return absolutePath; + } + + return path.join(match.newPath, path.relative(match.oldPath, absolutePath)); +} + +function toPosixRelative(fromDir: string, targetAbs: string): string { + const rel = path.relative(fromDir, targetAbs).split(path.sep).join("/"); + return rel.startsWith(".") ? rel : `./${rel}`; +} + +/** + * Rewrite local `source: file` references inside a flow after VS Code + * rename/move events. Also rebases relative paths when the flow itself moves. + * Returns true when any reference was changed. + */ +export function updateFileReferences( + flow: FlydeFlow, + flowPath: string, + moves: readonly FileMove[] +): boolean { + const newFlowPath = applyMove(flowPath, moves); + const oldDir = path.dirname(flowPath); + const newDir = path.dirname(newFlowPath); + let changed = false; + + const rewriteRef = (ref: string): string | undefined => { + const targetAbs = path.join(oldDir, ref); + const newTargetAbs = applyMove(targetAbs, moves); + const nextRef = toPosixRelative(newDir, newTargetAbs); + // Compare resolved locations rather than string form so equivalent + // relative spellings ("./x" vs "x") do not churn unnecessarily. + if (path.join(newDir, ref) === newTargetAbs) { + return undefined; + } + return nextRef; + }; + + const visit = (node: VisualNode) => { + for (const instance of node.instances) { + const source = instance.source as + | { type: string; data?: unknown } + | undefined; + if (!source) { + continue; + } + + if (source.type === "inline" && source.data) { + visit(source.data as VisualNode); + continue; + } + + if (source.type === "file" && typeof source.data === "string") { + const next = rewriteRef(source.data); + if (next !== undefined) { + source.data = next; + changed = true; + } + } + } + }; + + visit(flow.node); + + // Defensive: keep deprecated top-level imports map consistent if present. + if (flow.imports) { + const nextImports: Record = {}; + let importsChanged = false; + for (const [ref, names] of Object.entries(flow.imports)) { + const rewritten = rewriteRef(ref); + if (rewritten !== undefined) { + nextImports[rewritten] = names; + importsChanged = true; + } else { + nextImports[ref] = names; + } + } + if (importsChanged) { + flow.imports = nextImports; + changed = true; + } + } + + return changed; +}