Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added vscode/docs/file-move-demo-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added vscode/docs/file-move-demo.mp4
Binary file not shown.
44 changes: 44 additions & 0 deletions vscode/docs/file-moves.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
2 changes: 2 additions & 0 deletions vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions vscode/src/flowRename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as vscode from "vscode";
import { remapFlowPaths } from "./remapFlowPaths";

export function registerFlowRenameHandler(context: Pick<vscode.ExtensionContext, "subscriptions">): 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;
})());
}));
}
55 changes: 55 additions & 0 deletions vscode/src/remapFlowPaths.ts
Original file line number Diff line number Diff line change
@@ -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;
}
62 changes: 62 additions & 0 deletions vscode/src/test/flowRename.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
});
}
});
79 changes: 79 additions & 0 deletions vscode/src/test/remapFlowPaths.test.ts
Original file line number Diff line number Diff line change
@@ -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"), []));
});
});