From 72b81cfd0e0ba6aac411eb71abd92d162f7ca5ab Mon Sep 17 00:00:00 2001 From: mattm Date: Mon, 26 Oct 2026 12:52:24 -0700 Subject: [PATCH] fix(vscode): update relative Flyde file imports when files or folders move (#112) --- vscode/src/extension.ts | 78 +++++++ vscode/src/fileMoveImportUpdater.node.test.ts | 141 ++++++++++++ vscode/src/fileMoveImportUpdater.ts | 202 ++++++++++++++++++ 3 files changed, 421 insertions(+) create mode 100644 vscode/src/fileMoveImportUpdater.node.test.ts create mode 100644 vscode/src/fileMoveImportUpdater.ts diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..9d4ad5e8 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -15,6 +15,12 @@ import { activateReporter, reportEvent, analytics } from "./analytics"; import { showFirstRunPrivacyNotice, showPrivacySettings } from "./privacyNotice"; import { Template, getTemplates, scaffoldTemplate } from "./templateUtils"; +import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server"; +import { + RenamedFileEntry, + resolveMovedTargetPath, + updateFlowObjectImportsOnMove, +} from "./fileMoveImportUpdater"; // the application insights key (also known as instrumentation key) @@ -229,6 +235,78 @@ export function activate(context: vscode.ExtensionContext) { await showPrivacySettings(); }) ); + + context.subscriptions.push( + vscode.workspace.onDidRenameFiles(async (event) => { + try { + const renamedEntries: RenamedFileEntry[] = event.files.map((f) => ({ + oldPath: f.oldUri.fsPath, + newPath: f.newUri.fsPath, + })); + if (renamedEntries.length === 0) { + return; + } + + const flydeUris = await vscode.workspace.findFiles( + "**/*.flyde", + "**/node_modules/**" + ); + + const edit = new vscode.WorkspaceEdit(); + let anyUpdated = false; + + for (const flowUri of flydeUris) { + const newFlowPath = flowUri.fsPath; + let oldFlowPath = newFlowPath; + for (const entry of renamedEntries) { + if (entry.newPath === newFlowPath) { + oldFlowPath = entry.oldPath; + break; + } else if ( + newFlowPath.startsWith(entry.newPath + "/") || + newFlowPath.startsWith(entry.newPath + "\\") + ) { + const suffix = newFlowPath.slice(entry.newPath.length); + oldFlowPath = entry.oldPath + suffix; + break; + } + } + + try { + const doc = await vscode.workspace.openTextDocument(flowUri); + const raw = doc.getText(); + if (!raw.trim()) { + continue; + } + const flow = deserializeFlow(raw, newFlowPath); + const changed = updateFlowObjectImportsOnMove( + flow as any, + oldFlowPath, + newFlowPath, + renamedEntries + ); + if (changed) { + const serialized = serializeFlow(flow); + const fullRange = new vscode.Range(0, 0, doc.lineCount, 0); + edit.replace(flowUri, fullRange, serialized); + anyUpdated = true; + } + } catch (flowErr) { + console.error( + `Failed to update imports for ${newFlowPath} after file move:`, + flowErr + ); + } + } + + if (anyUpdated) { + await vscode.workspace.applyEdit(edit); + } + } catch (err) { + console.error("Error handling onDidRenameFiles in Flyde:", err); + } + }) + ); } // this method is called when your extension is deactivated diff --git a/vscode/src/fileMoveImportUpdater.node.test.ts b/vscode/src/fileMoveImportUpdater.node.test.ts new file mode 100644 index 00000000..cd6725d6 --- /dev/null +++ b/vscode/src/fileMoveImportUpdater.node.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import test from "node:test"; +import { + formatRelativeFlydeImport, + resolveMovedTargetPath, + updateFlowObjectImportsOnMove, +} from "./fileMoveImportUpdater.ts"; + +test("updates file source path when an imported code node is moved to a subfolder", () => { + const workspace = path.resolve("/workspace/project"); + const flowPath = path.join(workspace, "Main.flyde"); + const oldNodePath = path.join(workspace, "MyCodeNode.flyde.ts"); + const newNodePath = path.join(workspace, "nodes", "MyCodeNode.flyde.ts"); + + const flow = { + node: { + id: "Main", + instances: [ + { + id: "ins-1", + nodeId: "MyCodeNode", + type: "code", + source: { + type: "file", + data: "MyCodeNode.flyde.ts", + }, + }, + ], + }, + }; + + const changed = updateFlowObjectImportsOnMove(flow, flowPath, flowPath, [ + { oldPath: oldNodePath, newPath: newNodePath }, + ]); + + assert.equal(changed, true); + assert.equal(flow.node.instances[0].source.data, "nodes/MyCodeNode.flyde.ts"); +}); + +test("updates file source path when the importing .flyde flow itself is moved to another folder", () => { + const workspace = path.resolve("/workspace/project"); + const oldFlowPath = path.join(workspace, "Main.flyde"); + const newFlowPath = path.join(workspace, "flows", "Main.flyde"); + + const flow = { + node: { + id: "Main", + instances: [ + { + id: "ins-1", + nodeId: "Helper", + type: "code", + source: { + type: "file", + data: "./Helper.flyde.ts", + }, + }, + ], + }, + }; + + const changed = updateFlowObjectImportsOnMove(flow, oldFlowPath, newFlowPath, [ + { oldPath: oldFlowPath, newPath: newFlowPath }, + ]); + + assert.equal(changed, true); + assert.equal(flow.node.instances[0].source.data, "../Helper.flyde.ts"); +}); + +test("updates nested inline visual node imports and legacy imports map when a directory is renamed", () => { + const workspace = path.resolve("/workspace/project"); + const flowPath = path.join(workspace, "Main.flyde"); + const oldDir = path.join(workspace, "old-folder"); + const newDir = path.join(workspace, "new-folder"); + + const flow = { + imports: { + "./old-folder/Legacy.flyde.ts": ["Legacy"], + "@flyde/nodes": ["InlineValue"], + }, + node: { + id: "Main", + instances: [ + { + id: "inline-1", + type: "visual", + source: { + type: "inline", + data: { + id: "InlineSub", + instances: [ + { + id: "sub-1", + nodeId: "SubFlow", + type: "visual", + source: { + type: "file", + data: "./old-folder/SubFlow.flyde", + }, + }, + ], + }, + }, + }, + ], + }, + }; + + const changed = updateFlowObjectImportsOnMove(flow, flowPath, flowPath, [ + { oldPath: oldDir, newPath: newDir }, + ]); + + assert.equal(changed, true); + assert.equal( + flow.node.instances[0].source.data.instances[0].source.data, + "./new-folder/SubFlow.flyde" + ); + assert.deepEqual(flow.imports, { + "./new-folder/Legacy.flyde.ts": ["Legacy"], + "@flyde/nodes": ["InlineValue"], + }); +}); + +test("resolveMovedTargetPath and formatRelativeFlydeImport preserve unchanged paths", () => { + const workspace = path.resolve("/workspace/project"); + const target = path.join(workspace, "Unchanged.flyde.ts"); + assert.equal( + resolveMovedTargetPath(target, [ + { + oldPath: path.join(workspace, "Other.flyde.ts"), + newPath: path.join(workspace, "sub", "Other.flyde.ts"), + }, + ]), + target + ); + assert.equal( + formatRelativeFlydeImport(workspace, target, "./Unchanged.flyde.ts"), + "./Unchanged.flyde.ts" + ); +}); diff --git a/vscode/src/fileMoveImportUpdater.ts b/vscode/src/fileMoveImportUpdater.ts new file mode 100644 index 00000000..2973dcc7 --- /dev/null +++ b/vscode/src/fileMoveImportUpdater.ts @@ -0,0 +1,202 @@ +import * as path from "path"; + +export interface RenamedFileEntry { + oldPath: string; + newPath: string; +} + +export interface FlowLikeNode { + instances?: Array<{ + source?: { + type?: string; + data?: any; + }; + [key: string]: any; + }>; + [key: string]: any; +} + +export interface FlowDocumentLike { + imports?: Record; + node?: FlowLikeNode; + [key: string]: any; +} + +function normalizePathForCompare(p: string): string { + const resolved = path.resolve(p); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +/** + * Given an absolute path prior to a file/directory rename operation, + * returns its new absolute path if it was directly renamed or inside a renamed directory. + */ +export function resolveMovedTargetPath( + absTargetPath: string, + renamedEntries: ReadonlyArray +): string { + const resolvedTarget = path.resolve(absTargetPath); + const normTarget = normalizePathForCompare(resolvedTarget); + + for (const entry of renamedEntries) { + const resolvedOld = path.resolve(entry.oldPath); + const resolvedNew = path.resolve(entry.newPath); + const normOld = normalizePathForCompare(resolvedOld); + + if (normTarget === normOld) { + return resolvedNew; + } + + const oldPrefix = normOld.endsWith(path.sep) + ? normOld + : normOld + path.sep; + if (normTarget.startsWith(oldPrefix)) { + const suffix = resolvedTarget.slice(resolvedOld.length); + return path.join(resolvedNew, suffix); + } + } + + return resolvedTarget; +} + +/** + * Formats a POSIX-normalized relative path from `fromFlowDir` to `toTargetAbsPath`, + * preserving the original `./` prefix convention when located in the same or child folder. + */ +export function formatRelativeFlydeImport( + fromFlowDir: string, + toTargetAbsPath: string, + originalSpecifier: string +): string { + const rawRelative = path + .relative(path.resolve(fromFlowDir), path.resolve(toTargetAbsPath)) + .split(path.sep) + .join("/"); + + if (!rawRelative.startsWith(".")) { + if (originalSpecifier.startsWith("./")) { + return `./${rawRelative}`; + } + return rawRelative; + } + + return rawRelative; +} + +function isRelativeImportKey(specifier: string): boolean { + if (!specifier || specifier.startsWith("@")) { + return false; + } + return ( + specifier.startsWith(".") || + specifier.endsWith(".flyde") || + specifier.endsWith(".flyde.ts") || + specifier.endsWith(".flyde.js") + ); +} + +/** + * Updates `source: { type: "file", data: "..." }` references recursively across a VisualNode + * and its inline sub-nodes when either the flow file or imported files/folders move. + */ +export function updateVisualNodeFileSources( + node: FlowLikeNode | undefined, + oldFlowDir: string, + newFlowDir: string, + renamedEntries: ReadonlyArray +): boolean { + if (!node || !Array.isArray(node.instances)) { + return false; + } + + let changed = false; + + for (const instance of node.instances) { + const source = instance?.source; + if (!source) { + continue; + } + + if (source.type === "file" && typeof source.data === "string") { + const oldAbsTarget = path.resolve(oldFlowDir, source.data); + const newAbsTarget = resolveMovedTargetPath(oldAbsTarget, renamedEntries); + const updatedSpecifier = formatRelativeFlydeImport( + newFlowDir, + newAbsTarget, + source.data + ); + + if (updatedSpecifier !== source.data) { + source.data = updatedSpecifier; + changed = true; + } + } else if (source.type === "inline" && source.data && typeof source.data === "object") { + if ( + updateVisualNodeFileSources( + source.data as FlowLikeNode, + oldFlowDir, + newFlowDir, + renamedEntries + ) + ) { + changed = true; + } + } + } + + return changed; +} + +/** + * Updates both modern `node.instances[].source` (`type: "file"`) and legacy top-level `imports` + * on a parsed Flyde flow object when files or folders are moved. + */ +export function updateFlowObjectImportsOnMove( + flow: FlowDocumentLike, + oldFlowPath: string, + newFlowPath: string, + renamedEntries: ReadonlyArray +): boolean { + const oldFlowDir = path.dirname(path.resolve(oldFlowPath)); + const newFlowDir = path.dirname(path.resolve(newFlowPath)); + + let changed = updateVisualNodeFileSources( + flow.node, + oldFlowDir, + newFlowDir, + renamedEntries + ); + + if (flow.imports && typeof flow.imports === "object") { + const updatedImports: Record = {}; + let importsChanged = false; + + for (const [importKey, importedValue] of Object.entries(flow.imports)) { + if (isRelativeImportKey(importKey)) { + const oldAbsTarget = path.resolve(oldFlowDir, importKey); + const newAbsTarget = resolveMovedTargetPath( + oldAbsTarget, + renamedEntries + ); + const nextKey = formatRelativeFlydeImport( + newFlowDir, + newAbsTarget, + importKey + ); + if (nextKey !== importKey) { + importsChanged = true; + } + updatedImports[nextKey] = importedValue; + } else { + updatedImports[importKey] = importedValue; + } + } + + if (importsChanged) { + flow.imports = updatedImports; + changed = true; + } + } + + return changed; +}