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
68 changes: 68 additions & 0 deletions loader/src/serdes/file-reference-renames.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { strict as assert } from "assert";
import { resolve } from "path";
import { parse } from "yaml";
import { fileReferenceEditsForRenames, FileReferenceRename } from "./file-reference-renames";

const root = resolve("rename-fixture");
const at = (path: string) => resolve(root, path);
const text = (reference: string, type = "file") => `# keep comment\nnode:\n instances:\n - source: { type: ${type}, data: '${reference}' } # import\n config: { note: '${reference}' }\n`;
const rewrite = (input: string, file: string, renames: FileReferenceRename[]) =>
fileReferenceEditsForRenames(input, at(file), renames).sort((a, b) => b.start - a.start)
.reduce((content, edit) => content.slice(0, edit.start) + edit.text + content.slice(edit.end), input);
const move = (oldPath: string, newPath: string) => ({ oldPath: at(oldPath), newPath: at(newPath) });
const source = (input: string) => parse(input).node.instances[0].source.data;

describe("preserving file references across rename batches", () => {
it("replaces only a moved producer's YAML scalar", () => {
const before = text("./Child.flyde");
const after = rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]);
assert.equal(source(after), "./parts/Child.flyde");
assert.equal(after, before.replace("data: './Child.flyde'", 'data: "./parts/Child.flyde"'));
});
it("rebases a moved importing flow", () => {
assert.equal(source(rewrite(text("./Child.flyde"), "Main.flyde", [move("Main.flyde", "flows/Main.flyde")])), "../Child.flyde");
});
it("uses both original paths for independently moved producer and consumer", () => {
assert.equal(source(rewrite(text("./Child.flyde"), "Main.flyde", [move("Main.flyde", "flows/Main.flyde"), move("Child.flyde", "parts/Child.flyde")])), "../parts/Child.flyde");
});
it("keeps a reference unchanged when its entire folder moves", () => {
const before = text("./Child.flyde");
assert.equal(rewrite(before, "components/Main.flyde", [move("components", "parts")]), before);
});
it("does not confuse a containing folder with a similarly named sibling", () => {
const before = text("./components-extra/Child.flyde");
assert.equal(rewrite(before, "Main.flyde", [move("components", "parts")]), before);
});
it("maps a batch once and chooses the most specific original source", () => {
assert.equal(source(rewrite(text("./components/Child.flyde"), "Main.flyde", [move("components", "parts"), move("components/Child.flyde", "Child2.flyde"), move("Child2.flyde", "Child3.flyde")])), "./Child2.flyde");
});
it("traverses nested inline flows while leaving package/custom references alone", () => {
const before = `node:\n instances:\n - source:\n type: inline\n data:\n instances:\n - source: { type: file, data: ./Child.flyde }\n - source: { type: package, data: ./Child.flyde }\n - source: { type: custom, data: ./Child.flyde }\n`;
const after = parse(rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]));
assert.deepEqual(after.node.instances[0].source.data.instances.map((instance: any) => instance.source.data), ["./parts/Child.flyde", "./Child.flyde", "./Child.flyde"]);
});
it("updates relative legacy import-map keys only", () => {
const before = "imports:\n './Child.flyde': [Child]\n '@flyde/nodes': [Value]\nnode: {}\n";
assert.deepEqual(parse(rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")])).imports, { "./parts/Child.flyde": ["Child"], "@flyde/nodes": ["Value"] });
});
it("preserves absolute imports and safely quotes YAML punctuation in new names", () => {
const next = at("parts/Child: #2.flyde");
assert.equal(source(rewrite(text(at("Child.flyde")), "Main.flyde", [move("Child.flyde", "parts/Child: #2.flyde")])), next);
});
it("rejects malformed YAML instead of guessing at source ranges", () => {
assert.throws(() => rewrite("node: [", "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]));
});
for (const style of [">-", "|-"]) {
it(`preserves the next property and header comment after a ${style} block scalar`, () => {
const before = `node:\n instances:\n - source:\n type: file\n data: ${style} # path comment\n ./Child.flyde\n inputConfig: {}\n`;
const after = rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]);
assert.equal(source(after), "./parts/Child.flyde");
assert.deepEqual(parse(after).node.instances[0].inputConfig, {});
assert(after.includes('# path comment\n inputConfig: {}'));
});
}
it("does not rewrite anchors whose aliases may be used by unrelated data", () => {
const before = "node:\n instances:\n - source: {type: file, data: &path ./Child.flyde}\n config: {note: *path}\n";
assert.equal(rewrite(before, "Main.flyde", [move("Child.flyde", "parts/Child.flyde")]), before);
});
});
102 changes: 102 additions & 0 deletions loader/src/serdes/file-reference-renames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { dirname, isAbsolute, relative, resolve, sep } from "path";
import { isMap, isScalar, isSeq, parseDocument } from "yaml";

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

export interface FileReferenceEdit {
start: number;
end: number;
text: string;
}

/** Map original paths once: a rename batch is simultaneous, not a chain. */
export function renameFileReferencePath(file: string, renames: readonly FileReferenceRename[]): string {
const rename = [...renames].sort((a, b) => b.oldPath.length - a.oldPath.length).find(({ oldPath }) => {
const suffix = relative(oldPath, file);
return suffix === "" || (suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix));
});
return rename ? resolve(rename.newPath, relative(rename.oldPath, file)) : file;
}

