From e9373ed7c5f50b123ca863e1b085480e8da44100 Mon Sep 17 00:00:00 2001 From: Aldii24 Date: Mon, 26 Oct 2026 18:42:46 +0700 Subject: [PATCH 1/2] fix: resolve and update relative file imports after moving files (#112) --- loader/src/resolver/movedFileImports.spec.ts | 456 ++++++++++++++++++ .../server/findReferencedNodeServer.ts | 77 +-- .../src/resolver/server/movedFileImports.ts | 456 ++++++++++++++++++ loader/src/server.ts | 3 +- vscode/src/extension.ts | 3 + vscode/src/fileMoveHandler.ts | 77 +++ 6 files changed, 1040 insertions(+), 32 deletions(-) create mode 100644 loader/src/resolver/movedFileImports.spec.ts create mode 100644 loader/src/resolver/server/movedFileImports.ts create mode 100644 vscode/src/fileMoveHandler.ts diff --git a/loader/src/resolver/movedFileImports.spec.ts b/loader/src/resolver/movedFileImports.spec.ts new file mode 100644 index 00000000..8b67f16d --- /dev/null +++ b/loader/src/resolver/movedFileImports.spec.ts @@ -0,0 +1,456 @@ +import { dynamicNodeInput, execute } from "@flyde/core"; +import { spiedOutput } from "@flyde/core/dist/test-utils"; +import { assert } from "chai"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { deserializeFlow } from "../serdes"; +import { resolveEditorNode } from "./resolveEditorNode"; +import { createServerReferencedNodeFinder } from "./server/findReferencedNodeServer"; +import { updateFlowImportsForMovedFiles } from "./server/movedFileImports"; +import { resolveFlowByPath } from "./server/resolveFlowServer"; + +describe("moved file imports", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "flyde-moved-imports-")); + writeFileSync( + join(tempRoot, "package.json"), + JSON.stringify({ name: "flyde-moved-test", version: "1.0.0" }) + ); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("resolves and executes a flow after an imported code node file is moved to a subfolder", () => { + const subDir = join(tempRoot, "nodes", "math"); + mkdirSync(subDir, { recursive: true }); + + writeFileSync( + join(subDir, "AddOne.flyde.js"), + `module.exports = { + id: "AddOne", + inputs: { n: { mode: "required", type: "number" } }, + outputs: { r: { type: "number" } }, + run: (inputs, outputs) => outputs.r.next(inputs.n + 1) + };` + ); + + const flowPath = join(tempRoot, "Main.flyde"); + writeFileSync( + flowPath, + `imports: {} +node: + id: Main + inputs: + n: + mode: required + type: number + outputs: + r: + type: number + instances: + - id: ins1 + nodeId: AddOne + type: code + source: + type: file + data: ./AddOne.flyde.js + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + connections: + - from: + insId: __this + pinId: n + to: + insId: ins1 + pinId: n + - from: + insId: ins1 + pinId: r + to: + insId: __this + pinId: r + inputsPosition: {} + outputsPosition: {} +` + ); + + const resolved = resolveFlowByPath(flowPath); + const [spy, r] = spiedOutput(); + const n = dynamicNodeInput(); + + execute({ + node: resolved, + inputs: { n }, + outputs: { r }, + }); + + n.subject.next(10); + assert.equal(spy.lastCall.args[0], 11); + }); + + it("resolves and executes a flow after the flow file itself is moved into a subfolder", () => { + writeFileSync( + join(tempRoot, "MultiplyTwo.flyde.js"), + `module.exports = { + id: "MultiplyTwo", + inputs: { n: { mode: "required", type: "number" } }, + outputs: { r: { type: "number" } }, + run: (inputs, outputs) => outputs.r.next(inputs.n * 2) + };` + ); + + const movedFlowsDir = join(tempRoot, "flows", "nested"); + mkdirSync(movedFlowsDir, { recursive: true }); + const flowPath = join(movedFlowsDir, "MovedFlow.flyde"); + + writeFileSync( + flowPath, + `imports: {} +node: + id: MovedFlow + inputs: + n: + mode: required + type: number + outputs: + r: + type: number + instances: + - id: ins1 + nodeId: MultiplyTwo + type: code + source: + type: file + data: ./MultiplyTwo.flyde.js + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + connections: + - from: + insId: __this + pinId: n + to: + insId: ins1 + pinId: n + - from: + insId: ins1 + pinId: r + to: + insId: __this + pinId: r + inputsPosition: {} + outputsPosition: {} +` + ); + + const resolved = resolveFlowByPath(flowPath); + const [spy, r] = spiedOutput(); + const n = dynamicNodeInput(); + + execute({ + node: resolved, + inputs: { n }, + outputs: { r }, + }); + + n.subject.next(7); + assert.equal(spy.lastCall.args[0], 14); + }); + + it("resolves transitive dependencies when a visual subflow and its code node are in another folder", () => { + const subDir = join(tempRoot, "subflows"); + mkdirSync(subDir, { recursive: true }); + + writeFileSync( + join(subDir, "Step.flyde.js"), + `module.exports = { + id: "Step", + inputs: { n: { mode: "required", type: "number" } }, + outputs: { r: { type: "number" } }, + run: (inputs, outputs) => outputs.r.next(inputs.n + 5) + };` + ); + + writeFileSync( + join(subDir, "ChildFlow.flyde"), + `imports: {} +node: + id: ChildFlow + inputs: + n: + mode: required + type: number + outputs: + r: + type: number + instances: + - id: step1 + nodeId: Step + type: code + source: + type: file + data: ./Step.flyde.js + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + connections: + - from: + insId: __this + pinId: n + to: + insId: step1 + pinId: n + - from: + insId: step1 + pinId: r + to: + insId: __this + pinId: r + inputsPosition: {} + outputsPosition: {} +` + ); + + const parentPath = join(tempRoot, "ParentFlow.flyde"); + const parentRaw = `imports: {} +node: + id: ParentFlow + inputs: + n: + mode: required + type: number + outputs: + r: + type: number + instances: + - id: child1 + nodeId: ChildFlow + type: visual + source: + type: file + data: ./subflows/ChildFlow.flyde + inputConfig: {} + pos: + x: 0 + y: 0 + connections: + - from: + insId: __this + pinId: n + to: + insId: child1 + pinId: n + - from: + insId: child1 + pinId: r + to: + insId: __this + pinId: r + inputsPosition: {} + outputsPosition: {} +`; + writeFileSync(parentPath, parentRaw); + + const resolvedRuntime = resolveFlowByPath(parentPath); + const [spy, r] = spiedOutput(); + const n = dynamicNodeInput(); + + execute({ + node: resolvedRuntime, + inputs: { n }, + outputs: { r }, + }); + + n.subject.next(3); + assert.equal(spy.lastCall.args[0], 8); + + const parsedParent = deserializeFlow(parentRaw, parentPath); + const editorNode = resolveEditorNode( + parsedParent.node, + createServerReferencedNodeFinder(parentPath) + ); + const childEditorNode = editorNode.instances[0].node as any; + assert.equal(childEditorNode.id, "ChildFlow"); + assert.equal(childEditorNode.instances[0].node.id, "Step__step1"); + }); + + it("resolves custom editorComponentBundlePath relative to moved code node file", () => { + const subDir = join(tempRoot, "custom-ui"); + mkdirSync(subDir, { recursive: true }); + + writeFileSync(join(subDir, "bundle.js"), "window.__customBundle = true;"); + writeFileSync( + join(subDir, "CustomUiNode.flyde.js"), + `module.exports = { + id: "CustomUiNode", + inputs: { n: { type: "number" } }, + outputs: { r: { type: "number" } }, + editorConfig: { + type: "custom", + editorComponentBundlePath: "./bundle.js" + }, + run: (inputs, outputs) => outputs.r.next(inputs.n) + };` + ); + + const flowPath = join(tempRoot, "FlowWithCustomUi.flyde"); + const finder = createServerReferencedNodeFinder(flowPath); + const resolved = finder({ + id: "ins1", + nodeId: "CustomUiNode", + type: "code", + source: { + type: "file", + data: "./custom-ui/CustomUiNode.flyde.js", + }, + config: {}, + inputConfig: {}, + pos: { x: 0, y: 0 }, + }) as any; + + assert.equal( + resolved.editorConfig.editorComponentBundleContent, + "window.__customBundle = true;" + ); + }); + + it("updates relative file imports when an imported file or the flow itself is moved", () => { + const rawFlow = `imports: + ./legacy/OldHelper.flyde.js: + - OldHelper +node: + id: ParentFlow + inputs: {} + outputs: {} + instances: + - id: child1 + nodeId: ChildFlow + type: visual + source: + type: file + data: ./ChildFlow.flyde + inputConfig: {} + pos: + x: 0 + y: 0 + - id: inline1 + nodeId: InlineGroup + type: visual + source: + type: inline + data: + id: InlineGroup + inputs: {} + outputs: {} + instances: + - id: code1 + nodeId: HelperNode + type: code + source: + type: file + data: ChildHelper.flyde.ts + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + connections: [] + inputsPosition: {} + outputsPosition: {} + inputConfig: {} + pos: + x: 10 + y: 10 + connections: [] + inputsPosition: {} + outputsPosition: {} +`; + + const oldFlowPath = "/workspace/project/ParentFlow.flyde"; + const newFlowPath = "/workspace/project/flows/ParentFlow.flyde"; + + const result = updateFlowImportsForMovedFiles( + rawFlow, + oldFlowPath, + newFlowPath, + [ + { + oldPath: "/workspace/project/ParentFlow.flyde", + newPath: "/workspace/project/flows/ParentFlow.flyde", + }, + { + oldPath: "/workspace/project/ChildFlow.flyde", + newPath: "/workspace/project/subflows/ChildFlow.flyde", + }, + ] + ); + + assert.isTrue(result.changed); + assert.include(result.content, "../subflows/ChildFlow.flyde"); + assert.include(result.content, "../ChildHelper.flyde.ts"); + assert.include(result.content, "../legacy/OldHelper.flyde.js"); + }); + + it("keeps intra-folder relative imports unchanged when a folder is moved as a unit", () => { + const rawFlow = `imports: {} +node: + id: SubFlow + inputs: {} + outputs: {} + instances: + - id: local1 + nodeId: LocalNode + type: code + source: + type: file + data: ./LocalNode.flyde.ts + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + - id: external1 + nodeId: SharedNode + type: code + source: + type: file + data: ../shared/SharedNode.flyde.ts + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + connections: [] + inputsPosition: {} + outputsPosition: {} +`; + + const result = updateFlowImportsForMovedFiles( + rawFlow, + "/workspace/project/feature/SubFlow.flyde", + "/workspace/project/src/feature/SubFlow.flyde", + [ + { + oldPath: "/workspace/project/feature", + newPath: "/workspace/project/src/feature", + }, + ] + ); + + assert.isTrue(result.changed); + assert.include(result.content, "./LocalNode.flyde.ts"); + assert.include(result.content, "../../shared/SharedNode.flyde.ts"); + }); +}); diff --git a/loader/src/resolver/server/findReferencedNodeServer.ts b/loader/src/resolver/server/findReferencedNodeServer.ts index ecfc88f6..f6962864 100644 --- a/loader/src/resolver/server/findReferencedNodeServer.ts +++ b/loader/src/resolver/server/findReferencedNodeServer.ts @@ -13,6 +13,10 @@ import { resolveImportablePaths } from "./resolveImportablePaths"; import { deserializeFlowByPath } from "../../serdes"; import { ReferencedNodeFinder } from "./../ReferencedNodeFinder"; import { resolveCodeNodeDependencies } from "./serverUtils"; +import { + rebaseVisualNodeFileSources, + resolveMovedFilePath, +} from "./movedFileImports"; const LocalNodes = Object.values(_Nodes).reduce>( (acc, curr) => { @@ -49,8 +53,12 @@ export function createServerReferencedNodeFinder( if (isVisual) { try { - const node = deserializeFlowByPath(path).node; - // No longer adding sourcePath + const rawNode = deserializeFlowByPath(path).node; + const node = rebaseVisualNodeFileSources( + rawNode, + path, + fullFlowPath + ); return { ...node, sourcePath: path }; } catch (e) { return []; @@ -135,16 +143,25 @@ export function createServerReferencedNodeFinder( return nodeWrapper; } case "file": { - const fullFilePath = join(fullFlowPath, "..", instance.source.data); + const fullFilePath = resolveMovedFilePath( + fullFlowPath, + instance.source.data, + instance.nodeId + ); // Check if the file is a .flyde file (visual flow) - if (fullFilePath.endsWith('.flyde')) { - const node = deserializeFlowByPath(fullFilePath).node; - if (node.id !== instance.nodeId) { + if (fullFilePath.endsWith(".flyde")) { + const rawNode = deserializeFlowByPath(fullFilePath).node; + if (rawNode.id !== instance.nodeId) { throw new Error( - `Node ID mismatch: expected ${instance.nodeId} but found ${node.id} in ${fullFilePath}` + `Node ID mismatch: expected ${instance.nodeId} but found ${rawNode.id} in ${fullFilePath}` ); } + const node = rebaseVisualNodeFileSources( + rawNode, + fullFilePath, + fullFlowPath + ); return node as unknown as FlydeNode; } @@ -169,11 +186,18 @@ export function createServerReferencedNodeFinder( "type" in maybeAdvancedNode.editorConfig && maybeAdvancedNode.editorConfig.type === "custom" ) { - const fullPath = join( - fullFlowPath, + const nodeDirBundlePath = join( + fullFilePath, "..", maybeAdvancedNode.editorConfig.editorComponentBundlePath ); + const fullPath = existsSync(nodeDirBundlePath) + ? nodeDirBundlePath + : join( + fullFlowPath, + "..", + maybeAdvancedNode.editorConfig.editorComponentBundlePath + ); const fileContent = readFileSync(fullPath, "utf-8"); maybeAdvancedNode.editorConfig.editorComponentBundleContent = fileContent; @@ -205,15 +229,20 @@ function getLocalOrPackagePaths(fullFlowPath: string, importPath: string) { if (existsSync(fullImportPath)) { return [fullImportPath]; - } else { - try { - return resolveImportablePaths(fullFlowPath, importPath); - } catch (e) { - if (importPath !== "@flyde/nodes") { - throw new Error(`Cannot find module ${importPath} in ${fullFlowPath}`); - } - return []; + } else if (importPath.startsWith(".")) { + const movedPath = resolveMovedFilePath(fullFlowPath, importPath); + if (existsSync(movedPath)) { + return [movedPath]; + } + } + + try { + return resolveImportablePaths(fullFlowPath, importPath); + } catch (e) { + if (importPath !== "@flyde/nodes") { + throw new Error(`Cannot find module ${importPath} in ${fullFlowPath}`); } + return []; } } @@ -244,17 +273,3 @@ function getSourceCode(nodeId: string): string { } } } - -// Example of how to import and use the bundled configs: -/* -// Import all bundled configs -const bundledConfigs = require('@flyde/nodes/dist/bundled-config'); - -// Access a specific node bundle -const inlineValueBundle = bundledConfigs.InlineValue; - -// Or import a specific node directly -const nodeBundle = require('@flyde/nodes/dist/bundled-config/InlineValue'); - -// Each import provides the full bundle JavaScript as a string -*/ diff --git a/loader/src/resolver/server/movedFileImports.ts b/loader/src/resolver/server/movedFileImports.ts new file mode 100644 index 00000000..722b20b8 --- /dev/null +++ b/loader/src/resolver/server/movedFileImports.ts @@ -0,0 +1,456 @@ +import { + FlydeFlow, + isInlineVisualNodeInstance, + isVisualNode, + NodeInstance, + VisualNode, +} from "@flyde/core"; +import { existsSync, lstatSync, readdirSync } from "fs"; +import { basename, dirname, join, relative, resolve, sep } from "path"; +import * as yaml from "yaml"; +import findRoot = require("find-root"); +import { deserializeFlowByPath, serializeFlow } from "../../serdes"; +import { isCodeNodePath, resolveCodeNodeDependencies } from "./serverUtils"; + +export interface FileMove { + oldPath: string; + newPath: string; +} + +const IGNORED_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "out", + "build", + ".next", + "coverage", +]); + +export function formatRelativeImportPath( + fromDir: string, + toFilePath: string, + originalPath?: string, + forceDotPrefix = false +): string { + const rel = relative(resolve(fromDir), resolve(toFilePath)) + .split(sep) + .join("/"); + + const needsDotPrefix = + forceDotPrefix || (originalPath ? originalPath.startsWith("./") : false); + + if (needsDotPrefix && !rel.startsWith(".")) { + return `./${rel}`; + } + + return rel; +} + +function getMovedTargetPath( + oldTargetAbsPath: string, + moves: FileMove[] +): string { + const normalizedTarget = resolve(oldTargetAbsPath); + + for (const move of moves) { + const oldAbs = resolve(move.oldPath); + const newAbs = resolve(move.newPath); + + if (normalizedTarget === oldAbs) { + return newAbs; + } + + if (normalizedTarget.startsWith(oldAbs + sep)) { + return join(newAbs, relative(oldAbs, normalizedTarget)); + } + } + + return normalizedTarget; +} + +export function rebaseVisualNodeFileSources( + node: VisualNode, + fromFlowPath: string, + toFlowPath: string +): VisualNode { + const fromDir = dirname(resolve(fromFlowPath)); + const toDir = dirname(resolve(toFlowPath)); + + if (fromDir === toDir) { + return node; + } + + const rebasedInstances = node.instances.map((instance) => { + let nextInstance: NodeInstance = { ...instance }; + + if ( + nextInstance.source?.type === "file" && + typeof nextInstance.source.data === "string" + ) { + const resolvedTarget = resolveMovedFilePath( + fromFlowPath, + nextInstance.source.data, + nextInstance.nodeId + ); + const rebasedData = formatRelativeImportPath( + toDir, + resolvedTarget, + nextInstance.source.data + ); + nextInstance = { + ...nextInstance, + source: { + ...nextInstance.source, + data: rebasedData, + }, + }; + } else if (isInlineVisualNodeInstance(nextInstance)) { + nextInstance = { + ...nextInstance, + source: { + ...nextInstance.source, + data: rebaseVisualNodeFileSources( + nextInstance.source.data, + fromFlowPath, + toFlowPath + ), + }, + }; + } + + const nestedNode = (nextInstance as any).node; + if (nestedNode && isVisualNode(nestedNode)) { + (nextInstance as any).node = rebaseVisualNodeFileSources( + nestedNode, + fromFlowPath, + toFlowPath + ); + } + + return nextInstance; + }); + + return { + ...node, + instances: rebasedInstances, + }; +} + +function collectCandidateFiles( + dir: string, + predicate: (filePath: string, fileName: string) => boolean, + maxDepth = 7, + results: string[] = [] +): string[] { + if (maxDepth < 0 || !existsSync(dir)) { + return results; + } + + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return results; + } + + for (const entry of entries) { + if (IGNORED_DIRS.has(entry)) { + continue; + } + + const fullPath = join(dir, entry); + let stats; + try { + stats = lstatSync(fullPath); + } catch { + continue; + } + + if (stats.isDirectory()) { + collectCandidateFiles(fullPath, predicate, maxDepth - 1, results); + } else if (stats.isFile() && predicate(fullPath, entry)) { + results.push(fullPath); + } + } + + return results; +} + +function candidateDefinesNode(candidatePath: string, nodeId?: string): boolean { + if (!nodeId) { + return true; + } + + try { + if (candidatePath.endsWith(".flyde")) { + const flow = deserializeFlowByPath(candidatePath); + return flow.node.id === nodeId; + } + + if (isCodeNodePath(candidatePath)) { + const { nodes } = resolveCodeNodeDependencies(candidatePath); + return nodes.some(({ node }) => node.id === nodeId); + } + } catch { + return false; + } + + return false; +} + +function getSearchRoot(fullFlowPath: string): string { + const flowDir = dirname(resolve(fullFlowPath)); + try { + return findRoot(flowDir); + } catch { + let current = flowDir; + for (let i = 0; i < 2; i++) { + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return current; + } +} + +function rankByProximity(flowDir: string, candidates: string[]): string[] { + return [...candidates].sort((a, b) => { + const relA = relative(flowDir, a).split(sep).length; + const relB = relative(flowDir, b).split(sep).length; + return relA - relB; + }); +} + +export function resolveMovedFilePath( + fullFlowPath: string, + relativeImportPath: string, + nodeId?: string +): string { + const flowDir = dirname(resolve(fullFlowPath)); + const directPath = join(flowDir, relativeImportPath); + + if (existsSync(directPath)) { + return directPath; + } + + const targetFileName = basename(relativeImportPath); + const directDir = dirname(directPath); + if (existsSync(directDir)) { + try { + const siblingMatch = readdirSync(directDir).find( + (entry) => entry.toLowerCase() === targetFileName.toLowerCase() + ); + if (siblingMatch) { + const matchedSiblingPath = join(directDir, siblingMatch); + if (existsSync(matchedSiblingPath)) { + return matchedSiblingPath; + } + } + } catch { + // Ignore directory read errors + } + } + + const searchRoot = getSearchRoot(fullFlowPath); + + const sameNameCandidates = rankByProximity( + flowDir, + collectCandidateFiles( + searchRoot, + (filePath, fileName) => + fileName.toLowerCase() === targetFileName.toLowerCase() && + resolve(filePath) !== resolve(fullFlowPath) + ) + ); + + for (const candidate of sameNameCandidates) { + if (candidateDefinesNode(candidate, nodeId)) { + return candidate; + } + } + + if (sameNameCandidates.length === 1) { + return sameNameCandidates[0]; + } + + if (nodeId) { + const isVisualTarget = targetFileName.endsWith(".flyde"); + const nodeMatchCandidates = rankByProximity( + flowDir, + collectCandidateFiles(searchRoot, (filePath, fileName) => { + if (resolve(filePath) === resolve(fullFlowPath)) { + return false; + } + if (isVisualTarget) { + return fileName.endsWith(".flyde"); + } + return ( + fileName.endsWith(".flyde.ts") || fileName.endsWith(".flyde.js") + ); + }) + ); + + for (const candidate of nodeMatchCandidates) { + if (candidateDefinesNode(candidate, nodeId)) { + return candidate; + } + } + } + + return directPath; +} + +export function updateVisualNodeMovedFileImports( + node: VisualNode, + oldFlowPath: string, + newFlowPath: string, + moves: FileMove[] +): { node: VisualNode; changed: boolean } { + const oldFlowDir = dirname(resolve(oldFlowPath)); + const newFlowDir = dirname(resolve(newFlowPath)); + const flowMoved = oldFlowDir !== newFlowDir; + + let changed = false; + + const updatedInstances = node.instances.map((instance) => { + if ( + instance.source?.type === "file" && + typeof instance.source.data === "string" + ) { + const oldTargetAbsPath = resolve(oldFlowDir, instance.source.data); + const newTargetAbsPath = getMovedTargetPath(oldTargetAbsPath, moves); + const targetMoved = newTargetAbsPath !== oldTargetAbsPath; + + if (flowMoved || targetMoved) { + const nextRelPath = formatRelativeImportPath( + newFlowDir, + newTargetAbsPath, + instance.source.data + ); + if (nextRelPath !== instance.source.data) { + changed = true; + return { + ...instance, + source: { + ...instance.source, + data: nextRelPath, + }, + }; + } + } + return instance; + } + + if (isInlineVisualNodeInstance(instance)) { + const nestedResult = updateVisualNodeMovedFileImports( + instance.source.data, + oldFlowPath, + newFlowPath, + moves + ); + if (nestedResult.changed) { + changed = true; + return { + ...instance, + source: { + ...instance.source, + data: nestedResult.node, + }, + }; + } + } + + return instance; + }); + + if (!changed) { + return { node, changed: false }; + } + + return { + node: { + ...node, + instances: updatedInstances, + }, + changed: true, + }; +} + +export function updateFlowImportsForMovedFiles( + rawFlow: string, + oldFlowPath: string, + newFlowPath: string, + moves: FileMove[] +): { content: string; changed: boolean } { + if (!rawFlow.trim()) { + return { content: rawFlow, changed: false }; + } + + const parsed = yaml.parse(rawFlow); + if (!parsed || typeof parsed !== "object" || !parsed.node) { + return { content: rawFlow, changed: false }; + } + + const oldFlowDir = dirname(resolve(oldFlowPath)); + const newFlowDir = dirname(resolve(newFlowPath)); + const flowMoved = oldFlowDir !== newFlowDir; + + let importsChanged = false; + const nextImports: Record = {}; + + if (parsed.imports && typeof parsed.imports === "object") { + for (const [importKey, importedValue] of Object.entries(parsed.imports)) { + if (importKey.startsWith(".")) { + const oldTargetAbsPath = resolve(oldFlowDir, importKey); + const newTargetAbsPath = getMovedTargetPath(oldTargetAbsPath, moves); + const targetMoved = newTargetAbsPath !== oldTargetAbsPath; + + if (flowMoved || targetMoved) { + const nextKey = formatRelativeImportPath( + newFlowDir, + newTargetAbsPath, + importKey, + true + ); + if (nextKey !== importKey) { + importsChanged = true; + } + nextImports[nextKey] = importedValue as string | string[]; + continue; + } + } + nextImports[importKey] = importedValue as string | string[]; + } + } + + const visualResult = updateVisualNodeMovedFileImports( + parsed.node as VisualNode, + oldFlowPath, + newFlowPath, + moves + ); + + if (!importsChanged && !visualResult.changed) { + return { content: rawFlow, changed: false }; + } + + const updatedFlow = { + ...(parsed.imports ? { imports: nextImports } : {}), + node: visualResult.node, + }; + + try { + return { + content: serializeFlow(updatedFlow as FlydeFlow), + changed: true, + }; + } catch { + return { + content: yaml.stringify(updatedFlow, { aliasDuplicateObjects: false }), + changed: true, + }; + } +} diff --git a/loader/src/server.ts b/loader/src/server.ts index cfb3376b..dc3e6000 100644 --- a/loader/src/server.ts +++ b/loader/src/server.ts @@ -8,4 +8,5 @@ export * from "./run-flow"; export { createServerReferencedNodeFinder } from "./resolver/server/findReferencedNodeServer"; export * from "./resolver/server/resolveImportablePaths"; export { resolveFlowByPath } from "./resolver/server/resolveFlowServer"; -export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils"; \ No newline at end of file +export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils"; +export * from "./resolver/server/movedFileImports"; diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..39c4fdea 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 { registerFileMoveHandler } from "./fileMoveHandler"; // 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) ); + context.subscriptions.push(registerFileMoveHandler()); + function getWorkspaceRootPath(): vscode.Uri | undefined { // Check if there is an open workspace if ( diff --git a/vscode/src/fileMoveHandler.ts b/vscode/src/fileMoveHandler.ts new file mode 100644 index 00000000..1249a450 --- /dev/null +++ b/vscode/src/fileMoveHandler.ts @@ -0,0 +1,77 @@ +import * as vscode from "vscode"; +import { join, relative, resolve, sep } from "path"; +import { + FileMove, + updateFlowImportsForMovedFiles, +} from "@flyde/loader/dist/server"; + +function getNewFlowPath(flowPath: string, moves: FileMove[]): string { + const normalizedFlow = resolve(flowPath); + + for (const move of moves) { + const oldAbs = resolve(move.oldPath); + const newAbs = resolve(move.newPath); + + if (normalizedFlow === oldAbs) { + return newAbs; + } + + if (normalizedFlow.startsWith(oldAbs + sep)) { + return join(newAbs, relative(oldAbs, normalizedFlow)); + } + } + + return normalizedFlow; +} + +export async function createMovedFilesWorkspaceEdit( + renamedFiles: readonly { + readonly oldUri: vscode.Uri; + readonly newUri: vscode.Uri; + }[] +): Promise { + const edit = new vscode.WorkspaceEdit(); + const moves: FileMove[] = renamedFiles.map(({ oldUri, newUri }) => ({ + oldPath: oldUri.fsPath, + newPath: newUri.fsPath, + })); + + if (moves.length === 0) { + return edit; + } + + const flydeUris = await vscode.workspace.findFiles( + "**/*.flyde", + "**/node_modules/**" + ); + + for (const uri of flydeUris) { + try { + const document = await vscode.workspace.openTextDocument(uri); + const oldFlowPath = uri.fsPath; + const newFlowPath = getNewFlowPath(oldFlowPath, moves); + + const { content, changed } = updateFlowImportsForMovedFiles( + document.getText(), + oldFlowPath, + newFlowPath, + moves + ); + + if (changed) { + const fullRange = new vscode.Range(0, 0, document.lineCount, 0); + edit.replace(uri, fullRange, content); + } + } catch (err) { + console.error(`Failed to update imports for ${uri.fsPath}:`, err); + } + } + + return edit; +} + +export function registerFileMoveHandler(): vscode.Disposable { + return vscode.workspace.onWillRenameFiles((event) => { + event.waitUntil(createMovedFilesWorkspaceEdit(event.files)); + }); +} From 9ea36da1c0752bc4c31dfad69d1943e95575f75f Mon Sep 17 00:00:00 2001 From: Aldii24 Date: Mon, 26 Oct 2026 19:05:34 +0700 Subject: [PATCH 2/2] refactor: simplify moved file lookup using glob.sync --- loader/src/resolver/movedFileImports.spec.ts | 242 +++++------------- .../src/resolver/server/movedFileImports.ts | 182 +++---------- 2 files changed, 97 insertions(+), 327 deletions(-) diff --git a/loader/src/resolver/movedFileImports.spec.ts b/loader/src/resolver/movedFileImports.spec.ts index 8b67f16d..ef99c6e6 100644 --- a/loader/src/resolver/movedFileImports.spec.ts +++ b/loader/src/resolver/movedFileImports.spec.ts @@ -10,6 +10,53 @@ import { createServerReferencedNodeFinder } from "./server/findReferencedNodeSer import { updateFlowImportsForMovedFiles } from "./server/movedFileImports"; import { resolveFlowByPath } from "./server/resolveFlowServer"; +function singleNodeFlowYaml( + flowId: string, + insId: string, + nodeId: string, + type: "code" | "visual", + filePath: string +): string { + return `imports: {} +node: + id: ${flowId} + inputs: + n: + mode: required + type: number + outputs: + r: + type: number + instances: + - id: ${insId} + nodeId: ${nodeId} + type: ${type} + source: + type: file + data: ${filePath} + config: {} + inputConfig: {} + pos: + x: 0 + y: 0 + connections: + - from: + insId: __this + pinId: n + to: + insId: ${insId} + pinId: n + - from: + insId: ${insId} + pinId: r + to: + insId: __this + pinId: r + inputsPosition: {} + outputsPosition: {} +`; +} + describe("moved file imports", () => { let tempRoot: string; @@ -42,56 +89,14 @@ describe("moved file imports", () => { const flowPath = join(tempRoot, "Main.flyde"); writeFileSync( flowPath, - `imports: {} -node: - id: Main - inputs: - n: - mode: required - type: number - outputs: - r: - type: number - instances: - - id: ins1 - nodeId: AddOne - type: code - source: - type: file - data: ./AddOne.flyde.js - config: {} - inputConfig: {} - pos: - x: 0 - y: 0 - connections: - - from: - insId: __this - pinId: n - to: - insId: ins1 - pinId: n - - from: - insId: ins1 - pinId: r - to: - insId: __this - pinId: r - inputsPosition: {} - outputsPosition: {} -` + singleNodeFlowYaml("Main", "ins1", "AddOne", "code", "./AddOne.flyde.js") ); const resolved = resolveFlowByPath(flowPath); const [spy, r] = spiedOutput(); const n = dynamicNodeInput(); - execute({ - node: resolved, - inputs: { n }, - outputs: { r }, - }); - + execute({ node: resolved, inputs: { n }, outputs: { r } }); n.subject.next(10); assert.equal(spy.lastCall.args[0], 11); }); @@ -113,56 +118,20 @@ node: writeFileSync( flowPath, - `imports: {} -node: - id: MovedFlow - inputs: - n: - mode: required - type: number - outputs: - r: - type: number - instances: - - id: ins1 - nodeId: MultiplyTwo - type: code - source: - type: file - data: ./MultiplyTwo.flyde.js - config: {} - inputConfig: {} - pos: - x: 0 - y: 0 - connections: - - from: - insId: __this - pinId: n - to: - insId: ins1 - pinId: n - - from: - insId: ins1 - pinId: r - to: - insId: __this - pinId: r - inputsPosition: {} - outputsPosition: {} -` + singleNodeFlowYaml( + "MovedFlow", + "ins1", + "MultiplyTwo", + "code", + "./MultiplyTwo.flyde.js" + ) ); const resolved = resolveFlowByPath(flowPath); const [spy, r] = spiedOutput(); const n = dynamicNodeInput(); - execute({ - node: resolved, - inputs: { n }, - outputs: { r }, - }); - + execute({ node: resolved, inputs: { n }, outputs: { r } }); n.subject.next(7); assert.equal(spy.lastCall.args[0], 14); }); @@ -183,96 +152,24 @@ node: writeFileSync( join(subDir, "ChildFlow.flyde"), - `imports: {} -node: - id: ChildFlow - inputs: - n: - mode: required - type: number - outputs: - r: - type: number - instances: - - id: step1 - nodeId: Step - type: code - source: - type: file - data: ./Step.flyde.js - config: {} - inputConfig: {} - pos: - x: 0 - y: 0 - connections: - - from: - insId: __this - pinId: n - to: - insId: step1 - pinId: n - - from: - insId: step1 - pinId: r - to: - insId: __this - pinId: r - inputsPosition: {} - outputsPosition: {} -` + singleNodeFlowYaml("ChildFlow", "step1", "Step", "code", "./Step.flyde.js") ); const parentPath = join(tempRoot, "ParentFlow.flyde"); - const parentRaw = `imports: {} -node: - id: ParentFlow - inputs: - n: - mode: required - type: number - outputs: - r: - type: number - instances: - - id: child1 - nodeId: ChildFlow - type: visual - source: - type: file - data: ./subflows/ChildFlow.flyde - inputConfig: {} - pos: - x: 0 - y: 0 - connections: - - from: - insId: __this - pinId: n - to: - insId: child1 - pinId: n - - from: - insId: child1 - pinId: r - to: - insId: __this - pinId: r - inputsPosition: {} - outputsPosition: {} -`; + const parentRaw = singleNodeFlowYaml( + "ParentFlow", + "child1", + "ChildFlow", + "visual", + "./subflows/ChildFlow.flyde" + ); writeFileSync(parentPath, parentRaw); const resolvedRuntime = resolveFlowByPath(parentPath); const [spy, r] = spiedOutput(); const n = dynamicNodeInput(); - execute({ - node: resolvedRuntime, - inputs: { n }, - outputs: { r }, - }); - + execute({ node: resolvedRuntime, inputs: { n }, outputs: { r } }); n.subject.next(3); assert.equal(spy.lastCall.args[0], 8); @@ -378,13 +275,10 @@ node: outputsPosition: {} `; - const oldFlowPath = "/workspace/project/ParentFlow.flyde"; - const newFlowPath = "/workspace/project/flows/ParentFlow.flyde"; - const result = updateFlowImportsForMovedFiles( rawFlow, - oldFlowPath, - newFlowPath, + "/workspace/project/ParentFlow.flyde", + "/workspace/project/flows/ParentFlow.flyde", [ { oldPath: "/workspace/project/ParentFlow.flyde", diff --git a/loader/src/resolver/server/movedFileImports.ts b/loader/src/resolver/server/movedFileImports.ts index 722b20b8..8a86673a 100644 --- a/loader/src/resolver/server/movedFileImports.ts +++ b/loader/src/resolver/server/movedFileImports.ts @@ -5,7 +5,8 @@ import { NodeInstance, VisualNode, } from "@flyde/core"; -import { existsSync, lstatSync, readdirSync } from "fs"; +import { existsSync } from "fs"; +import * as glob from "glob"; import { basename, dirname, join, relative, resolve, sep } from "path"; import * as yaml from "yaml"; import findRoot = require("find-root"); @@ -17,16 +18,6 @@ export interface FileMove { newPath: string; } -const IGNORED_DIRS = new Set([ - "node_modules", - ".git", - "dist", - "out", - "build", - ".next", - "coverage", -]); - export function formatRelativeImportPath( fromDir: string, toFilePath: string, @@ -40,11 +31,7 @@ export function formatRelativeImportPath( const needsDotPrefix = forceDotPrefix || (originalPath ? originalPath.startsWith("./") : false); - if (needsDotPrefix && !rel.startsWith(".")) { - return `./${rel}`; - } - - return rel; + return needsDotPrefix && !rel.startsWith(".") ? `./${rel}` : rel; } function getMovedTargetPath( @@ -93,16 +80,15 @@ export function rebaseVisualNodeFileSources( nextInstance.source.data, nextInstance.nodeId ); - const rebasedData = formatRelativeImportPath( - toDir, - resolvedTarget, - nextInstance.source.data - ); nextInstance = { ...nextInstance, source: { ...nextInstance.source, - data: rebasedData, + data: formatRelativeImportPath( + toDir, + resolvedTarget, + nextInstance.source.data + ), }, }; } else if (isInlineVisualNodeInstance(nextInstance)) { @@ -137,46 +123,6 @@ export function rebaseVisualNodeFileSources( }; } -function collectCandidateFiles( - dir: string, - predicate: (filePath: string, fileName: string) => boolean, - maxDepth = 7, - results: string[] = [] -): string[] { - if (maxDepth < 0 || !existsSync(dir)) { - return results; - } - - let entries: string[]; - try { - entries = readdirSync(dir); - } catch { - return results; - } - - for (const entry of entries) { - if (IGNORED_DIRS.has(entry)) { - continue; - } - - const fullPath = join(dir, entry); - let stats; - try { - stats = lstatSync(fullPath); - } catch { - continue; - } - - if (stats.isDirectory()) { - collectCandidateFiles(fullPath, predicate, maxDepth - 1, results); - } else if (stats.isFile() && predicate(fullPath, entry)) { - results.push(fullPath); - } - } - - return results; -} - function candidateDefinesNode(candidatePath: string, nodeId?: string): boolean { if (!nodeId) { return true; @@ -184,8 +130,7 @@ function candidateDefinesNode(candidatePath: string, nodeId?: string): boolean { try { if (candidatePath.endsWith(".flyde")) { - const flow = deserializeFlowByPath(candidatePath); - return flow.node.id === nodeId; + return deserializeFlowByPath(candidatePath).node.id === nodeId; } if (isCodeNodePath(candidatePath)) { @@ -204,26 +149,10 @@ function getSearchRoot(fullFlowPath: string): string { try { return findRoot(flowDir); } catch { - let current = flowDir; - for (let i = 0; i < 2; i++) { - const parent = dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return current; + return resolve(flowDir, "../.."); } } -function rankByProximity(flowDir: string, candidates: string[]): string[] { - return [...candidates].sort((a, b) => { - const relA = relative(flowDir, a).split(sep).length; - const relB = relative(flowDir, b).split(sep).length; - return relA - relB; - }); -} - export function resolveMovedFilePath( fullFlowPath: string, relativeImportPath: string, @@ -237,70 +166,27 @@ export function resolveMovedFilePath( } const targetFileName = basename(relativeImportPath); - const directDir = dirname(directPath); - if (existsSync(directDir)) { - try { - const siblingMatch = readdirSync(directDir).find( - (entry) => entry.toLowerCase() === targetFileName.toLowerCase() - ); - if (siblingMatch) { - const matchedSiblingPath = join(directDir, siblingMatch); - if (existsSync(matchedSiblingPath)) { - return matchedSiblingPath; - } - } - } catch { - // Ignore directory read errors - } - } - const searchRoot = getSearchRoot(fullFlowPath); + const candidates = glob + .sync(`**/${targetFileName}`, { + cwd: searchRoot, + ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/out/**"], + absolute: true, + }) + .filter((filePath) => resolve(filePath) !== resolve(fullFlowPath)) + .sort( + (a, b) => + relative(flowDir, a).split(sep).length - + relative(flowDir, b).split(sep).length + ); - const sameNameCandidates = rankByProximity( - flowDir, - collectCandidateFiles( - searchRoot, - (filePath, fileName) => - fileName.toLowerCase() === targetFileName.toLowerCase() && - resolve(filePath) !== resolve(fullFlowPath) - ) - ); - - for (const candidate of sameNameCandidates) { + for (const candidate of candidates) { if (candidateDefinesNode(candidate, nodeId)) { return candidate; } } - if (sameNameCandidates.length === 1) { - return sameNameCandidates[0]; - } - - if (nodeId) { - const isVisualTarget = targetFileName.endsWith(".flyde"); - const nodeMatchCandidates = rankByProximity( - flowDir, - collectCandidateFiles(searchRoot, (filePath, fileName) => { - if (resolve(filePath) === resolve(fullFlowPath)) { - return false; - } - if (isVisualTarget) { - return fileName.endsWith(".flyde"); - } - return ( - fileName.endsWith(".flyde.ts") || fileName.endsWith(".flyde.js") - ); - }) - ); - - for (const candidate of nodeMatchCandidates) { - if (candidateDefinesNode(candidate, nodeId)) { - return candidate; - } - } - } - - return directPath; + return candidates.length === 1 ? candidates[0] : directPath; } export function updateVisualNodeMovedFileImports( @@ -322,9 +208,8 @@ export function updateVisualNodeMovedFileImports( ) { const oldTargetAbsPath = resolve(oldFlowDir, instance.source.data); const newTargetAbsPath = getMovedTargetPath(oldTargetAbsPath, moves); - const targetMoved = newTargetAbsPath !== oldTargetAbsPath; - if (flowMoved || targetMoved) { + if (flowMoved || newTargetAbsPath !== oldTargetAbsPath) { const nextRelPath = formatRelativeImportPath( newFlowDir, newTargetAbsPath, @@ -366,17 +251,9 @@ export function updateVisualNodeMovedFileImports( return instance; }); - if (!changed) { - return { node, changed: false }; - } - - return { - node: { - ...node, - instances: updatedInstances, - }, - changed: true, - }; + return changed + ? { node: { ...node, instances: updatedInstances }, changed: true } + : { node, changed: false }; } export function updateFlowImportsForMovedFiles( @@ -406,9 +283,8 @@ export function updateFlowImportsForMovedFiles( if (importKey.startsWith(".")) { const oldTargetAbsPath = resolve(oldFlowDir, importKey); const newTargetAbsPath = getMovedTargetPath(oldTargetAbsPath, moves); - const targetMoved = newTargetAbsPath !== oldTargetAbsPath; - if (flowMoved || targetMoved) { + if (flowMoved || newTargetAbsPath !== oldTargetAbsPath) { const nextKey = formatRelativeImportPath( newFlowDir, newTargetAbsPath,