From d1d12f90aa042769357be83ed92bcdbd5e510490 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:24:03 +0000 Subject: [PATCH 1/5] Initial plan From eb00f010a58eff17f59101baf17cc2b787a599b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:42:51 +0000 Subject: [PATCH 2/5] Add CMakeCacheEditorProvider to enable Ctrl+S in CMake Cache Editor UI Co-authored-by: hanniavalera <90047725+hanniavalera@users.noreply.github.com> --- CHANGELOG.md | 1 + package.json | 15 +- package.nls.json | 3 +- src/cmakeProject.ts | 37 +-- src/extension.ts | 16 + src/ui/cmakeCacheEditorProvider.ts | 511 +++++++++++++++++++++++++++++ 6 files changed, 553 insertions(+), 30 deletions(-) create mode 100644 src/ui/cmakeCacheEditorProvider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8f1262da..ce1ba5320a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Features: Bug Fixes: +- Enable Ctrl+S (and File > Save) keyboard shortcut to work in the CMake Cache Editor UI. [#4000](https://github.com/microsoft/vscode-cmake-tools/issues/4000) - Fix user-level tasks defined in `~/.config/Code/User/tasks.json` causing infinite spinner. [#4659](https://github.com/microsoft/vscode-cmake-tools/pull/4659) - Fix "Copy Value" in CMake debugger copying variable name instead of value. [#4551](https://github.com/microsoft/vscode-cmake-tools/issues/4551) - cmakeDriver: Fixes getCompilerVersion by using compilerPath instead of compilerName. [#4647](https://github.com/microsoft/vscode-cmake-tools/pull/4647) [@lygstate](https://github.com/lygstate) diff --git a/package.json b/package.json index b5bc7b2524..13aca20522 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "workspaceContains:.vscode/cmake-kits.json", "onFileSystem:cmake-tools-schema", "onLanguage:cmake", - "onLanguage:cmake-cache" + "onLanguage:cmake-cache", + "onCustomEditor:cmake.cmakeCacheEditor" ], "main": "./dist/main", "contributes": { @@ -140,6 +141,18 @@ "path": "./syntaxes/CMakeCache.tmLanguage" } ], + "customEditors": [ + { + "viewType": "cmake.cmakeCacheEditor", + "displayName": "%cmake-tools.customEditors.cmakeCacheEditor.displayName%", + "selector": [ + { + "filenamePattern": "CMakeCache.txt" + } + ], + "priority": "option" + } + ], "commands": [ { "command": "cmake.openCMakePresets", diff --git a/package.nls.json b/package.nls.json index 3e2918e177..aaccc2e48e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -383,5 +383,6 @@ "cmake-tools.debugger.label": "CMake Debugger", "cmake-tools.command.cmake.appendBuildDirectoryToWorkspace.title": "Append Build Directory to Current Workspace", "cmake-tools.command.workbench.action.tasks.configureTaskRunner.title":"Configure Task", - "cmake-tools.command.workbench.action.tasks.runTask.title":"Run Task" + "cmake-tools.command.workbench.action.tasks.runTask.title":"Run Task", + "cmake-tools.customEditors.cmakeCacheEditor.displayName": "CMake Cache Editor" } diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index afea14ba4a..ffd685307b 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -39,7 +39,6 @@ import rollbar from '@cmt/rollbar'; import * as telemetry from '@cmt/telemetry'; import { VariantManager } from '@cmt/kits/variant'; import * as nls from 'vscode-nls'; -import { ConfigurationWebview } from '@cmt/ui/cacheView'; import { enableFullFeatureSet, extensionManager, updateFullFeatureSet, setContextAndStore } from '@cmt/extension'; import { CMakeCommunicationMode, ConfigurationReader, OptionConfig, UseCMakePresets, checkConfigureOverridesPresent } from '@cmt/config'; import * as preset from '@cmt/presets/preset'; @@ -857,11 +856,6 @@ export class CMakeProject { */ private cmakeDriver: Promise = Promise.resolve(null); - /** - * This object manages the CMake Cache Editor GUI - */ - private cacheEditorWebview: ConfigurationWebview | undefined; - /** * Event fired just as CMakeProject is about to be disposed */ @@ -1982,11 +1976,6 @@ export class CMakeProject { const filePath = util.platformNormalizePath(uri.fsPath); const driver: CMakeDriver | null = await this.getCMakeDriverInstance(); - // If we detect a change in the CMake cache file, refresh the webview - if (this.cacheEditorWebview && driver && filePath === util.platformNormalizePath(driver.cachePath)) { - await this.cacheEditorWebview.refreshPanel(); - } - const sourceDirectory = util.platformNormalizePath(this.sourceDir); let isCmakeFile: boolean; @@ -2230,25 +2219,17 @@ export class CMakeProject { * Implementation of `cmake.EditCacheUI` */ async editCacheUI(): Promise { - if (!this.cacheEditorWebview) { - const drv = await this.getCMakeDriverInstance(); - if (!drv) { - void vscode.window.showErrorMessage(localize('cache.load.failed', 'No CMakeCache.txt file has been found. Please configure project first!')); - return 1; - } - - this.cacheEditorWebview = new ConfigurationWebview(drv.cachePath, () => { - void this.configureInternal(ConfigureTrigger.commandEditCacheUI, [], ConfigureType.Cache); - }); - await this.cacheEditorWebview.initPanel(); - - this.cacheEditorWebview.panel.onDidDispose(() => { - this.cacheEditorWebview = undefined; - }); - } else { - this.cacheEditorWebview.panel.reveal(); + const drv = await this.getCMakeDriverInstance(); + if (!drv) { + void vscode.window.showErrorMessage(localize('cache.load.failed', 'No CMakeCache.txt file has been found. Please configure project first!')); + return 1; } + // Open the CMakeCache.txt file with our custom editor + // This uses the CustomTextEditorProvider which supports Ctrl+S save functionality + const cacheUri = vscode.Uri.file(drv.cachePath); + await vscode.commands.executeCommand('vscode.openWith', cacheUri, 'cmake.cmakeCacheEditor'); + return 0; } diff --git a/src/extension.ts b/src/extension.ts index 9b05222071..f34e9203d3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -51,6 +51,7 @@ import { DebuggerInformation, getDebuggerPipeName } from '@cmt/debug/cmakeDebugg import { DebugConfigurationProvider, DynamicDebugConfigurationProvider } from '@cmt/debug/cmakeDebugger/debugConfigurationProvider'; import { deIntegrateTestExplorer } from "@cmt/ctest"; import { LanguageServiceData } from './languageServices/languageServiceData'; +import { CMakeCacheEditorProvider } from '@cmt/ui/cmakeCacheEditorProvider'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize: nls.LocalizeFunc = nls.loadMessageBundle(); @@ -2633,6 +2634,21 @@ export async function activate(context: vscode.ExtensionContext): Promise { + // Trigger reconfigure after saving the cache + // Note: extensionManager is available after registration since this callback + // is only invoked when the user saves the document (after extension is fully initialized) + if (extensionManager) { + const project = extensionManager.getActiveProject(); + if (project) { + void project.configureInternal(ConfigureTrigger.commandEditCacheUI, [], ConfigureType.Cache); + } + } + })); + await setContextAndStore("inCMakeProject", true); taskProvider = vscode.tasks.registerTaskProvider(CMakeTaskProvider.CMakeScriptType, cmakeTaskProvider); diff --git a/src/ui/cmakeCacheEditorProvider.ts b/src/ui/cmakeCacheEditorProvider.ts new file mode 100644 index 0000000000..6554cdf634 --- /dev/null +++ b/src/ui/cmakeCacheEditorProvider.ts @@ -0,0 +1,511 @@ +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import * as telemetry from '@cmt/telemetry'; +import * as util from '@cmt/util'; + +import { CacheEntryType, CMakeCache } from '@cmt/cache'; + +import * as logging from '@cmt/logging'; +const log = logging.createLogger('cache'); + +nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); +const localize: nls.LocalizeFunc = nls.loadMessageBundle(); + +export interface IOption { + key: string; // same as CMake cache variable key names + type: string; // "Bool" for boolean and "String" for anything else for now + helpString: string; + choices: string[]; + value: string; // value from the cache file or changed in the UI + dirty: boolean; // if the variable was edited in the UI +} + +/** + * Provider for the CMake Cache Editor custom text editor. + * This implements CustomTextEditorProvider to enable standard VS Code save functionality (Ctrl+S). + */ +export class CMakeCacheEditorProvider implements vscode.CustomTextEditorProvider { + public static readonly viewType = 'cmake.cmakeCacheEditor'; + + private readonly cmakeCacheEditorText = localize("cmake.cache.editor", "CMake Cache Editor"); + + // Callback to trigger reconfigure after save + private onSaveCallback: (() => void) | undefined; + + public static register(context: vscode.ExtensionContext, onSave?: () => void): vscode.Disposable { + const provider = new CMakeCacheEditorProvider(context, onSave); + return vscode.window.registerCustomEditorProvider( + CMakeCacheEditorProvider.viewType, + provider, + { + webviewOptions: { retainContextWhenHidden: true }, + supportsMultipleEditorsPerDocument: false + } + ); + } + + constructor( + private readonly context: vscode.ExtensionContext, + onSave?: () => void + ) { + this.onSaveCallback = onSave; + } + + /** + * Set the callback to be invoked when the cache is saved. + */ + public setOnSaveCallback(callback: () => void): void { + this.onSaveCallback = callback; + } + + /** + * Called when the custom editor is opened. + */ + async resolveCustomTextEditor( + document: vscode.TextDocument, + webviewPanel: vscode.WebviewPanel, + _token: vscode.CancellationToken + ): Promise { + // Set up the webview options + webviewPanel.webview.options = { + enableScripts: true + }; + + // Parse options from the document + let options = await this.getConfigurationOptionsFromDocument(document); + + // Render the webview + webviewPanel.webview.html = this.getWebviewMarkup(options); + + // Handle messages from the webview + const messageHandler = webviewPanel.webview.onDidReceiveMessage(async (message: IOption | false) => { + if (message === false) { + // Save button was clicked - apply pending edits to the document + await this.applyEditsToDocument(document, options); + } else { + // Option was edited in the webview + const option = message as IOption; + const index = options.findIndex(opt => opt.key === option.key); + if (index !== -1 && options[index].value !== option.value) { + options[index].dirty = true; + options[index].type = option.type; + options[index].value = option.value; + + // Apply the edit to the document immediately so Ctrl+S works + await this.applyEditsToDocument(document, options); + } + } + }); + + // Handle document changes from external sources + const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(e => { + if (e.document.uri.toString() === document.uri.toString() && e.contentChanges.length > 0) { + // Reload options from the document + this.getConfigurationOptionsFromDocument(document).then(newOptions => { + options = newOptions; + webviewPanel.webview.html = this.getWebviewMarkup(options); + }); + } + }); + + // Handle document save events + const saveSubscription = vscode.workspace.onDidSaveTextDocument(savedDocument => { + if (savedDocument.uri.toString() === document.uri.toString()) { + telemetry.logEvent("editCMakeCache", { command: "saveCMakeCacheUI" }); + void vscode.window.showInformationMessage(localize('cmake.cache.saved', 'CMake options have been saved.')); + + // Trigger reconfigure if callback is set + if (this.onSaveCallback) { + this.onSaveCallback(); + } + + // Mark all options as not dirty + options.forEach(opt => opt.dirty = false); + } + }); + + // Clean up when the panel is closed + webviewPanel.onDidDispose(() => { + messageHandler.dispose(); + changeDocumentSubscription.dispose(); + saveSubscription.dispose(); + }); + } + + /** + * Parse the CMake cache options from the document content. + */ + private async getConfigurationOptionsFromDocument(document: vscode.TextDocument): Promise { + const options: IOption[] = []; + const content = document.getText(); + const entries = CMakeCache.parseCache(content); + + for (const entry of entries.values()) { + // Static cache entries are set automatically by CMake, overriding any value set by the user in this view. + // Not useful to show these entries in the list. + if (entry.type !== CacheEntryType.Static) { + options.push({ + key: entry.key, + helpString: entry.helpString, + choices: entry.choices, + type: (entry.type === CacheEntryType.Bool) ? "Bool" : "String", + value: entry.value, + dirty: false + }); + } + } + + return options; + } + + /** + * Apply the edited options to the document. + */ + private async applyEditsToDocument(document: vscode.TextDocument, options: IOption[]): Promise { + const dirtyOptions = options.filter(opt => opt.dirty); + if (dirtyOptions.length === 0) { + return; + } + + const edit = new vscode.WorkspaceEdit(); + let content = document.getText(); + + for (const option of dirtyOptions) { + content = this.replaceOptionInContent(content, option); + } + + // Replace the entire document content + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ); + edit.replace(document.uri, fullRange, content); + await vscode.workspace.applyEdit(edit); + } + + /** + * Replace a single option value in the content string. + */ + private replaceOptionInContent(content: string, option: IOption): string { + // Handle keys that may need to be quoted (contain special characters) + const escapedKey = option.key.replace(/[^A-Za-z0-9_]/g, '\\$&'); + const quotedEscapedKey = `"${option.key.replace(/[^A-Za-z0-9_]/g, '\\$&')}"`; + + // Try unquoted key first, then quoted + let re = RegExp(`^(${escapedKey}:[^=]+=)(.*)$`, 'm'); + let match = content.match(re); + + if (!match) { + re = RegExp(`^(${quotedEscapedKey}:[^=]+=)(.*)$`, 'm'); + match = content.match(re); + } + + if (match) { + let newValue: string; + if (option.type === "Bool") { + newValue = util.isTruthy(option.value) ? "TRUE" : "FALSE"; + } else { + // Truncate at newlines + const newlineIndex = option.value.search(/[\r\n]/); + if (newlineIndex >= 0) { + newValue = option.value.substring(0, newlineIndex); + log.warning(localize('cache.value.truncation.warning', 'Newline(s) found in cache entry {0}. Value has been truncated to {1}', `"${option.key}"`, `"${newValue}"`)); + } else { + newValue = option.value; + } + } + + const oldLine = match[0]; + const prefix = match[1]; + const newLine = prefix + newValue; + content = content.replace(oldLine, newLine); + } + + return content; + } + + /** + * Returns an HTML markup for the webview. + */ + getWebviewMarkup(options: IOption[]) { + const key = '%TABLE_ROWS%'; + const searchButtonText = localize("search", "Search"); + const saveButtonText = localize("save", "Save"); + const keyColumnText = localize("key", "Key"); + const valueColumnText = localize("value", "Value"); + + let html = ` + + + + + + ${this.cmakeCacheEditorText} + + + + +
+ +

${this.cmakeCacheEditorText}

+ + + + + + + + ${key} +
${keyColumnText}${valueColumnText}
+
+ + `; + + // compile a list of table rows that contain the key and value pairs + const tableRows = options.map(option => { + + // HTML attributes may not contain literal double quotes or ambiguous ampersands + const escapeAttribute = (text: string) => text.replace(/&/g, "&").replace(/"/g, """); + // Escape HTML special characters that may not occur literally in any text + const escapeHtml = (text: string) => + escapeAttribute(text) + .replace(//g, ">") + .replace(/'/g, "'") + .replace(/ /g, " "); // we are usually dealing with single line entities - avoid unintential line breaks + + const id = escapeAttribute(option.key); + let editControls = ''; + + if (option.type === "Bool") { + editControls = ` +