Skip to content
Closed
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
19 changes: 11 additions & 8 deletions pnpm-lock.yaml

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

1 change: 1 addition & 0 deletions vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
"socket.io": "^4.5.1",
"ts-node": "^10.9.2",
"use-debounce": "^8.0.4",
"yaml": "^2.3.1",
"walker": "^1.0.8"
},
"packageManager": "yarn@3.3.1"
Expand Down
3 changes: 3 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 { registerFlydeImportUpdater } from "./update-imports-on-rename";

// the application insights key (also known as instrumentation key)

Expand Down Expand Up @@ -106,6 +107,8 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("flyde.openAsText", openAsTextHandler)
);

registerFlydeImportUpdater(context);

function getWorkspaceRootPath(): vscode.Uri | undefined {
// Check if there is an open workspace
if (
Expand Down
103 changes: 103 additions & 0 deletions vscode/src/test/rename-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as assert from "assert";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import {
updateImportsAfterRename,
updateFlowImports,
} from "../update-imports-on-rename";

const parentFlowYaml = `imports: {}
node:
instances:
- pos:
x: 300
y: 200
id: nested-instance
inputConfig: {}
nodeId: ChildFlow
config: {}
type: visual
source:
type: file
data: ./ChildFlow.flyde
connections: []
id: ParentFlow
inputs: {}
outputs: {}
`;

suite("Import updates after file rename", () => {
let tmpDir: string;

setup(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "flyde-rename-test-"));
});

test("updates flows referencing a moved imported file", () => {
const parentPath = path.join(tmpDir, "ParentFlow.flyde");
fs.writeFileSync(parentPath, parentFlowYaml);

const oldChild = path.join(tmpDir, "ChildFlow.flyde");
const subDir = path.join(tmpDir, "sub");
fs.mkdirSync(subDir);
const newChild = path.join(subDir, "ChildFlow.flyde");

const updated = updateImportsAfterRename(oldChild, newChild, [parentPath]);

assert.deepStrictEqual(updated, [parentPath]);
const text = fs.readFileSync(parentPath, "utf-8");
assert.ok(
text.includes("data: ./sub/ChildFlow.flyde"),
`Expected updated import, got:\n${text}`
);
});

test("updates imports inside the moved flow itself", () => {
const childPath = path.join(tmpDir, "ChildFlow.flyde");
fs.writeFileSync(childPath, "imports: {}\nnode: {}\n");

const oldParent = path.join(tmpDir, "ParentFlow.flyde");
fs.writeFileSync(oldParent, parentFlowYaml);

const subDir = path.join(tmpDir, "sub");
fs.mkdirSync(subDir);
const newParent = path.join(subDir, "ParentFlow.flyde");
fs.renameSync(oldParent, newParent);

const updated = updateImportsAfterRename(oldParent, newParent, [newParent]);

assert.deepStrictEqual(updated, [newParent]);
const text = fs.readFileSync(newParent, "utf-8");
assert.ok(
text.includes("data: ../ChildFlow.flyde"),
`Expected updated import, got:\n${text}`
);
});

test("leaves unrelated imports untouched", () => {
const parentPath = path.join(tmpDir, "ParentFlow.flyde");
fs.writeFileSync(parentPath, parentFlowYaml);

const updated = updateImportsAfterRename(
path.join(tmpDir, "Other.flyde"),
path.join(tmpDir, "Other2.flyde"),
[parentPath]
);

assert.deepStrictEqual(updated, []);
const text = fs.readFileSync(parentPath, "utf-8");
assert.ok(text.includes("data: ./ChildFlow.flyde"));
});

test("updateFlowImports returns null when nothing changes", () => {
const result = updateFlowImports(parentFlowYaml, () => undefined);
assert.strictEqual(result, null);
});

test("updateFlowImports returns null for invalid yaml", () => {
const result = updateFlowImports("{{{{", () => "./x");
assert.strictEqual(result, null);
});

});
180 changes: 180 additions & 0 deletions vscode/src/update-imports-on-rename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import * as vscode from "vscode";
import * as fs from "fs";
import * as path from "path";
import { isMap, isSeq, parseDocument } from "yaml";

