From d569c9a07f450f6adf031cc67dd27dab9f7e1035 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:19:57 +0000 Subject: [PATCH 1/5] Initial plan From 25cf68efd758a551b7a1cfbe947b583d3215d094 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:25:43 +0000 Subject: [PATCH 2/5] Add quick fix actions for invalidConfigSection diagnostic Add "Remove section" and "Link to plugin..." quick fix code actions for the invalidConfigSection warning. The remove action deletes the orphaned config section. The link action shows a quick pick of plugins without a configSection and adds the property to the selected plugin. Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> --- src/code-actions.ts | 195 ++++++++++++++++++ src/test/code-actions.test.ts | 96 ++++++++- .../config-invalid-config-section.json | 16 ++ 3 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 src/test/examples/config-invalid-config-section.json diff --git a/src/code-actions.ts b/src/code-actions.ts index 26e389f..a069872 100644 --- a/src/code-actions.ts +++ b/src/code-actions.ts @@ -74,6 +74,7 @@ export const registerCodeActions = (context: vscode.ExtensionContext) => { registerOptionalConfigFixes(context); registerMissingConfigFixes(context); registerUnknownConfigPropertyFixes(context); + registerInvalidConfigSectionFixes(context); }; function registerInvalidSchemaFixes( @@ -741,3 +742,197 @@ function calculatePropertyDeleteRange( return propertyRange; } + +/** + * Registers code actions for invalid config sections. + * Provides "Remove section" and "Link to plugin" quick fixes. + */ +function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): void { + // Register the command for linking a config section to a plugin + context.subscriptions.push( + vscode.commands.registerCommand( + 'dev-proxy-toolkit.linkConfigSectionToPlugin', + async (documentUri: vscode.Uri, configSectionName: string) => { + const document = await vscode.workspace.openTextDocument(documentUri); + + let documentNode: parse.ObjectNode; + try { + documentNode = parse(document.getText()) as parse.ObjectNode; + } catch { + return; + } + + const pluginsNode = getASTNode(documentNode.children, 'Identifier', 'plugins'); + if (!pluginsNode || pluginsNode.value.type !== 'Array') { + return; + } + + const pluginNodes = (pluginsNode.value as parse.ArrayNode) + .children as parse.ObjectNode[]; + + // Find plugins that don't have a configSection property + const availablePlugins: { name: string; node: parse.ObjectNode }[] = []; + pluginNodes.forEach(pluginNode => { + const nameNode = getASTNode(pluginNode.children, 'Identifier', 'name'); + const configSectionNode = getASTNode(pluginNode.children, 'Identifier', 'configSection'); + if (nameNode && !configSectionNode) { + availablePlugins.push({ + name: (nameNode.value as parse.LiteralNode).value as string, + node: pluginNode, + }); + } + }); + + if (availablePlugins.length === 0) { + vscode.window.showInformationMessage('All plugins already have a configSection.'); + return; + } + + const selected = await vscode.window.showQuickPick( + availablePlugins.map(p => p.name), + { placeHolder: 'Select a plugin to link this config section to' } + ); + + if (!selected) { + return; + } + + const selectedPlugin = availablePlugins.find(p => p.name === selected); + if (!selectedPlugin) { + return; + } + + const edit = new vscode.WorkspaceEdit(); + const lastProperty = selectedPlugin.node.children[selectedPlugin.node.children.length - 1]; + const insertPos = new vscode.Position( + lastProperty.loc!.end.line - 1, + lastProperty.loc!.end.column + ); + + edit.insert( + documentUri, + insertPos, + `,\n "configSection": "${configSectionName}"` + ); + + await vscode.workspace.applyEdit(edit); + await vscode.commands.executeCommand('editor.action.formatDocument'); + } + ) + ); + + const invalidConfigSection: vscode.CodeActionProvider = { + provideCodeActions: (document, range, context) => { + const currentDiagnostic = findDiagnosticByCode( + context.diagnostics, + 'invalidConfigSection', + range + ); + + if (!currentDiagnostic) { + return []; + } + + // Extract config section name from diagnostic message + const match = currentDiagnostic.message.match(/^Config section '(\w+)'/); + if (!match) { + return []; + } + + const configSectionName = match[1]; + const fixes: vscode.CodeAction[] = []; + + // 1. "Remove section" fix + try { + const documentNode = parse(document.getText()) as parse.ObjectNode; + const configSectionProperty = getASTNode( + documentNode.children, + 'Identifier', + configSectionName + ); + + if (configSectionProperty) { + const removeFix = new vscode.CodeAction( + `Remove '${configSectionName}' section`, + vscode.CodeActionKind.QuickFix + ); + + removeFix.edit = new vscode.WorkspaceEdit(); + + const deleteRange = calculateConfigSectionDeleteRange( + document, + configSectionProperty + ); + removeFix.edit.delete(document.uri, deleteRange); + + removeFix.command = { + command: 'editor.action.formatDocument', + title: 'Format Document', + }; + + removeFix.isPreferred = true; + fixes.push(removeFix); + } + } catch { + // If AST parsing fails, skip the remove fix + } + + // 2. "Link to plugin" fix + const linkFix = new vscode.CodeAction( + `Link '${configSectionName}' to a plugin...`, + vscode.CodeActionKind.QuickFix + ); + linkFix.command = { + command: 'dev-proxy-toolkit.linkConfigSectionToPlugin', + title: 'Link config section to plugin', + arguments: [document.uri, configSectionName], + }; + fixes.push(linkFix); + + return fixes; + }, + }; + + registerJsonCodeActionProvider(context, invalidConfigSection); +} + +/** + * Calculate the range to delete for a config section property, including comma handling. + */ +function calculateConfigSectionDeleteRange( + document: vscode.TextDocument, + propertyNode: parse.PropertyNode, +): vscode.Range { + const propRange = getRangeFromASTNode(propertyNode); + + // Check if there's a comma after the property on the end line + const endLineText = document.lineAt(propRange.end.line).text; + const afterProp = endLineText.substring(propRange.end.character); + const commaAfterMatch = afterProp.match(/^\s*,/); + + if (commaAfterMatch) { + // Delete from start of line to end of line (including comma) + return new vscode.Range( + new vscode.Position(propRange.start.line, 0), + new vscode.Position(propRange.end.line + 1, 0) + ); + } + + // No comma after - remove preceding comma if exists + if (propRange.start.line > 0) { + const prevLineText = document.lineAt(propRange.start.line - 1).text; + if (prevLineText.trimEnd().endsWith(',')) { + const commaPos = prevLineText.lastIndexOf(','); + return new vscode.Range( + new vscode.Position(propRange.start.line - 1, commaPos), + new vscode.Position(propRange.end.line + 1, 0) + ); + } + } + + // Fallback: delete just the property lines + return new vscode.Range( + new vscode.Position(propRange.start.line, 0), + new vscode.Position(propRange.end.line + 1, 0) + ); +} diff --git a/src/test/code-actions.test.ts b/src/test/code-actions.test.ts index d64b741..aa21f4c 100644 --- a/src/test/code-actions.test.ts +++ b/src/test/code-actions.test.ts @@ -53,8 +53,8 @@ suite('Code Actions', () => { registerCodeActions(contextWithInstall); - // Should register 14 providers (2 per fix type: json + jsonc, 7 fix types) - assert.strictEqual(registerSpy.callCount, 14, 'Should register 14 code action providers'); + // Should register 16 providers (2 per fix type: json + jsonc, 8 fix types) + assert.strictEqual(registerSpy.callCount, 16, 'Should register 16 code action providers'); }); test('should handle beta version correctly', () => { @@ -460,6 +460,92 @@ suite('Code Actions', () => { await vscode.commands.executeCommand('workbench.action.files.revert'); }); }); + + suite('Invalid Config Section Fix', () => { + test('should provide remove and link fixes when invalidConfigSection diagnostic exists', async () => { + const context = await getExtensionContext(); + await context.globalState.update( + 'devProxyInstall', + createDevProxyInstall({ version: '0.24.0' }) + ); + + const fileName = 'config-invalid-config-section.json'; + const filePath = getFixturePath(fileName); + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document); + await sleep(1000); + + const diagnostics = vscode.languages.getDiagnostics(document.uri); + const invalidConfigDiagnostic = diagnostics.find(d => + d.message.includes('does not correspond to any plugin') + ); + + assert.ok(invalidConfigDiagnostic, 'Should have invalidConfigSection diagnostic'); + + const codeActions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + document.uri, + invalidConfigDiagnostic!.range, + vscode.CodeActionKind.QuickFix.value + ); + + const removeFix = codeActions?.find(a => a.title.includes('Remove')); + assert.ok(removeFix, 'Should provide remove section fix'); + assert.ok(removeFix!.edit, 'Remove fix should have an edit'); + + const linkFix = codeActions?.find(a => a.title.includes('Link')); + assert.ok(linkFix, 'Should provide link to plugin fix'); + assert.ok(linkFix!.command, 'Link fix should have a command'); + + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + }); + + test('should remove config section when remove fix is applied', async () => { + const context = await getExtensionContext(); + await context.globalState.update( + 'devProxyInstall', + createDevProxyInstall({ version: '0.24.0' }) + ); + + const fileName = 'config-invalid-config-section.json'; + const filePath = getFixturePath(fileName); + const document = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(document); + await sleep(1000); + + const diagnostics = vscode.languages.getDiagnostics(document.uri); + const invalidConfigDiagnostic = diagnostics.find(d => + d.message.includes('does not correspond to any plugin') + ); + + assert.ok(invalidConfigDiagnostic, 'Should have invalidConfigSection diagnostic'); + + const codeActions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + document.uri, + invalidConfigDiagnostic!.range, + vscode.CodeActionKind.QuickFix.value + ); + + const removeFix = codeActions?.find(a => a.title.includes('Remove')); + assert.ok(removeFix, 'Should have remove fix'); + + // Apply the edit + const applied = await vscode.workspace.applyEdit(removeFix!.edit!); + assert.ok(applied, 'Edit should be applied successfully'); + + // Verify the config section was removed + const updatedText = document.getText(); + assert.ok( + !updatedText.includes('"orphanedConfig"'), + 'Config section should be removed' + ); + + // Revert the changes + await vscode.commands.executeCommand('workbench.action.files.revert'); + await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); + }); + }); }); suite('Invalid Schema Code Action Logic', () => { @@ -613,8 +699,8 @@ suite('Code Action Provider Registration', () => { const jsonCalls = registerSpy.getCalls().filter(call => call.args[0] === 'json'); const jsoncCalls = registerSpy.getCalls().filter(call => call.args[0] === 'jsonc'); - assert.strictEqual(jsonCalls.length, 7, 'Should register 7 providers for json'); - assert.strictEqual(jsoncCalls.length, 7, 'Should register 7 providers for jsonc'); + assert.strictEqual(jsonCalls.length, 8, 'Should register 8 providers for json'); + assert.strictEqual(jsoncCalls.length, 8, 'Should register 8 providers for jsonc'); }); test('should add subscriptions to context', () => { @@ -637,7 +723,7 @@ suite('Code Action Provider Registration', () => { registerCodeActions(contextWithInstall); - assert.strictEqual(subscriptions.length, 14, 'Should add 14 subscriptions'); + assert.strictEqual(subscriptions.length, 17, 'Should add 17 subscriptions'); }); test('should strip beta suffix from version for schema URL', () => { diff --git a/src/test/examples/config-invalid-config-section.json b/src/test/examples/config-invalid-config-section.json new file mode 100644 index 0000000..08dc2e4 --- /dev/null +++ b/src/test/examples/config-invalid-config-section.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.24.0/rc.schema.json", + "plugins": [ + { + "name": "MockResponsePlugin", + "enabled": true, + "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll" + } + ], + "orphanedConfig": { + "key": "value" + }, + "urlsToWatch": [ + "https://api.example.com/*" + ] +} From 5d4e9c1667deb9e2514211a06a5546f4c02c680a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:26:37 +0000 Subject: [PATCH 3/5] Add empty children check for plugin node safety Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> --- src/code-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/code-actions.ts b/src/code-actions.ts index a069872..d919bcb 100644 --- a/src/code-actions.ts +++ b/src/code-actions.ts @@ -798,7 +798,7 @@ function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): vo } const selectedPlugin = availablePlugins.find(p => p.name === selected); - if (!selectedPlugin) { + if (!selectedPlugin || selectedPlugin.node.children.length === 0) { return; } From 9873223c670e9e8353fd98cdbbccf9b12a5810b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:55:48 +0000 Subject: [PATCH 4/5] Fix duplicate command registration error in tests Wrap the linkConfigSectionToPlugin command registration in try-catch to handle cases where the command is already registered (e.g., during test runs that call registerCodeActions multiple times). Update the subscription count assertion from 17 to 16 accordingly. Co-authored-by: garrytrinder <11563347+garrytrinder@users.noreply.github.com> --- src/code-actions.ts | 132 ++++++++++++++++++---------------- src/test/code-actions.test.ts | 2 +- 2 files changed, 70 insertions(+), 64 deletions(-) diff --git a/src/code-actions.ts b/src/code-actions.ts index d919bcb..e2125ad 100644 --- a/src/code-actions.ts +++ b/src/code-actions.ts @@ -748,78 +748,84 @@ function calculatePropertyDeleteRange( * Provides "Remove section" and "Link to plugin" quick fixes. */ function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): void { - // Register the command for linking a config section to a plugin - context.subscriptions.push( - vscode.commands.registerCommand( - 'dev-proxy-toolkit.linkConfigSectionToPlugin', - async (documentUri: vscode.Uri, configSectionName: string) => { - const document = await vscode.workspace.openTextDocument(documentUri); - - let documentNode: parse.ObjectNode; - try { - documentNode = parse(document.getText()) as parse.ObjectNode; - } catch { - return; - } - - const pluginsNode = getASTNode(documentNode.children, 'Identifier', 'plugins'); - if (!pluginsNode || pluginsNode.value.type !== 'Array') { - return; - } - - const pluginNodes = (pluginsNode.value as parse.ArrayNode) - .children as parse.ObjectNode[]; + // Register the command for linking a config section to a plugin. + // Use try-catch to handle cases where the command is already registered + // (e.g., during test runs that call registerCodeActions multiple times). + try { + context.subscriptions.push( + vscode.commands.registerCommand( + 'dev-proxy-toolkit.linkConfigSectionToPlugin', + async (documentUri: vscode.Uri, configSectionName: string) => { + const document = await vscode.workspace.openTextDocument(documentUri); + + let documentNode: parse.ObjectNode; + try { + documentNode = parse(document.getText()) as parse.ObjectNode; + } catch { + return; + } - // Find plugins that don't have a configSection property - const availablePlugins: { name: string; node: parse.ObjectNode }[] = []; - pluginNodes.forEach(pluginNode => { - const nameNode = getASTNode(pluginNode.children, 'Identifier', 'name'); - const configSectionNode = getASTNode(pluginNode.children, 'Identifier', 'configSection'); - if (nameNode && !configSectionNode) { - availablePlugins.push({ - name: (nameNode.value as parse.LiteralNode).value as string, - node: pluginNode, - }); + const pluginsNode = getASTNode(documentNode.children, 'Identifier', 'plugins'); + if (!pluginsNode || pluginsNode.value.type !== 'Array') { + return; } - }); - if (availablePlugins.length === 0) { - vscode.window.showInformationMessage('All plugins already have a configSection.'); - return; - } + const pluginNodes = (pluginsNode.value as parse.ArrayNode) + .children as parse.ObjectNode[]; + + // Find plugins that don't have a configSection property + const availablePlugins: { name: string; node: parse.ObjectNode }[] = []; + pluginNodes.forEach(pluginNode => { + const nameNode = getASTNode(pluginNode.children, 'Identifier', 'name'); + const configSectionNode = getASTNode(pluginNode.children, 'Identifier', 'configSection'); + if (nameNode && !configSectionNode) { + availablePlugins.push({ + name: (nameNode.value as parse.LiteralNode).value as string, + node: pluginNode, + }); + } + }); + + if (availablePlugins.length === 0) { + vscode.window.showInformationMessage('All plugins already have a configSection.'); + return; + } - const selected = await vscode.window.showQuickPick( - availablePlugins.map(p => p.name), - { placeHolder: 'Select a plugin to link this config section to' } - ); + const selected = await vscode.window.showQuickPick( + availablePlugins.map(p => p.name), + { placeHolder: 'Select a plugin to link this config section to' } + ); - if (!selected) { - return; - } + if (!selected) { + return; + } - const selectedPlugin = availablePlugins.find(p => p.name === selected); - if (!selectedPlugin || selectedPlugin.node.children.length === 0) { - return; - } + const selectedPlugin = availablePlugins.find(p => p.name === selected); + if (!selectedPlugin || selectedPlugin.node.children.length === 0) { + return; + } - const edit = new vscode.WorkspaceEdit(); - const lastProperty = selectedPlugin.node.children[selectedPlugin.node.children.length - 1]; - const insertPos = new vscode.Position( - lastProperty.loc!.end.line - 1, - lastProperty.loc!.end.column - ); + const edit = new vscode.WorkspaceEdit(); + const lastProperty = selectedPlugin.node.children[selectedPlugin.node.children.length - 1]; + const insertPos = new vscode.Position( + lastProperty.loc!.end.line - 1, + lastProperty.loc!.end.column + ); - edit.insert( - documentUri, - insertPos, - `,\n "configSection": "${configSectionName}"` - ); + edit.insert( + documentUri, + insertPos, + `,\n "configSection": "${configSectionName}"` + ); - await vscode.workspace.applyEdit(edit); - await vscode.commands.executeCommand('editor.action.formatDocument'); - } - ) - ); + await vscode.workspace.applyEdit(edit); + await vscode.commands.executeCommand('editor.action.formatDocument'); + } + ) + ); + } catch { + // Command already registered, skip + } const invalidConfigSection: vscode.CodeActionProvider = { provideCodeActions: (document, range, context) => { diff --git a/src/test/code-actions.test.ts b/src/test/code-actions.test.ts index aa21f4c..35c5d09 100644 --- a/src/test/code-actions.test.ts +++ b/src/test/code-actions.test.ts @@ -723,7 +723,7 @@ suite('Code Action Provider Registration', () => { registerCodeActions(contextWithInstall); - assert.strictEqual(subscriptions.length, 17, 'Should add 17 subscriptions'); + assert.strictEqual(subscriptions.length, 16, 'Should add 16 subscriptions'); }); test('should strip beta suffix from version for schema URL', () => { From af073041dc40877466238272dc2497a50d6a7dbc Mon Sep 17 00:00:00 2001 From: Garry Trinder Date: Mon, 2 Mar 2026 12:04:50 +0000 Subject: [PATCH 5/5] Disambiguate duplicate plugins in quick pick and update docs - Show plugin index in quick pick when duplicate plugin names exist - Add CHANGELOG entries for new quick fixes - Add README entries for remove and link quick fixes --- CHANGELOG.md | 2 ++ README.md | 2 ++ src/code-actions.ts | 24 +++++++++++++++++++----- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b0bc4..1a9c248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Workspace: Added automatic prompt to recommend extension in `.vscode/extensions.json` when Dev Proxy config files are detected - Command: Added `Add to Workspace Recommendations` to manually add extension to workspace recommendations - Command: Added `Reset State` to clear all extension state +- Quick Fixes: Added fix to remove orphaned config sections not linked to any plugin +- Quick Fixes: Added fix to link orphaned config section to a plugin ### Fixed: diff --git a/README.md b/README.md index e6bbfbb..906b79b 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ One-click fixes for common issues: - **Enable local language model** - Add or update `languageModel.enabled: true` for plugins that support it - **Add plugin configuration** - Add optional config section for plugins that support it - **Add missing config section** - Create config section when plugin references one that doesn't exist +- **Remove orphaned config section** - Remove config sections not linked to any plugin +- **Link config section to plugin** - Link an orphaned config section to a plugin via quick pick ### Code Lens diff --git a/src/code-actions.ts b/src/code-actions.ts index e2125ad..f88afc6 100644 --- a/src/code-actions.ts +++ b/src/code-actions.ts @@ -774,13 +774,14 @@ function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): vo .children as parse.ObjectNode[]; // Find plugins that don't have a configSection property - const availablePlugins: { name: string; node: parse.ObjectNode }[] = []; - pluginNodes.forEach(pluginNode => { + const availablePlugins: { name: string; index: number; node: parse.ObjectNode }[] = []; + pluginNodes.forEach((pluginNode, index) => { const nameNode = getASTNode(pluginNode.children, 'Identifier', 'name'); const configSectionNode = getASTNode(pluginNode.children, 'Identifier', 'configSection'); if (nameNode && !configSectionNode) { availablePlugins.push({ name: (nameNode.value as parse.LiteralNode).value as string, + index, node: pluginNode, }); } @@ -791,8 +792,21 @@ function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): vo return; } + // Check for duplicate plugin names to disambiguate in the picker + const nameCounts = new Map(); + availablePlugins.forEach(p => { + nameCounts.set(p.name, (nameCounts.get(p.name) ?? 0) + 1); + }); + + const quickPickItems = availablePlugins.map(p => ({ + label: nameCounts.get(p.name)! > 1 + ? `${p.name} (plugin #${p.index + 1})` + : p.name, + plugin: p, + })); + const selected = await vscode.window.showQuickPick( - availablePlugins.map(p => p.name), + quickPickItems, { placeHolder: 'Select a plugin to link this config section to' } ); @@ -800,8 +814,8 @@ function registerInvalidConfigSectionFixes(context: vscode.ExtensionContext): vo return; } - const selectedPlugin = availablePlugins.find(p => p.name === selected); - if (!selectedPlugin || selectedPlugin.node.children.length === 0) { + const selectedPlugin = selected.plugin; + if (selectedPlugin.node.children.length === 0) { return; }