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

// 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)");
registerMovedFlydeImportUpdater(context, mainOutputChannel);

let currentTheme = vscode.window.activeColorTheme;

Expand Down
122 changes: 122 additions & 0 deletions vscode/src/flydeImportPathUpdater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import * as path from "path";
import * as yaml from "yaml";

export type FileRename = {
oldPath: string;
newPath: string;
};

type ImportUpdateResult = {
changed: boolean;
contents: string;
};

export function updateFlydeImportsForRenamedFiles(
flowContents: string,
currentFlowPath: string,
renames: FileRename[]
): ImportUpdateResult {
const flow = yaml.parse(flowContents);
const imports = (flow?.imports ?? {}) as Record<string, unknown>;
const oldFlowPath = findOldFlowPath(currentFlowPath, renames);
const updatedImports: Record<string, string | string[]> = {};
let changed = false;

for (const [importPath, nodeIds] of Object.entries(imports)) {
if (!isLocalImport(importPath)) {
updatedImports[importPath] = toImportedNodeIds(nodeIds);
continue;
}

const previousImportTarget = path.resolve(
path.dirname(oldFlowPath),
importPath
);
const nextImportTarget =
mapRenamedPath(previousImportTarget, renames) ?? previousImportTarget;
const nextImportPath = toFlydeRelativeImportPath(
currentFlowPath,
nextImportTarget
);

if (normalizeImportPath(importPath) !== nextImportPath) {
changed = true;
}

const importedNodeIds = toImportedNodeIds(nodeIds);
const existing = updatedImports[nextImportPath] ?? [];
const existingNodeIds = Array.isArray(existing) ? existing : [existing];
updatedImports[nextImportPath] = Array.from(
new Set([...existingNodeIds, ...importedNodeIds])
);
}

if (!changed) {
return { changed: false, contents: flowContents };
}

flow.imports = updatedImports;

return {
changed: true,
contents: yaml.stringify(flow, { aliasDuplicateObjects: false }),
};
}

function findOldFlowPath(currentFlowPath: string, renames: FileRename[]) {
const matchedRename = renames.find((rename) =>
samePath(rename.newPath, currentFlowPath)
);

return matchedRename?.oldPath ?? currentFlowPath;
}

function mapRenamedPath(targetPath: string, renames: FileRename[]) {
const normalizedTarget = comparablePath(targetPath);

for (const rename of renames) {
const normalizedOldPath = comparablePath(rename.oldPath);

if (normalizedTarget === normalizedOldPath) {
return rename.newPath;
}
}
}

function toFlydeRelativeImportPath(fromFlowPath: string, targetPath: string) {
let relativePath = path
.relative(path.dirname(fromFlowPath), targetPath)
.split(path.sep)
.join("/");

if (!relativePath.startsWith(".")) {
relativePath = `./${relativePath}`;
}

return relativePath;
}

function isLocalImport(importPath: string) {
return importPath.startsWith(".") || path.isAbsolute(importPath);
}

function toImportedNodeIds(value: unknown) {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}

return typeof value === "string" ? [value] : [];
}

function normalizeImportPath(importPath: string) {
return importPath.split(path.sep).join("/");
}

function samePath(a: string, b: string) {
return comparablePath(a) === comparablePath(b);
}

function comparablePath(filePath: string) {
const normalized = path.normalize(path.resolve(filePath));
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
48 changes: 48 additions & 0 deletions vscode/src/test/updateMovedFlydeImports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert = require("assert");
import * as path from "path";
import { updateFlydeImportsForRenamedFiles } from "../flydeImportPathUpdater";

const flowWithImport = (importPath: string) => `imports:
"${importPath}": ChildFlow
node:
id: ParentFlow
inputs: {}
outputs: {}
instances: []
connections: []
inputsPosition: {}
outputsPosition: {}
`;

suite("updateMovedFlydeImportsForRenamedFiles", () => {
test("updates imports that point at a moved flow", () => {
const projectRoot = path.resolve("/project");
const parentPath = path.join(projectRoot, "Parent.flyde");
const oldChildPath = path.join(projectRoot, "nested", "Child.flyde");
const newChildPath = path.join(projectRoot, "shared", "Child.flyde");

const result = updateFlydeImportsForRenamedFiles(
flowWithImport("./nested/Child.flyde"),
parentPath,
[{ oldPath: oldChildPath, newPath: newChildPath }]
);

assert.equal(result.changed, true);
assert.match(result.contents, /\.\/shared\/Child\.flyde/);
});

test("keeps imports valid when the importing flow is moved", () => {
const projectRoot = path.resolve("/project");
const oldParentPath = path.join(projectRoot, "Parent.flyde");
const newParentPath = path.join(projectRoot, "moved", "Parent.flyde");

const result = updateFlydeImportsForRenamedFiles(
flowWithImport("./Child.flyde"),
newParentPath,
[{ oldPath: oldParentPath, newPath: newParentPath }]
);

assert.equal(result.changed, true);
assert.match(result.contents, /\.\.\/Child\.flyde/);
});
});
65 changes: 65 additions & 0 deletions vscode/src/updateMovedFlydeImports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as path from "path";
import * as vscode from "vscode";
import { updateFlydeImportsForRenamedFiles } from "./flydeImportPathUpdater";

const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();

export function registerMovedFlydeImportUpdater(
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel
) {
context.subscriptions.push(
vscode.workspace.onDidRenameFiles(async (event) => {
const renames = event.files.map((file) => ({
oldPath: file.oldUri.fsPath,
newPath: file.newUri.fsPath,
}));

if (!renames.some((rename) => isFlydePath(rename.oldPath) || isFlydePath(rename.newPath))) {
return;
}

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

let updatedCount = 0;

for (const uri of flydeFiles) {
try {
const bytes = await vscode.workspace.fs.readFile(uri);
const contents = textDecoder.decode(bytes);
const result = updateFlydeImportsForRenamedFiles(
contents,
uri.fsPath,
renames
);

if (result.changed) {
await vscode.workspace.fs.writeFile(
uri,
textEncoder.encode(result.contents)
);
updatedCount += 1;
}
} catch (error) {
outputChannel.appendLine(
`Failed to update Flyde imports in ${uri.fsPath}: ${error}`
);
}
}

if (updatedCount > 0) {
outputChannel.appendLine(
`Updated Flyde imports in ${updatedCount} file(s) after rename.`
);
}
})
);
}

function isFlydePath(filePath: string) {
return path.extname(filePath) === ".flyde";
}