diff --git a/loader/src/serdes/index.ts b/loader/src/serdes/index.ts index 5e1098cb..f2568dc2 100644 --- a/loader/src/serdes/index.ts +++ b/loader/src/serdes/index.ts @@ -1,3 +1,4 @@ export * from "./serialize"; export * from "./deserialize"; export * from "./migrations"; +export * from "./updateFlowReferencesOnRename"; diff --git a/loader/src/serdes/updateFlowReferencesOnRename.ts b/loader/src/serdes/updateFlowReferencesOnRename.ts new file mode 100644 index 00000000..6cf98362 --- /dev/null +++ b/loader/src/serdes/updateFlowReferencesOnRename.ts @@ -0,0 +1,87 @@ +import { FlydeFlow, VisualNode } from "@flyde/core"; +import { dirname, isAbsolute, relative, resolve, sep } from "path"; + +export type RenamedFile = { + oldPath: string; + newPath: string; +}; + +const normalizePath = (path: string) => path.replace(/[\\/]/g, sep); + +function mapPath( + path: string, + renames: RenamedFile[], + direction: "forward" | "backward" +) { + const candidates = renames + .map((rename) => ({ + from: normalizePath( + direction === "forward" ? rename.oldPath : rename.newPath + ), + to: normalizePath( + direction === "forward" ? rename.newPath : rename.oldPath + ), + })) + .filter(({ from }) => path === from || path.startsWith(from + sep)) + .sort((a, b) => b.from.length - a.from.length); + + const match = candidates[0]; + return match ? match.to + path.slice(match.from.length) : path; +} + +function toRelativePath(from: string, to: string) { + const value = relative(from, to).split(sep).join("/"); + return value.startsWith(".") ? value : `./${value}`; +} + +export function updateFlowReferencesOnRename( + flow: FlydeFlow, + flowPath: string, + renames: RenamedFile[] +): boolean { + const currentFlowPath = normalizePath(flowPath); + const originalFlowPath = mapPath(currentFlowPath, renames, "backward"); + const flowMoved = originalFlowPath !== currentFlowPath; + let changed = false; + + const updateNode = (node: VisualNode) => { + for (const instance of node.instances) { + if (instance.source?.type === "inline") { + updateNode(instance.source.data); + continue; + } + + if ( + instance.source?.type !== "file" || + typeof instance.source.data !== "string" + ) { + continue; + } + + const sourcePath = instance.source.data; + const originalTargetPath = normalizePath( + isAbsolute(sourcePath) + ? sourcePath + : resolve(dirname(originalFlowPath), sourcePath) + ); + const movedTargetPath = mapPath(originalTargetPath, renames, "forward"); + + if (!flowMoved && movedTargetPath === originalTargetPath) { + continue; + } + + const updatedSourcePath = toRelativePath( + dirname(currentFlowPath), + movedTargetPath + ); + + if (updatedSourcePath !== sourcePath) { + instance.source.data = updatedSourcePath; + changed = true; + } + } + }; + + updateNode(flow.node); + return changed; +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..cf55ea2c 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 { updateFlowReferencesForRename } from "./updateFlowReferencesOnRename"; // the application insights key (also known as instrumentation key) @@ -52,6 +53,17 @@ export function activate(context: vscode.ExtensionContext) { } }); + context.subscriptions.push( + vscode.workspace.onDidRenameFiles((event) => { + updateFlowReferencesForRename( + event.files.map(({ oldUri, newUri }) => ({ + oldPath: oldUri.fsPath, + newPath: newUri.fsPath, + })) + ).catch((error) => console.error("Failed to update Flyde flow references", error)); + }) + ); + const mainOutputChannel = vscode.window.createOutputChannel("Flyde"); const debugOutputChannel = vscode.window.createOutputChannel("Flyde (Debug)"); diff --git a/vscode/src/test/updateFlowReferencesOnRename.test.ts b/vscode/src/test/updateFlowReferencesOnRename.test.ts new file mode 100644 index 00000000..a54d2615 --- /dev/null +++ b/vscode/src/test/updateFlowReferencesOnRename.test.ts @@ -0,0 +1,242 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import { FlydeFlow } from "@flyde/core"; +import { updateFlowReferencesOnRename } from "@flyde/loader"; +import { deserializeFlow } from "@flyde/loader/dist/server"; +import { resolveFlowServer } from "@flyde/loader/dist/resolver/server/resolveFlowServer"; +import { updateFlowReferencesForRename } from "../updateFlowReferencesOnRename"; + +function createFlow(source: any): FlydeFlow { + return { + node: { + id: "Parent", + inputs: {}, + outputs: {}, + inputsPosition: {}, + outputsPosition: {}, + instances: [ + { + id: "imported", + nodeId: "Imported", + type: "visual", + source, + inputConfig: {}, + pos: { x: 0, y: 0 }, + }, + ], + connections: [], + }, + }; +} + +function getFileSourceData(flow: FlydeFlow, instanceIndex = 0): string { + const source = flow.node.instances[instanceIndex].source; + if (!source || source.type !== "file") { + throw new Error("Expected a file source"); + } + return source.data; +} + +suite("Flow rename references", () => { + test("updates code and visual file imports after their target moves", () => { + const visualFlow = createFlow({ + type: "file", + data: "./nodes/Visual.flyde", + }); + const codeFlow = createFlow({ + type: "file", + data: "./nodes/Code.flyde.ts", + }); + const renames = [ + { + oldPath: "/workspace/nodes/Visual.flyde", + newPath: "/workspace/shared/Visual.flyde", + }, + { + oldPath: "/workspace/nodes/Code.flyde.ts", + newPath: "/workspace/shared/Code.flyde.ts", + }, + ]; + + assert( + updateFlowReferencesOnRename( + visualFlow, + "/workspace/Parent.flyde", + renames + ) + ); + assert( + updateFlowReferencesOnRename( + codeFlow, + "/workspace/Parent.flyde", + renames + ) + ); + assert.strictEqual( + getFileSourceData(visualFlow), + "./shared/Visual.flyde" + ); + assert.strictEqual( + getFileSourceData(codeFlow), + "./shared/Code.flyde.ts" + ); + }); + + test("rebases imports when the referencing flow moves", () => { + const flow = createFlow({ type: "file", data: "./Child.flyde" }); + + assert( + updateFlowReferencesOnRename(flow, "/workspace/flows/Parent.flyde", [ + { + oldPath: "/workspace/Parent.flyde", + newPath: "/workspace/flows/Parent.flyde", + }, + ]) + ); + assert.strictEqual(getFileSourceData(flow), "../Child.flyde"); + }); + + test("leaves package, self, and unrelated file references unchanged", () => { + const flow = createFlow({ type: "package", data: "@flyde/nodes" }); + flow.node.instances.push({ + id: "self", + nodeId: "Parent", + type: "visual", + source: { type: "self" }, + inputConfig: {}, + pos: { x: 0, y: 0 }, + }); + flow.node.instances.push({ + id: "unrelated", + nodeId: "Other", + type: "visual", + source: { type: "file", data: "Other.flyde" }, + inputConfig: {}, + pos: { x: 0, y: 0 }, + }); + + assert.strictEqual( + updateFlowReferencesOnRename(flow, "/workspace/Parent.flyde", [ + { + oldPath: "/workspace/Elsewhere.flyde", + newPath: "/workspace/Moved.flyde", + }, + ]), + false + ); + + const packageSource = flow.node.instances[0].source; + assert(packageSource && packageSource.type === "package"); + assert.strictEqual(packageSource.data, "@flyde/nodes"); + assert.strictEqual(flow.node.instances[1].source!.type, "self"); + assert.strictEqual(getFileSourceData(flow, 2), "Other.flyde"); + }); + + test("updates a dirty document and keeps a moved code import resolvable", async () => { + const testRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "flyde-rename-test-") + ); + const nodesDirectory = path.join(testRoot, "nodes"); + const sharedDirectory = path.join(testRoot, "shared"); + const oldCodePath = path.join(nodesDirectory, "Increment.flyde.ts"); + const newCodePath = path.join(sharedDirectory, "Increment.flyde.ts"); + const parentPath = path.join(testRoot, "Parent.flyde"); + const parentUri = vscode.Uri.file(parentPath); + let document: vscode.TextDocument | undefined; + let findFilesStub: sinon.SinonStub | undefined; + + fs.mkdirSync(nodesDirectory); + fs.mkdirSync(sharedDirectory); + fs.writeFileSync( + oldCodePath, + `import { CodeNode } from "@flyde/core"; +export const Increment: CodeNode = { + id: "Increment", + inputs: { value: { mode: "required" } }, + outputs: { result: {} }, + run: ({ value }, { result }) => result.next(value + 1), +}; +` + ); + fs.writeFileSync( + parentPath, + `imports: {} +node: + id: Parent + inputs: {} + outputs: {} + instances: + - id: increment + nodeId: Increment + type: code + source: + type: file + data: ./nodes/Increment.flyde.ts + inputConfig: {} + config: {} + pos: + x: 0 + y: 0 + connections: [] +` + ); + + try { + const initialFlow = deserializeFlow( + fs.readFileSync(parentPath, "utf8"), + parentPath + ); + assert.doesNotThrow(() => + resolveFlowServer(initialFlow.node, parentPath) + ); + + document = await vscode.workspace.openTextDocument(parentUri); + const metadataEdit = new vscode.WorkspaceEdit(); + const inputsOffset = document.getText().indexOf(" inputs: {}"); + metadataEdit.insert( + parentUri, + document.positionAt(inputsOffset), + " description: unsaved metadata\n" + ); + assert(await vscode.workspace.applyEdit(metadataEdit)); + assert.strictEqual(document.isDirty, true); + + fs.renameSync(oldCodePath, newCodePath); + findFilesStub = sinon + .stub(vscode.workspace, "findFiles") + .resolves([parentUri]); + + const rejectedEdit = sinon.stub(vscode.workspace, "applyEdit").resolves(false); + try { + await assert.rejects(updateFlowReferencesForRename([ + { oldPath: oldCodePath, newPath: newCodePath }, + ]), /Failed to update flow references/); + } finally { + rejectedEdit.restore(); + } + + await updateFlowReferencesForRename([ + { oldPath: oldCodePath, newPath: newCodePath }, + ]); + + assert.strictEqual(document.isDirty, true); + assert(document.getText().includes("description: unsaved metadata")); + assert(document.getText().includes("data: ./shared/Increment.flyde.ts")); + + const updatedFlow = deserializeFlow(document.getText(), parentPath); + assert.doesNotThrow(() => + resolveFlowServer(updatedFlow.node, parentPath) + ); + } finally { + findFilesStub?.restore(); + if (document?.isDirty) { + await document.save(); + } + fs.rmSync(testRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/vscode/src/updateFlowReferencesOnRename.ts b/vscode/src/updateFlowReferencesOnRename.ts new file mode 100644 index 00000000..4e322c4a --- /dev/null +++ b/vscode/src/updateFlowReferencesOnRename.ts @@ -0,0 +1,58 @@ +import * as vscode from "vscode"; +import { + deserializeFlow, + RenamedFile, + serializeFlow, + updateFlowReferencesOnRename, +} from "@flyde/loader"; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +export async function updateFlowReferencesForRename( + renames: RenamedFile[] +): Promise { + const flowUris = await vscode.workspace.findFiles( + "**/*.flyde", + "{**/node_modules/**,**/.git/**}" + ); + + await Promise.all( + flowUris.map(async (uri) => { + const document = vscode.workspace.textDocuments.find( + (candidate) => candidate.uri.fsPath === uri.fsPath + ); + + let flow; + let updatedContents: string; + try { + const contents = document + ? document.getText() + : decoder.decode(await vscode.workspace.fs.readFile(uri)); + flow = deserializeFlow(contents, uri.fsPath); + if (!updateFlowReferencesOnRename(flow, uri.fsPath, renames)) { + return; + } + updatedContents = serializeFlow(flow); + } catch { + return; + } + + if (document) { + const edit = new vscode.WorkspaceEdit(); + edit.replace( + uri, + document.validateRange( + new vscode.Range(0, 0, Number.MAX_VALUE, 0) + ), + updatedContents + ); + if (!(await vscode.workspace.applyEdit(edit))) { + throw new Error(`Failed to update flow references in ${uri.fsPath}`); + } + } else { + await vscode.workspace.fs.writeFile(uri, encoder.encode(updatedContents)); + } + }) + ); +}