From fd7ee92ccae1c791c4e01a97cbb0ad6e55c03967 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 23:00:07 +0200 Subject: [PATCH 1/2] refactor: extract CommandRegistry for declarative command registration Adds src/commands/commandRegistry.ts with three helpers (add, addGuarded, addRefreshing) that fold the common command shapes: plain commands, sign-in-gated commands, and gated+refresh-on-truthy commands. Migrates all 64 command registrations in extension.ts to use the registry. Net savings: 256 lines (1186 -> 930). No behavior change. All command IDs, signatures, and semantics preserved. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/commands/commandRegistry.ts | 60 ++ src/extension.ts | 1072 ++++++++++++------------------- 2 files changed, 468 insertions(+), 664 deletions(-) create mode 100644 src/commands/commandRegistry.ts diff --git a/src/commands/commandRegistry.ts b/src/commands/commandRegistry.ts new file mode 100644 index 0000000..7a6b628 --- /dev/null +++ b/src/commands/commandRegistry.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode'; + +/** + * Lightweight registry for VS Code command registrations. + * + * Helpers fold the most common command shapes: + * - `add(id, handler)` plain command. + * - `addGuarded(id, handler)` ensures sign-in first; aborts if not signed in. + * - `addRefreshing(id, handler, refresh)` guarded + calls refresh() when handler returns truthy. + * + * Call {@link registerAll} once to push everything onto `context.subscriptions`. + */ +export class CommandRegistry { + private readonly _disposables: vscode.Disposable[] = []; + + constructor(private readonly _ensureSignedIn: () => Promise) {} + + add( + id: string, + handler: (...args: T) => unknown | Promise + ): void { + this._disposables.push(vscode.commands.registerCommand(id, handler)); + } + + addGuarded( + id: string, + handler: (...args: T) => unknown | Promise + ): void { + this._disposables.push( + vscode.commands.registerCommand(id, async (...args: T) => { + if (!(await this._ensureSignedIn())) { + return; + } + return handler(...args); + }) + ); + } + + addRefreshing( + id: string, + handler: (...args: T) => unknown | Promise, + refresh: () => void + ): void { + this._disposables.push( + vscode.commands.registerCommand(id, async (...args: T) => { + if (!(await this._ensureSignedIn())) { + return; + } + const result = await handler(...args); + if (result) { + refresh(); + } + }) + ); + } + + registerAll(context: vscode.ExtensionContext): void { + context.subscriptions.push(...this._disposables); + } +} diff --git a/src/extension.ts b/src/extension.ts index 6f9128a..9317308 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -74,6 +74,7 @@ import { installNotificationMirroring, showErrorMessage, showInformationMessage, import { WorkItemHoverProvider, PullRequestHoverProvider } from './providers/hoverProvider'; import { adoErrorFingerprint, classifyAdoAuthError } from './utils/adoErrors'; import type { AuthRecoveryResult } from './utils/authRecovery'; +import { CommandRegistry } from './commands/commandRegistry'; export async function activate(context: vscode.ExtensionContext): Promise { installNotificationMirroring(); @@ -258,143 +259,115 @@ export async function activate(context: vscode.ExtensionContext): Promise // ------------------------------------------------------------------------- // Commands // ------------------------------------------------------------------------- + const registry = new CommandRegistry(ensureSignedIn); // Sign in - context.subscriptions.push( - vscode.commands.registerCommand('adoext.showOutput', () => { - showOutputChannel(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('adoext.signIn', async (forceNewSession?: boolean) => { - const ok = forceNewSession ? await auth.reauthenticate() : await auth.signIn(); - if (ok) { - attemptedForbiddenRecoveries.clear(); - rebuildClient(); - showInformationMessage( - `Signed in as ${auth.accountName}` - ); - refreshAllViews(); - } - }) - ); + registry.add('adoext.showOutput', () => { + showOutputChannel(); + }); + + registry.add('adoext.signIn', async (forceNewSession?: boolean) => { + const ok = forceNewSession ? await auth.reauthenticate() : await auth.signIn(); + if (ok) { + attemptedForbiddenRecoveries.clear(); + rebuildClient(); + showInformationMessage( + `Signed in as ${auth.accountName}` + ); + refreshAllViews(); + } + }); // Sign out - context.subscriptions.push( - vscode.commands.registerCommand('adoext.signOut', () => { - auth.signOut(); - client.updateToken(''); - updateSignedInContext(); - notificationService.applyConfig(); - mcpManager.refresh(); - showInformationMessage('Signed out from Azure DevOps.'); - refreshAllViews(); - }) - ); + registry.add('adoext.signOut', () => { + auth.signOut(); + client.updateToken(''); + updateSignedInContext(); + notificationService.applyConfig(); + mcpManager.refresh(); + showInformationMessage('Signed out from Azure DevOps.'); + refreshAllViews(); + }); // Select organization - context.subscriptions.push( - vscode.commands.registerCommand('adoext.selectOrganization', async () => { - if (!(await ensureSignedIn())) { - const signedIn = await auth.signIn(); - if (!signedIn) { return; } - rebuildClient(); - } - const ok = await selectOrganization(client, config, auth); - if (ok) { - refreshAllViews(); - } - }) - ); + registry.add('adoext.selectOrganization', async () => { + if (!(await ensureSignedIn())) { + const signedIn = await auth.signIn(); + if (!signedIn) { return; } + rebuildClient(); + } + const ok = await selectOrganization(client, config, auth); + if (ok) { + refreshAllViews(); + } + }); // Detect and suggest org/project from the active workspace's git remotes - context.subscriptions.push( - vscode.commands.registerCommand('adoext.detectRepoContext', async () => { - const ok = await detectAndSuggestRepoContext(config); - if (ok) { - if (auth.isSignedIn && config.organization) { - client.connect(config.organization); - } - refreshAllViews(); + registry.add('adoext.detectRepoContext', async () => { + const ok = await detectAndSuggestRepoContext(config); + if (ok) { + if (auth.isSignedIn && config.organization) { + client.connect(config.organization); } - }) - ); + refreshAllViews(); + } + }); // Select project - context.subscriptions.push( - vscode.commands.registerCommand('adoext.selectProject', async () => { - if (!(await ensureSignedIn())) { return; } - const ok = await selectProject(client, config); - if (ok) { - refreshAllViews(); - } - }) - ); + registry.addRefreshing('adoext.selectProject', () => selectProject(client, config), refreshAllViews); // Refresh work items - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshWorkItems', async () => { - await ensureSignedIn(); - workItemProvider.refresh(); - }) - ); + registry.addGuarded('adoext.refreshWorkItems', () => { + workItemProvider.refresh(); + }); // Switch / persist work item query preset - context.subscriptions.push( - vscode.commands.registerCommand('adoext.selectWorkItemQuery', async () => { - const changed = await selectWorkItemQuery(config); - if (changed) { workItemProvider.refresh(); } - }) - ); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.saveWorkItemQuery', async () => { - const saved = await saveWorkItemQuery(config); - if (saved) { workItemProvider.refresh(); } - }) - ); + registry.add('adoext.selectWorkItemQuery', async () => { + const changed = await selectWorkItemQuery(config); + if (changed) { workItemProvider.refresh(); } + }); + registry.add('adoext.saveWorkItemQuery', async () => { + const saved = await saveWorkItemQuery(config); + if (saved) { workItemProvider.refresh(); } + }); // Set work item filter regex - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setWorkItemFilter', async () => { - const current = config.workItemFilterRegex; - const pattern = await vscode.window.showInputBox({ - prompt: 'Enter regex pattern to filter work items (leave empty to clear)', - value: current, - validateInput: (value) => { - if (!value.trim()) return undefined; - try { - new RegExp(value, 'i'); - return undefined; - } catch { - return 'Invalid regex pattern'; - } + registry.add('adoext.setWorkItemFilter', async () => { + const current = config.workItemFilterRegex; + const pattern = await vscode.window.showInputBox({ + prompt: 'Enter regex pattern to filter work items (leave empty to clear)', + value: current, + validateInput: (value) => { + if (!value.trim()) return undefined; + try { + new RegExp(value, 'i'); + return undefined; + } catch { + return 'Invalid regex pattern'; } - }); - if (pattern !== undefined) { - await config.setWorkItemFilterRegex(pattern); - workItemProvider.refresh(); } - }) - ); + }); + if (pattern !== undefined) { + await config.setWorkItemFilterRegex(pattern); + workItemProvider.refresh(); + } + }); // Set work item sort order - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setWorkItemSort', async () => { - const current = config.workItemSortOrder; - const choice = await vscode.window.showQuickPick( - [ - { label: 'Name (A-Z)', value: 'name', picked: current === 'name' }, - { label: 'Date (Newest first)', value: 'date', picked: current === 'date' } - ], - { placeHolder: 'Choose sort order for work items' } - ); - if (choice) { - await config.setWorkItemSortOrder(choice.value as 'name' | 'date'); - workItemProvider.refresh(); - } - }) - ); + registry.add('adoext.setWorkItemSort', async () => { + const current = config.workItemSortOrder; + const choice = await vscode.window.showQuickPick( + [ + { label: 'Name (A-Z)', value: 'name', picked: current === 'name' }, + { label: 'Date (Newest first)', value: 'date', picked: current === 'date' } + ], + { placeHolder: 'Choose sort order for work items' } + ); + if (choice) { + await config.setWorkItemSortOrder(choice.value as 'name' | 'date'); + workItemProvider.refresh(); + } + }); // Toggle hide "Done" work items function updateWorkItemDoneHiddenContext(): void { @@ -404,171 +377,106 @@ export async function activate(context: vscode.ExtensionContext): Promise updateWorkItemDoneHiddenContext(); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.toggleHideDoneWorkItems', async () => { - const hideStates = config.workItemHideStates; - const isDoneHidden = hideStates.some(s => s.toLowerCase() === 'done'); - const newHideStates = isDoneHidden - ? hideStates.filter(s => s.toLowerCase() !== 'done') - : [...hideStates, 'Done']; - await config.setWorkItemHideStates(newHideStates); - updateWorkItemDoneHiddenContext(); - workItemProvider.refresh(); - backlogProvider.refresh(); - sprintProvider.refresh(); - boardProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshBacklog', async () => { - await ensureSignedIn(); - backlogProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshSprints', async () => { - await ensureSignedIn(); - sprintProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshBoards', async () => { - await ensureSignedIn(); - boardProvider.refresh(); - }) - ); + registry.add('adoext.toggleHideDoneWorkItems', async () => { + const hideStates = config.workItemHideStates; + const isDoneHidden = hideStates.some(s => s.toLowerCase() === 'done'); + const newHideStates = isDoneHidden + ? hideStates.filter(s => s.toLowerCase() !== 'done') + : [...hideStates, 'Done']; + await config.setWorkItemHideStates(newHideStates); + updateWorkItemDoneHiddenContext(); + workItemProvider.refresh(); + backlogProvider.refresh(); + sprintProvider.refresh(); + boardProvider.refresh(); + }); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setPlanningAssignedFilter', async () => { - const current = config.planningAssignedFilter; - const choice = await vscode.window.showQuickPick( - [ - { label: 'All items', value: 'all', picked: current === 'all' }, - { label: 'Assigned to me', value: 'mine', picked: current === 'mine' } - ], - { placeHolder: 'Choose assignee filter for Backlog, Sprint, and Board views' } - ); - if (!choice || choice.value === current) { - return; - } + registry.addGuarded('adoext.refreshBacklog', () => { + backlogProvider.refresh(); + }); - await config.setPlanningAssignedFilter(choice.value as 'all' | 'mine'); - backlogProvider.refresh(); - sprintProvider.refresh(); - boardProvider.refresh(); - }) - ); + registry.addGuarded('adoext.refreshSprints', () => { + sprintProvider.refresh(); + }); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.openBacklogView', async () => { - if (!(await ensureSignedIn())) { return; } - await PlanningPanel.show(context, 'backlog', client, config, refreshAllViews); - }) - ); + registry.addGuarded('adoext.refreshBoards', () => { + boardProvider.refresh(); + }); + + registry.add('adoext.setPlanningAssignedFilter', async () => { + const current = config.planningAssignedFilter; + const choice = await vscode.window.showQuickPick( + [ + { label: 'All items', value: 'all', picked: current === 'all' }, + { label: 'Assigned to me', value: 'mine', picked: current === 'mine' } + ], + { placeHolder: 'Choose assignee filter for Backlog, Sprint, and Board views' } + ); + if (!choice || choice.value === current) { + return; + } - context.subscriptions.push( - vscode.commands.registerCommand('adoext.openBoardView', async () => { - if (!(await ensureSignedIn())) { return; } - await PlanningPanel.show(context, 'board', client, config, refreshAllViews); - }) - ); + await config.setPlanningAssignedFilter(choice.value as 'all' | 'mine'); + backlogProvider.refresh(); + sprintProvider.refresh(); + boardProvider.refresh(); + }); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.openSprintView', async () => { - if (!(await ensureSignedIn())) { return; } - await PlanningPanel.show(context, 'sprint', client, config, refreshAllViews); - }) - ); + registry.addGuarded('adoext.openBacklogView', () => PlanningPanel.show(context, 'backlog', client, config, refreshAllViews)); + registry.addGuarded('adoext.openBoardView', () => PlanningPanel.show(context, 'board', client, config, refreshAllViews)); + registry.addGuarded('adoext.openSprintView', () => PlanningPanel.show(context, 'sprint', client, config, refreshAllViews)); // View work item details in webview - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.viewWorkItemDetails', - (node?: WorkItemNode) => viewWorkItemDetails(context, node, client, config) - ) + registry.add( + 'adoext.viewWorkItemDetails', + (node?: WorkItemNode) => viewWorkItemDetails(context, node, client, config) ); // Open work item in browser (secondary action) - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openWorkItem', - (node: WorkItemNode) => openWorkItem(node, client, config) - ) + registry.add( + 'adoext.openWorkItem', + (node: WorkItemNode) => openWorkItem(node, client, config) ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.changeWorkItemState', - async (node?: WorkItemNode) => { - const updated = await changeWorkItemState(node, client, config); - if (updated) { - refreshAllViews(); - } - } - ) + registry.addRefreshing( + 'adoext.changeWorkItemState', + (node?: WorkItemNode) => changeWorkItemState(node, client, config), + refreshAllViews ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.startWorkingOnWorkItem', - async (nodeOrItem?: WorkItemNode | import('./api/adoClient').WorkItem, organization?: string, project?: string) => { - if (!nodeOrItem) { - showInformationMessage('Select a work item first, then run "Start Working".'); - return; - } - // Accept either a WorkItemNode (from context menu) or a raw WorkItem - // (forwarded from the details panel webview message handler). - const isNode = nodeOrItem instanceof WorkItemNode; - const workItem = isNode ? nodeOrItem.workItem : nodeOrItem as import('./api/adoClient').WorkItem; - const org = isNode - ? (nodeOrItem.organization ?? client.organization ?? config.organization) - : organization; - const proj = isNode ? (nodeOrItem.project ?? config.project) : project; - await startWorkingOnWorkItem(workItem, org, proj); + registry.add( + 'adoext.startWorkingOnWorkItem', + async (nodeOrItem?: WorkItemNode | import('./api/adoClient').WorkItem, organization?: string, project?: string) => { + if (!nodeOrItem) { + showInformationMessage('Select a work item first, then run "Start Working".'); + return; } - ) + // Accept either a WorkItemNode (from context menu) or a raw WorkItem + // (forwarded from the details panel webview message handler). + const isNode = nodeOrItem instanceof WorkItemNode; + const workItem = isNode ? nodeOrItem.workItem : nodeOrItem as import('./api/adoClient').WorkItem; + const org = isNode + ? (nodeOrItem.organization ?? client.organization ?? config.organization) + : organization; + const proj = isNode ? (nodeOrItem.project ?? config.project) : project; + await startWorkingOnWorkItem(workItem, org, proj); + } ); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.openSavedQuery', async () => { - if (!(await ensureSignedIn())) { return; } - await openSavedQuery(context, client, config); - }) - ); + registry.addGuarded('adoext.openSavedQuery', () => openSavedQuery(context, client, config)); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.createWorkItem', async () => { - if (!(await ensureSignedIn())) { return; } - const created = await createWorkItem(client, config); - if (created) { - refreshAllViews(); - } - }) - ); + registry.addRefreshing('adoext.createWorkItem', () => createWorkItem(client, config), refreshAllViews); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.createWorkItemFromSelection', - async () => { - if (!(await ensureSignedIn())) { return; } - const created = await createWorkItemFromSelection(context, client, config); - if (created) { refreshAllViews(); } - } - ) + registry.addRefreshing( + 'adoext.createWorkItemFromSelection', + () => createWorkItemFromSelection(context, client, config), + refreshAllViews ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.createWorkItemFromTodo', - async (todoText?: string, lineNumber?: number) => { - if (!(await ensureSignedIn())) { return; } - const created = await createWorkItemFromTodo(context, client, config, todoText, lineNumber); - if (created) { refreshAllViews(); } - } - ) + registry.addRefreshing( + 'adoext.createWorkItemFromTodo', + (todoText?: string, lineNumber?: number) => createWorkItemFromTodo(context, client, config, todoText, lineNumber), + refreshAllViews ); context.subscriptions.push( @@ -580,417 +488,267 @@ export async function activate(context: vscode.ExtensionContext): Promise ); // Refresh pull requests - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshPullRequests', async () => { - await ensureSignedIn(); - pullRequestProvider.refresh(); - }) - ); + registry.addGuarded('adoext.refreshPullRequests', () => { + pullRequestProvider.refresh(); + }); // Set pull request filter regex - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setPullRequestFilter', async () => { - const current = config.pullRequestFilterRegex; - const pattern = await vscode.window.showInputBox({ - prompt: 'Enter regex pattern to filter pull requests (leave empty to clear)', - value: current, - validateInput: (value) => { - if (!value.trim()) return undefined; - try { - new RegExp(value, 'i'); - return undefined; - } catch { - return 'Invalid regex pattern'; - } + registry.add('adoext.setPullRequestFilter', async () => { + const current = config.pullRequestFilterRegex; + const pattern = await vscode.window.showInputBox({ + prompt: 'Enter regex pattern to filter pull requests (leave empty to clear)', + value: current, + validateInput: (value) => { + if (!value.trim()) return undefined; + try { + new RegExp(value, 'i'); + return undefined; + } catch { + return 'Invalid regex pattern'; } - }); - if (pattern !== undefined) { - await config.setPullRequestFilterRegex(pattern); - pullRequestProvider.refresh(); } - }) - ); + }); + if (pattern !== undefined) { + await config.setPullRequestFilterRegex(pattern); + pullRequestProvider.refresh(); + } + }); // Set pull request sort order - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setPullRequestSort', async () => { - const current = config.pullRequestSortOrder; - const choice = await vscode.window.showQuickPick( - [ - { label: 'Title (A-Z)', value: 'title', picked: current === 'title' }, - { label: 'Date (Newest first)', value: 'date', picked: current === 'date' } - ], - { placeHolder: 'Choose sort order for pull requests' } - ); - if (choice) { - await config.setPullRequestSortOrder(choice.value as 'title' | 'date'); - pullRequestProvider.refresh(); - } - }) - ); + registry.add('adoext.setPullRequestSort', async () => { + const current = config.pullRequestSortOrder; + const choice = await vscode.window.showQuickPick( + [ + { label: 'Title (A-Z)', value: 'title', picked: current === 'title' }, + { label: 'Date (Newest first)', value: 'date', picked: current === 'date' } + ], + { placeHolder: 'Choose sort order for pull requests' } + ); + if (choice) { + await config.setPullRequestSortOrder(choice.value as 'title' | 'date'); + pullRequestProvider.refresh(); + } + }); // Refresh a single pull request bucket independently - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshPullRequestBucket', async (node: PullRequestBucketNode) => { - if (!(await ensureSignedIn())) { return; } - pullRequestProvider.refreshBucket(node); - }) - ); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.savePullRequestQuery', async () => { - const saved = await savePullRequestQuery(config); - if (saved) { pullRequestProvider.refresh(); } - }) - ); + registry.addGuarded('adoext.refreshPullRequestBucket', (node: PullRequestBucketNode) => { + pullRequestProvider.refreshBucket(node); + }); + registry.add('adoext.savePullRequestQuery', async () => { + const saved = await savePullRequestQuery(config); + if (saved) { pullRequestProvider.refresh(); } + }); // Open pull request in browser - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPullRequest', - (node: PullRequestNode) => openPullRequest(node, client, config) - ) + registry.add( + 'adoext.openPullRequest', + (node: PullRequestNode) => openPullRequest(node, client, config) ); // Open pull request source branch in browser - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPullRequestSourceBranch', - (node: PullRequestNode) => openPullRequestSourceBranch(node, client, config) - ) + registry.add( + 'adoext.openPullRequestSourceBranch', + (node: PullRequestNode) => openPullRequestSourceBranch(node, client, config) ); // Open pull request head commit in browser - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPullRequestCommit', - (node: PullRequestNode) => openPullRequestCommit(node, client, config) - ) + registry.add( + 'adoext.openPullRequestCommit', + (node: PullRequestNode) => openPullRequestCommit(node, client, config) ); // View PR details in webview - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.viewPullRequestDetails', - (node: PullRequestNode) => - viewPullRequestDetails(node, context, client, config) - ) + registry.add( + 'adoext.viewPullRequestDetails', + (node: PullRequestNode) => viewPullRequestDetails(node, context, client, config) ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.viewPullRequestDiff', - async (node: PullRequestNode | { pr: import('./api/adoClient').GitPullRequest; organization?: string; project?: string }) => { - if (!(await ensureSignedIn())) { return; } - await viewPullRequestDiff(node, client, config, prCommentController, diffCache); - } - ) + registry.addGuarded( + 'adoext.viewPullRequestDiff', + (node: PullRequestNode | { pr: import('./api/adoClient').GitPullRequest; organization?: string; project?: string }) => + viewPullRequestDiff(node, client, config, prCommentController, diffCache) ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.approvePullRequest', - async (node?: PullRequestNode) => { - if (!(await ensureSignedIn())) { return; } - const updated = await approvePullRequest(node, client, config); - if (updated) { - pullRequestProvider.refresh(); - } - } - ) + registry.addRefreshing( + 'adoext.approvePullRequest', + (node?: PullRequestNode) => approvePullRequest(node, client, config), + () => pullRequestProvider.refresh() ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.approvePullRequestWithSuggestions', - async (node?: PullRequestNode) => { - if (!(await ensureSignedIn())) { return; } - const updated = await approvePullRequestWithSuggestions(node, client, config); - if (updated) { - pullRequestProvider.refresh(); - } - } - ) + registry.addRefreshing( + 'adoext.approvePullRequestWithSuggestions', + (node?: PullRequestNode) => approvePullRequestWithSuggestions(node, client, config), + () => pullRequestProvider.refresh() ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.waitForPullRequestAuthor', - async (node?: PullRequestNode) => { - if (!(await ensureSignedIn())) { return; } - const updated = await waitForPullRequestAuthor(node, client, config); - if (updated) { - pullRequestProvider.refresh(); - } - } - ) + registry.addRefreshing( + 'adoext.waitForPullRequestAuthor', + (node?: PullRequestNode) => waitForPullRequestAuthor(node, client, config), + () => pullRequestProvider.refresh() ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.rejectPullRequest', - async (node?: PullRequestNode) => { - if (!(await ensureSignedIn())) { return; } - const updated = await rejectPullRequest(node, client, config); - if (updated) { - pullRequestProvider.refresh(); - } - } - ) + registry.addRefreshing( + 'adoext.rejectPullRequest', + (node?: PullRequestNode) => rejectPullRequest(node, client, config), + () => pullRequestProvider.refresh() ); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.resetPullRequestVote', - async (node?: PullRequestNode) => { - if (!(await ensureSignedIn())) { return; } - const updated = await resetPullRequestVote(node, client, config); - if (updated) { - pullRequestProvider.refresh(); - } - } - ) + registry.addRefreshing( + 'adoext.resetPullRequestVote', + (node?: PullRequestNode) => resetPullRequestVote(node, client, config), + () => pullRequestProvider.refresh() ); // Checkout PR branch - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.checkoutPullRequest', - (node: PullRequestNode) => checkoutPullRequest(node, client, config, prCommentController) - ) + registry.add( + 'adoext.checkoutPullRequest', + (node: PullRequestNode) => checkoutPullRequest(node, client, config, prCommentController) ); // Inline comment controller commands (used by the gutter/title affordances). - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.prComment.create', - async (reply: CommentReply) => { - await prCommentController.createOrReply(reply); - } - ), - vscode.commands.registerCommand( - 'adoext.prComment.reply', - async (reply: CommentReply) => { - await prCommentController.createOrReply(reply); - } - ), - vscode.commands.registerCommand( - 'adoext.prComment.resolve', - async (thread: vscode.CommentThread) => { - await prCommentController.setThreadStatus(thread, 2 /* Fixed */); - } - ), - vscode.commands.registerCommand( - 'adoext.prComment.reopen', - async (thread: vscode.CommentThread) => { - await prCommentController.setThreadStatus(thread, 1 /* Active */); - } - ) - ); + registry.add('adoext.prComment.create', (reply: CommentReply) => prCommentController.createOrReply(reply)); + registry.add('adoext.prComment.reply', (reply: CommentReply) => prCommentController.createOrReply(reply)); + registry.add('adoext.prComment.resolve', (thread: vscode.CommentThread) => prCommentController.setThreadStatus(thread, 2 /* Fixed */)); + registry.add('adoext.prComment.reopen', (thread: vscode.CommentThread) => prCommentController.setThreadStatus(thread, 1 /* Active */)); // Reply to a comment (from tree context menu) - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.replyToComment', - async (node: PullRequestCommentNode) => { - await replyToComment(node, client, config); - pullRequestProvider.refresh(); - } - ) - ); + registry.add('adoext.replyToComment', async (node: PullRequestCommentNode) => { + await replyToComment(node, client, config); + pullRequestProvider.refresh(); + }); // Resolve thread - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.resolveThread', - async (node: PullRequestThreadNode) => { - await resolveThread(node, client, config); - pullRequestProvider.refresh(); - } - ) - ); + registry.add('adoext.resolveThread', async (node: PullRequestThreadNode) => { + await resolveThread(node, client, config); + pullRequestProvider.refresh(); + }); // Reopen thread - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.reopenThread', - async (node: PullRequestThreadNode) => { - await reopenThread(node, client, config); - pullRequestProvider.refresh(); - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.toggleResolvedPullRequestThreads', - async () => { - await toggleResolvedPullRequestThreads(config); - pullRequestProvider.refresh(); - await PrDetailsPanel.refreshAllOpenPanels(); - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('adoext.refreshPipelines', async () => { - await ensureSignedIn(); - pipelineLogContentProvider.clear(); - pipelinesProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.viewPipelineRunDetails', - async (node?: PipelineRunNode) => { - if (!(await ensureSignedIn())) { return; } - await viewPipelineRunDetails(context, node, client, config); - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPipelineRun', - async (node?: PipelineRunNode) => { - if (!(await ensureSignedIn())) { return; } - await openPipelineRunInBrowser(node, client, config); - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPipelineRunLogs', - async (node?: PipelineRunNode) => { - if (!(await ensureSignedIn())) { return; } - await openPipelineRunLogsInBrowser(node, client, config); - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPipelineStepLog', - async (node?: PipelineStepLogNode) => { - if (!(await ensureSignedIn())) { return; } - if (!node) { return; } - - const uri = pipelineLogContentProvider.createUri({ - organization: node.organization, - project: node.project, - buildId: node.buildId, - logId: node.logId, - stepName: node.stepName, - runLabel: node.runLabel - }); - const document = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(document, { preview: false }); - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.rerunPipelineRun', - async (node?: PipelineRunNode) => { - if (!(await ensureSignedIn())) { return; } - const newId = await rerunPipelineRun(node, client, config); - if (typeof newId === 'number' && newId > 0) { - await PipelineRunDetailsPanel.show(context, client, config, newId, { - organization: node?.organization, - project: node?.project - }); - } - pipelinesProvider.refresh(); - } - ) - ); + registry.add('adoext.reopenThread', async (node: PullRequestThreadNode) => { + await reopenThread(node, client, config); + pullRequestProvider.refresh(); + }); - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.cancelPipelineRun', - async (node?: PipelineRunNode) => { - if (!(await ensureSignedIn())) { return; } - await cancelPipelineRun(node, client, config); - pipelinesProvider.refresh(); - } - ) - ); + registry.add('adoext.toggleResolvedPullRequestThreads', async () => { + await toggleResolvedPullRequestThreads(config); + pullRequestProvider.refresh(); + await PrDetailsPanel.refreshAllOpenPanels(); + }); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setPipelineRunsFilter', async () => { - const current = config.pipelineRunsFilter; - const choice = await vscode.window.showQuickPick( - [ - { label: 'All runs', description: 'Recent pipeline runs', value: 'all', picked: current === 'all' }, - { label: 'Running', description: 'Queued or in-progress runs', value: 'running', picked: current === 'running' }, - { label: 'Failed', description: 'Failed or partially succeeded runs', value: 'failed', picked: current === 'failed' }, - { label: 'Mine', description: 'Runs requested by me', value: 'mine', picked: current === 'mine' } - ], - { placeHolder: 'Choose which pipeline runs to show' } - ); - if (!choice || choice.value === current) { - return; - } - await config.setPipelineRunsFilter(choice.value as typeof current); - pipelinesProvider.refresh(); - }) - ); + registry.addGuarded('adoext.refreshPipelines', () => { + pipelineLogContentProvider.clear(); + pipelinesProvider.refresh(); + }); + + registry.addGuarded( + 'adoext.viewPipelineRunDetails', + (node?: PipelineRunNode) => viewPipelineRunDetails(context, node, client, config) + ); + + registry.addGuarded( + 'adoext.openPipelineRun', + (node?: PipelineRunNode) => openPipelineRunInBrowser(node, client, config) + ); + + registry.addGuarded( + 'adoext.openPipelineRunLogs', + (node?: PipelineRunNode) => openPipelineRunLogsInBrowser(node, client, config) + ); + + registry.addGuarded('adoext.openPipelineStepLog', async (node?: PipelineStepLogNode) => { + if (!node) { return; } + const uri = pipelineLogContentProvider.createUri({ + organization: node.organization, + project: node.project, + buildId: node.buildId, + logId: node.logId, + stepName: node.stepName, + runLabel: node.runLabel + }); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); + }); + + registry.addGuarded('adoext.rerunPipelineRun', async (node?: PipelineRunNode) => { + const newId = await rerunPipelineRun(node, client, config); + if (typeof newId === 'number' && newId > 0) { + await PipelineRunDetailsPanel.show(context, client, config, newId, { + organization: node?.organization, + project: node?.project + }); + } + pipelinesProvider.refresh(); + }); - context.subscriptions.push( - vscode.commands.registerCommand('adoext.setPipelineRunsGroupBy', async () => { - const current = config.pipelineRunsGroupBy; - const choice = await vscode.window.showQuickPick( - [ - { label: 'No grouping', value: 'none', picked: current === 'none' }, - { label: 'Group by repository', value: 'repository', picked: current === 'repository' }, - { label: 'Group by branch', value: 'branch', picked: current === 'branch' } - ], - { placeHolder: 'Choose grouping for pipeline runs' } - ); - if (!choice || choice.value === current) { - return; - } - await config.setPipelineRunsGroupBy(choice.value as typeof current); - pipelinesProvider.refresh(); - }) - ); + registry.addGuarded('adoext.cancelPipelineRun', async (node?: PipelineRunNode) => { + await cancelPipelineRun(node, client, config); + pipelinesProvider.refresh(); + }); + + registry.add('adoext.setPipelineRunsFilter', async () => { + const current = config.pipelineRunsFilter; + const choice = await vscode.window.showQuickPick( + [ + { label: 'All runs', description: 'Recent pipeline runs', value: 'all', picked: current === 'all' }, + { label: 'Running', description: 'Queued or in-progress runs', value: 'running', picked: current === 'running' }, + { label: 'Failed', description: 'Failed or partially succeeded runs', value: 'failed', picked: current === 'failed' }, + { label: 'Mine', description: 'Runs requested by me', value: 'mine', picked: current === 'mine' } + ], + { placeHolder: 'Choose which pipeline runs to show' } + ); + if (!choice || choice.value === current) { + return; + } + await config.setPipelineRunsFilter(choice.value as typeof current); + pipelinesProvider.refresh(); + }); + + registry.add('adoext.setPipelineRunsGroupBy', async () => { + const current = config.pipelineRunsGroupBy; + const choice = await vscode.window.showQuickPick( + [ + { label: 'No grouping', value: 'none', picked: current === 'none' }, + { label: 'Group by repository', value: 'repository', picked: current === 'repository' }, + { label: 'Group by branch', value: 'branch', picked: current === 'branch' } + ], + { placeHolder: 'Choose grouping for pipeline runs' } + ); + if (!choice || choice.value === current) { + return; + } + await config.setPipelineRunsGroupBy(choice.value as typeof current); + pipelinesProvider.refresh(); + }); // Add new comment to PR - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.addPullRequestComment', - async (node: PullRequestNode) => { - if (!node) { return; } - const content = await vscode.window.showInputBox({ - prompt: 'Enter your comment', - placeHolder: 'Write a comment…' - }); - if (!content) { return; } - - const pr = node.pr; - const repoId = pr.repository?.id ?? ''; - const prId = pr.pullRequestId ?? 0; - const project = node.project ?? config.project; - const organization = node.organization ?? client.organization ?? config.organization; + registry.add('adoext.addPullRequestComment', async (node: PullRequestNode) => { + if (!node) { return; } + const content = await vscode.window.showInputBox({ + prompt: 'Enter your comment', + placeHolder: 'Write a comment…' + }); + if (!content) { return; } + + const pr = node.pr; + const repoId = pr.repository?.id ?? ''; + const prId = pr.pullRequestId ?? 0; + const project = node.project ?? config.project; + const organization = node.organization ?? client.organization ?? config.organization; - try { - await client.addPullRequestComment( - project, - repoId, - prId, - content, - organization - ); - showInformationMessage('Comment added.'); - pullRequestProvider.refresh(); - } catch (err) { - showErrorMessage(`Failed to add comment: ${err}`); - } - } - ) - ); + try { + await client.addPullRequestComment( + project, + repoId, + prId, + content, + organization + ); + showInformationMessage('Comment added.'); + pullRequestProvider.refresh(); + } catch (err) { + showErrorMessage(`Failed to add comment: ${err}`); + } + }); // ------------------------------------------------------------------------- // MCP Server @@ -1018,53 +776,39 @@ export async function activate(context: vscode.ExtensionContext): Promise ); // Command: open a work item in the browser by numeric ID + org/project args. - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openWorkItemById', - (args: { id: number; org: string; project: string }) => { - if (!args?.id || !args?.org || !args?.project) { return; } - const url = `https://dev.azure.com/${encodeURIComponent(args.org)}/${encodeURIComponent(args.project)}/_workitems/edit/${args.id}`; - void vscode.env.openExternal(vscode.Uri.parse(url)); - } - ) - ); + registry.add('adoext.openWorkItemById', (args: { id: number; org: string; project: string }) => { + if (!args?.id || !args?.org || !args?.project) { return; } + const url = `https://dev.azure.com/${encodeURIComponent(args.org)}/${encodeURIComponent(args.project)}/_workitems/edit/${args.id}`; + void vscode.env.openExternal(vscode.Uri.parse(url)); + }); // Command: open a work item details panel by numeric ID + org/project args. - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.viewWorkItemDetailsById', - async (args: { id: number; org: string; project: string }) => { - if (!args?.id || !args?.org || !args?.project) { return; } - if (!(await ensureSignedIn())) { return; } - try { - const workItem = await client.getWorkItemById(args.project, args.id, args.org); - if (!workItem) { - showErrorMessage(`Work item #${args.id} not found.`); - return; - } - const { WorkItemDetailsPanel } = await import('./views/workItemDetailsPanel'); - await WorkItemDetailsPanel.show(context, client, config, workItem, { - organization: args.org, - project: args.project - }); - } catch (err) { - showErrorMessage(`Failed to open work item #${args.id}: ${err instanceof Error ? err.message : String(err)}`); - } + registry.addGuarded('adoext.viewWorkItemDetailsById', async (args: { id: number; org: string; project: string }) => { + if (!args?.id || !args?.org || !args?.project) { return; } + try { + const workItem = await client.getWorkItemById(args.project, args.id, args.org); + if (!workItem) { + showErrorMessage(`Work item #${args.id} not found.`); + return; } - ) - ); + const { WorkItemDetailsPanel } = await import('./views/workItemDetailsPanel'); + await WorkItemDetailsPanel.show(context, client, config, workItem, { + organization: args.org, + project: args.project + }); + } catch (err) { + showErrorMessage(`Failed to open work item #${args.id}: ${err instanceof Error ? err.message : String(err)}`); + } + }); // Command: open a pull request in the browser by numeric ID + scope args. - context.subscriptions.push( - vscode.commands.registerCommand( - 'adoext.openPullRequestById', - (args: { id: number; org: string; project: string; repo: string }) => { - if (!args?.id || !args?.org || !args?.project || !args?.repo) { return; } - const url = `https://dev.azure.com/${encodeURIComponent(args.org)}/${encodeURIComponent(args.project)}/_git/${encodeURIComponent(args.repo)}/pullrequest/${args.id}`; - void vscode.env.openExternal(vscode.Uri.parse(url)); - } - ) - ); + registry.add('adoext.openPullRequestById', (args: { id: number; org: string; project: string; repo: string }) => { + if (!args?.id || !args?.org || !args?.project || !args?.repo) { return; } + const url = `https://dev.azure.com/${encodeURIComponent(args.org)}/${encodeURIComponent(args.project)}/_git/${encodeURIComponent(args.repo)}/pullrequest/${args.id}`; + void vscode.env.openExternal(vscode.Uri.parse(url)); + }); + + registry.registerAll(context); // ------------------------------------------------------------------------- // Auto-restore session on activation From 18a7daf395085f758873d429ee12eda07814f152 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Thu, 21 May 2026 00:34:54 +0200 Subject: [PATCH 2/2] review: refresh commands now always refresh, even when signed out Pre-refactor behavior was that 'adoext.refresh*' commands always invoked the provider's refresh() regardless of sign-in state, which let the tree transition into its setup / signed-out node. After moving them to addGuarded, refresh() was suppressed on sign-in failure and the tree kept whatever stale content it had. Reviewer flagged six commands: refreshWorkItems, refreshBacklog, refreshSprints, refreshBoards, refreshPullRequests, refreshPipelines. Add CommandRegistry.addRefresh() for the 'fire-and-forget view refresh' shape and switch all six over. --- src/commands/commandRegistry.ts | 7 +++++++ src/extension.ts | 22 ++++++---------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/commands/commandRegistry.ts b/src/commands/commandRegistry.ts index 7a6b628..c90c307 100644 --- a/src/commands/commandRegistry.ts +++ b/src/commands/commandRegistry.ts @@ -7,6 +7,9 @@ import * as vscode from 'vscode'; * - `add(id, handler)` plain command. * - `addGuarded(id, handler)` ensures sign-in first; aborts if not signed in. * - `addRefreshing(id, handler, refresh)` guarded + calls refresh() when handler returns truthy. + * - `addRefresh(id, refresh)` plain "refresh this view" command - always runs refresh() + * even if not signed in, so the tree can transition into its + * signed-out / setup state instead of keeping stale content. * * Call {@link registerAll} once to push everything onto `context.subscriptions`. */ @@ -54,6 +57,10 @@ export class CommandRegistry { ); } + addRefresh(id: string, refresh: () => void): void { + this._disposables.push(vscode.commands.registerCommand(id, () => refresh())); + } + registerAll(context: vscode.ExtensionContext): void { context.subscriptions.push(...this._disposables); } diff --git a/src/extension.ts b/src/extension.ts index 9317308..a05f6b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -317,9 +317,7 @@ export async function activate(context: vscode.ExtensionContext): Promise registry.addRefreshing('adoext.selectProject', () => selectProject(client, config), refreshAllViews); // Refresh work items - registry.addGuarded('adoext.refreshWorkItems', () => { - workItemProvider.refresh(); - }); + registry.addRefresh('adoext.refreshWorkItems', () => workItemProvider.refresh()); // Switch / persist work item query preset registry.add('adoext.selectWorkItemQuery', async () => { @@ -391,17 +389,11 @@ export async function activate(context: vscode.ExtensionContext): Promise boardProvider.refresh(); }); - registry.addGuarded('adoext.refreshBacklog', () => { - backlogProvider.refresh(); - }); + registry.addRefresh('adoext.refreshBacklog', () => backlogProvider.refresh()); - registry.addGuarded('adoext.refreshSprints', () => { - sprintProvider.refresh(); - }); + registry.addRefresh('adoext.refreshSprints', () => sprintProvider.refresh()); - registry.addGuarded('adoext.refreshBoards', () => { - boardProvider.refresh(); - }); + registry.addRefresh('adoext.refreshBoards', () => boardProvider.refresh()); registry.add('adoext.setPlanningAssignedFilter', async () => { const current = config.planningAssignedFilter; @@ -488,9 +480,7 @@ export async function activate(context: vscode.ExtensionContext): Promise ); // Refresh pull requests - registry.addGuarded('adoext.refreshPullRequests', () => { - pullRequestProvider.refresh(); - }); + registry.addRefresh('adoext.refreshPullRequests', () => pullRequestProvider.refresh()); // Set pull request filter regex registry.add('adoext.setPullRequestFilter', async () => { @@ -635,7 +625,7 @@ export async function activate(context: vscode.ExtensionContext): Promise await PrDetailsPanel.refreshAllOpenPanels(); }); - registry.addGuarded('adoext.refreshPipelines', () => { + registry.addRefresh('adoext.refreshPipelines', () => { pipelineLogContentProvider.clear(); pipelinesProvider.refresh(); });