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
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 { registerFileRenameHandler } from "./registerFileRenameHandler";

// 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(registerFileRenameHandler(mainOutputChannel));

let currentTheme = vscode.window.activeColorTheme;

Expand Down
61 changes: 61 additions & 0 deletions vscode/src/registerFileRenameHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as vscode from "vscode";
import { deserializeFlow, serializeFlow } from "@flyde/loader/dist/server";
import { updateFileReferences } from "./updateFileReferences";

/**
* Keep local .flyde file references valid when VS Code renames or moves
* files/folders. Uses onWillRenameFiles + WorkspaceEdit so unsaved buffers
* and undo stay under the editor's control.
*/
export function registerFileRenameHandler(
output: Pick<vscode.OutputChannel, "appendLine">
): vscode.Disposable {
return vscode.workspace.onWillRenameFiles((event) => {
event.waitUntil(
(async () => {
const moves = event.files.map(({ oldUri, newUri }) => ({
oldPath: oldUri.fsPath,
newPath: newUri.fsPath,
}));

const uris = await vscode.workspace.findFiles(
"**/*.flyde",
"**/node_modules/**"
);
const documents = new Map(uris.map((uri) => [uri.toString(), uri]));
for (const document of vscode.workspace.textDocuments) {
if (
!document.isUntitled &&
document.uri.path.endsWith(".flyde")
) {
documents.set(document.uri.toString(), document.uri);
}
}

const edit = new vscode.WorkspaceEdit();
for (const uri of documents.values()) {
try {
const document = await vscode.workspace.openTextDocument(uri);
const text = document.getText();
const flow = deserializeFlow(text, uri.fsPath);
if (updateFileReferences(flow, uri.fsPath, moves)) {
edit.replace(
uri,
new vscode.Range(
document.positionAt(0),
document.positionAt(text.length)
),
serializeFlow(flow)
);
}
} catch (error) {
output.appendLine(
`Could not update Flyde imports in ${uri.fsPath}: ${error}`
);
}
}
return edit;
})()
);
});
}
174 changes: 174 additions & 0 deletions vscode/src/test/updateFileReferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import assert = require("assert");
import * as path from "path";
import type { FlydeFlow } from "@flyde/core";
import { applyMove, updateFileReferences } from "../updateFileReferences";

const root = path.resolve("rename-test-workspace");
const at = (...parts: string[]) => path.join(root, ...parts);

const flowWithFileRef = (reference: string): FlydeFlow => ({
node: {
id: "Main",
inputs: {},
outputs: {},
connections: [],
inputsPosition: {},
outputsPosition: {},
instances: [
{
id: "dependency",
type: "code",
nodeId: "Dependency",
source: { type: "file", data: reference },
config: {},
inputConfig: {},
pos: { x: 0, y: 0 },
},
],
},
});

const fileRef = (value: FlydeFlow): string => {
const source = value.node.instances[0].source;
assert.strictEqual(source.type, "file");
return (source as { type: "file"; data: string }).data;
};