/**
* Keeps `.flyde` imports working when files are moved or renamed in VS Code.
* Handles both directions:
* - a file imported by `.flyde` flows was moved (update the referencing flows)
* - a `.flyde` flow itself was moved (update its own relative imports)
*/
export function registerFlydeImportUpdater(context: vscode.ExtensionContext) {
const sub = vscode.workspace.onDidRenameFiles(async (event) => {
try {
await updateImportsForRenameEvent(event);
} catch (e) {
// Never throw out of a workspace listener
console.error("[flyde] failed to update imports after rename:", e);
}
});
context.subscriptions.push(sub);
}

export interface RenameEventLike {
files: ReadonlyArray<{ oldUri: vscode.Uri; newUri: vscode.Uri }>;
}

export async function updateImportsForRenameEvent(
event: RenameEventLike
): Promise<string[]> {
const flydeFiles = (
await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**")
).map((uri) => uri.fsPath);

const updated = new Set<string>();
for (const file of event.files) {
for (const changed of updateImportsAfterRename(
file.oldUri.fsPath,
file.newUri.fsPath,
flydeFiles
)) {
updated.add(changed);
}
}
return [...updated];
}

/**
* Rewrites relative `source.data` import paths in the given `.flyde` files so
* they keep resolving after `oldPath` was renamed/moved to `newPath`.
* Exported for tests; returns the list of files that were modified.
*/
export function updateImportsAfterRename(
oldPath: string,
newPath: string,
flydeFiles: string[]
): string[] {
const updated: string[] = [];

// Case 1: the moved file is referenced by other flows
for (const flydeFile of flydeFiles) {
const text = safeRead(flydeFile);
if (text === null) {
continue;
}

const flowDir = path.dirname(flydeFile);
const next = updateFlowImports(text, (relImport) => {
const abs = path.resolve(flowDir, relImport);
return samePath(abs, oldPath)
? toRelImport(path.relative(flowDir, newPath))
: undefined;
});
if (next !== null) {
safeWrite(flydeFile, next);
updated.push(flydeFile);
}
}

// Case 2: the moved file is itself a flow; fix its own imports so they
// still point at the same absolute targets from the new location
if (newPath.endsWith(".flyde") && fs.existsSync(newPath)) {
const oldDir = path.dirname(oldPath);
const newDir = path.dirname(newPath);
if (oldDir !== newDir) {
const text = safeRead(newPath);
if (text !== null) {
const next = updateFlowImports(text, (relImport) => {
const abs = path.resolve(oldDir, relImport);
const next = toRelImport(path.relative(newDir, abs));
return next !== relImport ? next : undefined;
});
if (next !== null) {
safeWrite(newPath, next);
updated.push(newPath);
}
}
}
}

return updated;
}

/**
* Applies `transform` to every `source.data` value of `type: file` imports in
* a serialized flow. Returns the updated text, or null when nothing changed.
* Uses a YAML document so comments and formatting are preserved.
*/
export function updateFlowImports(
flowText: string,
transform: (currentRelImport: string) => string | undefined
): string | null {
let doc;
try {
doc = parseDocument(flowText);
} catch {
return null;
}
if (doc.errors.length > 0) {
return null;
}

let changed = false;
const node = doc.get("node");
if (isMap(node)) {
const instances = node.get("instances");
if (isSeq(instances)) {
for (const instance of instances.items) {
if (!isMap(instance)) {
continue;
}
const source = instance.get("source");
if (!isMap(source)) {
continue;
}
if (source.get("type") !== "file") {
continue;
}
const data = source.get("data");
if (typeof data !== "string") {
continue;
}
const next = transform(data);
if (next !== undefined && next !== data) {
source.set("data", next);
changed = true;
}
}
}
}
return changed ? doc.toString() : null;
}

function toRelImport(rel: string): string {
const posix = rel.split(path.sep).join("/");
return posix.startsWith(".") ? posix : `./${posix}`;
}

function samePath(a: string, b: string): boolean {
const norm = (s: string) => path.resolve(s);
return process.platform === "win32"
? norm(a).toLowerCase() === norm(b).toLowerCase()
: norm(a) === norm(b);
}

function safeRead(filePath: string): string | null {
try {
return fs.readFileSync(filePath, "utf-8");
} catch {
return null;
}
}

function safeWrite(filePath: string, content: string): void {
try {
fs.writeFileSync(filePath, content, "utf-8");
} catch (e) {
console.error(`[flyde] failed to write ${filePath}:`, e);
}
}