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
1 change: 1 addition & 0 deletions loader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./resolver";
export * from "./serdes";
export * from "./runtime";
export * from "./run-flow";
export { updateFlowPathsForRenames, FlowPathRename } from "./serdes/updateFlowPathsForRenames";

// Server-specific exports
export { createServerReferencedNodeFinder } from "./resolver/server/findReferencedNodeServer";
Expand Down
166 changes: 166 additions & 0 deletions loader/src/serdes/updateFlowPathsForRenames.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { assert } from "chai";
import * as yaml from "yaml";
import { updateFlowPathsForRenames } from "./updateFlowPathsForRenames";

const flowPath = "/workspace/flows/main.flyde";
const reference = (data: string, type = "file") =>
`node:\n instances:\n - source:\n type: ${type}\n data: ${data}\n`;

describe("updateFlowPathsForRenames", () => {
it("renames a file reference and preserves comments, quotes, CRLF and unrelated data", () => {
const raw = "# keep this\r\nnode:\r\n instances:\r\n - source:\r\n type: file\r\n data: './old.flyde' # dependency\r\n config: {value: './old.flyde'}\r\n";
const result = updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new.flyde" },
]);
assert.equal(result, raw.replace("data: './old.flyde'", "data: './new.flyde'"));
});

it("updates bare code file references and both legacy file import suffixes", () => {
const raw = "imports:\n old.flyde.ts: [Code]\n './old.flyde': Visual\n '@flyde/nodes': [Add]\n some-package: [Other]\n" + reference("old.flyde.ts");
const result = updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/flows/old.flyde.ts", newPath: "/workspace/flows/new.flyde.ts" },
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new.flyde" },
]);
assert.equal(result, raw.replace(/old\.flyde/g, "new.flyde"));
assert.deepEqual(Object.keys(yaml.parse(result).imports), ["new.flyde.ts", "./new.flyde", "@flyde/nodes", "some-package"]);
});

it("maps folder descendants without matching a shared string prefix", () => {
const raw = "node:\n instances:\n - source: {type: file, data: ../nodes/child.flyde}\n - source: {type: file, data: ../nodes-extra/child.flyde}\n";
const result = updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/nodes", newPath: "/workspace/renamed" },
]);
assert.equal(result, raw.replace("../nodes/child.flyde", "../renamed/child.flyde"));
});

it("rebases an external dependency when only the importer moves", () => {
assert.equal(
updateFlowPathsForRenames(reference("../nodes/child.flyde"), flowPath, [
{ oldPath: flowPath, newPath: "/workspace/archive/flows/main.flyde" },
]),
reference("../../nodes/child.flyde")
);
});

it("leaves references unchanged when importer and dependency move together", () => {
assert.isUndefined(updateFlowPathsForRenames(reference("./child.flyde"), flowPath, [
{ oldPath: "/workspace/flows", newPath: "/workspace/renamed" },
]));
});

it("maps an independently moved importer and dependency against the original batch", () => {
assert.equal(
updateFlowPathsForRenames(reference("../nodes/child.flyde"), flowPath, [
{ oldPath: "/workspace/nodes/child.flyde", newPath: "/workspace/shared/new.flyde" },
{ oldPath: flowPath, newPath: "/workspace/archive/main.flyde" },
]),
reference("../shared/new.flyde")
);
});

it("does not chain renames in a batch", () => {
assert.equal(
updateFlowPathsForRenames(reference("./a.flyde"), flowPath, [
{ oldPath: "/workspace/flows/a.flyde", newPath: "/workspace/flows/b.flyde" },
{ oldPath: "/workspace/flows/b.flyde", newPath: "/workspace/flows/c.flyde" },
]),
reference("./b.flyde")
);
});

it("uses the most specific original path when file and folder renames overlap", () => {
assert.equal(
updateFlowPathsForRenames(reference("../nodes/child.flyde"), flowPath, [
{ oldPath: "/workspace/nodes", newPath: "/workspace/renamed" },
{ oldPath: "/workspace/nodes/child.flyde", newPath: "/workspace/special.flyde" },
]),
reference("../special.flyde")
);
});

