diff --git a/loader/src/resolver/movedFileImports.spec.ts b/loader/src/resolver/movedFileImports.spec.ts new file mode 100644 index 00000000..ef99c6e6 --- /dev/null +++ b/loader/src/resolver/movedFileImports.spec.ts @@ -0,0 +1,350 @@ +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"; + +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; + + 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, + 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 } }); + 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, + 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 } }); + 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"), + singleNodeFlowYaml("ChildFlow", "step1", "Step", "code", "./Step.flyde.js") + ); + + const parentPath = join(tempRoot, "ParentFlow.flyde"); + 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 } }); + 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 result = updateFlowImportsForMovedFiles( + rawFlow, + "/workspace/project/ParentFlow.flyde", + "/workspace/project/flows/ParentFlow.flyde", + [ + { + 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..8a86673a --- /dev/null +++ b/loader/src/resolver/server/movedFileImports.ts @@ -0,0 +1,332 @@ +import { + FlydeFlow, + isInlineVisualNodeInstance, + isVisualNode, + NodeInstance, + VisualNode, +} from "@flyde/core"; +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"); +import { deserializeFlowByPath, serializeFlow } from "../../serdes"; +import { isCodeNodePath, resolveCodeNodeDependencies } from "./serverUtils"; + +export interface FileMove { + oldPath: string; + newPath: string; +} + +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); + + return needsDotPrefix && !rel.startsWith(".") ? `./${rel}` : 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 + ); + nextInstance = { + ...nextInstance, + source: { + ...nextInstance.source, + data: formatRelativeImportPath( + toDir, + resolvedTarget, + nextInstance.source.data + ), + }, + }; + } 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 candidateDefinesNode(candidatePath: string, nodeId?: string): boolean { + if (!nodeId) { + return true; + } + + try { + if (candidatePath.endsWith(".flyde")) { + return deserializeFlowByPath(candidatePath).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 { + return resolve(flowDir, "../.."); + } +} + +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 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 + ); + + for (const candidate of candidates) { + if (candidateDefinesNode(candidate, nodeId)) { + return candidate; + } + } + + return candidates.length === 1 ? candidates[0] : 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); + + if (flowMoved || newTargetAbsPath !== oldTargetAbsPath) { + 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; + }); + + return changed + ? { node: { ...node, instances: updatedInstances }, changed: true } + : { node, changed: false }; +} + +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); + + if (flowMoved || newTargetAbsPath !== oldTargetAbsPath) { + 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)); + }); +}