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.

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
Expand Up @@ -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)

Expand Down Expand Up @@ -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;

Expand Down
100 changes: 100 additions & 0 deletions vscode/src/flowImportEdits.ts
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions vscode/src/renameFlowImports.ts
Original file line number Diff line number Diff line change
@@ -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;
})());
});
}
91 changes: 91 additions & 0 deletions vscode/src/test/flowImportEdits.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
13 changes: 13 additions & 0 deletions vscode/test-evidence/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added vscode/test-evidence/rename-imports.mp4
Binary file not shown.
10 changes: 10 additions & 0 deletions vscode/test-evidence/result.json
Original file line number Diff line number Diff line change
@@ -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"
}