it("updates modern and legacy nested inline visual nodes without touching package or config paths", () => {
const raw = "node:\n instances:\n - source:\n type: inline\n data:\n instances:\n - source: {type: file, data: ./old.flyde}\n - node:\n instances:\n - source: {type: file, data: ./old.flyde}\n - source: {type: package, data: ./old.flyde}\n config: {data: ./old.flyde}\n";
const result = updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new.flyde" },
]);
assert.equal(result, raw.replace(/type: file, data: \.\/old\.flyde/g, "type: file, data: ./new.flyde"));
});

it("keeps package imports and unrelated documents unchanged", () => {
const raw = "imports:\n '@flyde/nodes': [Add]\n package: [Node]\n" + reference("'@flyde/nodes'", "package");
assert.isUndefined(updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/flows", newPath: "/workspace/moved" },
]));
assert.isUndefined(updateFlowPathsForRenames(reference("./other.flyde"), flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new.flyde" },
]));
});

it("preserves an unreferenced anchor and double-quoted scalar", () => {
const raw = reference('&dependency "./old.flyde"');
assert.equal(updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new.flyde" },
]), raw.replace('"./old.flyde"', '"./new.flyde"'));
});

it("quotes a renamed plain scalar if its filename is not safe in YAML", () => {
const result = updateFlowPathsForRenames(reference("./old.flyde"), flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new: file.flyde" },
]);
assert.equal(yaml.parse(result).node.instances[0].source.data, "./new: file.flyde");
});

for (const filename of ["new,file.flyde", "new[file.flyde", "new]file.flyde", "new{file.flyde", "new}file.flyde"]) {
it(`quotes renamed plain scalars containing flow delimiters (${filename})`, () => {
const raw = "imports: {./old.flyde: [Visual], '@flyde/nodes': [Add]} # imports\nnode:\n instances:\n - source: {type: file, data: ./old.flyde} # dependency\n config: {value: ./old.flyde}\n";
const renamedReference = `./${filename}`;
const result = updateFlowPathsForRenames(raw, flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: `/workspace/flows/${filename}` },
]);
const encoded = JSON.stringify(renamedReference);
assert.equal(result, raw
.replace("{./old.flyde:", `{${encoded}:`)
.replace("data: ./old.flyde}", `data: ${encoded}}`));
const parsed = yaml.parse(result);
assert.deepEqual(parsed.node.instances[0].source, { type: "file", data: renamedReference });
assert.deepEqual(Object.keys(parsed.imports), [renamedReference, "@flyde/nodes"]);
});
}

it("escapes renamed single-quoted paths without changing their quote style", () => {
assert.equal(updateFlowPathsForRenames(reference("'./old.flyde'"), flowPath, [
{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/child's.flyde" },
]), reference("'./child''s.flyde'"));
});

it("does not change file reference spelling when only the flow filename changes", () => {
assert.isUndefined(updateFlowPathsForRenames(reference("./nested/../child.flyde"), flowPath, [
{ oldPath: flowPath, newPath: "/workspace/flows/renamed.flyde" },
]));
});

it("updates absolute references independently of importer movement", () => {
assert.equal(updateFlowPathsForRenames(reference("/workspace/nodes/old.flyde"), flowPath, [
{ oldPath: "/workspace/nodes/old.flyde", newPath: "/workspace/nodes/new.flyde" },
{ oldPath: flowPath, newPath: "/workspace/elsewhere/main.flyde" },
]), reference("/workspace/nodes/new.flyde"));
});

it("supports Windows paths without host-platform-dependent resolution", () => {
assert.equal(updateFlowPathsForRenames(reference("../nodes/old.flyde.ts"), "C:\\workspace\\flows\\main.flyde", [
{ oldPath: "C:\\workspace\\nodes\\old.flyde.ts", newPath: "C:\\workspace\\shared\\new.flyde.ts" },
]), reference("../shared/new.flyde.ts"));
});

it("rejects invalid YAML, alias references and collisions without returning a partial rewrite", () => {
const renames = [{ oldPath: "/workspace/flows/old.flyde", newPath: "/workspace/flows/new.flyde" }];
assert.throws(() => updateFlowPathsForRenames("node: [", flowPath, renames), /invalid YAML/);
assert.throws(() => updateFlowPathsForRenames("dependency: &dependency ./old.flyde\n" + reference("*dependency"), flowPath, renames), /aliased reference/);
assert.throws(() => updateFlowPathsForRenames(reference('&dependency "./old.flyde"') + "unrelated: *dependency\n", flowPath, renames), /aliased reference/);
assert.throws(() => updateFlowPathsForRenames("imports:\n ./old.flyde: [Old]\n ./new.flyde: [New]\nnode: {instances: []}\n", flowPath, renames), /import keys would collide/);
assert.throws(() => updateFlowPathsForRenames(reference("./old.flyde"), flowPath, [
...renames,
{ oldPath: "/workspace/flows/other.flyde", newPath: "/workspace/flows/new.flyde" },
]), /colliding destinations/);
});
});
166 changes: 166 additions & 0 deletions loader/src/serdes/updateFlowPathsForRenames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as path from "path";
import * as yaml from "yaml";

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

