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/docs/file-move-demo-preview.png b/vscode/docs/file-move-demo-preview.png new file mode 100644 index 00000000..bfce63c0 Binary files /dev/null and b/vscode/docs/file-move-demo-preview.png differ diff --git a/vscode/docs/file-move-demo.mp4 b/vscode/docs/file-move-demo.mp4 new file mode 100644 index 00000000..27b6c6e1 Binary files /dev/null and b/vscode/docs/file-move-demo.mp4 differ diff --git a/vscode/docs/file-moves.md b/vscode/docs/file-moves.md new file mode 100644 index 00000000..25e20b99 --- /dev/null +++ b/vscode/docs/file-moves.md @@ -0,0 +1,44 @@ +# File moves and flow references + +Moving or renaming files through VS Code's Explorer now updates the current +flow format's `source: { type: file, data: ... }` references in `.flyde` documents. +It also rebases a flow's outgoing references when that flow itself moves. +Folder moves and multiple renames in one operation use the original paths; +package sources are left alone. YAML comments and unsaved document edits are +preserved. Invalid/unreadable flows are skipped with a warning. + +This participates in `workspace.onWillRenameFiles`, so the text edits are part +of VS Code's rename operation. Renames performed externally (for example, by a +shell or a Git checkout) do not generate that event and are outside this patch. +The handler does not migrate legacy `imports` documents into the current format. + +## Demonstration + +[Short animated test walkthrough](file-move-demo.mp4) + +The video illustrates the recorded extension-host results; it is not a screen +recording of the full Flyde webview. To reproduce interactively, import a child +flow in a parent flow, rename the child in Explorer, then inspect the parent's +file source. Moving the parent into a subdirectory should rebase its relative +path. Moving their common folder should leave their relative relationship intact. + +## Validation + +- TypeScript 5.5.4 strict compilation of both new modules and their tests. +- 12 Mocha path-remapping tests (including swaps, folder boundaries, nested + inline nodes, package references, comments, absolute paths and invalid YAML). +- Four tests run in an actual isolated VS Code 1.87.2 extension host using + `WorkspaceEdit.renameFile`: target move, parent move, folder move and a dirty + parent document. All four passed. +- YAML dependency uses the existing lockfile's 2.8.0 resolution. + +The repository extension-test runner discovers the new `*.test.ts` files after +compilation. In a fully installed workspace, run the existing VS Code test +workflow (`pnpm --filter flyde-vscode test`, including its normal pretest build). +Validation here compiled the new modules separately and loaded their real event +handler in a small isolated extension host; the full workspace/webview build +was not run because dependency resolution encountered connection resets. +No full-editor visual or external-file-watcher behavior is claimed. + +This is an AI-assisted contribution. No private credentials or backend +configuration are included. 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..94403a07 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,6 +1,7 @@ // The module 'vscode' contains the VS Code extensibility API // Import the module and reference it with the alias vscode in your code below import * as vscode from "vscode"; +import { registerFlowRenameHandler } from "./flowRename"; import { FlydeEditorEditorProvider } from "./flydeEditor"; import * as execa from "execa"; @@ -29,6 +30,7 @@ let process: execa.ExecaChildProcess | undefined; const FLYDE_DEFAULT_SERVER_PORT = 8545; export function activate(context: vscode.ExtensionContext) { + registerFlowRenameHandler(context); // eslint-disable-next-line @typescript-eslint/naming-convention const { Uri } = vscode; const { fs } = vscode.workspace; diff --git a/vscode/src/flowRename.ts b/vscode/src/flowRename.ts new file mode 100644 index 00000000..7d29c864 --- /dev/null +++ b/vscode/src/flowRename.ts @@ -0,0 +1,40 @@ +import * as vscode from "vscode"; +import { remapFlowPaths } from "./remapFlowPaths"; + +export function registerFlowRenameHandler(context: Pick): void { + context.subscriptions.push(vscode.workspace.onWillRenameFiles((event) => { + event.waitUntil((async () => { + const moves = event.files + .filter(({ oldUri, newUri }) => oldUri.scheme === "file" && newUri.scheme === "file") + .map(({ oldUri, newUri }) => ({ oldPath: oldUri.fsPath, newPath: newUri.fsPath })); + if (!moves.length) { return; } + const diskFlows = await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**"); + const flows = new Map(diskFlows.map((uri) => [uri.toString(), uri])); + // Include dirty and open flows that have not yet been written to disk. + for (const document of vscode.workspace.textDocuments) { + if (document.uri.scheme === "file" && document.uri.path.endsWith(".flyde")) { + flows.set(document.uri.toString(), document.uri); + } + } + const edit = new vscode.WorkspaceEdit(); + const failed: string[] = []; + for (const uri of flows.values()) { + try { + const document = await vscode.workspace.openTextDocument(uri); + const text = document.getText(); + const replacement = remapFlowPaths(text, uri.fsPath, moves); + if (replacement !== undefined) { + edit.replace(uri, new vscode.Range(document.positionAt(0), document.positionAt(text.length)), replacement); + } + } catch { + // One invalid/unreadable flow must not prevent updating valid flows. + failed.push(vscode.workspace.asRelativePath(uri)); + } + } + if (failed.length) { + void vscode.window.showWarningMessage(`Flyde could not update file references in: ${failed.join(", ")}`); + } + return edit; + })()); + })); +} diff --git a/vscode/src/remapFlowPaths.ts b/vscode/src/remapFlowPaths.ts new file mode 100644 index 00000000..c01a3cf1 --- /dev/null +++ b/vscode/src/remapFlowPaths.ts @@ -0,0 +1,55 @@ +import * as path from "path"; +import { isMap, isScalar, visit, parseDocument } from "yaml"; + +export interface FileMove { + oldPath: string; + newPath: string; +} + +// All moves are evaluated against the original paths, so swaps and chained +// renames in one VS Code operation are not applied twice. +export function movedPath(file: string, moves: readonly FileMove[]): string { + const match = moves + .filter(({ oldPath }) => { + const relative = path.relative(oldPath, file); + return relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && + !relative.startsWith(`..${path.sep}`)); + }) + .sort((a, b) => b.oldPath.length - a.oldPath.length)[0]; + return match ? path.join(match.newPath, path.relative(match.oldPath, file)) : file; +} + +/** Rebase file sources, including inline/nested nodes, without migrating a flow. */ +export function remapFlowPaths( + text: string, + flowPath: string, + moves: readonly FileMove[] +): string | undefined { + const document = parseDocument(text); + if (document.errors.length) { + throw new Error(document.errors.map((error) => error.message).join("; ")); + } + const newFlowPath = movedPath(flowPath, moves); + let changed = false; + visit(document, { + Map(_key, node) { + // A file source is deliberately distinguished from package and inline + // sources: similarly named user configuration must not be rewritten. + const source = node.get("source", true); + if (!isMap(source) || source.get("type") !== "file") { return; } + const data = source.get("data", true); + if (!isScalar(data) || typeof data.value !== "string" || !data.value) { return; } + const oldTarget = path.resolve(path.dirname(flowPath), data.value); + const newTarget = movedPath(oldTarget, moves); + if (oldTarget === newTarget && flowPath === newFlowPath) { return; } + // Preserve absolute references; relative paths in .flyde files use '/'. + let next = path.isAbsolute(data.value) + ? newTarget + : path.relative(path.dirname(newFlowPath), newTarget).split(path.sep).join("/"); + if (!path.isAbsolute(data.value) && !next.startsWith(".")) { next = `./${next}`; } + if (next !== data.value) { data.value = next; changed = true; } + }, + }); + return changed ? document.toString() : undefined; +} diff --git a/vscode/src/test/flowRename.integration.test.ts b/vscode/src/test/flowRename.integration.test.ts new file mode 100644 index 00000000..0dd97baa --- /dev/null +++ b/vscode/src/test/flowRename.integration.test.ts @@ -0,0 +1,62 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as assert from "assert"; +import { registerFlowRenameHandler } from "../flowRename"; + +suite("flow rename extension host", () => { + for (const scenario of ["target", "parent", "folder", "dirty"]) { + test(scenario, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "flyde-rename-")); + const parent = path.join(dir, "Parent.flyde"); + const child = path.join(dir, "Child.flyde"); + fs.writeFileSync(child, "node: {}\n"); + fs.writeFileSync(parent, "# preserved\nnode:\n instances:\n - source:\n type: file\n data: ./Child.flyde\n"); + const subscriptions: vscode.Disposable[] = []; + registerFlowRenameHandler({ subscriptions }); + try { + const document = await vscode.workspace.openTextDocument(parent); + if (scenario === "dirty") { + const typing = new vscode.WorkspaceEdit(); + typing.insert(document.uri, new vscode.Position(0, 0), "# unsaved edit\n"); + assert.ok(await vscode.workspace.applyEdit(typing)); + assert.ok(document.isDirty); + } + let from = child; + let to = path.join(dir, "New.flyde"); + let expected = "./New.flyde"; + let resultPath = parent; + if (scenario === "parent") { + from = parent; + to = path.join(dir, "nested", "Parent.flyde"); + fs.mkdirSync(path.dirname(to)); + expected = "../Child.flyde"; + resultPath = to; + } else if (scenario === "folder") { + from = dir; + to = `${dir}-moved`; + expected = "./Child.flyde"; + resultPath = path.join(to, "Parent.flyde"); + } + const move = new vscode.WorkspaceEdit(); + move.renameFile(vscode.Uri.file(from), vscode.Uri.file(to)); + assert.ok(await vscode.workspace.applyEdit(move)); + const result = await vscode.workspace.openTextDocument(resultPath); + assert.ok(result.getText().includes(`data: ${expected}`), result.getText()); + assert.ok(result.getText().includes("# preserved")); + if (scenario === "dirty") { + assert.ok(result.getText().includes("# unsaved edit")); + assert.ok(result.isDirty); + } + await result.save(); + } finally { + subscriptions.forEach((subscription) => subscription.dispose()); + fs.rmSync(dir, { recursive: true, force: true }); + if (scenario === "folder") { + fs.rmSync(`${dir}-moved`, { recursive: true, force: true }); + } + } + }); + } +}); diff --git a/vscode/src/test/remapFlowPaths.test.ts b/vscode/src/test/remapFlowPaths.test.ts new file mode 100644 index 00000000..751f43cc --- /dev/null +++ b/vscode/src/test/remapFlowPaths.test.ts @@ -0,0 +1,79 @@ +import * as assert from "assert"; +import * as path from "path"; +import { parse } from "yaml"; +import { movedPath, remapFlowPaths } from "../remapFlowPaths"; + +suite("flow file moves", () => { + const root = path.resolve("test-workspace"); + const file = (relative: string) => path.join(root, relative); + const flow = (target: string, type = "file") => + `# keep this comment\nnode:\n instances:\n - id: child\n source:\n type: ${type}\n data: '${target}' # source comment\n`; + const source = (text: string) => parse(text).node.instances[0].source.data; + test("renames a referenced file and preserves comments", () => { + const result = remapFlowPaths(flow("./Child.flyde"), file("Parent.flyde"), [ + { oldPath: file("Child.flyde"), newPath: file("nodes/Renamed.flyde") }, + ])!; + assert.strictEqual(source(result), "./nodes/Renamed.flyde"); + assert.ok(result.includes("# keep this comment")); + assert.ok(result.includes("# source comment")); + }); + test("rebases outgoing references when the parent flow moves", () => { + const result = remapFlowPaths(flow("./Child.flyde"), file("Parent.flyde"), [ + { oldPath: file("Parent.flyde"), newPath: file("flows/Parent.flyde") }, + ])!; + assert.strictEqual(source(result), "../Child.flyde"); + }); + test("a whole folder move keeps relationships within it unchanged", () => { + assert.strictEqual(remapFlowPaths(flow("./Child.flyde"), file("old/Parent.flyde"), [ + { oldPath: file("old"), newPath: file("new") }, + ]), undefined); + }); + test("folder moves update incoming references", () => { + assert.strictEqual(source(remapFlowPaths(flow("./old/Child.flyde"), file("Parent.flyde"), [ + { oldPath: file("old"), newPath: file("new") }, + ])!), "./new/Child.flyde"); + }); + test("folder names are matched by path boundary, not string prefix", () => { + assert.strictEqual(movedPath(file("nodes-other/Child.flyde"), [ + { oldPath: file("nodes"), newPath: file("renamed") }, + ]), file("nodes-other/Child.flyde")); + }); + test("simultaneous swaps are applied only once", () => { + assert.strictEqual(source(remapFlowPaths(flow("./A.flyde"), file("Parent.flyde"), [ + { oldPath: file("A.flyde"), newPath: file("B.flyde") }, + { oldPath: file("B.flyde"), newPath: file("A.flyde") }, + ])!), "./B.flyde"); + }); + test("nested inline flows are visited", () => { + const text = `node:\n instances:\n - node:\n instances:\n - source:\n type: file\n data: ./Child.flyde\n`; + const result = remapFlowPaths(text, file("Parent.flyde"), [ + { oldPath: file("Child.flyde"), newPath: file("New.flyde") }, + ])!; + assert.strictEqual(parse(result).node.instances[0].node.instances[0].source.data, "./New.flyde"); + }); + test("package references are not modified", () => { + assert.strictEqual(remapFlowPaths(flow("./Child.flyde", "package"), file("Parent.flyde"), [ + { oldPath: file("Child.flyde"), newPath: file("New.flyde") }, + ]), undefined); + }); + test("unrelated moves return no edit", () => { + assert.strictEqual(remapFlowPaths(flow("./Child.flyde"), file("Parent.flyde"), [ + { oldPath: file("Other.flyde"), newPath: file("New.flyde") }, + ]), undefined); + }); + test("absolute references remain absolute", () => { + const target = file("Child.flyde"); + assert.strictEqual(source(remapFlowPaths(flow(target), file("Parent.flyde"), [ + { oldPath: target, newPath: file("New.flyde") }, + ])!), file("New.flyde")); + }); + test("more specific nested moves win", () => { + assert.strictEqual(movedPath(file("old/Child.flyde"), [ + { oldPath: file("old"), newPath: file("new") }, + { oldPath: file("old/Child.flyde"), newPath: file("special.flyde") }, + ]), file("special.flyde")); + }); + test("invalid YAML is rejected without producing replacement text", () => { + assert.throws(() => remapFlowPaths("node: [", file("Parent.flyde"), [])); + }); +});