From f42ddc833f2d1cb73ddf43e989b569c46c587182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Ho=C3=A0i=20Nam?= Date: Tue, 19 May 2026 17:21:30 +0700 Subject: [PATCH] fix(vscode): update flyde imports after file moves --- vscode/src/extension.ts | 3 + vscode/src/flydeImportPathRewriter.ts | 139 ++++++++++++++++++ vscode/src/registerFlydeImportPathRewriter.ts | 56 +++++++ .../src/test/flydeImportPathRewriter.test.ts | 81 ++++++++++ 4 files changed, 279 insertions(+) create mode 100644 vscode/src/flydeImportPathRewriter.ts create mode 100644 vscode/src/registerFlydeImportPathRewriter.ts create mode 100644 vscode/src/test/flydeImportPathRewriter.test.ts diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..e9fbdc4a 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 { registerFlydeImportPathRewriter } from "./registerFlydeImportPathRewriter"; // the application insights key (also known as instrumentation key) @@ -37,6 +38,8 @@ export function activate(context: vscode.ExtensionContext) { vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; const fileRoot = firstWorkspace ? firstWorkspace.uri.fsPath : ""; + registerFlydeImportPathRewriter(context); + // Initialize analytics with context analytics.setContext(context); diff --git a/vscode/src/flydeImportPathRewriter.ts b/vscode/src/flydeImportPathRewriter.ts new file mode 100644 index 00000000..47521b05 --- /dev/null +++ b/vscode/src/flydeImportPathRewriter.ts @@ -0,0 +1,139 @@ +import * as path from "path"; + +type RewriteCandidate = { + dataLineIndex: number; + importPath: string; + quote: string; +}; + +export function isFlydeFile(filePath: string) { + return filePath.endsWith(".flyde"); +} + +export function toFlydeImportPath(fromFlowPath: string, targetPath: string) { + const fromDir = path.dirname(fromFlowPath); + const relativePath = path.relative(fromDir, targetPath).replace(/\\/g, "/"); + + if (relativePath.startsWith(".")) { + return relativePath; + } + + return `./${relativePath}`; +} + +export function rewriteMovedFlowImports( + content: string, + oldFlowPath: string, + newFlowPath: string +) { + return rewriteFileSourceImports(content, (importPath) => { + const originalTargetPath = path.resolve(path.dirname(oldFlowPath), importPath); + return toFlydeImportPath(newFlowPath, originalTargetPath); + }); +} + +export function rewriteReferencesToMovedFile( + content: string, + flowPath: string, + oldTargetPath: string, + newTargetPath: string +) { + return rewriteFileSourceImports(content, (importPath) => { + const resolvedImportPath = path.resolve(path.dirname(flowPath), importPath); + + if (!samePath(resolvedImportPath, oldTargetPath)) { + return importPath; + } + + return toFlydeImportPath(flowPath, newTargetPath); + }); +} + +function rewriteFileSourceImports( + content: string, + rewriteImportPath: (importPath: string) => string +) { + const hasTrailingNewline = content.endsWith("\n"); + const lines = content.split(/\r?\n/); + + const candidates = findFileSourceImportCandidates(lines); + + for (const candidate of candidates) { + const rewrittenImportPath = rewriteImportPath(candidate.importPath); + + if (rewrittenImportPath === candidate.importPath) { + continue; + } + + lines[candidate.dataLineIndex] = lines[candidate.dataLineIndex].replace( + /(\bdata:\s*)(['"]?)(.+?)(\2)\s*$/, + `$1${candidate.quote}${rewrittenImportPath}${candidate.quote}` + ); + } + + const rewritten = lines.join("\n"); + return hasTrailingNewline ? rewritten : rewritten.replace(/\n$/, ""); +} + +function findFileSourceImportCandidates(lines: string[]) { + const candidates: RewriteCandidate[] = []; + + for (let sourceLineIndex = 0; sourceLineIndex < lines.length; sourceLineIndex++) { + const sourceLine = lines[sourceLineIndex]; + const sourceMatch = sourceLine.match(/^(\s*)(-\s*)?source:\s*$/); + + if (!sourceMatch) { + continue; + } + + const sourceIndent = sourceMatch[1].length + (sourceMatch[2]?.length ?? 0); + let hasFileType = false; + let dataLineIndex = -1; + let importPath = ""; + let quote = ""; + + for (let index = sourceLineIndex + 1; index < lines.length; index++) { + const line = lines[index]; + + if (line.trim() === "") { + continue; + } + + const currentIndent = line.match(/^(\s*)/)?.[1].length ?? 0; + + if (currentIndent <= sourceIndent) { + break; + } + + const typeMatch = line.match(/^\s*type:\s*file\s*$/); + if (typeMatch) { + hasFileType = true; + continue; + } + + const dataMatch = line.match(/^\s*data:\s*(['"]?)(.+?)\1\s*$/); + if (dataMatch) { + dataLineIndex = index; + quote = dataMatch[1]; + importPath = dataMatch[2]; + } + } + + if (hasFileType && dataLineIndex !== -1) { + candidates.push({ dataLineIndex, importPath, quote }); + } + } + + return candidates; +} + +function samePath(left: string, right: string) { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + + if (process.platform === "win32") { + return normalizedLeft.toLowerCase() === normalizedRight.toLowerCase(); + } + + return normalizedLeft === normalizedRight; +} diff --git a/vscode/src/registerFlydeImportPathRewriter.ts b/vscode/src/registerFlydeImportPathRewriter.ts new file mode 100644 index 00000000..890bdf1f --- /dev/null +++ b/vscode/src/registerFlydeImportPathRewriter.ts @@ -0,0 +1,56 @@ +import * as vscode from "vscode"; +import { + isFlydeFile, + rewriteMovedFlowImports, + rewriteReferencesToMovedFile, +} from "./flydeImportPathRewriter"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +export function registerFlydeImportPathRewriter(context: vscode.ExtensionContext) { + const disposable = vscode.workspace.onDidRenameFiles(async (event) => { + for (const file of event.files) { + await rewriteMovedFlow(file.oldUri, file.newUri); + await rewriteWorkspaceReferences(file.oldUri, file.newUri); + } + }); + + context.subscriptions.push(disposable); +} + +async function rewriteMovedFlow(oldUri: vscode.Uri, newUri: vscode.Uri) { + if (!isFlydeFile(oldUri.fsPath) || !isFlydeFile(newUri.fsPath)) { + return; + } + + await rewriteFile(newUri, (content) => + rewriteMovedFlowImports(content, oldUri.fsPath, newUri.fsPath) + ); +} + +async function rewriteWorkspaceReferences(oldUri: vscode.Uri, newUri: vscode.Uri) { + const flowUris = await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**"); + + for (const flowUri of flowUris) { + await rewriteFile(flowUri, (content) => + rewriteReferencesToMovedFile(content, flowUri.fsPath, oldUri.fsPath, newUri.fsPath) + ); + } +} + +async function rewriteFile( + uri: vscode.Uri, + rewrite: (content: string) => string +) { + try { + const content = decoder.decode(await vscode.workspace.fs.readFile(uri)); + const rewritten = rewrite(content); + + if (rewritten !== content) { + await vscode.workspace.fs.writeFile(uri, encoder.encode(rewritten)); + } + } catch { + // A single unreadable or malformed flow should not block the user's file move. + } +} diff --git a/vscode/src/test/flydeImportPathRewriter.test.ts b/vscode/src/test/flydeImportPathRewriter.test.ts new file mode 100644 index 00000000..84384136 --- /dev/null +++ b/vscode/src/test/flydeImportPathRewriter.test.ts @@ -0,0 +1,81 @@ +import * as assert from "assert"; +import * as path from "path"; +import { + rewriteMovedFlowImports, + rewriteReferencesToMovedFile, + toFlydeImportPath, +} from "../flydeImportPathRewriter"; + +suite("flyde import path rewriter", () => { + test("rewrites a moved flow's own relative imports", () => { + const content = [ + "imports: {}", + "node:", + " instances:", + " - source:", + " type: file", + " data: ./ChildFlow.flyde", + " nodeId: ChildFlow", + "", + ].join("\n"); + + const rewritten = rewriteMovedFlowImports( + content, + path.join("workspace", "flows", "ParentFlow.flyde"), + path.join("workspace", "flows", "nested", "ParentFlow.flyde") + ); + + assert.match(rewritten, /data: \.\.\/ChildFlow\.flyde/); + }); + + test("rewrites references when the imported flow moves", () => { + const content = [ + "imports: {}", + "node:", + " instances:", + " - source:", + " type: file", + " data: ./ChildFlow.flyde", + " nodeId: ChildFlow", + "", + ].join("\n"); + + const rewritten = rewriteReferencesToMovedFile( + content, + path.join("workspace", "flows", "ParentFlow.flyde"), + path.join("workspace", "flows", "ChildFlow.flyde"), + path.join("workspace", "shared", "ChildFlow.flyde") + ); + + assert.match(rewritten, /data: \.\.\/shared\/ChildFlow\.flyde/); + }); + + test("leaves package sources untouched", () => { + const content = [ + "node:", + " instances:", + " - source:", + " type: package", + " data: \"@flyde/nodes\"", + " nodeId: Add", + "", + ].join("\n"); + + const rewritten = rewriteMovedFlowImports( + content, + path.join("workspace", "flows", "ParentFlow.flyde"), + path.join("workspace", "nested", "ParentFlow.flyde") + ); + + assert.strictEqual(rewritten, content); + }); + + test("uses relative POSIX import paths for same-folder targets", () => { + const importPath = toFlydeImportPath( + path.join("workspace", "flows", "ParentFlow.flyde"), + path.join("workspace", "flows", "ChildFlow.flyde") + ); + + assert.strictEqual(importPath, "./ChildFlow.flyde"); + }); +});