/**
* Update file references using the importer's pre-rename absolute path.
* The whole batch is applied once, so moving an importer and a dependency
* together does not resolve a reference against an intermediate location.
* Only scalar ranges are replaced; formatting and unrelated flow data survive.
*/
export function updateFlowPathsForRenames(
raw: string,
flowPath: string,
renames: FlowPathRename[]
): string | undefined {
if (renames.length === 0) return undefined;

const fail = (reason: string): never => {
throw new Error(`Cannot update file references in ${flowPath}: ${reason}`);
};
const paths = /^[A-Za-z]:[\\/]|^\\\\/.test(flowPath) ? path.win32 : path;
if (!paths.isAbsolute(flowPath)) fail("the original flow path must be absolute");

const moves = renames.map(({ oldPath, newPath }) => {
if (!paths.isAbsolute(oldPath) || !paths.isAbsolute(newPath)) {
fail("rename paths must be absolute");
}
return { oldPath: paths.normalize(oldPath), newPath: paths.normalize(newPath) };
});
const destinations = new Map<string, string>();
const origins = new Map<string, string>();
for (const move of moves) {
if (origins.has(move.oldPath) && origins.get(move.oldPath) !== move.newPath) {
fail("the rename batch has conflicting destinations");
}
if (destinations.has(move.newPath) && destinations.get(move.newPath) !== move.oldPath) {
fail("the rename batch has colliding destinations");
}
origins.set(move.oldPath, move.newPath);
destinations.set(move.newPath, move.oldPath);
}
moves.sort((a, b) => b.oldPath.length - a.oldPath.length);

const mapPath = (original: string): string => {
const normalized = paths.normalize(original);
for (const move of moves) {
const relative = paths.relative(move.oldPath, normalized);
if (relative === "") return move.newPath;
if (!paths.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${paths.sep}`)) {
return paths.join(move.newPath, relative);
}
}
return normalized;
};

const oldFlowPath = paths.normalize(flowPath);
const oldDirectory = paths.dirname(oldFlowPath);
const newDirectory = paths.dirname(mapPath(oldFlowPath));
const portablePath = (value: string) => paths === path.win32 ? value.replace(/\\/g, "/") : value;
const rewrittenPath = (value: string): string => {
const absolute = paths.isAbsolute(value);
const oldTarget = paths.resolve(oldDirectory, value);
const newTarget = mapPath(oldTarget);
if (newTarget === oldTarget && (absolute || newDirectory === oldDirectory)) return value;
if (absolute) return portablePath(newTarget);
const relative = portablePath(paths.relative(newDirectory, newTarget));
// Keep bare relative imports bare; preserve the explicit ./ convention.
return value.startsWith("./") || value.startsWith(".\\")
? relative.startsWith(".") || paths.isAbsolute(relative) ? relative : `./${relative}`
: relative;
};

const document = yaml.parseDocument(raw, { keepSourceTokens: true });
if (document.errors.length) fail("invalid YAML");
if (!yaml.isMap(document.contents)) return undefined;

const aliases = new Set<string>();
yaml.visit(document, {
Alias: (_key, alias) => { aliases.add(alias.source); },
});
const edits: { start: number; end: number; value: string }[] = [];
const checkAlias = (node: unknown): void => {
if (yaml.isAlias(node) || (yaml.isNode(node) && node.anchor && aliases.has(node.anchor))) {
fail("an aliased reference cannot be changed without affecting other data");
}
};
const get = (map: yaml.YAMLMap, key: string): unknown => {
const value = map.get(key, true);
checkAlias(value);
return value;
};
const replaceScalar = (scalar: yaml.Scalar, value: string): void => {
if (scalar.value === value) return;
checkAlias(scalar);
if (!scalar.range || scalar.type === "BLOCK_FOLDED" || scalar.type === "BLOCK_LITERAL") {
fail("a multiline reference cannot be rewritten safely");
}
let encoded: string;
if (scalar.type === "QUOTE_SINGLE") {
encoded = `'${value.replace(/'/g, "''")}'`;
} else if (scalar.type === "QUOTE_DOUBLE") {
encoded = JSON.stringify(value);
} else {
const plain = yaml.parseDocument(value);
// Flow collections interpret these delimiters even inside otherwise valid plain scalars.
encoded = !/[,\[\]{}]/.test(value) && !plain.errors.length && yaml.isScalar(plain.contents) && plain.contents.value === value
? value
: JSON.stringify(value);
}
edits.push({ start: scalar.range[0], end: scalar.range[1], value: encoded });
};