suite("updateFileReferences", () => {
test("rewrites a moved file dependency", () => {
const value = flowWithFileRef("./Dependency.flyde.ts");
assert.ok(
updateFileReferences(value, at("Main.flyde"), [
{
oldPath: at("Dependency.flyde.ts"),
newPath: at("nodes", "Dependency.flyde.ts"),
},
])
);
assert.strictEqual(fileRef(value), "./nodes/Dependency.flyde.ts");
});

test("rebases imports when the importing flow moves", () => {
const value = flowWithFileRef("./Dependency.flyde.ts");
assert.ok(
updateFileReferences(value, at("Main.flyde"), [
{ oldPath: at("Main.flyde"), newPath: at("flows", "Main.flyde") },
])
);
assert.strictEqual(fileRef(value), "../Dependency.flyde.ts");
});

test("rewrites references into a renamed folder", () => {
const value = flowWithFileRef("./nodes/Dependency.flyde");
assert.ok(
updateFileReferences(value, at("Main.flyde"), [
{ oldPath: at("nodes"), newPath: at("lib") },
])
);
assert.strictEqual(fileRef(value), "./lib/Dependency.flyde");
});

test("leaves relative paths unchanged when both files move together", () => {
const value = flowWithFileRef("Dependency.flyde.ts");
assert.strictEqual(
updateFileReferences(value, at("nodes", "Main.flyde"), [
{ oldPath: at("nodes"), newPath: at("lib") },
]),
false
);
assert.strictEqual(fileRef(value), "Dependency.flyde.ts");
});

test("does not match a directory that only shares a name prefix", () => {
const value = flowWithFileRef("./nodes-extra/Dependency.flyde.ts");
assert.strictEqual(
updateFileReferences(value, at("Main.flyde"), [
{ oldPath: at("nodes"), newPath: at("lib") },
]),
false
);
});

test("updates references nested inside inline visual nodes", () => {
const value = flowWithFileRef("./Dependency.flyde.ts");
const nested = flowWithFileRef("./Dependency.flyde.ts").node;
value.node.instances[0] = {
id: "inline",
type: "visual",
nodeId: "Nested",
source: { type: "inline", data: nested },
inputConfig: {},
pos: { x: 0, y: 0 },
};
assert.ok(
updateFileReferences(value, at("Main.flyde"), [
{
oldPath: at("Dependency.flyde.ts"),
newPath: at("Renamed.flyde.ts"),
},
])
);
assert.strictEqual(fileRef({ node: nested }), "./Renamed.flyde.ts");
});

test("leaves package and custom sources untouched", () => {
for (const type of ["package", "custom"] as const) {
const value = flowWithFileRef("./Dependency.flyde.ts");
value.node.instances[0].source = {
type,
data: "./Dependency.flyde.ts",
} as any;
assert.strictEqual(
updateFileReferences(value, at("Main.flyde"), [
{
oldPath: at("Dependency.flyde.ts"),
newPath: at("Renamed.flyde.ts"),
},
]),
false
);
}
});

test("treats a batch as simultaneous moves (no chaining)", () => {
const value = flowWithFileRef("./A.flyde");
updateFileReferences(value, at("Main.flyde"), [
{ oldPath: at("A.flyde"), newPath: at("B.flyde") },
{ oldPath: at("B.flyde"), newPath: at("C.flyde") },
]);
assert.strictEqual(fileRef(value), "./B.flyde");
});

test("applyMove prefers the most specific move in a batch", () => {
assert.strictEqual(
applyMove(at("nodes", "A.flyde"), [
{ oldPath: at("nodes"), newPath: at("lib") },
{ oldPath: at("nodes", "A.flyde"), newPath: at("B.flyde") },
]),
at("B.flyde")
);
});

test("rewrites deprecated top-level imports map keys", () => {
const value: FlydeFlow = {
imports: { "./Child.flyde": ["Child"] },
node: {
id: "Main",
inputs: {},
outputs: {},
connections: [],
inputsPosition: {},
outputsPosition: {},
instances: [],
},
};
assert.ok(
updateFileReferences(value, at("Main.flyde"), [
{ oldPath: at("Child.flyde"), newPath: at("shared", "Child.flyde") },
])
);
assert.deepStrictEqual(value.imports, {
"./shared/Child.flyde": ["Child"],
});
});
});
114 changes: 114 additions & 0 deletions vscode/src/updateFileReferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { FlydeFlow, VisualNode } from "@flyde/core";
import * as path from "path";

export interface FileMove {
oldPath: string;
newPath: string;
}

/**
* Apply a batch of simultaneous renames/moves to an absolute path,
* choosing the most specific (longest) matching source path.
*/
export function applyMove(
absolutePath: string,
moves: readonly FileMove[]
): string {
const match = [...moves]
.sort((a, b) => b.oldPath.length - a.oldPath.length)
.find(({ oldPath }) => {
const rel = path.relative(oldPath, absolutePath);
return (
rel === "" ||
(rel !== ".." &&
!rel.startsWith(`..${path.sep}`) &&
!path.isAbsolute(rel))
);
});

if (!match) {
return absolutePath;
}

return path.join(match.newPath, path.relative(match.oldPath, absolutePath));
}

function toPosixRelative(fromDir: string, targetAbs: string): string {
const rel = path.relative(fromDir, targetAbs).split(path.sep).join("/");
return rel.startsWith(".") ? rel : `./${rel}`;
}

/**
* Rewrite local `source: file` references inside a flow after VS Code
* rename/move events. Also rebases relative paths when the flow itself moves.
* Returns true when any reference was changed.
*/
export function updateFileReferences(
flow: FlydeFlow,
flowPath: string,
moves: readonly FileMove[]
): boolean {
const newFlowPath = applyMove(flowPath, moves);
const oldDir = path.dirname(flowPath);
const newDir = path.dirname(newFlowPath);
let changed = false;

const rewriteRef = (ref: string): string | undefined => {
const targetAbs = path.join(oldDir, ref);
const newTargetAbs = applyMove(targetAbs, moves);
const nextRef = toPosixRelative(newDir, newTargetAbs);
// Compare resolved locations rather than string form so equivalent
// relative spellings ("./x" vs "x") do not churn unnecessarily.
if (path.join(newDir, ref) === newTargetAbs) {
return undefined;
}
return nextRef;
};

const visit = (node: VisualNode) => {
for (const instance of node.instances) {
const source = instance.source as
| { type: string; data?: unknown }
| undefined;
if (!source) {
continue;
}

if (source.type === "inline" && source.data) {
visit(source.data as VisualNode);
continue;
}

if (source.type === "file" && typeof source.data === "string") {
const next = rewriteRef(source.data);
if (next !== undefined) {
source.data = next;
changed = true;
}
}
}
};

visit(flow.node);

// Defensive: keep deprecated top-level imports map consistent if present.
if (flow.imports) {
const nextImports: Record<string, string[]> = {};
let importsChanged = false;
for (const [ref, names] of Object.entries(flow.imports)) {
const rewritten = rewriteRef(ref);
if (rewritten !== undefined) {
nextImports[rewritten] = names;
importsChanged = true;
} else {
nextImports[ref] = names;
}
}
if (importsChanged) {
flow.imports = nextImports;
changed = true;
}
}

return changed;
}