diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..fb2b0167 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 { registerFileMoveImportUpdater } from "./updateImportsOnMove"; // the application insights key (also known as instrumentation key) @@ -229,6 +230,8 @@ export function activate(context: vscode.ExtensionContext) { await showPrivacySettings(); }) ); + + registerFileMoveImportUpdater(context); } // this method is called when your extension is deactivated diff --git a/vscode/src/flowImportLogic.ts b/vscode/src/flowImportLogic.ts new file mode 100644 index 00000000..c33c89a1 --- /dev/null +++ b/vscode/src/flowImportLogic.ts @@ -0,0 +1,196 @@ +import * as path from "path"; +import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server"; + +export interface RenamedFileEntry { + oldPath: string; + newPath: string; +} + +function toPosixPath(p: string): string { + return p.split(path.sep).join("/"); +} + +function normalizeFsPath(p: string): string { + const resolved = path.resolve(p); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +export function mapPathThroughRenames( + targetPath: string, + renamedFiles: ReadonlyArray, + direction: "oldToNew" | "newToOld" +): string { + const resolvedTarget = path.resolve(targetPath); + const normTarget = normalizeFsPath(resolvedTarget); + + for (const entry of renamedFiles) { + const fromPath = path.resolve( + direction === "oldToNew" ? entry.oldPath : entry.newPath + ); + const toPath = path.resolve( + direction === "oldToNew" ? entry.newPath : entry.oldPath + ); + const normFrom = normalizeFsPath(fromPath); + + if (normTarget === normFrom) { + return toPath; + } + + const fromPrefix = normFrom.endsWith(path.sep) + ? normFrom + : normFrom + path.sep; + if (normTarget.startsWith(fromPrefix)) { + const suffix = resolvedTarget.slice(fromPath.length); + return toPath + suffix; + } + } + + return resolvedTarget; +} + +export function isRelativeImportPath(importPath: string): boolean { + if (!importPath || typeof importPath !== "string") { + return false; + } + if (importPath.includes("://") || importPath.startsWith("@")) { + return false; + } + return ( + importPath.startsWith("./") || + importPath.startsWith("../") || + importPath.endsWith(".flyde") || + importPath.endsWith(".flyde.ts") || + importPath.endsWith(".flyde.js") || + importPath.endsWith(".flyde.mjs") || + importPath.endsWith(".flyde.cjs") + ); +} + +export function computeUpdatedRelativeImport( + importPath: string, + oldFlowPath: string, + newFlowPath: string, + renamedFiles: ReadonlyArray +): string { + if (!isRelativeImportPath(importPath)) { + return importPath; + } + + const oldFlowDir = path.dirname(path.resolve(oldFlowPath)); + const newFlowDir = path.dirname(path.resolve(newFlowPath)); + + const oldTargetAbs = path.resolve(oldFlowDir, importPath); + const newTargetAbs = mapPathThroughRenames( + oldTargetAbs, + renamedFiles, + "oldToNew" + ); + + if ( + normalizeFsPath(oldFlowDir) === normalizeFsPath(newFlowDir) && + normalizeFsPath(oldTargetAbs) === normalizeFsPath(newTargetAbs) + ) { + return importPath; + } + + const relPosix = path.posix.relative( + toPosixPath(newFlowDir), + toPosixPath(newTargetAbs) + ); + + return relPosix.startsWith(".") ? relPosix : `./${relPosix}`; +} + +export function updateFlowObjectImports( + flow: any, + currentFlowPath: string, + renamedFiles: ReadonlyArray +): boolean { + if (!flow || typeof flow !== "object") { + return false; + } + + const oldFlowPath = mapPathThroughRenames( + currentFlowPath, + renamedFiles, + "newToOld" + ); + let modified = false; + + if (flow.imports && typeof flow.imports === "object") { + const updatedImports: Record = {}; + for (const [impKey, impVal] of Object.entries(flow.imports)) { + const newKey = computeUpdatedRelativeImport( + impKey, + oldFlowPath, + currentFlowPath, + renamedFiles + ); + if (newKey !== impKey) { + modified = true; + } + updatedImports[newKey] = impVal; + } + flow.imports = updatedImports; + } + + function visitVisualNode(node: any) { + if (!node || !Array.isArray(node.instances)) { + return; + } + for (const instance of node.instances) { + if ( + instance?.source?.type === "file" && + typeof instance.source.data === "string" + ) { + const updatedPath = computeUpdatedRelativeImport( + instance.source.data, + oldFlowPath, + currentFlowPath, + renamedFiles + ); + if (updatedPath !== instance.source.data) { + instance.source.data = updatedPath; + modified = true; + } + } else if ( + instance?.source?.type === "inline" && + instance.source.data && + typeof instance.source.data === "object" + ) { + visitVisualNode(instance.source.data); + } + if (instance?.node && typeof instance.node === "object") { + visitVisualNode(instance.node); + } + } + } + + if (flow.node) { + visitVisualNode(flow.node); + } + + return modified; +} + +export function updateFlowImportsOnRename( + flowContent: string, + currentFlowPath: string, + renamedFiles: ReadonlyArray +): string | null { + try { + const flow = deserializeFlow(flowContent, currentFlowPath); + const modified = updateFlowObjectImports( + flow, + currentFlowPath, + renamedFiles + ); + if (!modified) { + return null; + } + return serializeFlow(flow); + } catch (err) { + console.error(`Failed to update imports for flow at ${currentFlowPath}:`, err); + return null; + } +} diff --git a/vscode/src/updateImportsOnMove.pure.test.ts b/vscode/src/updateImportsOnMove.pure.test.ts new file mode 100644 index 00000000..2cf62ebc --- /dev/null +++ b/vscode/src/updateImportsOnMove.pure.test.ts @@ -0,0 +1,130 @@ +import * as assert from "assert"; +import { + computeUpdatedRelativeImport, + updateFlowObjectImports, +} from "./flowImportLogic"; + +suite("updateImportsOnMove Unit Tests", () => { + test("updates file-sourced instance when an imported node file is moved", () => { + const flow = { + imports: {}, + node: { + id: "MainFlow", + instances: [ + { + id: "ins1", + nodeId: "Add", + type: "code", + source: { + type: "file", + data: "./Add.flyde.ts", + }, + }, + ], + }, + }; + + const changed = updateFlowObjectImports(flow, "/workspace/flows/MainFlow.flyde", [ + { + oldPath: "/workspace/flows/Add.flyde.ts", + newPath: "/workspace/flows/sub/Add.flyde.ts", + }, + ]); + + assert.strictEqual(changed, true); + assert.strictEqual(flow.node.instances[0].source.data, "./sub/Add.flyde.ts"); + }); + + test("updates relative file imports when the flow file itself is moved", () => { + const flow = { + imports: {}, + node: { + id: "MainFlow", + instances: [ + { + id: "ins1", + nodeId: "ChildFlow", + type: "visual", + source: { + type: "file", + data: "./ChildFlow.flyde", + }, + }, + { + id: "ins2", + nodeId: "InlineValue", + type: "code", + source: { + type: "package", + data: "@flyde/nodes", + }, + }, + ], + }, + }; + + const changed = updateFlowObjectImports( + flow, + "/workspace/flows/nested/MainFlow.flyde", + [ + { + oldPath: "/workspace/flows/MainFlow.flyde", + newPath: "/workspace/flows/nested/MainFlow.flyde", + }, + ] + ); + + assert.strictEqual(changed, true); + assert.strictEqual(flow.node.instances[0].source.data, "../ChildFlow.flyde"); + assert.strictEqual(flow.node.instances[1].source.data, "@flyde/nodes"); + }); + + test("computes updated relative import across directory moves", () => { + const updated = computeUpdatedRelativeImport( + "./shared/Helper.flyde.js", + "/workspace/flows/MainFlow.flyde", + "/workspace/flows/MainFlow.flyde", + [ + { + oldPath: "/workspace/flows/shared", + newPath: "/workspace/common", + }, + ] + ); + + assert.strictEqual(updated, "../common/Helper.flyde.js"); + }); + + test("leaves package and external sources untouched", () => { + const flow = { + imports: { + "@flyde/nodes": ["Add"], + }, + node: { + id: "MainFlow", + instances: [ + { + id: "ins1", + nodeId: "Add", + type: "code", + source: { + type: "package", + data: "@flyde/nodes", + }, + }, + ], + }, + }; + + const changed = updateFlowObjectImports(flow, "/workspace/flows/MainFlow.flyde", [ + { + oldPath: "/workspace/flows/Add.flyde.ts", + newPath: "/workspace/flows/sub/Add.flyde.ts", + }, + ]); + + assert.strictEqual(changed, false); + assert.strictEqual(flow.node.instances[0].source.data, "@flyde/nodes"); + assert.deepStrictEqual(flow.imports, { "@flyde/nodes": ["Add"] }); + }); +}); diff --git a/vscode/src/updateImportsOnMove.ts b/vscode/src/updateImportsOnMove.ts new file mode 100644 index 00000000..22c1e4b4 --- /dev/null +++ b/vscode/src/updateImportsOnMove.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode"; +import { + RenamedFileEntry, + updateFlowImportsOnRename, +} from "./flowImportLogic"; + +export function registerFileMoveImportUpdater( + context: vscode.ExtensionContext +) { + const disposable = vscode.workspace.onDidRenameFiles(async (event) => { + const renamedFiles: RenamedFileEntry[] = event.files.map((f) => ({ + oldPath: f.oldUri.fsPath, + newPath: f.newUri.fsPath, + })); + + if (renamedFiles.length === 0) { + return; + } + + const flydeUris = await vscode.workspace.findFiles( + "**/*.flyde", + "**/node_modules/**" + ); + + const workspaceEdit = new vscode.WorkspaceEdit(); + const docsToSave: vscode.TextDocument[] = []; + let hasEdits = false; + + for (const uri of flydeUris) { + try { + const openDoc = vscode.workspace.textDocuments.find( + (doc) => doc.uri.toString() === uri.toString() + ); + const wasDirty = openDoc ? openDoc.isDirty : false; + const document = + openDoc ?? (await vscode.workspace.openTextDocument(uri)); + const currentText = document.getText(); + + const updatedText = updateFlowImportsOnRename( + currentText, + uri.fsPath, + renamedFiles + ); + + if (updatedText !== null && updatedText !== currentText) { + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(currentText.length) + ); + workspaceEdit.replace(uri, fullRange, updatedText); + hasEdits = true; + if (!wasDirty) { + docsToSave.push(document); + } + } + } catch (err) { + console.error(`Error updating Flyde imports in ${uri.fsPath}:`, err); + } + } + + if (hasEdits) { + const applied = await vscode.workspace.applyEdit(workspaceEdit); + if (applied) { + for (const doc of docsToSave) { + await doc.save(); + } + } + } + }); + + context.subscriptions.push(disposable); +}