const walkVisualNode = (node: unknown): void => {
checkAlias(node);
if (!yaml.isMap(node)) return;
const instances = get(node, "instances");
if (!yaml.isSeq(instances)) return;
for (const instance of instances.items) {
checkAlias(instance);
if (!yaml.isMap(instance)) continue;
const source = get(instance, "source");
if (yaml.isMap(source)) {
const type = get(source, "type");
if (yaml.isScalar(type) && type.value === "file") {
const data = get(source, "data");
if (yaml.isScalar(data) && typeof data.value === "string") {
replaceScalar(data, rewrittenPath(data.value));
}
} else if (yaml.isScalar(type) && type.value === "inline") {
walkVisualNode(get(source, "data"));
}
}
// Older flows embed inline visual nodes directly in the instance.
walkVisualNode(get(instance, "node"));
}
};
walkVisualNode(get(document.contents, "node"));

const imports = get(document.contents, "imports");
if (yaml.isMap(imports)) {
const rewrittenKeys = new Set<string>();
for (const entry of imports.items) {
checkAlias(entry.key);
if (!yaml.isScalar(entry.key) || typeof entry.key.value !== "string") continue;
const key = entry.key.value;
// Match the legacy migration's file suffixes, never a package import.
const fileImport = !key.startsWith("@") && /\.flyde(?:\.(?:ts|js))?$/.test(key);
const newKey = fileImport ? rewrittenPath(key) : key;
if (rewrittenKeys.has(newKey)) fail("rewritten legacy import keys would collide");
rewrittenKeys.add(newKey);
if (fileImport) replaceScalar(entry.key, newKey);
}
}

if (!edits.length) return undefined;
edits.sort((a, b) => b.start - a.start);
let result = raw;
let previousStart = raw.length;
for (const edit of edits) {
if (edit.end > previousStart) fail("overlapping YAML scalar ranges");
result = result.slice(0, edit.start) + edit.value + result.slice(edit.end);
previousStart = edit.start;
}
return result;
}
3 changes: 2 additions & 1 deletion loader/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ export * from "./resolver";
export * from "./serdes";
export * from "./runtime";
export * from "./run-flow";
export { updateFlowPathsForRenames, FlowPathRename } from "./serdes/updateFlowPathsForRenames";

// Server-specific exports
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";
3 changes: 3 additions & 0 deletions vscode/src/editor/open-flyde-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type WebviewContentParams = {
initialNode: EditorVisualNode;
webviewId: string;
darkMode: boolean;
documentVersion?: number;
};

const isDev = process.env.MODE === "dev";
Expand Down Expand Up @@ -71,6 +72,7 @@ export async function getWebviewContent(params: WebviewContentParams) {
initialNode,
executionId,
darkMode,
documentVersion,
} = params;
const stylePath = vscode.Uri.joinPath(extensionUri, "media", "style.css");

Expand All @@ -96,6 +98,7 @@ export async function getWebviewContent(params: WebviewContentParams) {
executionId,
hasOpenAiToken,
darkMode,
documentVersion,
};
const serializedBootstrapData = Buffer.from(
JSON.stringify(bootstrapData)
Expand Down
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 { registerFlowImportRenameHandler } from "./updateFlowImportsOnRename";

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

let currentTheme = vscode.window.activeColorTheme;

Expand Down
Loading