From 3ebe3e5e129c4c4c0dd907d0a8a1a49bb06bbfd4 Mon Sep 17 00:00:00 2001 From: Aryan gurjar <151118329+gurjara450@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:30:11 +0530 Subject: [PATCH] fix(vscode): preserve imports and editor saves across file renames --- loader/src/index.ts | 1 + .../serdes/updateFlowPathsForRenames.spec.ts | 166 ++++++++++++++++ .../src/serdes/updateFlowPathsForRenames.ts | 166 ++++++++++++++++ loader/src/server.ts | 3 +- vscode/src/editor/open-flyde-panel.ts | 3 + vscode/src/extension.ts | 2 + vscode/src/flowDocumentSync.ts | 102 ++++++++++ vscode/src/flydeEditor.ts | 105 ++++++++-- vscode/src/test/flowDocumentSync.test.ts | 186 ++++++++++++++++++ vscode/src/test/importRename.test.ts | 161 +++++++++++++++ vscode/src/test/runTest.ts | 27 ++- vscode/src/test/testUtils.ts | 14 +- vscode/src/updateFlowImportsOnRename.ts | 68 +++++++ vscode/src/webview/VSCodeFlowEditor.tsx | 46 +++-- vscode/src/webview/bootstrap.ts | 3 +- vscode/src/webview/index.tsx | 4 +- 16 files changed, 1010 insertions(+), 47 deletions(-) create mode 100644 loader/src/serdes/updateFlowPathsForRenames.spec.ts create mode 100644 loader/src/serdes/updateFlowPathsForRenames.ts create mode 100644 vscode/src/flowDocumentSync.ts create mode 100644 vscode/src/test/flowDocumentSync.test.ts create mode 100644 vscode/src/test/importRename.test.ts create mode 100644 vscode/src/updateFlowImportsOnRename.ts diff --git a/loader/src/index.ts b/loader/src/index.ts index f27fc0b9..4946cd86 100644 --- a/loader/src/index.ts +++ b/loader/src/index.ts @@ -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"; diff --git a/loader/src/serdes/updateFlowPathsForRenames.spec.ts b/loader/src/serdes/updateFlowPathsForRenames.spec.ts new file mode 100644 index 00000000..a2caebf6 --- /dev/null +++ b/loader/src/serdes/updateFlowPathsForRenames.spec.ts @@ -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/); + }); +}); diff --git a/loader/src/serdes/updateFlowPathsForRenames.ts b/loader/src/serdes/updateFlowPathsForRenames.ts new file mode 100644 index 00000000..a6ecbee0 --- /dev/null +++ b/loader/src/serdes/updateFlowPathsForRenames.ts @@ -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(); + const origins = new Map(); + 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(); + 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(); + 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; +} diff --git a/loader/src/server.ts b/loader/src/server.ts index cfb3376b..a240e9e5 100644 --- a/loader/src/server.ts +++ b/loader/src/server.ts @@ -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"; \ No newline at end of file +export { resolveCodeNodeDependencies, isCodeNodePath } from "./resolver/server/serverUtils"; diff --git a/vscode/src/editor/open-flyde-panel.ts b/vscode/src/editor/open-flyde-panel.ts index fc48b709..109c5f31 100644 --- a/vscode/src/editor/open-flyde-panel.ts +++ b/vscode/src/editor/open-flyde-panel.ts @@ -10,6 +10,7 @@ export type WebviewContentParams = { initialNode: EditorVisualNode; webviewId: string; darkMode: boolean; + documentVersion?: number; }; const isDev = process.env.MODE === "dev"; @@ -71,6 +72,7 @@ export async function getWebviewContent(params: WebviewContentParams) { initialNode, executionId, darkMode, + documentVersion, } = params; const stylePath = vscode.Uri.joinPath(extensionUri, "media", "style.css"); @@ -96,6 +98,7 @@ export async function getWebviewContent(params: WebviewContentParams) { executionId, hasOpenAiToken, darkMode, + documentVersion, }; const serializedBootstrapData = Buffer.from( JSON.stringify(bootstrapData) diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ea022727..b2d99fe1 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -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) @@ -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; diff --git a/vscode/src/flowDocumentSync.ts b/vscode/src/flowDocumentSync.ts new file mode 100644 index 00000000..65ee13ce --- /dev/null +++ b/vscode/src/flowDocumentSync.ts @@ -0,0 +1,102 @@ +export interface FlowSaveResult { + documentVersion: number; + applied: boolean; +} + +/** A document change is an echo only for the exact edit currently being applied. */ +export class FlowDocumentWriter { + private expectedChange?: { text: string; version: number }; + private writes: Promise = Promise.resolve(); + + constructor(private readonly document: { + getText(): string; + readonly version: number; + apply(text: string): PromiseLike; + }) { } + + isOwnChange(text: string, version: number): boolean { + const expected = this.expectedChange; + this.expectedChange = undefined; + return expected?.text === text && expected.version === version; + } + + write(text: string, documentVersion?: number): Promise { + const write = this.writes.then(async () => { + if (documentVersion !== undefined && documentVersion !== this.document.version) { + return { documentVersion: this.document.version, applied: false }; + } + if (text === this.document.getText()) { + return { documentVersion: this.document.version, applied: true }; + } + + const expected = { text, version: this.document.version + 1 }; + this.expectedChange = expected; + try { + if (!await this.document.apply(text)) { + throw new Error("The flow document changed before the save could be applied."); + } + return { documentVersion: this.document.version, applied: true }; + } finally { + if (this.expectedChange === expected) { + this.expectedChange = undefined; + } + } + }); + // A rejected save must not prevent a later edit from being saved. + this.writes = write.catch(() => undefined); + return write; + } +} + +/** Serialize visual saves and invalidate old snapshots when the document changes externally. */ +export class WebviewFlowSaver { + private timer?: ReturnType; + private generation = 0; + private writes: Promise = Promise.resolve(); + + constructor( + private documentVersion: number, + private readonly save: (flow: T, documentVersion: number) => Promise, + private readonly onError: (error: unknown) => void, + private readonly delay = 500 + ) { } + + schedule(flow: T): void { + this.cancelPending(); + const generation = this.generation; + this.timer = setTimeout(() => { + this.timer = undefined; + const write = this.writes.then(async () => { + if (generation !== this.generation) { return; } + const result = await this.save(flow, this.documentVersion); + // An acknowledgement may arrive after a newer external update. + this.documentVersion = Math.max(this.documentVersion, result.documentVersion); + if (!result.applied && generation === this.generation) { + this.generation++; + this.cancelPending(); + } + }); + this.writes = write.catch(this.onError); + }, this.delay); + } + + acceptExternalChange(documentVersion = this.documentVersion): boolean { + if (documentVersion < this.documentVersion) { return false; } + this.documentVersion = documentVersion; + this.generation++; + this.cancelPending(); + return true; + } + + dispose(): void { + this.generation++; + this.cancelPending(); + } + + private cancelPending(): void { + if (this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + } +} diff --git a/vscode/src/flydeEditor.ts b/vscode/src/flydeEditor.ts index c0ab2496..abd35e06 100644 --- a/vscode/src/flydeEditor.ts +++ b/vscode/src/flydeEditor.ts @@ -39,6 +39,7 @@ import { customCodeNodeFromCode } from "@flyde/core/dist/misc/custom-code-node-f import { createAiCompletion } from "./ai"; import { getAvailableSecrets, addNewSecret, getSecrets } from "./secretsService"; import { createNodeFinderWithOverrides } from "./createNodeFinderWithOverrides"; +import { FlowDocumentWriter } from "./flowDocumentSync"; // Helper functions // export type EditorPortType = keyof any; @@ -134,9 +135,18 @@ export class FlydeEditorEditorProvider const relative = path.relative(fileRoot, document.fileName); - let lastSaveBy = ""; - let lastFlow: FlydeFlow; + const documentWriter = new FlowDocumentWriter({ + getText: () => document.getText(), + get version() { return document.version; }, + apply: async (text) => { + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, new vscode.Range( + document.positionAt(0), document.positionAt(document.getText().length) + ), text); + return vscode.workspace.applyEdit(edit); + }, + }); const messageResponse = (event: FlydePortMessage, payload: any) => { webviewPanel.webview.postMessage({ @@ -171,8 +181,50 @@ export class FlydeEditorEditorProvider const webviewId = `wv-${(Date.now() + randomInt(999)).toString(32)}`; - const fullDocumentPath = document.uri.fsPath; + let fullDocumentPath = document.uri.fsPath; const executionId = fullDocumentPath; + let pendingResolution = { needsResolution: false, importPaths: new Set() }; + + const getResolutionState = (node: EditorVisualNode) => { + const state = { needsResolution: false, importPaths: new Set() }; + const visit = (visualNode: EditorVisualNode) => { + for (const instance of visualNode.instances) { + if (instance.node?.id === "__errorResolvingNode") { + state.needsResolution = true; + if (instance.source.type === "file" && typeof instance.source.data === "string") { + state.importPaths.add(path.resolve(path.dirname(fullDocumentPath), instance.source.data)); + } + } else if (instance.node && "instances" in instance.node) { + visit(instance.node); + } + } + }; + visit(node); + return state; + }; + + const notifyExternalFlowChange = () => { + fullDocumentPath = document.uri.fsPath; + try { + const flow = deserializeFlow(document.getText(), fullDocumentPath); + lastFlow = flow; + const node = resolveEditorNode(flow.node, createNodeFinderWithOverrides(fullDocumentPath)); + // The resolver catches failed imports and returns an Error placeholder. + // Those imports still need another resolution once a rename completes. + pendingResolution = getResolutionState(node); + webviewPanel.webview.postMessage({ + type: "onExternalFlowChange", + requestId: "TODO-cuid", + params: { flow: { ...flow, node }, documentVersion: document.version }, + source: "extension", + }); + } catch (error) { + // Preserve pending imports for a matching rename; later document edits + // also retry when the document itself cannot currently be read. + pendingResolution.needsResolution = true; + this.params.mainOutputChannel.appendLine(`Could not refresh ${fullDocumentPath}: ${error}`); + } + }; const renderWebview = async () => { const raw = document.getText(); @@ -217,6 +269,7 @@ export class FlydeEditorEditorProvider webviewPanel.webview.html = `Errors: ${resolvedNode.message}
Try opening it with a text editor and fix the problem`; return; } + pendingResolution = getResolutionState(resolvedNode); // used to avoid triggering "onChange" of the same webview webviewPanel.webview.html = await getWebviewContent({ @@ -228,6 +281,7 @@ export class FlydeEditorEditorProvider webviewId, executionId, darkMode: this.params.darkMode, + documentVersion: document.version, }); lastFlow = initialFlow as FlydeFlow; @@ -382,15 +436,14 @@ export class FlydeEditorEditorProvider } const serialized = serializeFlow({ node: cleanNode(node) }); - lastFlow = flow; - const edit = new vscode.WorkspaceEdit(); - - // replacing everything for simplicity. TODO - pass only delta changes - const range = new vscode.Range(0, 0, document.lineCount, 0); - edit.replace(document.uri, range, serialized); - lastSaveBy = webviewId; - await vscode.workspace.applyEdit(edit); - messageResponse(event, undefined); + const result = await documentWriter.write(serialized, event.params.documentVersion); + if (result.applied && document.getText() === serialized) { + lastFlow = flow; + } else { + // A save posted before an external edit must not restore stale imports. + notifyExternalFlowChange(); + } + messageResponse(event, result); break; } @@ -751,22 +804,32 @@ export class FlydeEditorEditorProvider (e) => { const isSameUri = e.document.uri.toString() === document.uri.toString(); - if (isSameUri && lastSaveBy !== webviewId) { - const raw = document.getText(); - const flow: FlydeFlow = deserializeFlow(raw, fullDocumentPath); - webviewPanel.webview.postMessage({ - type: "onExternalFlowChange", - requestId: "TODO-cuid", - params: { flow }, - source: "extension", - }); + if (isSameUri && e.contentChanges.length > 0 && + !documentWriter.isOwnChange(document.getText(), document.version)) { + notifyExternalFlowChange(); } } ); + const renameSubscription = vscode.workspace.onDidRenameFiles((event) => { + const includesUnresolvedImport = (uri: vscode.Uri) => uri.scheme === "file" && + [...pendingResolution.importPaths].some((importPath) => { + const relative = path.relative(uri.fsPath, importPath); + return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && + !relative.startsWith(`..${path.sep}`)); + }); + const resolvesPendingImport = pendingResolution.needsResolution && event.files.some( + ({ oldUri, newUri }) => includesUnresolvedImport(oldUri) || includesUnresolvedImport(newUri) + ); + if (resolvesPendingImport || document.uri.fsPath !== fullDocumentPath) { + notifyExternalFlowChange(); + } + }); + // Make sure we get rid of the listener when our editor is closed. webviewPanel.onDidDispose(() => { changeDocumentSubscription.dispose(); + renameSubscription.dispose(); }); } diff --git a/vscode/src/test/flowDocumentSync.test.ts b/vscode/src/test/flowDocumentSync.test.ts new file mode 100644 index 00000000..1198264f --- /dev/null +++ b/vscode/src/test/flowDocumentSync.test.ts @@ -0,0 +1,186 @@ +import * as assert from "assert"; +import * as sinon from "sinon"; +import { FlowDocumentWriter, FlowSaveResult, WebviewFlowSaver } from "../flowDocumentSync"; + +type Flow = { source: string; x: number }; +const initialFlow = (): Flow => ({ source: "./Child.flyde", x: 0 }); + +suite("Flow document synchronization", () => { + let clock: sinon.SinonFakeTimers; + setup(() => { clock = sinon.useFakeTimers(); }); + teardown(() => { clock.restore(); }); + + test("a prior visual save does not suppress a rename or restore its old import on the next edit", async () => { + let text = JSON.stringify(initialFlow()); + let version = 1; + let visualFlow = initialFlow(); + const changes: boolean[] = []; + const errors: unknown[] = []; + let saver: WebviewFlowSaver; + const changed = () => { + const ownChange = writer.isOwnChange(text, version); + changes.push(ownChange); + if (!ownChange && saver.acceptExternalChange(version)) { + visualFlow = JSON.parse(text); + } + }; + const writer = new FlowDocumentWriter({ + getText: () => text, + get version() { return version; }, + apply: async (value) => { + text = value; + version++; + changed(); + return true; + }, + }); + saver = new WebviewFlowSaver(1, + (flow, baseVersion) => writer.write(JSON.stringify(flow), baseVersion), + (error) => errors.push(error)); + + visualFlow = { ...visualFlow, x: 10 }; + saver.schedule(visualFlow); + await clock.tickAsync(500); + assert.deepStrictEqual(changes, [true]); + + text = JSON.stringify({ ...visualFlow, source: "./moved/Child.flyde" }); + version++; + changed(); + assert.deepStrictEqual(changes, [true, false]); + assert.strictEqual(visualFlow.source, "./moved/Child.flyde"); + + visualFlow = { ...visualFlow, x: 20 }; + saver.schedule(visualFlow); + await clock.tickAsync(500); + assert.deepStrictEqual(JSON.parse(text), { source: "./moved/Child.flyde", x: 20 }); + assert.deepStrictEqual(changes, [true, false, true]); + assert.deepStrictEqual(errors, []); + saver.dispose(); + }); + + test("an external rename cancels a pending debounced save", async () => { + const writes: Flow[] = []; + const saver = new WebviewFlowSaver(1, async (flow, version) => { + writes.push(flow); + return { documentVersion: version + 1, applied: true }; + }, () => assert.fail("save should not fail")); + + saver.schedule({ ...initialFlow(), x: 10 }); + await clock.tickAsync(250); + saver.acceptExternalChange(2); + await clock.tickAsync(1000); + assert.deepStrictEqual(writes, []); + + saver.schedule({ source: "./moved/Child.flyde", x: 20 }); + await clock.tickAsync(500); + assert.deepStrictEqual(writes, [{ source: "./moved/Child.flyde", x: 20 }]); + saver.dispose(); + }); + + test("queued old saves are discarded and a late acknowledgement cannot roll back the version", async () => { + let finishFirst!: (value: FlowSaveResult) => void; + const first = new Promise((resolve) => { finishFirst = resolve; }); + const calls: { flow: Flow; version: number }[] = []; + const saver = new WebviewFlowSaver(1, async (flow, version) => { + calls.push({ flow, version }); + return calls.length === 1 ? first : { documentVersion: version + 1, applied: true }; + }, () => assert.fail("save should not fail")); + + saver.schedule({ ...initialFlow(), x: 1 }); + await clock.tickAsync(500); + saver.schedule({ ...initialFlow(), x: 2 }); + await clock.tickAsync(500); + assert.strictEqual(calls.length, 1); + + saver.acceptExternalChange(5); + finishFirst({ documentVersion: 2, applied: true }); + await clock.tickAsync(0); + assert.strictEqual(calls.length, 1); + assert.strictEqual(saver.acceptExternalChange(4), false); + + saver.schedule({ source: "./moved/Child.flyde", x: 3 }); + await clock.tickAsync(500); + assert.strictEqual(calls[1].version, 5); + assert.strictEqual(calls[1].flow.source, "./moved/Child.flyde"); + saver.dispose(); + }); + + test("a save posted before an external edit cannot overwrite the new document", async () => { + let text = "renamed document"; + let applies = 0; + const writer = new FlowDocumentWriter({ + getText: () => text, + version: 3, + apply: async (value) => { applies++; text = value; return true; }, + }); + assert.deepStrictEqual(await writer.write("stale document", 2), { + documentVersion: 3, applied: false, + }); + assert.strictEqual(text, "renamed document"); + assert.strictEqual(applies, 0); + }); + + test("a stale-save rejection discards queued snapshots before external resolution completes", async () => { + let finishFirst!: (value: FlowSaveResult) => void; + const first = new Promise((resolve) => { finishFirst = resolve; }); + let writes = 0; + const saver = new WebviewFlowSaver(1, async () => { + writes++; + return first; + }, () => assert.fail("save should not fail")); + saver.schedule(initialFlow()); + await clock.tickAsync(500); + saver.schedule({ ...initialFlow(), x: 1 }); + await clock.tickAsync(500); + finishFirst({ documentVersion: 2, applied: false }); + await clock.tickAsync(0); + assert.strictEqual(writes, 1); + saver.dispose(); + }); + + test("failed WorkspaceEdits reject the save and do not poison later saves", async () => { + let text = "before"; + let version = 1; + let fail = true; + const writer = new FlowDocumentWriter({ + getText: () => text, + get version() { return version; }, + apply: async (value) => { + if (fail) { return false; } + text = value; + version++; + assert.strictEqual(writer.isOwnChange(text, version), true); + return true; + }, + }); + await assert.rejects(writer.write("failed", 1), /changed before the save/); + assert.strictEqual(writer.isOwnChange("failed", 2), false); + fail = false; + assert.deepStrictEqual(await writer.write("saved", 1), { documentVersion: 2, applied: true }); + }); + + test("an identical text change with the wrong version is external, not a self echo", async () => { + const writer = new FlowDocumentWriter({ + getText: () => "before", + version: 1, + apply: async (text) => { + assert.strictEqual(writer.isOwnChange(text, 3), false); + assert.strictEqual(writer.isOwnChange(text, 2), false); + return true; + }, + }); + await writer.write("after", 1); + }); + + test("disposing the visual editor cancels pending saves", async () => { + let writes = 0; + const saver = new WebviewFlowSaver(1, async () => { + writes++; + return { documentVersion: 2, applied: true }; + }, () => assert.fail("save should not fail")); + saver.schedule(initialFlow()); + saver.dispose(); + await clock.tickAsync(1000); + assert.strictEqual(writes, 0); + }); +}); diff --git a/vscode/src/test/importRename.test.ts b/vscode/src/test/importRename.test.ts new file mode 100644 index 00000000..898dfde2 --- /dev/null +++ b/vscode/src/test/importRename.test.ts @@ -0,0 +1,161 @@ +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import { deserializeFlow } from "@flyde/loader"; +import { eventually } from "@flyde/core"; +import { addNodeFromMenu, getInstances, openFlydeFile, waitForFlowEditor, waitForInstanceCount } from "./pageObjects"; + +const flow = (reference: string) => `# Preserve this comment\nnode:\n id: Parent\n instances:\n - id: child\n source: { type: file, data: '${reference}' }\n`; + +suite("Imported flow renames", () => { + let root: string; + const documents = new Set(); + + suiteSetup(async () => { + const extension = vscode.extensions.getExtension("flyde.flyde-vscode"); + assert.ok(extension, "The development extension must be loaded"); + await extension.activate(); // Never register a second rename handler in tests. + root = fs.mkdtempSync(path.join(os.tmpdir(), "flyde-import-renames-")); + const existing = vscode.workspace.workspaceFolders?.length ?? 0; + assert.ok(existing > 0, "Launch tests with an initial workspace to avoid restarting the extension host"); + const changed = new Promise((resolve) => { + const listener = vscode.workspace.onDidChangeWorkspaceFolders(() => { + listener.dispose(); + resolve(); + }); + }); + assert.strictEqual(vscode.workspace.updateWorkspaceFolders(existing, 0, { uri: vscode.Uri.file(root) }), true); + await changed; + }); + + teardown(async () => { + for (const document of documents) { + if (!document.isClosed && document.isDirty) { await document.save(); } + } + documents.clear(); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + }); + + suiteTeardown(() => { + const index = vscode.workspace.workspaceFolders?.findIndex((folder) => folder.uri.fsPath === root); + if (index !== undefined && index > 0) { + vscode.workspace.updateWorkspaceFolders(index, 1); + } + if (root) { fs.rmSync(root, { recursive: true, force: true }); } + }); + + function write(relative: string, text: string): vscode.Uri { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + return vscode.Uri.file(file); + } + + async function open(uri: vscode.Uri): Promise { + const document = await vscode.workspace.openTextDocument(uri); + documents.add(document); + return document; + } + + async function rename(pairs: [vscode.Uri, vscode.Uri][]): Promise { + const edit = new vscode.WorkspaceEdit(); + for (const [from, to] of pairs) { edit.renameFile(from, to); } + assert.strictEqual(await vscode.workspace.applyEdit(edit), true); + } + + test("repairs a dirty consumer without discarding its unsaved change", async () => { + const dependency = write("dirty/Child.flyde", "node: { id: Child, instances: [] }\n"); + const parent = write("dirty/Parent.flyde", flow("./Child.flyde")); + const document = await open(parent); + const edit = new vscode.WorkspaceEdit(); + edit.insert(parent, document.positionAt(document.getText().length), "# Unsaved user change\n"); + assert.strictEqual(await vscode.workspace.applyEdit(edit), true); + fs.mkdirSync(path.join(root, "dirty/lib")); + await rename([[dependency, vscode.Uri.file(path.join(root, "dirty/lib/Child.flyde"))]]); + assert.ok(document.getText().includes("'./lib/Child.flyde'")); + assert.ok(document.getText().endsWith("# Unsaved user change\n")); + assert.strictEqual(document.isDirty, true); + assert.strictEqual(fs.readFileSync(parent.fsPath, "utf8"), flow("./Child.flyde")); + }); + + test("rebases a moved importer against the dependency's original location", async () => { + write("importer/Child.flyde", "node: { id: Child, instances: [] }\n"); + const oldParent = write("importer/Parent.flyde", flow("./Child.flyde")); + fs.mkdirSync(path.join(root, "importer/deeper")); + const newParent = vscode.Uri.file(path.join(root, "importer/deeper/Parent.flyde")); + await rename([[oldParent, newParent]]); + const document = await open(newParent); + assert.ok(document.getText().includes("'../Child.flyde'")); + }); + + test("handles one batch moving both the importer and its dependency", async () => { + const dependency = write("batch/Child.flyde", "node: { id: Child, instances: [] }\n"); + const parent = write("batch/Parent.flyde", flow("./Child.flyde")); + fs.mkdirSync(path.join(root, "batch/moved")); + const newParent = vscode.Uri.file(path.join(root, "batch/moved/Parent.flyde")); + await rename([ + [parent, newParent], + [dependency, vscode.Uri.file(path.join(root, "batch/moved/Renamed.flyde"))], + ]); + assert.ok((await open(newParent)).getText().includes("'./Renamed.flyde'")); + }); + + test("repairs a folder move while preserving prefix siblings and malformed neighbors", async () => { + write("folder/lib/Child.flyde", "node: { id: Child, instances: [] }\n"); + write("folder/library/Other.flyde", "node: { id: Other, instances: [] }\n"); + const parent = write("folder/Parent.flyde", flow("./lib/Child.flyde")); + const sibling = write("folder/Sibling.flyde", flow("./library/Other.flyde")); + const malformed = write("folder/Invalid.flyde", "node: [\n"); + await rename([[ + vscode.Uri.file(path.join(root, "folder/lib")), + vscode.Uri.file(path.join(root, "folder/components")), + ]]); + assert.ok((await open(parent)).getText().includes("'./components/Child.flyde'")); + assert.strictEqual((await open(sibling)).getText(), flow("./library/Other.flyde")); + assert.strictEqual(fs.readFileSync(malformed.fsPath, "utf8"), "node: [\n"); + }); + + test("a visual edit after moving a dependency keeps the repaired import without reopening", async () => { + const assertRepairedPath = (value: ReturnType) => { + const child = value.node.instances.find((instance) => instance.id === "nested-instance"); + assert.ok(child && child.source.type === "file", "Expected the imported child flow"); + assert.strictEqual(child.source.data, "./components/ChildFlow.flyde"); + }; + const fixtures = path.resolve(__dirname, "../../test-fixtures/nested-flows"); + const parent = write("visual/ParentFlow.flyde", fs.readFileSync(path.join(fixtures, "ParentFlow.flyde"), "utf8")); + const dependency = write("visual/ChildFlow.flyde", fs.readFileSync(path.join(fixtures, "ChildFlow.flyde"), "utf8")); + const document = await open(parent); + await openFlydeFile(parent.fsPath); + await waitForFlowEditor(); + await eventually(async () => assert.ok((await getInstances()).some((instance) => instance.textContent?.includes("ChildFlow"))), 4000); + await addNodeFromMenu("Value"); + await waitForInstanceCount(2); + await eventually(() => assert.strictEqual(deserializeFlow(document.getText(), parent.fsPath).node.instances.length, 2), 4000); + + fs.mkdirSync(path.join(root, "visual/components")); + await rename([[dependency, vscode.Uri.file(path.join(root, "visual/components/ChildFlow.flyde"))]]); + await eventually(() => { + assertRepairedPath(deserializeFlow(document.getText(), parent.fsPath)); + }, 4000); + await eventually(async () => { + const instances = await getInstances(); + assert.ok(instances.some((instance) => instance.textContent?.includes("ChildFlow")), "The moved child must resolve in the open editor"); + assert.ok(instances.every((instance) => !instance.textContent?.includes("Error")), "The editor must not keep a temporary unresolved node"); + }, 4000); + + // The next edit uses the still-open visual editor's state, not a fresh load. + await addNodeFromMenu("Value"); + await waitForInstanceCount(3); + await eventually(() => { + const instances = deserializeFlow(document.getText(), parent.fsPath).node.instances; + assert.strictEqual(instances.length, 3); + assertRepairedPath(deserializeFlow(document.getText(), parent.fsPath)); + }, 4000); + assert.strictEqual(await document.save(), true); + const saved = deserializeFlow(fs.readFileSync(parent.fsPath, "utf8"), parent.fsPath); + assert.strictEqual(saved.node.instances.length, 3); + assertRepairedPath(saved); + }).timeout(20000); +}); diff --git a/vscode/src/test/runTest.ts b/vscode/src/test/runTest.ts index ccbfc4cd..f3f6ab80 100644 --- a/vscode/src/test/runTest.ts +++ b/vscode/src/test/runTest.ts @@ -1,8 +1,23 @@ import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; import { runTests } from "@vscode/test-electron"; async function main() { + // macOS Unix socket paths are limited to 103 characters. Keep the isolated + // test profile outside the potentially long checkout path. + const testProfile = fs.mkdtempSync(path.join(os.tmpdir(), "flyde-test-")); + const userData = path.join(testProfile, "user-data"); + fs.mkdirSync(path.join(userData, "User"), { recursive: true }); + fs.writeFileSync(path.join(userData, "User/settings.json"), JSON.stringify({ + "flyde.telemetry.enabled": false, + "telemetry.telemetryLevel": "off", + })); + const workspaceFile = path.join(testProfile, "tests.code-workspace"); + fs.writeFileSync(workspaceFile, JSON.stringify({ + folders: [{ path: path.resolve(__dirname, "../../test-fixtures") }], + })); try { // The folder containing the Extension Manifest package.json // Passed to `--extensionDevelopmentPath` @@ -27,12 +42,18 @@ async function main() { extensionDevelopmentPath, extensionTestsPath, version: '1.98.2', - launchArgs: ["--disable-extensions"], + launchArgs: [ + workspaceFile, + "--user-data-dir", userData, + "--disable-extensions", "--skip-welcome", "--skip-release-notes", + ], timeout: 60000, }); } catch (err) { - console.error("Failed to run tests"); - process.exit(1); + console.error("Failed to run tests", err); + process.exitCode = 1; + } finally { + fs.rmSync(testProfile, { recursive: true, force: true }); } } diff --git a/vscode/src/test/testUtils.ts b/vscode/src/test/testUtils.ts index cff37806..3bf03762 100644 --- a/vscode/src/test/testUtils.ts +++ b/vscode/src/test/testUtils.ts @@ -23,6 +23,8 @@ export type WebViewTestingCommands = { }; }; +let nextTestingRequestId = 0; + export async function webviewTestingCommand< T extends keyof WebViewTestingCommands >( @@ -30,9 +32,16 @@ export async function webviewTestingCommand< params: WebViewTestingCommands[T]["params"] ): Promise { const webview = await getLastWebview(); + const testingRequestId = ++nextTestingRequestId; return new Promise((res, rej) => { - webview.onDidReceiveMessage((message) => { - if (message.type === "__webviewTestingResponse") { + const timer = setTimeout(() => { + listener.dispose(); + rej(new Error(`Webview did not answer ${command} within 1000ms`)); + }, 1000); + const listener = webview.onDidReceiveMessage((message) => { + if (message.type === "__webviewTestingResponse" && message.testingRequestId === testingRequestId) { + clearTimeout(timer); + listener.dispose(); if (message.error) { rej(message.error); } else { @@ -43,6 +52,7 @@ export async function webviewTestingCommand< webview.postMessage({ type: "__webviewTestingCommand", + testingRequestId, command, params, }); diff --git a/vscode/src/updateFlowImportsOnRename.ts b/vscode/src/updateFlowImportsOnRename.ts new file mode 100644 index 00000000..84ea449b --- /dev/null +++ b/vscode/src/updateFlowImportsOnRename.ts @@ -0,0 +1,68 @@ +import * as vscode from "vscode"; +import { updateFlowPathsForRenames } from "@flyde/loader"; + +/** Build edits against the old URIs; VS Code applies them before the move. */ +export async function createFlowImportRenameEdit( + files: readonly { oldUri: vscode.Uri; newUri: vscode.Uri }[], + output: Pick +): Promise { + const edit = new vscode.WorkspaceEdit(); + const renames = files + .filter(({ oldUri, newUri }) => oldUri.scheme === "file" && newUri.scheme === "file") + .map(({ oldUri, newUri }) => ({ oldPath: oldUri.fsPath, newPath: newUri.fsPath })); + + if (renames.length === 0) { + return edit; + } + + // findFiles searches every workspace root. Include unsaved/open flows as well. + const uris = new Map(); + for (const uri of await vscode.workspace.findFiles("**/*.flyde", "**/{node_modules,.git}/**")) { + uris.set(uri.toString(), uri); + } + for (const document of vscode.workspace.textDocuments) { + if (document.uri.scheme === "file" && document.uri.path.endsWith(".flyde")) { + uris.set(document.uri.toString(), document.uri); + } + } + + const documents = await Promise.all( + [...uris.values()].map(async (uri) => { + try { + return await vscode.workspace.openTextDocument(uri); + } catch (error) { + output.appendLine(`Could not check Flyde imports in ${uri.fsPath}: ${error}`); + return undefined; + } + }) + ); + + // Read current buffers only after all asynchronous discovery has finished. + // Do not save documents: the user's existing unsaved changes must survive. + for (const document of documents) { + if (!document || document.isClosed) { + continue; + } + try { + const text = updateFlowPathsForRenames(document.getText(), document.uri.fsPath, renames); + if (text !== undefined) { + const range = new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)); + edit.replace(document.uri, range, text); + } + } catch (error) { + // One malformed flow must not prevent valid consumers from being repaired. + output.appendLine(`Could not update Flyde imports in ${document.uri.fsPath}: ${error}`); + } + } + + return edit; +} + +export function registerFlowImportRenameHandler( + output: Pick +): vscode.Disposable { + return vscode.workspace.onWillRenameFiles((event) => { + // waitUntil must be called synchronously while the event is being dispatched. + event.waitUntil(createFlowImportRenameEdit(event.files, output)); + }); +} diff --git a/vscode/src/webview/VSCodeFlowEditor.tsx b/vscode/src/webview/VSCodeFlowEditor.tsx index db887176..f5340ad7 100644 --- a/vscode/src/webview/VSCodeFlowEditor.tsx +++ b/vscode/src/webview/VSCodeFlowEditor.tsx @@ -17,8 +17,8 @@ import { isVisualNode, } from "@flyde/core"; import { createEditorClient, EditorDebuggerClient } from "@flyde/editor"; -import { createVsCodePorts } from "./vscode-ports"; -import { useDebouncedCallback } from "use-debounce"; +import { createVsCodePorts, postMessageCallback } from "./vscode-ports"; +import { WebviewFlowSaver, FlowSaveResult } from "../flowDocumentSync"; const isEqual = (a: any, b: any): boolean => { if (a === b) return true; @@ -50,7 +50,8 @@ export const VSCodeFlowEditor: React.FC = ({ port, relativeFile, executionId, - darkMode + darkMode, + documentVersion = 0, }) => { // Check if we're in test mode (either dummy data or vscode test environment) const isDummyMode = new URLSearchParams(window.location.search).get('dummy') === 'true'; @@ -75,7 +76,7 @@ export const VSCodeFlowEditor: React.FC = ({ const [debuggerClient, setDebuggerClient] = useState(); const runtimePlayer = useRef(); - const lastChangeReason = useRef(""); + const externallyAppliedFlow = useRef(); const didMount = useRef(false); const ports = useMemo(() => createVsCodePorts(), []); @@ -127,8 +128,9 @@ export const VSCodeFlowEditor: React.FC = ({ } }, [debuggerClient, isTestMode]); - const debouncedSaveFile = useDebouncedCallback( - (flow: FlydeFlow, src: string) => { + const flowSaver = useMemo(() => new WebviewFlowSaver( + documentVersion, + async (flow, version): Promise => { const cleanFlow: FlydeFlow = { ...flow, node: { @@ -143,30 +145,40 @@ export const VSCodeFlowEditor: React.FC = ({ }), }, }; - ports.setFlow({ absPath: src, flow: cleanFlow }); + return postMessageCallback("setFlow", { + absPath: relativeFile, + flow: cleanFlow, + documentVersion: version, + }); }, - 500 - ); + (error) => console.error("Failed to save flow", error) + ), [documentVersion, relativeFile]); + + useEffect(() => () => flowSaver.dispose(), [flowSaver]); useEffect(() => { - return ports.onExternalFlowChange(({ flow }) => { + return ports.onExternalFlowChange((change) => { + const { flow, documentVersion: version } = change as typeof change & { documentVersion?: number }; + if (!flowSaver.acceptExternalChange(version)) { return; } if (isEqual(flow, editorState.flow) === false) { + externallyAppliedFlow.current = flow; setEditorState((state) => ({ ...state, flow })); - lastChangeReason.current = "external-changes"; } }); - }, [editorState.flow, ports]); + }, [editorState.flow, ports, flowSaver]); useEffect(() => { if (!didMount.current) { didMount.current = true; } else { - if (lastChangeReason.current !== "external-changes") { - debouncedSaveFile(editorState.flow, relativeFile); - lastChangeReason.current = "n/a"; + if (externallyAppliedFlow.current === editorState.flow) { + externallyAppliedFlow.current = undefined; + } else { + externallyAppliedFlow.current = undefined; + flowSaver.schedule(editorState.flow); } } - }, [editorState.flow, debouncedSaveFile, relativeFile]); + }, [editorState.flow, flowSaver]); const onRequestHistory = useCallback( (insId: string, pinId?: string, pinType?: any) => { @@ -204,4 +216,4 @@ export const VSCodeFlowEditor: React.FC = ({ ); -}; \ No newline at end of file +}; diff --git a/vscode/src/webview/bootstrap.ts b/vscode/src/webview/bootstrap.ts index 4d67a772..fe97f558 100644 --- a/vscode/src/webview/bootstrap.ts +++ b/vscode/src/webview/bootstrap.ts @@ -7,6 +7,7 @@ export interface BootstrapData { executionId: string; hasOpenAiToken: boolean; darkMode: boolean; + documentVersion?: number; } export const getBootstrapData = (): BootstrapData | null => { @@ -181,4 +182,4 @@ export const getBootstrapData = (): BootstrapData | null => { console.error("Failed to parse bootstrap data:", error); return null; } -}; \ No newline at end of file +}; diff --git a/vscode/src/webview/index.tsx b/vscode/src/webview/index.tsx index 9bcad4d9..cad909ab 100644 --- a/vscode/src/webview/index.tsx +++ b/vscode/src/webview/index.tsx @@ -131,8 +131,8 @@ window.addEventListener("message", (event) => { } vscode.postMessage( - { type: "__webviewTestingResponse", response, error }, + { type: "__webviewTestingResponse", testingRequestId: data.testingRequestId, response, error }, "*" ); } -}); \ No newline at end of file +});