From dbf09fcfc5f9bbb05e0fec9e30131bb9109f7590 Mon Sep 17 00:00:00 2001 From: saurav-codes Date: Tue, 8 Sep 2026 09:49:03 +0530 Subject: [PATCH] fix(vscode): update .flyde imports after file rename/move (#112) --- pnpm-lock.yaml | 19 +-- vscode/package.json | 1 + vscode/src/extension.ts | 3 + vscode/src/test/rename-imports.test.ts | 103 ++++++++++++++ vscode/src/update-imports-on-rename.ts | 180 +++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 vscode/src/test/rename-imports.test.ts create mode 100644 vscode/src/update-imports-on-rename.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40423ef1..5a341124 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,9 @@ importers: walker: specifier: ^1.0.8 version: 1.0.8 + yaml: + specifier: ^2.3.1 + version: 2.8.0 devDependencies: '@playwright/test': specifier: ^1.53.2 @@ -11972,8 +11975,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3) eslint: 9.30.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.30.1(jiti@2.4.2)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.30.1(jiti@2.4.2)) eslint-plugin-react: 7.37.5(eslint@9.30.1(jiti@2.4.2)) eslint-plugin-react-hooks: 5.2.0(eslint@9.30.1(jiti@2.4.2)) @@ -11992,7 +11995,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.30.1(jiti@2.4.2)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) @@ -12003,22 +12006,22 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.10.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.4.2)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.4.2)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)): dependencies: debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3) eslint: 9.30.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.30.1(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.4.2)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -12029,7 +12032,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.30.1(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.30.1(jiti@2.4.2)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)))(eslint@9.30.1(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/vscode/package.json b/vscode/package.json index 3f1678cc..51b76c3f 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -254,6 +254,7 @@ "socket.io": "^4.5.1", "ts-node": "^10.9.2", "use-debounce": "^8.0.4", + "yaml": "^2.3.1", "walker": "^1.0.8" }, "packageManager": "yarn@3.3.1" diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..a1cf5c33 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 { registerFlydeImportUpdater } 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) ); + registerFlydeImportUpdater(context); + function getWorkspaceRootPath(): vscode.Uri | undefined { // Check if there is an open workspace if ( diff --git a/vscode/src/test/rename-imports.test.ts b/vscode/src/test/rename-imports.test.ts new file mode 100644 index 00000000..83894689 --- /dev/null +++ b/vscode/src/test/rename-imports.test.ts @@ -0,0 +1,103 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + updateImportsAfterRename, + updateFlowImports, +} from "../update-imports-on-rename"; + +const parentFlowYaml = `imports: {} +node: + instances: + - pos: + x: 300 + y: 200 + id: nested-instance + inputConfig: {} + nodeId: ChildFlow + config: {} + type: visual + source: + type: file + data: ./ChildFlow.flyde + connections: [] + id: ParentFlow + inputs: {} + outputs: {} +`; + +suite("Import updates after file rename", () => { + let tmpDir: string; + + setup(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "flyde-rename-test-")); + }); + + test("updates flows referencing a moved imported file", () => { + const parentPath = path.join(tmpDir, "ParentFlow.flyde"); + fs.writeFileSync(parentPath, parentFlowYaml); + + const oldChild = path.join(tmpDir, "ChildFlow.flyde"); + const subDir = path.join(tmpDir, "sub"); + fs.mkdirSync(subDir); + const newChild = path.join(subDir, "ChildFlow.flyde"); + + const updated = updateImportsAfterRename(oldChild, newChild, [parentPath]); + + assert.deepStrictEqual(updated, [parentPath]); + const text = fs.readFileSync(parentPath, "utf-8"); + assert.ok( + text.includes("data: ./sub/ChildFlow.flyde"), + `Expected updated import, got:\n${text}` + ); + }); + + test("updates imports inside the moved flow itself", () => { + const childPath = path.join(tmpDir, "ChildFlow.flyde"); + fs.writeFileSync(childPath, "imports: {}\nnode: {}\n"); + + const oldParent = path.join(tmpDir, "ParentFlow.flyde"); + fs.writeFileSync(oldParent, parentFlowYaml); + + const subDir = path.join(tmpDir, "sub"); + fs.mkdirSync(subDir); + const newParent = path.join(subDir, "ParentFlow.flyde"); + fs.renameSync(oldParent, newParent); + + const updated = updateImportsAfterRename(oldParent, newParent, [newParent]); + + assert.deepStrictEqual(updated, [newParent]); + const text = fs.readFileSync(newParent, "utf-8"); + assert.ok( + text.includes("data: ../ChildFlow.flyde"), + `Expected updated import, got:\n${text}` + ); + }); + + test("leaves unrelated imports untouched", () => { + const parentPath = path.join(tmpDir, "ParentFlow.flyde"); + fs.writeFileSync(parentPath, parentFlowYaml); + + const updated = updateImportsAfterRename( + path.join(tmpDir, "Other.flyde"), + path.join(tmpDir, "Other2.flyde"), + [parentPath] + ); + + assert.deepStrictEqual(updated, []); + const text = fs.readFileSync(parentPath, "utf-8"); + assert.ok(text.includes("data: ./ChildFlow.flyde")); + }); + + test("updateFlowImports returns null when nothing changes", () => { + const result = updateFlowImports(parentFlowYaml, () => undefined); + assert.strictEqual(result, null); + }); + + test("updateFlowImports returns null for invalid yaml", () => { + const result = updateFlowImports("{{{{", () => "./x"); + assert.strictEqual(result, null); + }); + +}); diff --git a/vscode/src/update-imports-on-rename.ts b/vscode/src/update-imports-on-rename.ts new file mode 100644 index 00000000..a1a1b7d8 --- /dev/null +++ b/vscode/src/update-imports-on-rename.ts @@ -0,0 +1,180 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { isMap, isSeq, parseDocument } from "yaml"; + +/** + * Keeps `.flyde` imports working when files are moved or renamed in VS Code. + * Handles both directions: + * - a file imported by `.flyde` flows was moved (update the referencing flows) + * - a `.flyde` flow itself was moved (update its own relative imports) + */ +export function registerFlydeImportUpdater(context: vscode.ExtensionContext) { + const sub = vscode.workspace.onDidRenameFiles(async (event) => { + try { + await updateImportsForRenameEvent(event); + } catch (e) { + // Never throw out of a workspace listener + console.error("[flyde] failed to update imports after rename:", e); + } + }); + context.subscriptions.push(sub); +} + +export interface RenameEventLike { + files: ReadonlyArray<{ oldUri: vscode.Uri; newUri: vscode.Uri }>; +} + +export async function updateImportsForRenameEvent( + event: RenameEventLike +): Promise { + const flydeFiles = ( + await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**") + ).map((uri) => uri.fsPath); + + const updated = new Set(); + for (const file of event.files) { + for (const changed of updateImportsAfterRename( + file.oldUri.fsPath, + file.newUri.fsPath, + flydeFiles + )) { + updated.add(changed); + } + } + return [...updated]; +} + +/** + * Rewrites relative `source.data` import paths in the given `.flyde` files so + * they keep resolving after `oldPath` was renamed/moved to `newPath`. + * Exported for tests; returns the list of files that were modified. + */ +export function updateImportsAfterRename( + oldPath: string, + newPath: string, + flydeFiles: string[] +): string[] { + const updated: string[] = []; + + // Case 1: the moved file is referenced by other flows + for (const flydeFile of flydeFiles) { + const text = safeRead(flydeFile); + if (text === null) { + continue; + } + + const flowDir = path.dirname(flydeFile); + const next = updateFlowImports(text, (relImport) => { + const abs = path.resolve(flowDir, relImport); + return samePath(abs, oldPath) + ? toRelImport(path.relative(flowDir, newPath)) + : undefined; + }); + if (next !== null) { + safeWrite(flydeFile, next); + updated.push(flydeFile); + } + } + + // Case 2: the moved file is itself a flow; fix its own imports so they + // still point at the same absolute targets from the new location + if (newPath.endsWith(".flyde") && fs.existsSync(newPath)) { + const oldDir = path.dirname(oldPath); + const newDir = path.dirname(newPath); + if (oldDir !== newDir) { + const text = safeRead(newPath); + if (text !== null) { + const next = updateFlowImports(text, (relImport) => { + const abs = path.resolve(oldDir, relImport); + const next = toRelImport(path.relative(newDir, abs)); + return next !== relImport ? next : undefined; + }); + if (next !== null) { + safeWrite(newPath, next); + updated.push(newPath); + } + } + } + } + + return updated; +} + +/** + * Applies `transform` to every `source.data` value of `type: file` imports in + * a serialized flow. Returns the updated text, or null when nothing changed. + * Uses a YAML document so comments and formatting are preserved. + */ +export function updateFlowImports( + flowText: string, + transform: (currentRelImport: string) => string | undefined +): string | null { + let doc; + try { + doc = parseDocument(flowText); + } catch { + return null; + } + if (doc.errors.length > 0) { + return null; + } + + let changed = false; + const node = doc.get("node"); + if (isMap(node)) { + const instances = node.get("instances"); + if (isSeq(instances)) { + for (const instance of instances.items) { + if (!isMap(instance)) { + continue; + } + const source = instance.get("source"); + if (!isMap(source)) { + continue; + } + if (source.get("type") !== "file") { + continue; + } + const data = source.get("data"); + if (typeof data !== "string") { + continue; + } + const next = transform(data); + if (next !== undefined && next !== data) { + source.set("data", next); + changed = true; + } + } + } + } + return changed ? doc.toString() : null; +} + +function toRelImport(rel: string): string { + const posix = rel.split(path.sep).join("/"); + return posix.startsWith(".") ? posix : `./${posix}`; +} + +function samePath(a: string, b: string): boolean { + const norm = (s: string) => path.resolve(s); + return process.platform === "win32" + ? norm(a).toLowerCase() === norm(b).toLowerCase() + : norm(a) === norm(b); +} + +function safeRead(filePath: string): string | null { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } +} + +function safeWrite(filePath: string, content: string): void { + try { + fs.writeFileSync(filePath, content, "utf-8"); + } catch (e) { + console.error(`[flyde] failed to write ${filePath}:`, e); + } +}