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/README.md b/vscode/README.md index e1e01f37..ba0e407a 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -65,6 +65,15 @@ Not sure where to start? Join our [discord](https://www.flyde.dev/discord) and w Check out the official website at [https://flyde.dev](https://flyde.dev) for more information. +## Moving imported flows + +Renaming or moving files and folders through VS Code updates local file imports +in workspace `.flyde` documents, including imports inside a moved flow. The edit +preserves YAML comments and unsaved document changes. Package imports are not +rewritten. This applies to VS Code rename operations; changes made externally on +disk do not emit the event used by the extension. Files with invalid YAML are +skipped with a message in the Flyde output channel. + ## Telemetry Flyde VS Code extension collects anonymous usage data to help improve the product. This includes events like extension activation, flow creation, and error reporting. No personal information or code content is collected. You can disable telemetry in VS Code settings (`flyde.telemetry.enabled`) or by setting the environment variable `FLYDE_TELEMETRY_DISABLED=true`. 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..de7a69bd 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { FlydeEditorEditorProvider } from "./flydeEditor"; +import { registerImportRenames } from "./renameImports"; import * as execa from "execa"; var fp = require("find-free-port"); @@ -53,6 +54,7 @@ export function activate(context: vscode.ExtensionContext) { }); const mainOutputChannel = vscode.window.createOutputChannel("Flyde"); + context.subscriptions.push(registerImportRenames(mainOutputChannel)); const debugOutputChannel = vscode.window.createOutputChannel("Flyde (Debug)"); let currentTheme = vscode.window.activeColorTheme; diff --git a/vscode/src/importEdits.ts b/vscode/src/importEdits.ts new file mode 100644 index 00000000..393f7a0e --- /dev/null +++ b/vscode/src/importEdits.ts @@ -0,0 +1,122 @@ +import * as path from "path"; +import { isMap, isScalar, isSeq, parseDocument } from "yaml"; + +export interface FileMove { + oldPath: string; + newPath: string; +} + +export interface ImportEdit { + start: number; + end: number; + text: string; +} + +// Resolve every move against the original paths, not against earlier moves in +// the same batch (which could otherwise redirect an import twice). +function movedPath( + file: string, + moves: readonly FileMove[], + paths: typeof path, +): string { + for (const move of [...moves].sort( + (a, b) => b.oldPath.length - a.oldPath.length, + )) { + const relative = paths.relative(move.oldPath, file); + if ( + relative === "" || + (!paths.isAbsolute(relative) && + relative !== ".." && + !relative.startsWith(`..${paths.sep}`)) + ) { + return paths.join(move.newPath, relative); + } + } + return file; +} + +/** Rewrite only import scalars, preserving comments, formatting, and unsaved edits. */ +export function getImportEdits( + text: string, + file: string, + moves: readonly FileMove[], + paths: typeof path = path, +): ImportEdit[] { + const document = parseDocument(text); + if (document.errors.length) { + throw new Error(document.errors[0].message); + } + const edits: ImportEdit[] = []; + const destination = movedPath(file, moves, paths); + + function rewrite(value: unknown): void { + if (!isScalar(value) || typeof value.value !== "string" || !value.range) { + return; + } + const original = value.value; + if ( + !original.startsWith("./") && + !original.startsWith("../") && + !original.startsWith(".\\") && + !original.startsWith("..\\") && + !paths.isAbsolute(original) + ) { + return; + } + const target = paths.resolve(paths.dirname(file), original); + const newTarget = movedPath(target, moves, paths); + if (destination === file && target === newTarget) { + return; + } + let updated = paths.isAbsolute(original) + ? newTarget + : paths.relative(paths.dirname(destination), newTarget); + updated = updated.split(paths.sep).join("/"); + if (!paths.isAbsolute(updated) && !updated.startsWith("../")) { + updated = `./${updated}`; + } + if (updated !== original) { + edits.push({ + start: value.range[0], + end: value.range[1], + text: JSON.stringify(updated), + }); + } + } + + function visitNode(node: unknown): void { + 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)) { + if (source.get("type") === "file") { + rewrite(source.get("data", true)); + } else if (source.get("type") === "inline") { + visitNode(source.get("data", true)); + } + } + // Older flows keep inline nodes directly on the instance. + visitNode(instance.get("node", true)); + } + } + + if (isMap(document.contents)) { + visitNode(document.contents.get("node", true)); + const imports = document.contents.get("imports", true); + if (isMap(imports)) { + for (const entry of imports.items) { + rewrite(entry.key); + } + } + } + return edits; +} diff --git a/vscode/src/renameImports.ts b/vscode/src/renameImports.ts new file mode 100644 index 00000000..a9b15f4a --- /dev/null +++ b/vscode/src/renameImports.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode"; +import { getImportEdits } from "./importEdits"; + +export function registerImportRenames( + output: vscode.OutputChannel, +): vscode.Disposable { + return vscode.workspace.onWillRenameFiles((event) => { + // waitUntil must be called synchronously during event dispatch. VS Code + // applies the returned edits before moving files, including dirty documents. + event.waitUntil(prepareImportRenames(event, output)); + }); +} + +async function prepareImportRenames( + event: vscode.FileWillRenameEvent, + output: vscode.OutputChannel, +): Promise { + const edit = new vscode.WorkspaceEdit(); + const moves = event.files + .filter( + (file) => file.oldUri.scheme === "file" && file.newUri.scheme === "file", + ) + .map((file) => ({ + oldPath: file.oldUri.fsPath, + newPath: file.newUri.fsPath, + })); + if (!moves.length) { + return edit; + } + const uris = await vscode.workspace.findFiles( + "**/*.flyde", + "**/{node_modules,.git}/**", + undefined, + event.token, + ); + const documents = new Map(uris.map((uri) => [uri.toString(), uri])); + for (const document of vscode.workspace.textDocuments) { + if ( + document.uri.scheme === "file" && + document.uri.path.endsWith(".flyde") + ) { + documents.set(document.uri.toString(), document.uri); + } + } + for (const uri of documents.values()) { + if (event.token.isCancellationRequested) { + return new vscode.WorkspaceEdit(); + } + try { + const document = await vscode.workspace.openTextDocument(uri); + for (const change of getImportEdits( + document.getText(), + uri.fsPath, + moves, + )) { + edit.replace( + uri, + new vscode.Range( + document.positionAt(change.start), + document.positionAt(change.end), + ), + change.text, + ); + } + } catch (error) { + output.appendLine( + `Could not update Flyde imports in ${uri.fsPath}: ${error}`, + ); + } + } + return edit; +} diff --git a/vscode/src/test/importEdits.test.ts b/vscode/src/test/importEdits.test.ts new file mode 100644 index 00000000..c6e574c2 --- /dev/null +++ b/vscode/src/test/importEdits.test.ts @@ -0,0 +1,191 @@ +import * as assert from "assert"; +import * as path from "path"; +import { parse } from "yaml"; +import { FileMove, getImportEdits } from "../importEdits"; + +const flow = + "# keep this comment\r\nnode:\r\n instances:\r\n - source: {type: file, data: './Child.flyde'} # child\r\n config: {message: untouched}\r\n"; + +function rewrite( + text: string, + file: string, + moves: FileMove[], + paths = path.posix, +): string { + return getImportEdits(text, file, moves, paths) + .sort((a, b) => b.start - a.start) + .reduce( + (result, edit) => + result.slice(0, edit.start) + edit.text + result.slice(edit.end), + text, + ); +} + +function reference(text: string): string { + return parse(text).node.instances[0].source.data; +} + +suite("Flyde import edits", () => { + test("updates imported files and preserves surrounding bytes", () => { + const result = rewrite(flow, "/project/Parent.flyde", [ + { + oldPath: "/project/Child.flyde", + newPath: "/project/shared/Child.flyde", + }, + ]); + assert.strictEqual( + result, + flow.replace("'./Child.flyde'", '"./shared/Child.flyde"'), + ); + }); + + test("updates imports inside a moved flow", () => { + assert.strictEqual( + reference( + rewrite(flow, "/project/Parent.flyde", [ + { + oldPath: "/project/Parent.flyde", + newPath: "/project/nested/Parent.flyde", + }, + ]), + ), + "../Child.flyde", + ); + }); + + test("moving a whole folder together keeps internal references unchanged", () => { + assert.deepStrictEqual( + getImportEdits( + flow, + "/project/flows/Parent.flyde", + [{ oldPath: "/project/flows", newPath: "/project/renamed" }], + path.posix, + ), + [], + ); + }); + + test("updates references to descendants of a moved folder", () => { + const text = flow.replace("./Child.flyde", "./nodes/nested/Child.flyde"); + assert.strictEqual( + reference( + rewrite(text, "/project/Parent.flyde", [ + { oldPath: "/project/nodes", newPath: "/project/shared" }, + ]), + ), + "./shared/nested/Child.flyde", + ); + }); + + test("folder names must match a path boundary", () => { + assert.strictEqual( + rewrite(flow, "/project/flows-extra/Parent.flyde", [ + { oldPath: "/project/flows", newPath: "/project/shared" }, + ]), + flow, + ); + }); + + test("batch moves are simultaneous and do not cascade", () => { + const moves = [ + { oldPath: "/project/Child.flyde", newPath: "/project/Other.flyde" }, + { oldPath: "/project/Other.flyde", newPath: "/project/Child.flyde" }, + { + oldPath: "/project/Parent.flyde", + newPath: "/project/flows/Parent.flyde", + }, + ]; + assert.strictEqual( + reference(rewrite(flow, "/project/Parent.flyde", moves)), + "../Other.flyde", + ); + }); + + test("handles Windows paths and emits portable import separators", () => { + assert.strictEqual( + reference( + rewrite( + flow, + "C:\\project\\Parent.flyde", + [ + { + oldPath: "C:\\project\\Child.flyde", + newPath: "C:\\project\\nodes\\Child.flyde", + }, + ], + path.win32, + ), + ), + "./nodes/Child.flyde", + ); + }); + + test("handles an imported file moving to another Windows drive", () => { + assert.strictEqual( + reference( + rewrite( + flow, + "C:\\project\\Parent.flyde", + [ + { + oldPath: "C:\\project\\Child.flyde", + newPath: "D:\\shared\\Child.flyde", + }, + ], + path.win32, + ), + ), + "D:/shared/Child.flyde", + ); + }); + + test("rewrites nested inline nodes without touching config or package data", () => { + const text = `node: + instances: + - source: + type: inline + data: + instances: + - source: {type: file, data: ./Child.flyde} + - source: {type: package, data: ./Child.flyde} + config: {source: {type: file, data: ./Child.flyde}} +`; + const result = parse( + rewrite(text, "/project/Parent.flyde", [ + { oldPath: "/project/Child.flyde", newPath: "/project/Renamed.flyde" }, + ]), + ); + assert.strictEqual( + result.node.instances[0].source.data.instances[0].source.data, + "./Renamed.flyde", + ); + assert.strictEqual(result.node.instances[1].source.data, "./Child.flyde"); + assert.strictEqual( + result.node.instances[1].config.source.data, + "./Child.flyde", + ); + }); + + test("supports legacy import maps and code-node files", () => { + const text = + "imports:\n ./Child.flyde.ts: [Child]\n '@flyde/nodes': [Add]\nnode: {}\n"; + const result = parse( + rewrite(text, "/project/Parent.flyde", [ + { + oldPath: "/project/Child.flyde.ts", + newPath: "/project/Code.flyde.ts", + }, + ]), + ); + assert.deepStrictEqual(result.imports, { + "./Code.flyde.ts": ["Child"], + "@flyde/nodes": ["Add"], + }); + }); + + test("invalid YAML is rejected without producing edits", () => { + assert.throws(() => + getImportEdits("node: [", "/project/Parent.flyde", [], path.posix), + ); + }); +}); diff --git a/vscode/src/test/renameImports.test.ts b/vscode/src/test/renameImports.test.ts new file mode 100644 index 00000000..f17d47a8 --- /dev/null +++ b/vscode/src/test/renameImports.test.ts @@ -0,0 +1,49 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import * as path from "path"; +import { tmpdir } from "os"; +import { mkdtempSync, rmSync } from "fs"; + +suite("Flyde file rename integration", () => { + test("renames a target and updates a dirty open flow without losing edits", async () => { + const extension = vscode.extensions.getExtension("flyde.flyde-vscode"); + assert.ok(extension, "The Flyde development extension must be loaded"); + // Activation owns the rename listener. Registering another listener here + // would return overlapping edits when the full extension suite runs. + await extension.activate(); + const directory = mkdtempSync(path.join(tmpdir(), "flyde-import-rename-")); + const parent = vscode.Uri.file(path.join(directory, "Parent.flyde")); + const child = vscode.Uri.file(path.join(directory, "Child.flyde")); + const renamed = vscode.Uri.file(path.join(directory, "Renamed.flyde")); + try { + const content = + "# unsaved comment\nnode:\n instances:\n - source: {type: file, data: ./Child.flyde}\n"; + await vscode.workspace.fs.writeFile(parent, Buffer.from("node: {}\n")); + await vscode.workspace.fs.writeFile(child, Buffer.from("node: {}\n")); + const document = await vscode.workspace.openTextDocument(parent); + const dirtyEdit = new vscode.WorkspaceEdit(); + dirtyEdit.replace( + parent, + new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length), + ), + content, + ); + assert.ok(await vscode.workspace.applyEdit(dirtyEdit)); + assert.ok(document.isDirty); + + const rename = new vscode.WorkspaceEdit(); + rename.renameFile(child, renamed); + assert.ok(await vscode.workspace.applyEdit(rename)); + assert.strictEqual( + document.getText(), + content.replace("./Child.flyde", '"./Renamed.flyde"'), + ); + assert.ok(document.isDirty); + await document.save(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +});