diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40423ef1..13c9694d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,9 @@ importers: walker: specifier: ^1.0.8 version: 1.0.8 + yaml: + specifier: ^2.3.1 + version: 2.8.0 devDependencies: '@playwright/test': specifier: ^1.53.2 diff --git a/vscode/package.json b/vscode/package.json index 3f1678cc..840ef292 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -254,7 +254,8 @@ "socket.io": "^4.5.1", "ts-node": "^10.9.2", "use-debounce": "^8.0.4", - "walker": "^1.0.8" + "walker": "^1.0.8", + "yaml": "^2.3.1" }, "packageManager": "yarn@3.3.1" -} \ No newline at end of file +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..df5b8d1b 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 { registerFlowImportRenames } from "./renameFlowImports"; // the application insights key (also known as instrumentation key) @@ -54,6 +55,7 @@ export function activate(context: vscode.ExtensionContext) { const mainOutputChannel = vscode.window.createOutputChannel("Flyde"); const debugOutputChannel = vscode.window.createOutputChannel("Flyde (Debug)"); + context.subscriptions.push(registerFlowImportRenames(mainOutputChannel)); let currentTheme = vscode.window.activeColorTheme; diff --git a/vscode/src/flowImportEdits.ts b/vscode/src/flowImportEdits.ts new file mode 100644 index 00000000..1dac2905 --- /dev/null +++ b/vscode/src/flowImportEdits.ts @@ -0,0 +1,100 @@ +import { posix as path } from "path"; +import { isMap, isScalar, isSeq, parseDocument, Scalar, YAMLMap } from "yaml"; + +export interface FileMove { + oldPath: string; + newPath: string; +} + +export interface ImportEdit { + start: number; + end: number; + text: string; +} + +export function getFlowImportEdits( + text: string, + flowPath: string, + moves: readonly FileMove[] +): ImportEdit[] { + const normalizeDrive = (value: string) => value.replace(/^\/([A-Z]):/, (_, drive: string) => `/${drive.toLowerCase()}:`); + flowPath = normalizeDrive(flowPath); + moves = moves.map(({ oldPath, newPath }) => ({ + oldPath: normalizeDrive(oldPath), newPath: normalizeDrive(newPath), + })); + const document = parseDocument(text); + if (document.errors.length) { + throw new Error(document.errors.map((error) => error.message).join("\n")); + } + const sortedMoves = [...moves].sort((a, b) => b.oldPath.length - a.oldPath.length); + const movedPath = (original: string) => { + const move = sortedMoves.find(({ oldPath }) => + original === oldPath || original.startsWith(oldPath + "/") + ); + return move ? move.newPath + original.slice(move.oldPath.length) : original; + }; + const oldDirectory = path.dirname(flowPath); + const newDirectory = path.dirname(movedPath(flowPath)); + const edits: ImportEdit[] = []; + + const updateSource = (scalar: Scalar) => { + if (typeof scalar.value !== "string" || !scalar.range) { + return; + } + const reference = scalar.value.replace(/\\/g, "/"); + const absolute = path.isAbsolute(reference); + const oldTarget = path.resolve(oldDirectory, reference); + const newTarget = movedPath(oldTarget); + if (newTarget === oldTarget && newDirectory === oldDirectory) { + return; + } + let next = absolute ? newTarget : path.relative(newDirectory, newTarget); + if (!absolute && !next.startsWith(".")) { + next = "./" + next; + } + if (path.resolve(newDirectory, reference) === newTarget) { + return; + } + edits.push({ start: scalar.range[0], end: scalar.range[1], text: JSON.stringify(next) }); + }; + + const visitNode = (node: unknown) => { + if (!isMap(node)) { + return; + } + const instances = node.get("instances", true); + if (!isSeq(instances)) { + return; + } + for (const instance of instances.items) { + if (!isMap(instance)) { + continue; + } + const source = instance.get("source", true); + if (isMap(source)) { + const data = source.get("data", true); + if (source.get("type") === "file" && isScalar(data)) { + updateSource(data); + } else if (source.get("type") === "inline") { + visitNode(data); + } + } + visitNode(instance.get("node", true)); + } + }; + + if (isMap(document.contents)) { + const flow = document.contents as YAMLMap; + visitNode(flow.get("node", true)); + const imports = flow.get("imports", true); + if (isMap(imports)) { + for (const entry of imports.items) { + if (isScalar(entry.key) && typeof entry.key.value === "string" && + (/^(\.{1,2}[/\\]|\/)/.test(entry.key.value) || /\.flyde(?:\.[jt]s)?$/.test(entry.key.value))) { + updateSource(entry.key); + } + } + } + } + return edits; +} diff --git a/vscode/src/renameFlowImports.ts b/vscode/src/renameFlowImports.ts new file mode 100644 index 00000000..dd6b1781 --- /dev/null +++ b/vscode/src/renameFlowImports.ts @@ -0,0 +1,32 @@ +import * as vscode from "vscode"; +import { getFlowImportEdits } from "./flowImportEdits"; + +export function registerFlowImportRenames(output: vscode.OutputChannel): vscode.Disposable { + return vscode.workspace.onWillRenameFiles((event) => { + event.waitUntil((async () => { + const edit = new vscode.WorkspaceEdit(); + const files = await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**"); + for (const uri of files) { + const moves = event.files + .filter(({ oldUri, newUri }) => oldUri.scheme === uri.scheme && + newUri.scheme === uri.scheme && oldUri.authority === uri.authority && + newUri.authority === uri.authority) + .map(({ oldUri, newUri }) => ({ oldPath: oldUri.path, newPath: newUri.path })); + if (!moves.length) { + continue; + } + try { + const document = await vscode.workspace.openTextDocument(uri); + for (const change of getFlowImportEdits(document.getText(), uri.path, moves)) { + edit.replace(uri, new vscode.Range( + document.positionAt(change.start), document.positionAt(change.end) + ), change.text); + } + } catch (error) { + output.appendLine(`Could not update imports in ${uri.fsPath}: ${error}`); + } + } + return edit; + })()); + }); +} diff --git a/vscode/src/test/flowImportEdits.test.ts b/vscode/src/test/flowImportEdits.test.ts new file mode 100644 index 00000000..f9250998 --- /dev/null +++ b/vscode/src/test/flowImportEdits.test.ts @@ -0,0 +1,91 @@ +import * as assert from "assert"; +import { getFlowImportEdits, FileMove } from "../flowImportEdits"; +import { parse } from "yaml"; + +function flow(source: string, type = "file") { + return `# preserved comment\nnode:\n instances:\n - source: {type: ${type}, data: ${JSON.stringify(source)}} # node comment\n config: {data: "./Node.flyde.ts"}\n`; +} + +function apply(text: string, moves: FileMove[], importer = "/project/Main.flyde") { + return getFlowImportEdits(text, importer, moves) + .sort((a, b) => b.start - a.start) + .reduce((result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end), text); +} + +const move = [{ oldPath: "/project/Node.flyde.ts", newPath: "/project/nodes/Node.flyde.ts" }]; + +suite("Flow import renames", () => { + test("updates a moved code node without changing comments or config", () => { + assert.strictEqual(apply(flow("./Node.flyde.ts"), move), flow("./nodes/Node.flyde.ts")); + }); + + test("updates visual node references and extensionless local sources", () => { + const moves = [{ oldPath: "/project/nodes", newPath: "/project/lib" }]; + assert.strictEqual(apply(flow("./nodes/Child.flyde"), moves), flow("./lib/Child.flyde")); + assert.strictEqual(apply(flow("nodes/Child"), moves), flow("./lib/Child")); + }); + + test("rebases imports when the importer moves", () => { + const moves = [{ oldPath: "/project/Main.flyde", newPath: "/project/flows/Main.flyde" }]; + assert.strictEqual(apply(flow("./Node.flyde.ts"), moves), flow("../Node.flyde.ts")); + }); + + test("keeps references unchanged when a whole subtree moves together", () => { + const input = flow("./Node.flyde.ts"); + assert.strictEqual(apply(input, [{ oldPath: "/project", newPath: "/renamed" }]), input); + }); + + test("uses simultaneous original paths for batched moves", () => { + const moves = [...move, { oldPath: "/project/Main.flyde", newPath: "/project/flows/Main.flyde" }]; + assert.strictEqual(apply(flow("./Node.flyde.ts"), moves), flow("../nodes/Node.flyde.ts")); + }); + + test("does not match sibling names with a common directory prefix", () => { + const input = flow("./nodes-extra/A.flyde"); + assert.strictEqual(apply(input, [{ oldPath: "/project/nodes", newPath: "/project/lib" }]), input); + }); + + test("leaves package and custom sources alone", () => { + for (const type of ["package", "custom"]) { + const input = flow("./Node.flyde.ts", type); + assert.strictEqual(apply(input, move), input); + } + }); + + test("updates nested inline nodes", () => { + const input = "node:\n instances:\n - source:\n type: inline\n data:\n instances:\n - source: {type: file, data: './Node.flyde.ts'}\n"; + const output = parse(apply(input, move)); + assert.strictEqual(output.node.instances[0].source.data.instances[0].source.data, "./nodes/Node.flyde.ts"); + }); + + test("updates legacy imports without rewriting the flow format", () => { + const input = "imports:\n './Node.flyde.ts': [Node] # preserve\n '@flyde/nodes': [Value]\nnode: {instances: []}\n"; + assert.strictEqual(apply(input, move), input.replace("'./Node.flyde.ts'", '"./nodes/Node.flyde.ts"')); + }); + + test("handles Windows URI paths and backslashes in relative references", () => { + const moves = [{ oldPath: "/C:/project/Node.flyde.ts", newPath: "/C:/project/nodes/Node.flyde.ts" }]; + assert.strictEqual(apply(flow(".\\Node.flyde.ts"), moves, "/C:/project/Main.flyde"), flow("./nodes/Node.flyde.ts")); + }); + + test("updates bare legacy file imports used by existing flows", () => { + const input = "imports:\n Node.flyde.ts: [Node]\n '@flyde/nodes': [Value]\nnode: {instances: []}\n"; + const output = parse(apply(input, move)); + assert.deepStrictEqual(output.imports["./nodes/Node.flyde.ts"], ["Node"]); + assert.deepStrictEqual(output.imports["@flyde/nodes"], ["Value"]); + }); + + test("matches Windows drive casing returned by workspace file search", () => { + const moves = [{ oldPath: "/E:/project/Node.flyde.ts", newPath: "/E:/project/nodes/Node.flyde.ts" }]; + assert.strictEqual(apply(flow("./Node.flyde.ts"), moves, "/e:/project/Main.flyde"), flow("./nodes/Node.flyde.ts")); + }); + + test("quotes paths containing YAML punctuation safely", () => { + const moves = [{ oldPath: "/project/Node.flyde.ts", newPath: "/project/a: #b/Node.flyde.ts" }]; + assert.strictEqual(parse(apply(flow("./Node.flyde.ts"), moves)).node.instances[0].source.data, "./a: #b/Node.flyde.ts"); + }); + + test("rejects invalid YAML rather than editing it", () => { + assert.throws(() => apply("node: [\n", move)); + }); +}); diff --git a/vscode/test-evidence/README.md b/vscode/test-evidence/README.md new file mode 100644 index 00000000..63a69c1b --- /dev/null +++ b/vscode/test-evidence/README.md @@ -0,0 +1,13 @@ +# File rename validation for #112 + +[Short demonstration](rename-imports.mp4) recorded from an actual VS Code 1.135.0 Extension Development Host on Windows. It shows a local node moving into `nodes/`, the importer reference updating, and an unsaved editor note surviving. This used a minimal validation extension registering the compiled `registerFlowImportRenames` from this patch; it is not a claim that the complete Flyde visual editor was exercised. `result.json` records the integration assertions, including moving the importer into `flows/` and checking its rebased reference. + +Reproduce the behavior after loading the extension: + +1. Open a workspace containing `Main.flyde` with a node instance whose source is `{type: file, data: "./Node.flyde.ts"}`, and a sibling `Node.flyde.ts`. +2. Open `Main.flyde` as text and add an unsaved comment. Move the node file into a `nodes` subdirectory using Explorer. Its reference must change to `./nodes/Node.flyde.ts` and the comment must remain. +3. Move `Main.flyde` into a `flows` subdirectory. Its reference must change to `../nodes/Node.flyde.ts`. + +The regression tests in `src/test/flowImportEdits.test.ts` cover modern code/visual sources, legacy imports, nested inline nodes, file/folder/importer moves, batch moves, Windows drive casing, comments/config preservation, and invalid YAML. After compiling the extension, run them independently from `vscode/` with `pnpm exec mocha --ui tdd out/test/flowImportEdits.test.js`. + +Scope: rename events initiated through VS Code; files moved by an external shell are not intercepted. Malformed YAML is skipped and reported to the Flyde output channel. Local scalar file sources are updated without reserializing unrelated YAML. diff --git a/vscode/test-evidence/rename-imports.mp4 b/vscode/test-evidence/rename-imports.mp4 new file mode 100644 index 00000000..5f3e6a56 Binary files /dev/null and b/vscode/test-evidence/rename-imports.mp4 differ diff --git a/vscode/test-evidence/result.json b/vscode/test-evidence/result.json new file mode 100644 index 00000000..4d7f8091 --- /dev/null +++ b/vscode/test-evidence/result.json @@ -0,0 +1,10 @@ +{ + "status": "passed", + "vscode": "1.135.0", + "checks": [ + "actual file rename", + "unsaved buffer preserved", + "importer move rebases references" + ], + "importerText": "# Unsaved editor note must survive\n# Move Node.flyde.ts into nodes/\nnode:\n instances:\n - source:\n type: file\n data: \"../nodes/Node.flyde.ts\"\n" +} \ No newline at end of file