From 177947da90b5f4ac6d13b20c21302cc5e894bf0e Mon Sep 17 00:00:00 2001 From: kubrickcode Date: Tue, 16 Sep 2025 11:23:08 +0000 Subject: [PATCH] Improved vscode user-defined scope settings - Previously, the workspace scope was always set, so the user's .vscode/settings.json file had to be constantly modified, which was inconvenient. - Now, users can select and set the scope depending on the situation. --- package.json | 15 + src/extension/src/config-constants.ts | 23 ++ src/extension/src/config-manager.ts | 89 ++++ src/extension/src/main.test.ts | 1 + src/extension/src/main.ts | 16 + src/extension/src/webview-provider.test.ts | 379 +++++++++++------- src/extension/src/webview-provider.ts | 75 ++-- src/web-view/src/components/header.tsx | 60 ++- .../src/context/vscode-command-context.tsx | 28 +- src/web-view/src/types.tsx | 3 +- 10 files changed, 485 insertions(+), 204 deletions(-) create mode 100644 src/extension/src/config-constants.ts create mode 100644 src/extension/src/config-manager.ts diff --git a/package.json b/package.json index 136c8b40..0db5aa66 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,11 @@ "title": "Open Configuration UI", "category": "Quick Commands", "icon": "$(gear)" + }, + { + "command": "quickCommandButtons.toggleConfigurationTarget", + "title": "Toggle Configuration Target (Workspace/Global)", + "category": "Quick Commands" } ], "viewsContainers": { @@ -120,6 +125,16 @@ } } }, + "quickCommandButtons.configurationTarget": { + "type": "string", + "enum": ["workspace", "global"], + "default": "workspace", + "description": "Where to save button configurations: 'workspace' saves to .vscode/settings.json (project-specific), 'global' saves to user settings (shared across all projects)", + "enumDescriptions": [ + "Save to workspace settings (.vscode/settings.json) - project-specific commands", + "Save to user settings - shared across all projects" + ] + }, "quickCommandButtons.buttons": { "type": "array", "default": [ diff --git a/src/extension/src/config-constants.ts b/src/extension/src/config-constants.ts new file mode 100644 index 00000000..057e6735 --- /dev/null +++ b/src/extension/src/config-constants.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode"; + +export const CONFIG_SECTION = "quickCommandButtons"; + +export const CONFIG_KEYS = { + BUTTONS: "buttons", + CONFIGURATION_TARGET: "configurationTarget", + REFRESH_BUTTON: "refreshButton", +} as const; + +export const CONFIGURATION_TARGETS = { + WORKSPACE: "workspace", + GLOBAL: "global", +} as const; + +export const VS_CODE_CONFIGURATION_TARGETS = { + [CONFIGURATION_TARGETS.WORKSPACE]: vscode.ConfigurationTarget.Workspace, + [CONFIGURATION_TARGETS.GLOBAL]: vscode.ConfigurationTarget.Global, +} as const; + +export type ConfigurationTargetType = + (typeof CONFIGURATION_TARGETS)[keyof typeof CONFIGURATION_TARGETS]; +export type ConfigKeyType = (typeof CONFIG_KEYS)[keyof typeof CONFIG_KEYS]; diff --git a/src/extension/src/config-manager.ts b/src/extension/src/config-manager.ts new file mode 100644 index 00000000..0bf161f3 --- /dev/null +++ b/src/extension/src/config-manager.ts @@ -0,0 +1,89 @@ +import * as vscode from "vscode"; +import { ButtonConfig } from "./types"; +import { + CONFIG_SECTION, + CONFIG_KEYS, + CONFIGURATION_TARGETS, + VS_CODE_CONFIGURATION_TARGETS, + ConfigurationTargetType, +} from "./config-constants"; + +export class ConfigManager { + static getCurrentConfigurationTarget(): ConfigurationTargetType { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + return config.get( + CONFIG_KEYS.CONFIGURATION_TARGET, + CONFIGURATION_TARGETS.WORKSPACE + ); + } + + static getVSCodeConfigurationTarget(): vscode.ConfigurationTarget { + const currentTarget = this.getCurrentConfigurationTarget(); + return VS_CODE_CONFIGURATION_TARGETS[currentTarget]; + } + + static async updateConfigurationTarget( + target: ConfigurationTargetType + ): Promise { + try { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + CONFIG_KEYS.CONFIGURATION_TARGET, + target, + vscode.ConfigurationTarget.Global // Configuration target setting itself should always be global + ); + + const targetMessage = + target === CONFIGURATION_TARGETS.GLOBAL + ? "user settings (shared across all projects)" + : "workspace settings (project-specific)"; + + vscode.window.showInformationMessage( + `Configuration target changed to: ${targetMessage}` + ); + } catch (error) { + console.error("Failed to update configuration target:", error); + vscode.window.showErrorMessage( + "Failed to update configuration target. Please try again." + ); + } + } + + static async updateButtonConfiguration( + buttons: ButtonConfig[] + ): Promise { + try { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const target = this.getVSCodeConfigurationTarget(); + + await config.update(CONFIG_KEYS.BUTTONS, buttons, target); + + const currentTarget = this.getCurrentConfigurationTarget(); + const targetMessage = + currentTarget === CONFIGURATION_TARGETS.GLOBAL + ? "user settings" + : "workspace settings"; + + vscode.window.showInformationMessage( + `Configuration updated successfully in ${targetMessage}!` + ); + } catch (error) { + console.error("Failed to update configuration:", error); + vscode.window.showErrorMessage( + "Failed to update configuration. Please try again." + ); + } + } + + static getConfigDataForWebview(configReader: { + getButtons(): ButtonConfig[]; + }): { + buttons: ButtonConfig[]; + configurationTarget: ConfigurationTargetType; + } { + return { + buttons: configReader.getButtons(), + configurationTarget: this.getCurrentConfigurationTarget(), + }; + } +} diff --git a/src/extension/src/main.test.ts b/src/extension/src/main.test.ts index 01e2f80e..5567b9bd 100644 --- a/src/extension/src/main.test.ts +++ b/src/extension/src/main.test.ts @@ -205,6 +205,7 @@ describe("main", () => { refreshTreeCommand: "mockDisposable", showAllCommandsCommand: "mockDisposable", openConfigCommand: "mockDisposable", + toggleConfigurationTargetCommand: "mockDisposable", }); }); }); diff --git a/src/extension/src/main.ts b/src/extension/src/main.ts index 02665e4c..5253b451 100644 --- a/src/extension/src/main.ts +++ b/src/extension/src/main.ts @@ -11,6 +11,8 @@ import { createVSCodeStatusBarCreator, createVSCodeQuickPickCreator, } from "./adapters"; +import { ConfigManager } from "./config-manager"; +import { CONFIGURATION_TARGETS } from "./config-constants"; export const registerCommands = ( context: vscode.ExtensionContext, @@ -67,6 +69,19 @@ export const registerCommands = ( ) ); + const toggleConfigurationTargetCommand = vscode.commands.registerCommand( + "quickCommandButtons.toggleConfigurationTarget", + async () => { + const currentTarget = ConfigManager.getCurrentConfigurationTarget(); + const newTarget = + currentTarget === CONFIGURATION_TARGETS.WORKSPACE + ? CONFIGURATION_TARGETS.GLOBAL + : CONFIGURATION_TARGETS.WORKSPACE; + + await ConfigManager.updateConfigurationTarget(newTarget); + } + ); + return { executeCommand, executeFromTreeCommand, @@ -74,6 +89,7 @@ export const registerCommands = ( refreshTreeCommand, showAllCommandsCommand, openConfigCommand, + toggleConfigurationTargetCommand, }; }; diff --git a/src/extension/src/webview-provider.test.ts b/src/extension/src/webview-provider.test.ts index a9706c7e..90b3df68 100644 --- a/src/extension/src/webview-provider.test.ts +++ b/src/extension/src/webview-provider.test.ts @@ -1,8 +1,19 @@ -import { generateFallbackHtml, replaceAssetPaths, injectSecurityAndVSCodeApi, checkWebviewFilesExist, buildWebviewHtml, updateButtonConfiguration } from "./webview-provider"; +import { + generateFallbackHtml, + replaceAssetPaths, + injectSecurityAndVSCodeApi, + checkWebviewFilesExist, + buildWebviewHtml, + updateButtonConfiguration, +} from "./webview-provider"; import * as vscode from "vscode"; import * as fs from "fs"; import * as path from "path"; +// Mock ConfigManager +jest.mock("./config-manager"); +import { ConfigManager } from "./config-manager"; + // Mock fs module jest.mock("fs"); @@ -31,41 +42,59 @@ describe("webview-provider", () => { it("should include viewport meta tag", () => { const result = generateFallbackHtml(); - expect(result).toContain(''); + expect(result).toContain( + '' + ); }); }); describe("replaceAssetPaths", () => { it("should replace /assets/ with provided assetsUri", () => { - const html = ' '; - const mockUri = { toString: () => "vscode-webview://assets-uri" } as vscode.Uri; + const html = + ' '; + const mockUri = { + toString: () => "vscode-webview://assets-uri", + } as vscode.Uri; const result = replaceAssetPaths(html, mockUri); - expect(result).toBe(' '); + expect(result).toBe( + ' ' + ); }); it("should replace multiple occurrences of /assets/", () => { - const html = ''; - const mockUri = { toString: () => "vscode-webview://test-uri" } as vscode.Uri; + const html = + ''; + const mockUri = { + toString: () => "vscode-webview://test-uri", + } as vscode.Uri; const result = replaceAssetPaths(html, mockUri); - expect(result).toBe(''); + expect(result).toBe( + '' + ); }); it("should handle HTML without /assets/ paths", () => { - const html = '
No assets here

Just regular content

'; - const mockUri = { toString: () => "vscode-webview://unused-uri" } as vscode.Uri; + const html = "
No assets here

Just regular content

"; + const mockUri = { + toString: () => "vscode-webview://unused-uri", + } as vscode.Uri; const result = replaceAssetPaths(html, mockUri); - expect(result).toBe('
No assets here

Just regular content

'); + expect(result).toBe( + "
No assets here

Just regular content

" + ); }); it("should handle empty HTML string", () => { const html = ""; - const mockUri = { toString: () => "vscode-webview://empty-uri" } as vscode.Uri; + const mockUri = { + toString: () => "vscode-webview://empty-uri", + } as vscode.Uri; const result = replaceAssetPaths(html, mockUri); @@ -73,12 +102,16 @@ describe("webview-provider", () => { }); it("should handle HTML with only /assets/ without following path", () => { - const html = '
/assets/
text /assets/ more text'; - const mockUri = { toString: () => "vscode-webview://edge-case-uri" } as vscode.Uri; + const html = "
/assets/
text /assets/ more text"; + const mockUri = { + toString: () => "vscode-webview://edge-case-uri", + } as vscode.Uri; const result = replaceAssetPaths(html, mockUri); - expect(result).toBe('
vscode-webview://edge-case-uri/
text vscode-webview://edge-case-uri/ more text'); + expect(result).toBe( + "
vscode-webview://edge-case-uri/
text vscode-webview://edge-case-uri/ more text" + ); }); it("should handle complex HTML structure with nested assets", () => { @@ -94,44 +127,59 @@ describe("webview-provider", () => { `; - const mockUri = { toString: () => "vscode-webview://complex-uri" } as vscode.Uri; + const mockUri = { + toString: () => "vscode-webview://complex-uri", + } as vscode.Uri; const result = replaceAssetPaths(html, mockUri); - expect(result).toContain('href="vscode-webview://complex-uri/styles/main.css"'); - expect(result).toContain('href="vscode-webview://complex-uri/favicon.ico"'); - expect(result).toContain('src="vscode-webview://complex-uri/images/logo.png"'); + expect(result).toContain( + 'href="vscode-webview://complex-uri/styles/main.css"' + ); + expect(result).toContain( + 'href="vscode-webview://complex-uri/favicon.ico"' + ); + expect(result).toContain( + 'src="vscode-webview://complex-uri/images/logo.png"' + ); expect(result).toContain('src="vscode-webview://complex-uri/js/main.js"'); }); }); describe("injectSecurityAndVSCodeApi", () => { const mockWebview = { - cspSource: "vscode-webview://test-source" + cspSource: "vscode-webview://test-source", } as vscode.Webview; it("should inject CSP meta tag and vscode API script into head section", () => { - const html = 'Test'; + const html = "Test"; const result = injectSecurityAndVSCodeApi(html, mockWebview); expect(result).toContain(' { - const html = 'Test Title'; + const html = + "Test Title"; const result = injectSecurityAndVSCodeApi(html, mockWebview); - const headIndex = result.indexOf(''); - const metaIndex = result.indexOf(''); - const titleIndex = result.indexOf('Test Title'); + const headIndex = result.indexOf(""); + const metaIndex = result.indexOf( + '"); + const titleIndex = result.indexOf("Test Title"); expect(metaIndex).toBeGreaterThan(headIndex); expect(scriptIndex).toBeGreaterThan(metaIndex); @@ -139,58 +187,73 @@ describe("webview-provider", () => { }); it("should handle HTML without head tag", () => { - const html = '
No head tag
'; + const html = "
No head tag
"; const result = injectSecurityAndVSCodeApi(html, mockWebview); - expect(result).toBe('
No head tag
'); + expect(result).toBe("
No head tag
"); }); it("should handle empty HTML string", () => { - const html = ''; + const html = ""; const result = injectSecurityAndVSCodeApi(html, mockWebview); - expect(result).toBe(''); + expect(result).toBe(""); }); it("should handle HTML with multiple head tags", () => { - const html = 'FirstSecond head'; + const html = + "FirstSecond head"; const result = injectSecurityAndVSCodeApi(html, mockWebview); - const firstHeadIndex = result.indexOf(''); - const metaIndex = result.indexOf('', firstHeadIndex + 1); + const firstHeadIndex = result.indexOf(""); + const metaIndex = result.indexOf( + '", firstHeadIndex + 1); expect(metaIndex).toBeGreaterThan(firstHeadIndex); expect(metaIndex).toBeLessThan(secondHeadIndex); - expect(result.indexOf(' { - const html = 'Test'; + const html = + 'Test'; const result = injectSecurityAndVSCodeApi(html, mockWebview); expect(result).toContain(''); - expect(result).toContain('Test'); + expect(result).toContain("Test"); expect(result).toContain(''); expect(result).toContain(' { const customWebview = { - cspSource: "vscode-webview://custom-source-123" + cspSource: "vscode-webview://custom-source-123", } as vscode.Webview; - const html = ''; + const html = ""; const result = injectSecurityAndVSCodeApi(html, customWebview); - expect(result).toContain('style-src vscode-webview://custom-source-123 \'unsafe-inline\''); - expect(result).toContain('script-src vscode-webview://custom-source-123 \'unsafe-inline\''); - expect(result).toContain('img-src vscode-webview://custom-source-123 https: data:'); + expect(result).toContain( + "style-src vscode-webview://custom-source-123 'unsafe-inline'" + ); + expect(result).toContain( + "script-src vscode-webview://custom-source-123 'unsafe-inline'" + ); + expect(result).toContain( + "img-src vscode-webview://custom-source-123 https: data:" + ); }); }); @@ -279,16 +342,16 @@ describe("webview-provider", () => { jest.clearAllMocks(); mockExtensionUri = { - fsPath: "/test/extension/path" + fsPath: "/test/extension/path", } as vscode.Uri; mockWebview = { cspSource: "vscode-webview://test-source", - asWebviewUri: jest.fn() + asWebviewUri: jest.fn(), } as unknown as vscode.Webview; mockAssetsUri = { - toString: () => "vscode-webview://assets-uri" + toString: () => "vscode-webview://assets-uri", } as vscode.Uri; (mockWebview.asWebviewUri as jest.Mock).mockReturnValue(mockAssetsUri); @@ -296,10 +359,17 @@ describe("webview-provider", () => { }); it("should return fallback HTML when webview files do not exist", () => { - const webviewPath = path.join(mockExtensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + mockExtensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); const indexPath = path.join(webviewPath, "index.html"); - mockedFs.existsSync.mockImplementation((filePath) => filePath !== indexPath); + mockedFs.existsSync.mockImplementation( + (filePath) => filePath !== indexPath + ); const result = buildWebviewHtml(mockExtensionUri, mockWebview); @@ -309,11 +379,19 @@ describe("webview-provider", () => { }); it("should process HTML file when webview files exist", () => { - const webviewPath = path.join(mockExtensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + mockExtensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); const indexPath = path.join(webviewPath, "index.html"); - const mockHtml = 'Test'; + const mockHtml = + 'Test'; - mockedFs.existsSync.mockImplementation((filePath) => filePath === indexPath); + mockedFs.existsSync.mockImplementation( + (filePath) => filePath === indexPath + ); mockedFs.readFileSync.mockReturnValue(mockHtml); const result = buildWebviewHtml(mockExtensionUri, mockWebview); @@ -321,16 +399,26 @@ describe("webview-provider", () => { expect(result).toBeDefined(); expect(mockedFs.existsSync).toHaveBeenCalledWith(indexPath); expect(mockedFs.readFileSync).toHaveBeenCalledWith(indexPath, "utf8"); - expect(vscode.Uri.file).toHaveBeenCalledWith(path.join(webviewPath, "assets")); + expect(vscode.Uri.file).toHaveBeenCalledWith( + path.join(webviewPath, "assets") + ); expect(mockWebview.asWebviewUri).toHaveBeenCalledWith(mockAssetsUri); }); it("should replace asset paths and inject security content", () => { - const webviewPath = path.join(mockExtensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + mockExtensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); const indexPath = path.join(webviewPath, "index.html"); - const mockHtml = 'Test'; + const mockHtml = + 'Test'; - mockedFs.existsSync.mockImplementation((filePath) => filePath === indexPath); + mockedFs.existsSync.mockImplementation( + (filePath) => filePath === indexPath + ); mockedFs.readFileSync.mockReturnValue(mockHtml); const result = buildWebviewHtml(mockExtensionUri, mockWebview); @@ -341,12 +429,19 @@ describe("webview-provider", () => { // Check security injection expect(result).toContain(' { - const webviewPath = path.join(mockExtensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + mockExtensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); const indexPath = path.join(webviewPath, "index.html"); const mockHtml = ` @@ -363,24 +458,39 @@ describe("webview-provider", () => { `; - mockedFs.existsSync.mockImplementation((filePath) => filePath === indexPath); + mockedFs.existsSync.mockImplementation( + (filePath) => filePath === indexPath + ); mockedFs.readFileSync.mockReturnValue(mockHtml); const result = buildWebviewHtml(mockExtensionUri, mockWebview); - expect(result).toContain('href="vscode-webview://assets-uri/styles/main.css"'); - expect(result).toContain('href="vscode-webview://assets-uri/favicon.ico"'); - expect(result).toContain('src="vscode-webview://assets-uri/images/logo.png"'); + expect(result).toContain( + 'href="vscode-webview://assets-uri/styles/main.css"' + ); + expect(result).toContain( + 'href="vscode-webview://assets-uri/favicon.ico"' + ); + expect(result).toContain( + 'src="vscode-webview://assets-uri/images/logo.png"' + ); expect(result).toContain('src="vscode-webview://assets-uri/js/main.js"'); expect(result).toContain('src="vscode-webview://assets-uri/js/utils.js"'); }); it("should handle empty HTML file", () => { - const webviewPath = path.join(mockExtensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + mockExtensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); const indexPath = path.join(webviewPath, "index.html"); const mockHtml = ""; - mockedFs.existsSync.mockImplementation((filePath) => filePath === indexPath); + mockedFs.existsSync.mockImplementation( + (filePath) => filePath === indexPath + ); mockedFs.readFileSync.mockReturnValue(mockHtml); const result = buildWebviewHtml(mockExtensionUri, mockWebview); @@ -390,49 +500,62 @@ describe("webview-provider", () => { }); it("should handle HTML without assets paths", () => { - const webviewPath = path.join(mockExtensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + mockExtensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); const indexPath = path.join(webviewPath, "index.html"); - const mockHtml = 'No Assets
Simple content
'; + const mockHtml = + "No Assets
Simple content
"; - mockedFs.existsSync.mockImplementation((filePath) => filePath === indexPath); + mockedFs.existsSync.mockImplementation( + (filePath) => filePath === indexPath + ); mockedFs.readFileSync.mockReturnValue(mockHtml); const result = buildWebviewHtml(mockExtensionUri, mockWebview); - expect(result).toContain('
Simple content
'); + expect(result).toContain("
Simple content
"); expect(result).toContain(' { - const mockConfig = { - update: jest.fn(), - }; - beforeEach(() => { jest.clearAllMocks(); - (vscode.workspace.getConfiguration as jest.Mock).mockReturnValue(mockConfig); - mockConfig.update.mockResolvedValue(undefined); + (ConfigManager.updateButtonConfiguration as jest.Mock).mockResolvedValue( + undefined + ); }); - it("should successfully update button configuration and show success message", async () => { + it("should successfully update button configuration", async () => { const buttons = [ { name: "Test Button", command: "echo test" }, - { name: "Group Button", group: [{ name: "Sub Button", command: "echo sub" }] } + { + name: "Group Button", + group: [{ name: "Sub Button", command: "echo sub" }], + }, ]; await updateButtonConfiguration(buttons); - expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("quickCommandButtons"); - expect(mockConfig.update).toHaveBeenCalledWith( - "buttons", - buttons, - vscode.ConfigurationTarget.Workspace + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons + ); + }); + + it("should delegate configuration update to ConfigManager", async () => { + const buttons = [{ name: "Test Button", command: "echo test" }]; + + await updateButtonConfiguration(buttons); + + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Configuration updated successfully!"); - expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); }); it("should handle empty button array", async () => { @@ -440,13 +563,9 @@ describe("webview-provider", () => { await updateButtonConfiguration(buttons); - expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("quickCommandButtons"); - expect(mockConfig.update).toHaveBeenCalledWith( - "buttons", - buttons, - vscode.ConfigurationTarget.Workspace + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Configuration updated successfully!"); }); it("should handle button configuration with all properties", async () => { @@ -458,18 +577,15 @@ describe("webview-provider", () => { color: "#FF0000", terminalName: "custom-terminal", shortcut: "c", - executeAll: false - } + executeAll: false, + }, ]; await updateButtonConfiguration(buttons); - expect(mockConfig.update).toHaveBeenCalledWith( - "buttons", - buttons, - vscode.ConfigurationTarget.Workspace + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Configuration updated successfully!"); }); it("should handle nested group configurations", async () => { @@ -480,68 +596,41 @@ describe("webview-provider", () => { { name: "Child 1", command: "echo child1" }, { name: "Nested Group", - group: [ - { name: "Deep Child", command: "echo deep" } - ] - } - ] - } + group: [{ name: "Deep Child", command: "echo deep" }], + }, + ], + }, ]; await updateButtonConfiguration(buttons); - expect(mockConfig.update).toHaveBeenCalledWith( - "buttons", - buttons, - vscode.ConfigurationTarget.Workspace + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons ); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("Configuration updated successfully!"); }); - it("should show error message when configuration update fails", async () => { + it("should delegate error handling to ConfigManager", async () => { const buttons = [{ name: "Test Button", command: "echo test" }]; - const error = new Error("Configuration update failed"); - mockConfig.update.mockRejectedValue(error); - // Mock console.error to avoid noise in test output - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + // ConfigManager handles errors internally, so it resolves normally + (ConfigManager.updateButtonConfiguration as jest.Mock).mockResolvedValue(undefined); await updateButtonConfiguration(buttons); - - expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith("quickCommandButtons"); - expect(mockConfig.update).toHaveBeenCalledWith( - "buttons", - buttons, - vscode.ConfigurationTarget.Workspace + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons ); - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - "Failed to update configuration. Please try again." - ); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith("Failed to update configuration:", error); - - consoleSpy.mockRestore(); }); - it("should handle workspace configuration service error", async () => { + it("should let ConfigManager handle errors internally", async () => { const buttons = [{ name: "Test Button", command: "echo test" }]; - const error = new Error("Workspace service unavailable"); - (vscode.workspace.getConfiguration as jest.Mock).mockImplementation(() => { - throw error; - }); - // Mock console.error to avoid noise in test output - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + // ConfigManager handles errors internally, so it resolves normally + (ConfigManager.updateButtonConfiguration as jest.Mock).mockResolvedValue(undefined); await updateButtonConfiguration(buttons); - - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - "Failed to update configuration. Please try again." + expect(ConfigManager.updateButtonConfiguration).toHaveBeenCalledWith( + buttons ); - expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith("Failed to update configuration:", error); - - consoleSpy.mockRestore(); }); }); }); diff --git a/src/extension/src/webview-provider.ts b/src/extension/src/webview-provider.ts index cf312fd1..cbc29808 100644 --- a/src/extension/src/webview-provider.ts +++ b/src/extension/src/webview-provider.ts @@ -3,6 +3,8 @@ import * as path from "path"; import * as fs from "fs"; import { ButtonConfig } from "./types"; import { ConfigReader } from "./adapters"; +import { ConfigManager } from "./config-manager"; +import { ConfigurationTargetType } from "./config-constants"; export const generateFallbackHtml = (): string => { return ` @@ -54,7 +56,12 @@ export const buildWebviewHtml = ( extensionUri: vscode.Uri, webview: vscode.Webview ): string => { - const webviewPath = path.join(extensionUri.fsPath, "src", "extension", "web-view-dist"); + const webviewPath = path.join( + extensionUri.fsPath, + "src", + "extension", + "web-view-dist" + ); if (!checkWebviewFilesExist(webviewPath)) { return generateFallbackHtml(); @@ -75,19 +82,29 @@ export const buildWebviewHtml = ( export const updateButtonConfiguration = async ( buttons: ButtonConfig[] ): Promise => { - try { - const config = vscode.workspace.getConfiguration("quickCommandButtons"); - await config.update( - "buttons", - buttons, - vscode.ConfigurationTarget.Workspace - ); - vscode.window.showInformationMessage("Configuration updated successfully!"); - } catch (error) { - console.error("Failed to update configuration:", error); - vscode.window.showErrorMessage( - "Failed to update configuration. Please try again." - ); + await ConfigManager.updateButtonConfiguration(buttons); +}; + +const handleWebviewMessage = async ( + data: any, + webview: vscode.Webview, + configReader: ConfigReader +): Promise => { + switch (data.type) { + case "getConfig": + webview.postMessage({ + type: "configData", + data: ConfigManager.getConfigDataForWebview(configReader), + }); + break; + case "setConfig": + await updateButtonConfiguration(data.data); + break; + case "setConfigurationTarget": + await ConfigManager.updateConfigurationTarget( + data.target as ConfigurationTargetType + ); + break; } }; @@ -115,24 +132,10 @@ export class ConfigWebviewProvider implements vscode.WebviewViewProvider { webviewView.webview.html = this._getHtmlForWebview(webviewView.webview); webviewView.webview.onDidReceiveMessage(async (data) => { - switch (data.type) { - case "getConfig": - webviewView.webview.postMessage({ - type: "configData", - data: this.configReader.getButtons(), - }); - break; - case "setConfig": - await this._updateConfiguration(data.data); - break; - } + await handleWebviewMessage(data, webviewView.webview, this.configReader); }, undefined); } - private async _updateConfiguration(buttons: ButtonConfig[]) { - await updateButtonConfiguration(buttons); - } - private _getHtmlForWebview(webview: vscode.Webview): string { return buildWebviewHtml(this._extensionUri, webview); } @@ -155,23 +158,13 @@ export class ConfigWebviewProvider implements vscode.WebviewViewProvider { panel.webview.html = buildWebviewHtml(extensionUri, panel.webview); panel.webview.onDidReceiveMessage(async (data) => { - switch (data.type) { - case "getConfig": - panel.webview.postMessage({ - type: "configData", - data: configReader.getButtons(), - }); - break; - case "setConfig": - await updateButtonConfiguration(data.data); - break; - } + await handleWebviewMessage(data, panel.webview, configReader); }, undefined); // Send initial config panel.webview.postMessage({ type: "configData", - data: configReader.getButtons(), + data: ConfigManager.getConfigDataForWebview(configReader), }); }; } diff --git a/src/web-view/src/components/header.tsx b/src/web-view/src/components/header.tsx index 6f68e681..39374963 100644 --- a/src/web-view/src/components/header.tsx +++ b/src/web-view/src/components/header.tsx @@ -4,29 +4,57 @@ import { useCommandForm } from "../context/command-form-context.tsx"; import { useDarkMode } from "../hooks/use-dark-mode.tsx"; export const Header = () => { - const { saveConfig } = useVscodeCommand(); + const { saveConfig, configurationTarget, setConfigurationTarget } = + useVscodeCommand(); const { openForm } = useCommandForm(); const { isDark, toggleTheme } = useDarkMode(); + const toggleConfigurationTarget = () => { + const newTarget = + configurationTarget === "workspace" ? "global" : "workspace"; + setConfigurationTarget(newTarget); + }; + return ( -
-

- Commands Configuration -

-
+
+
+

+ Commands Configuration +

+
+ + + +
+
+
+
+ + Save Location + + + {configurationTarget === "workspace" + ? "Workspace (.vscode/settings.json) - project-specific" + : "User Settings - shared across all projects"} + +
- -
diff --git a/src/web-view/src/context/vscode-command-context.tsx b/src/web-view/src/context/vscode-command-context.tsx index 68985e73..25750cd8 100644 --- a/src/web-view/src/context/vscode-command-context.tsx +++ b/src/web-view/src/context/vscode-command-context.tsx @@ -11,11 +11,13 @@ import { mockCommands } from "../mock/mock-data.tsx"; type VscodeCommandContextType = { commands: ButtonConfig[]; + configurationTarget: string; addCommand: (command: ButtonConfig) => void; updateCommand: (index: number, command: ButtonConfig) => void; deleteCommand: (index: number) => void; reorderCommands: (newCommands: ButtonConfig[]) => void; saveConfig: () => void; + setConfigurationTarget: (target: string) => void; }; const VscodeCommandContext = createContext< @@ -40,6 +42,8 @@ export const VscodeCommandProvider = ({ children, }: VscodeCommandProviderProps) => { const [commands, setCommands] = useState([]); + const [configurationTarget, setConfigurationTargetState] = + useState("workspace"); useEffect(() => { if (isDevelopment) { @@ -54,7 +58,20 @@ export const VscodeCommandProvider = ({ const handleMessage = (event: MessageEvent) => { const message = event.data; if (message?.type === "configData") { - setCommands(message.data || []); + if ( + message.data && + typeof message.data === "object" && + message.data.buttons + ) { + // New format with configurationTarget + setCommands(message.data.buttons || []); + setConfigurationTargetState( + message.data.configurationTarget || "workspace" + ); + } else { + // Old format (backward compatibility) + setCommands(message.data || []); + } } }; @@ -92,15 +109,24 @@ export const VscodeCommandProvider = ({ setCommands(newCommands); }; + const setConfigurationTarget = (target: string) => { + setConfigurationTargetState(target); + if (!isDevelopment) { + vscodeApi.postMessage({ type: "setConfigurationTarget", target }); + } + }; + return ( {children} diff --git a/src/web-view/src/types.tsx b/src/web-view/src/types.tsx index 21451114..b340179f 100644 --- a/src/web-view/src/types.tsx +++ b/src/web-view/src/types.tsx @@ -12,5 +12,6 @@ export type ButtonConfig = { export type VSCodeMessage = { data?: ButtonConfig[] | ButtonConfig; - type: "getConfig" | "setConfig"; + type: "getConfig" | "setConfig" | "setConfigurationTarget"; + target?: string; };