/** Change only file-source scalars, preserving comments and unrelated YAML. */
export function fileReferenceEditsForRenames(
text: string,
flowPath: string,
renames: readonly FileReferenceRename[]
): FileReferenceEdit[] {
const document = parseDocument(text);
if (document.errors.length) {
throw new Error(document.errors[0].message);
}
const edits: FileReferenceEdit[] = [];
const nextFlowPath = renameFileReferencePath(flowPath, renames);

const update = (scalar: unknown) => {
if (!isScalar(scalar) || typeof scalar.value !== "string" || !scalar.range || scalar.anchor) {
return;
}
const original = scalar.value;
const target = resolve(dirname(flowPath), original);
const nextTarget = renameFileReferencePath(target, renames);
if (resolve(dirname(nextFlowPath), original) === nextTarget) {
return;
}
const next = isAbsolute(original)
? nextTarget
: relative(dirname(nextFlowPath), nextTarget).split(sep).join("/");
const value = !isAbsolute(original) && original.startsWith("./") && !next.startsWith(".") ? `./${next}` : next;
let replacement = JSON.stringify(value);
if (scalar.type === "BLOCK_FOLDED" || scalar.type === "BLOCK_LITERAL") {
const originalText = text.slice(scalar.range[0], scalar.range[1]);
const header = originalText.split(/\r?\n/, 1)[0];
const comment = header.indexOf("#");
if (comment >= 0) { replacement += ` ${header.slice(comment)}`; }
// Block scalar ranges consume the final newline before the next property.
if (originalText.endsWith("\r\n")) { replacement += "\r\n"; }
else if (originalText.endsWith("\n")) { replacement += "\n"; }
}
edits.push({ start: scalar.range[0], end: scalar.range[1], text: replacement });
};

const visitNode = (node: unknown) => {
if (!isMap(node)) {
return;
}
const instances = node.get("instances", true);
if (!isSeq(instances)) {
return;
}
for (const instance of instances.items) {
if (!isMap(instance)) {
continue;
}
const source = instance.get("source", true);
if (!isMap(source)) {
continue;
}
if (source.get("type") === "file") {
update(source.get("data", true));
} else if (source.get("type") === "inline") {
visitNode(source.get("data", true));
}
}
};

if (isMap(document.contents)) {
visitNode(document.contents.get("node", true));
// Older flows retain relative paths as import-map keys until migration.
const imports = document.contents.get("imports", true);
if (isMap(imports)) {
for (const pair of imports.items) {
if (isScalar(pair.key) && typeof pair.key.value === "string" &&
(pair.key.value.startsWith(".") || isAbsolute(pair.key.value))) {
update(pair.key);
}
}
}
}
return edits;
}
1 change: 1 addition & 0 deletions loader/src/serdes/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./serialize";
export * from "./deserialize";
export * from "./migrations";
export * from "./file-reference-renames";
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;
renderGeneration?: string;
};

const isDev = process.env.MODE === "dev";
Expand Down Expand Up @@ -71,6 +72,7 @@ export async function getWebviewContent(params: WebviewContentParams) {
initialNode,
executionId,
darkMode,
renderGeneration,
} = 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,
renderGeneration,
};
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 { registerFileRenameParticipant } from "./fileRenameParticipant";

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

let currentTheme = vscode.window.activeColorTheme;

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

export function registerFileRenameParticipant(output: Pick<vscode.OutputChannel, "appendLine">): vscode.Disposable {
return vscode.workspace.onWillRenameFiles(event => {
event.waitUntil((async () => {
const uris = new Map((await vscode.workspace.findFiles("**/*.flyde", "**/node_modules/**"))
.map(uri => [uri.toString(), uri]));
for (const document of vscode.workspace.textDocuments) {
if (!document.isUntitled && document.uri.path.endsWith(".flyde")) {
uris.set(document.uri.toString(), document.uri);
}
}
const edit = new vscode.WorkspaceEdit();
const snapshots = new Map<vscode.TextDocument, number>();
for (const uri of uris.values()) {
if (event.token.isCancellationRequested) {
return;
}
const renames = event.files.filter(({ oldUri, newUri }) =>
oldUri.scheme === uri.scheme && newUri.scheme === uri.scheme &&
oldUri.authority === uri.authority && newUri.authority === uri.authority
).map(({ oldUri, newUri }) => ({ oldPath: oldUri.fsPath, newPath: newUri.fsPath }));
if (!renames.length) {
continue;
}
try {
const document = await vscode.workspace.openTextDocument(uri);
snapshots.set(document, document.version);
for (const replacement of fileReferenceEditsForRenames(document.getText(), uri.fsPath, renames)) {
edit.replace(uri, new vscode.Range(document.positionAt(replacement.start), document.positionAt(replacement.end)), replacement.text);
}
} catch (error) {
output.appendLine(`Unable to update imports in ${uri.fsPath}: ${error}`);
}
}
if (event.token.isCancellationRequested || [...snapshots].some(([document, version]) => document.version !== version)) {
output.appendLine("Skipped Flyde import updates because documents changed during the rename scan.");
return;
}
return edit;
})());
});
}
Loading