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
170 changes: 170 additions & 0 deletions loader/src/resolver/server/updateMovedFlydeImports.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { assert } from "chai";
import { updateFlydeImportsAfterMove } from "./updateMovedFlydeImports";

const parentFlow = `imports: {}
node:
instances:
- pos:
x: 300
y: 200
id: nested-instance
inputConfig: {}
nodeId: ChildFlow
config: {}
type: visual
source:
type: file
data: ./ChildFlow.flyde
- pos:
x: 100
y: 100
id: stdlib-add
inputConfig: {}
nodeId: Add
config: {}
type: code
source:
type: package
data: "@flyde/nodes"
connections: []
id: ParentFlow
inputs: {}
outputs: {}
`;

describe("updateFlydeImportsAfterMove", () => {
it("updates the import path when an imported .flyde file is moved to another folder", () => {
const updated = updateFlydeImportsAfterMove(
parentFlow,
"/workspace/ParentFlow.flyde",
"/workspace/ParentFlow.flyde",
{
oldPath: "/workspace/ChildFlow.flyde",
newPath: "/workspace/shared/ChildFlow.flyde",
}
);

assert.isNotNull(updated);
assert.include(updated!, "data: ./shared/ChildFlow.flyde");
assert.notInclude(updated!, "data: ./ChildFlow.flyde\n");
});

it("re-relativizes imports when the importing flow itself is moved", () => {
const updated = updateFlydeImportsAfterMove(
parentFlow,
"/workspace/ParentFlow.flyde",
"/workspace/flows/ParentFlow.flyde",
{
oldPath: "/workspace/ParentFlow.flyde",
newPath: "/workspace/flows/ParentFlow.flyde",
}
);

assert.isNotNull(updated);
assert.include(updated!, "data: ../ChildFlow.flyde");
});

it("keeps relative paths stable when a flow and its import are moved together", () => {
const afterFlowMove = updateFlydeImportsAfterMove(
parentFlow,
"/workspace/ParentFlow.flyde",
"/workspace/sub/ParentFlow.flyde",
{
oldPath: "/workspace/ParentFlow.flyde",
newPath: "/workspace/sub/ParentFlow.flyde",
}
);
assert.isNotNull(afterFlowMove);

// simulate the extension applying each move of the rename event in turn
const afterBothMoves = updateFlydeImportsAfterMove(
afterFlowMove!,
"/workspace/sub/ParentFlow.flyde",
"/workspace/sub/ParentFlow.flyde",
{
oldPath: "/workspace/ChildFlow.flyde",
newPath: "/workspace/sub/ChildFlow.flyde",
}
);

assert.isNotNull(afterBothMoves);
assert.include(afterBothMoves!, "data: ./ChildFlow.flyde");
});

it("returns null for moves that do not affect the flow", () => {
const updated = updateFlydeImportsAfterMove(
parentFlow,
"/workspace/ParentFlow.flyde",
"/workspace/ParentFlow.flyde",
{
oldPath: "/workspace/unrelated/Other.flyde",
newPath: "/workspace/other/Other.flyde",
}
);

assert.isNull(updated);
});

it("leaves package and inline sources untouched", () => {
const updated = updateFlydeImportsAfterMove(
parentFlow,
"/workspace/ParentFlow.flyde",
"/workspace/ParentFlow.flyde",
{
oldPath: "/workspace/ChildFlow.flyde",
newPath: "/workspace/shared/ChildFlow.flyde",
}
);

assert.include(updated!, 'data: "@flyde/nodes"');
});

it("updates code node file imports (.flyde.ts) as well", () => {
const flowWithCodeImport = parentFlow.replace(
"data: ./ChildFlow.flyde",
"data: ./Add.flyde.ts"
);

const updated = updateFlydeImportsAfterMove(
flowWithCodeImport,
"/workspace/ParentFlow.flyde",
"/workspace/ParentFlow.flyde",
{
oldPath: "/workspace/Add.flyde.ts",
newPath: "/workspace/lib/Add.flyde.ts",
}
);

assert.isNotNull(updated);
assert.include(updated!, "data: ./lib/Add.flyde.ts");
});

it("preserves unrelated formatting and content", () => {
const updated = updateFlydeImportsAfterMove(
parentFlow,
"/workspace/ParentFlow.flyde",
"/workspace/ParentFlow.flyde",
{
oldPath: "/workspace/ChildFlow.flyde",
newPath: "/workspace/shared/ChildFlow.flyde",
}
);

assert.include(updated!, "id: stdlib-add");
assert.include(updated!, "x: 300");
});

it("returns null for flows that fail to parse", () => {
const updated = updateFlydeImportsAfterMove(
"node: [unclosed",
"/workspace/ParentFlow.flyde",
"/workspace/ParentFlow.flyde",
{
oldPath: "/workspace/ChildFlow.flyde",
newPath: "/workspace/shared/ChildFlow.flyde",
}
);

assert.isNull(updated);
});
});
78 changes: 78 additions & 0 deletions loader/src/resolver/server/updateMovedFlydeImports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { dirname, relative, resolve } from "path";
import { parseDocument, isMap, isSeq } from "yaml";

export interface MovedFile {
/** Absolute path of the file before the move/rename */
oldPath: string;
/** Absolute path of the file after the move/rename */
newPath: string;
}

