From 7001a79f76445232997777f9c9247e35fb140217 Mon Sep 17 00:00:00 2001 From: Brice Date: Thu, 26 Mar 2026 13:22:20 -0500 Subject: [PATCH 1/3] feat: reload Kaoto editor on external file changes --- src/helpers/ExternalFileChangeWatcher.ts | 83 ++++++++++++ .../helpers/ExternalFileChangeWatcher.test.ts | 120 ++++++++++++++++++ src/webview/VSCodeKaotoEditorChannelApi.ts | 13 ++ 3 files changed, 216 insertions(+) create mode 100644 src/helpers/ExternalFileChangeWatcher.ts create mode 100644 src/test/helpers/ExternalFileChangeWatcher.test.ts diff --git a/src/helpers/ExternalFileChangeWatcher.ts b/src/helpers/ExternalFileChangeWatcher.ts new file mode 100644 index 00000000..bb1b4177 --- /dev/null +++ b/src/helpers/ExternalFileChangeWatcher.ts @@ -0,0 +1,83 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License", destination); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** + * Watches a file for changes made by external processes (e.g. git pull, AI edits) and + * invokes a callback with the new content. Changes originating from VS Code's own save + * are detected via onDidSaveTextDocument and suppressed to avoid unnecessary reloads. + */ +export class ExternalFileChangeWatcher implements vscode.Disposable { + private readonly didSaveDisposable: vscode.Disposable; + private readonly fileWatcher: vscode.FileSystemWatcher; + private lastSelfSavedContent: string | undefined; + private debounceTimer: NodeJS.Timeout | undefined; + + constructor( + private readonly docUri: vscode.Uri, + private readonly onExternalChange: (content: string) => Promise, + private readonly debounceMs: number = 300, + ) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(docUri); + const pattern = workspaceFolder + ? new vscode.RelativePattern(workspaceFolder, path.relative(workspaceFolder.uri.fsPath, docUri.fsPath)) + : new vscode.RelativePattern(path.dirname(docUri.fsPath), path.basename(docUri.fsPath)); + + // When VS Code saves the document, capture the content that was written to disk. + // The FileSystemWatcher will compare the on-disk content against this to determine + // whether the change came from VS Code itself (self-save, skip) or an external process. + this.didSaveDisposable = vscode.workspace.onDidSaveTextDocument((document) => { + if (document.uri.toString() === docUri.toString()) { + this.lastSelfSavedContent = document.getText(); + } + }); + + this.fileWatcher = vscode.workspace.createFileSystemWatcher(pattern); + this.fileWatcher.onDidChange(() => { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.debounceTimer = undefined; + void this.handleFileChange(); + }, this.debounceMs); + }); + } + + private async handleFileChange(): Promise { + const content = await fs.readFile(this.docUri.fsPath, 'utf8'); + // If this content matches what VS Code just saved, the file change originated from + // VS Code itself — no need to reload the editor. + if (this.lastSelfSavedContent !== undefined && this.lastSelfSavedContent === content) { + this.lastSelfSavedContent = undefined; + return; + } + this.lastSelfSavedContent = undefined; + await this.onExternalChange(content); + } + + dispose(): void { + this.fileWatcher.dispose(); + this.didSaveDisposable.dispose(); + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } +} diff --git a/src/test/helpers/ExternalFileChangeWatcher.test.ts b/src/test/helpers/ExternalFileChangeWatcher.test.ts new file mode 100644 index 00000000..9a8ac48c --- /dev/null +++ b/src/test/helpers/ExternalFileChangeWatcher.test.ts @@ -0,0 +1,120 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License", destination); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { assert } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { waitUntil } from 'async-wait-until'; +import { ExternalFileChangeWatcher } from '../../helpers/ExternalFileChangeWatcher'; + +const DEBOUNCE_MS = 10; +const NEGATIVE_WAIT_MS = 100; // must exceed DEBOUNCE_MS + OS notification delay + +suite('ExternalFileChangeWatcher', function () { + this.timeout(15_000); + + let tmpFile: string; + let tmpUri: vscode.Uri; + let watcher: ExternalFileChangeWatcher | undefined; + + setup(async () => { + // Use the workspace folder so VS Code's FileSystemWatcher reliably detects changes. + // Files in os.tmpdir() are outside the workspace and may not be watched on all platforms. + const workspaceFolder = vscode.workspace.workspaceFolders![0]; + tmpFile = path.join(workspaceFolder.uri.fsPath, `kaoto-test-${Date.now()}.camel.yaml`); + fs.writeFileSync(tmpFile, 'initial content'); + tmpUri = vscode.Uri.file(tmpFile); + watcher = undefined; + }); + + teardown(async () => { + watcher?.dispose(); + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + }); + + test('calls onExternalChange when the file is modified by an external process', async () => { + const receivedContents: string[] = []; + watcher = new ExternalFileChangeWatcher( + tmpUri, + async (content) => { + receivedContents.push(content); + }, + DEBOUNCE_MS, + ); + + // Allow the FileSystemWatcher to finish initialising before writing. + // Without this pause the write can race ahead of the watcher startup. + await new Promise((resolve) => setTimeout(resolve, 100)); + + fs.writeFileSync(tmpFile, 'externally changed content'); + + await waitUntil(() => receivedContents.length > 0, { timeout: 10_000, intervalBetweenAttempts: 100 }); + + assert.deepEqual(receivedContents, ['externally changed content']); + }); + + test('does not call onExternalChange for a VS Code save but does for a subsequent external change', async () => { + // Open the file as a VS Code text document so we can save it through VS Code, + // which will fire onDidSaveTextDocument with the saved content. + const document = await vscode.workspace.openTextDocument(tmpUri); + await vscode.window.showTextDocument(document); + + const edit = new vscode.WorkspaceEdit(); + edit.replace(tmpUri, new vscode.Range(0, 0, document.lineCount, 0), 'vs code saved content'); + await vscode.workspace.applyEdit(edit); + + const receivedContents: string[] = []; + watcher = new ExternalFileChangeWatcher( + tmpUri, + async (content) => { + receivedContents.push(content); + }, + DEBOUNCE_MS, + ); + + // Save through VS Code, then immediately write different content externally. + // If the self-save were not suppressed we'd receive two calls or the wrong content. + // Waiting for exactly one call with the external content proves both behaviours. + await document.save(); + fs.writeFileSync(tmpFile, 'external change after save'); + + await waitUntil(() => receivedContents.length > 0, { timeout: 10_000, intervalBetweenAttempts: 100 }); + + assert.deepEqual(receivedContents, ['external change after save']); + }); + + test('does not call onExternalChange after dispose', async () => { + const receivedContents: string[] = []; + watcher = new ExternalFileChangeWatcher( + tmpUri, + async (content) => { + receivedContents.push(content); + }, + DEBOUNCE_MS, + ); + + watcher.dispose(); + watcher = undefined; + + fs.writeFileSync(tmpFile, 'change after dispose'); + + await new Promise((resolve) => setTimeout(resolve, NEGATIVE_WAIT_MS)); + assert.isEmpty(receivedContents, 'onExternalChange should not be called after dispose'); + }); +}); diff --git a/src/webview/VSCodeKaotoEditorChannelApi.ts b/src/webview/VSCodeKaotoEditorChannelApi.ts index b10c05e9..16f9f603 100644 --- a/src/webview/VSCodeKaotoEditorChannelApi.ts +++ b/src/webview/VSCodeKaotoEditorChannelApi.ts @@ -15,11 +15,13 @@ import * as vscode from 'vscode'; import { KaotoOutputChannel } from '../extension/KaotoOutputChannel'; import { CamelJBang } from '../helpers/CamelJBang'; import { findClasspathRoot } from '../helpers/ClasspathRootFinder'; +import { ExternalFileChangeWatcher } from '../helpers/ExternalFileChangeWatcher'; import { StepsOnSaveManager } from '../helpers/StepsOnSaveManager'; import { getSuggestions } from '../helpers/SuggestionRegistry'; export class VSCodeKaotoEditorChannelApi extends DefaultVsCodeKieEditorChannelApiImpl implements KaotoEditorChannelApi { private readonly currentEditedDocument: vscode.TextDocument | VsCodeKieEditorCustomDocument; + private externalFileChangeWatcher: ExternalFileChangeWatcher | undefined; constructor( editor: VsCodeKieEditorController, @@ -34,10 +36,21 @@ export class VSCodeKaotoEditorChannelApi extends DefaultVsCodeKieEditorChannelAp super(editor, resourceContentService, workspaceApi, backendProxy, notificationsApi, javaCodeCompletionApi, viewType, i18n); this.currentEditedDocument = editor.document.document; + const docUri = this.currentEditedDocument.uri; + const workspaceFolder = vscode.workspace.getWorkspaceFolder(docUri); + this.externalFileChangeWatcher = new ExternalFileChangeWatcher(docUri, async (content) => { + const normalizedPath = workspaceFolder + ? path.relative(workspaceFolder.uri.fsPath, docUri.fsPath).split(path.sep).join('/') + : path.basename(docUri.fsPath); + await editor.setContent(normalizedPath, content); + KaotoOutputChannel.logInfo(`Kaoto editor reloaded after external change to: ${docUri.fsPath}`); + }); + // Dispose watcher when the webview/editor is closed editor.setupPanelOnDidDispose(); editor.panel.onDidDispose(() => { StepsOnSaveManager.instance.disposeFor(this.currentEditedDocument.uri); + this.externalFileChangeWatcher?.dispose(); }); } From 8770d127ab2caec3de2e79a15e81fad3837b5b1b Mon Sep 17 00:00:00 2001 From: Brice Date: Thu, 26 Mar 2026 13:51:27 -0500 Subject: [PATCH 2/3] test: add e2e test for external file change reload --- it-tests/ExternalFileChange.test.ts | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 it-tests/ExternalFileChange.test.ts diff --git a/it-tests/ExternalFileChange.test.ts b/it-tests/ExternalFileChange.test.ts new file mode 100644 index 00000000..39c69b95 --- /dev/null +++ b/it-tests/ExternalFileChange.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2026 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { By, until, VSBrowser, WebDriver, WebView } from 'vscode-extension-tester'; +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { closeEditor, openAndSwitchToKaotoFrame, openResourcesAndWaitForActivation } from './Util'; + +const ROUTE_WITH_DIRECT = `- route: + id: camelroute51 + from: + id: directID + uri: direct:start + parameters: {} + steps: + - log: + message: externally changed message +`; + +describe('External file change reloads Kaoto editor', function () { + this.timeout(60_000); + + const workspaceFolder = path.join(__dirname, '../test Fixture with speci@l chars'); + const testFileName = 'external-change-test.camel.yaml'; + const testFile = path.join(workspaceFolder, testFileName); + + let driver: WebDriver; + let globalKaotoWebView: WebView | undefined; + + before(async function () { + await openResourcesAndWaitForActivation(workspaceFolder); + fs.copySync(path.join(workspaceFolder, 'my.camel.yaml'), testFile); + driver = VSBrowser.instance.driver; + }); + + after(function () { + if (fs.existsSync(testFile)) { + fs.rmSync(testFile); + } + }); + + afterEach(async function () { + if (globalKaotoWebView !== undefined) { + try { + await globalKaotoWebView.switchBack(); + } catch { + // editor may already be closed, continue + } + globalKaotoWebView = undefined; + } + await closeEditor(testFileName, false); + }); + + it('reloads the diagram when the file is changed by an external process', async function () { + const { kaotoWebview } = await openAndSwitchToKaotoFrame(workspaceFolder, testFileName, driver, true); + globalKaotoWebView = kaotoWebview; + + // Verify initial state: timer component is present + await driver.wait( + until.elementLocated(By.xpath(`//*[name()='g' and starts-with(@data-testid,'custom-node__timer') or @data-nodelabel='timer']`)), + 10_000, + 'Initial timer node was not found in the Kaoto diagram', + ); + + await kaotoWebview.switchBack(); + + // Simulate an external process writing to the file (e.g. git pull, AI edit) + fs.writeFileSync(testFile, ROUTE_WITH_DIRECT, 'utf-8'); + + await kaotoWebview.switchToFrame(); + + // The editor should automatically reload and show the new direct component + await driver.wait( + until.elementLocated(By.xpath(`//*[name()='g' and starts-with(@data-testid,'custom-node__direct') or @data-nodelabel='direct']`)), + 15_000, + 'Kaoto editor did not reload after external file change', + ); + + await kaotoWebview.switchBack(); + }); +}); From 3c5bcd2107b79f430a13baae4b4b4ff803e280fb Mon Sep 17 00:00:00 2001 From: Lars Heinemann Date: Thu, 9 Apr 2026 08:05:55 +0200 Subject: [PATCH 3/3] Update src/helpers/ExternalFileChangeWatcher.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/helpers/ExternalFileChangeWatcher.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/helpers/ExternalFileChangeWatcher.ts b/src/helpers/ExternalFileChangeWatcher.ts index bb1b4177..4effd78b 100644 --- a/src/helpers/ExternalFileChangeWatcher.ts +++ b/src/helpers/ExternalFileChangeWatcher.ts @@ -61,7 +61,14 @@ export class ExternalFileChangeWatcher implements vscode.Disposable { } private async handleFileChange(): Promise { - const content = await fs.readFile(this.docUri.fsPath, 'utf8'); + let content: string; + try { + content = await fs.readFile(this.docUri.fsPath, 'utf8'); + } catch { + // File may have been deleted or renamed; ignore this event + this.lastSelfSavedContent = undefined; + return; + } // If this content matches what VS Code just saved, the file change originated from // VS Code itself — no need to reload the editor. if (this.lastSelfSavedContent !== undefined && this.lastSelfSavedContent === content) {