const toPosixPath = (p: string) => p.split("\\").join("/");

/**
* Rewrites the relative `source.data` file references inside a .flyde flow
* after a file in the workspace was moved or renamed.
*
* Handles both directions:
* - an imported file was moved: references resolving to `move.oldPath` are
* re-pointed at `move.newPath`
* - the flow file itself was moved (`flowPathBefore` !== `flowPathAfter`):
* every relative file reference is re-relativized against the new location
*
* Edits are applied through the YAML document AST so unrelated formatting and
* content are preserved. Returns the updated contents, or `null` when no
* reference needed updating.
*/
export function updateFlydeImportsAfterMove(
flowContents: string,
flowPathBefore: string,
flowPathAfter: string,
move: MovedFile
): string | null {
const doc = parseDocument(flowContents);
if (doc.errors.length > 0) {
return null;
}

const instances = doc.getIn(["node", "instances"], true);
if (!isSeq(instances)) {
return null;
}

const oldTarget = resolve(move.oldPath);
let changed = false;

for (const instance of instances.items) {
if (!isMap(instance)) {
continue;
}
const source = instance.get("source", true);
if (!isMap(source) || source.get("type") !== "file") {
continue;
}
const data = source.get("data");
if (typeof data !== "string" || data === "") {
continue;
}

const targetBefore = resolve(dirname(flowPathBefore), data);
const targetAfter = targetBefore === oldTarget ? resolve(move.newPath) : targetBefore;

if (targetAfter === targetBefore && flowPathBefore === flowPathAfter) {
continue;
}

let newData = toPosixPath(relative(dirname(flowPathAfter), targetAfter));
if (!newData.startsWith(".")) {
newData = `./${newData}`;
}

if (newData !== data) {
source.set("data", newData);
changed = true;
}
}

return changed ? String(doc) : null;
}
3 changes: 2 additions & 1 deletion loader/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export * from "./run-flow";
export { createServerReferencedNodeFinder } from "./resolver/server/findReferencedNodeServer";
export * from "./resolver/server/resolveImportablePaths";
export { resolveFlowByPath } from "./resolver/server/resolveFlowServer";
export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils";
export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils";
export { updateFlydeImportsAfterMove, MovedFile } from "./resolver/server/updateMovedFlydeImports";
4 changes: 3 additions & 1 deletion vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@
"release": "vsce package --no-yarn && vsce publish minor"
},
"main": "./out/extension.js",
"activationEvents": [],
"activationEvents": [
"workspaceContains:**/*.flyde"
],
"contributes": {
"customEditors": [
{
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 { registerFlydeImportMoveUpdater } 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)
);

registerFlydeImportMoveUpdater(context);

function getWorkspaceRootPath(): vscode.Uri | undefined {
// Check if there is an open workspace
if (
Expand Down
85 changes: 85 additions & 0 deletions vscode/src/update-imports-on-rename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as vscode from "vscode";
import {
updateFlydeImportsAfterMove,
MovedFile,
} from "@flyde/loader/dist/server";

/**
* Keeps .flyde imports working when files are moved or renamed in the
* workspace (fixes https://github.com/flydelabs/flyde/issues/112).
*
* Flow files reference imported nodes through workspace-relative paths, so
* moving an imported .flyde (or .flyde.ts) file used to break the import.
* This listener rewrites the affected `source.data` references in every flow
* file after a rename - both when an imported file moved and when a flow
* file itself moved (in which case its relative imports are re-relativized).
*/
export function registerFlydeImportMoveUpdater(
context: vscode.ExtensionContext
) {
context.subscriptions.push(
vscode.workspace.onDidRenameFiles((event) => {
handleRenamedFiles(event.files).catch((e) => {
console.error("Flyde: failed to update imports after file move", e);
});
})
);
}

async function handleRenamedFiles(
files: readonly { oldUri: vscode.Uri; newUri: vscode.Uri }[]
) {
const moves: MovedFile[] = files
.map(({ oldUri, newUri }) => ({
oldPath: oldUri.fsPath,
newPath: newUri.fsPath,
}))
.filter(({ oldPath, newPath }) => oldPath !== newPath);

if (moves.length === 0) {
return;
}

const previousPathByNewPath = new Map(
moves.map(({ oldPath, newPath }) => [newPath, oldPath])
);

const flowUris = await vscode.workspace.findFiles(
"**/*.flyde",
"**/node_modules/**"
);

await Promise.all(
flowUris.map(async (uri) => {
try {
const flowPathAfter = uri.fsPath;
const flowPathBefore =
previousPathByNewPath.get(flowPathAfter) ?? flowPathAfter;

const raw = await vscode.workspace.fs.readFile(uri);
const original = Buffer.from(raw).toString("utf8");

let contents = original;
let baseBefore = flowPathBefore;
for (const move of moves) {
const updated = updateFlydeImportsAfterMove(
contents,
baseBefore,
flowPathAfter,
move
);
if (updated !== null) {
contents = updated;
}
baseBefore = flowPathAfter;
}

if (contents !== original) {
await vscode.workspace.fs.writeFile(uri, Buffer.from(contents, "utf8"));
}
} catch (e) {
console.error(`Flyde: failed to update imports in ${uri.fsPath}`, e);
}
})
);
}