From c3d8a6a7c39bfd3644d509d67751c38f87c2c3dd Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:29:29 +0200 Subject: [PATCH 1/3] refactor: extract PanelBase with shared webview lifecycle PanelBase handles webview panel creation, common options (enableScripts, retainContextWhenHidden, localResourceRoots), onDidDispose wiring, message listener registration via onMessage(), and disposal cleanup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/views/panelBase.ts | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/views/panelBase.ts diff --git a/src/views/panelBase.ts b/src/views/panelBase.ts new file mode 100644 index 0000000..95e180b --- /dev/null +++ b/src/views/panelBase.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import type { AdoClient } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { webviewAssetRoots } from './webviewHtml'; + +/** + * Shared lifecycle for webview panels. Handles webview creation, + * message listener registration, and disposal. + * + * Subclasses implement their own static show(), refresh(), and + * handleMessage(). + */ +export abstract class PanelBase { + protected readonly _panel: vscode.WebviewPanel; + protected readonly _disposables: vscode.Disposable[] = []; + + constructor( + protected readonly _context: vscode.ExtensionContext, + protected readonly _client: AdoClient, + protected readonly _config: ConfigManager, + viewType: string, + title: string, + ) { + this._panel = vscode.window.createWebviewPanel( + viewType, + title, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: webviewAssetRoots(_context) + } + ); + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + } + + protected onMessage(handler: (msg: unknown) => Promise): void { + this._panel.webview.onDidReceiveMessage( + async (msg) => handler(msg), + null, + this._disposables + ); + } + + dispose(): void { + for (const d of this._disposables) { + d.dispose(); + } + this._disposables.length = 0; + } +} From 3d6fb1d93d5a4179d7767cac2c6149c6071edc6b Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:29:37 +0200 Subject: [PATCH 2/3] refactor: migrate PrDetails, WorkItemDetails, PipelineRunDetails panels to extend PanelBase Each panel now extends PanelBase, removing duplicate constructor boilerplate (webview creation, onDidDispose, onDidReceiveMessage, _disposables field). Common lifecycle managed in the base class. Net savings: ~62 lines. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/views/pipelineRunDetailsPanel.ts | 40 ++++++---------------- src/views/prDetailsPanel.ts | 45 +++++++------------------ src/views/workItemDetailsPanel.ts | 50 ++++++++-------------------- 3 files changed, 37 insertions(+), 98 deletions(-) diff --git a/src/views/pipelineRunDetailsPanel.ts b/src/views/pipelineRunDetailsPanel.ts index 6cf031d..1ef600b 100644 --- a/src/views/pipelineRunDetailsPanel.ts +++ b/src/views/pipelineRunDetailsPanel.ts @@ -5,7 +5,8 @@ import type { ConfigManager } from '../config/configManager'; import { showErrorMessage, showInformationMessage } from '../utils/notifications'; import { agentPoolUrl, agentQueueUrl, pipelineRunUrl } from '../utils/pipelineUrls'; import { createPipelineLogUri } from './pipelineLogContentProvider'; -import { buildMessageDocument, buildWebviewDocument, webviewAssetRoots } from './webviewHtml'; +import { buildMessageDocument, buildWebviewDocument } from './webviewHtml'; +import { PanelBase } from './panelBase'; import type { AgentPoolDiagnosticsViewModel, PipelineArtifactViewModel, @@ -33,17 +34,15 @@ interface TimelineRecordLike { log?: { id?: number; url?: string }; } -export class PipelineRunDetailsPanel { +export class PipelineRunDetailsPanel extends PanelBase { private static _panels = new Map(); - private readonly _panel: vscode.WebviewPanel; private readonly _panelKey: string; private readonly _organization?: string; private readonly _project?: string; private _buildId: number; private _agentDiagnosticsSummary = ''; private _agentDiagnosticsUrls: { poolUrl: string; queueUrl: string } | undefined; - private _disposables: vscode.Disposable[] = []; static async show( context: vscode.ExtensionContext, @@ -67,37 +66,21 @@ export class PipelineRunDetailsPanel { } private constructor( - private readonly _context: vscode.ExtensionContext, - private readonly _client: AdoClient, - private readonly _config: ConfigManager, + context: vscode.ExtensionContext, + client: AdoClient, + config: ConfigManager, buildId: number, panelKey: string, scope: PipelinePanelScope ) { + super(context, client, config, 'adoext.pipelineRunDetails', `Pipeline Run #${buildId}`); this._buildId = buildId; this._panelKey = panelKey; this._organization = scope.organization; this._project = scope.project; - this._panel = vscode.window.createWebviewPanel( - 'adoext.pipelineRunDetails', - `Pipeline Run #${buildId}`, - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: webviewAssetRoots(_context) - } - ); - - this._panel.onDidDispose(() => this._dispose(), null, this._disposables); - this._panel.webview.onDidReceiveMessage( - async (msg) => this._handleMessage(msg), - null, - this._disposables - ); - + this.onMessage(msg => this._handleMessage(msg as PipelineRunDetailsMessage)); PipelineRunDetailsPanel._panels.set(panelKey, this); - void this._refresh(_client, _config); + void this._refresh(client, config); } private async _refresh(client: AdoClient, config: ConfigManager): Promise { @@ -249,10 +232,9 @@ export class PipelineRunDetailsPanel { } } - private _dispose(): void { + override dispose(): void { PipelineRunDetailsPanel._panels.delete(this._panelKey); - this._disposables.forEach(d => d.dispose()); - this._disposables = []; + super.dispose(); } private static panelKey(buildId: number, organization?: string, project?: string): string { diff --git a/src/views/prDetailsPanel.ts b/src/views/prDetailsPanel.ts index 78e6c6c..ec4a3ce 100644 --- a/src/views/prDetailsPanel.ts +++ b/src/views/prDetailsPanel.ts @@ -8,7 +8,8 @@ import { showErrorMessage, showInformationMessage, showWarningMessage } from '.. import { isToolIdentity, isSystemThread } from '../utils/prCommentIdentity'; import { isResolvedPullRequestThread } from '../utils/prThreadStatus'; import { buildSummaryData } from './buildSummaryHtml'; -import { buildWebviewDocument, webviewAssetRoots } from './webviewHtml'; +import { buildWebviewDocument } from './webviewHtml'; +import { PanelBase } from './panelBase'; import { mapWithConcurrencyLimit } from '../utils/async'; import type { NamedBadgeRowViewModel, PrDetailsMessage, PrDetailsViewModel, PrTestResultsViewModel, PrWorkItemRefViewModel } from './webviewTypes'; // Note: the diff is now opened via VS Code's native diff editor, dispatched @@ -31,14 +32,12 @@ const STACK_TRACE_SNIPPET_MAX_CHARS = 600; * threads) in a VS Code webview panel. The user can reply to threads and * resolve/reopen them without leaving VS Code. */ -export class PrDetailsPanel { +export class PrDetailsPanel extends PanelBase { private static _panels = new Map(); - private readonly _panel: vscode.WebviewPanel; private readonly _panelKey: string; private readonly _organization?: string; private readonly _project?: string; - private _disposables: vscode.Disposable[] = []; static async show( context: vscode.ExtensionContext, @@ -71,38 +70,21 @@ export class PrDetailsPanel { } private constructor( - private readonly _context: vscode.ExtensionContext, - private readonly _client: AdoClient, - private readonly _config: ConfigManager, + context: vscode.ExtensionContext, + client: AdoClient, + config: ConfigManager, private _pr: GitPullRequest, panelKey: string, scope: PrPanelScope ) { + const prId = _pr.pullRequestId!; + super(context, client, config, 'adoext.prDetails', `PR #${prId}: ${_pr.title ?? ''}`); this._panelKey = panelKey; this._organization = scope.organization; this._project = scope.project; - const prId = _pr.pullRequestId!; - this._panel = vscode.window.createWebviewPanel( - 'adoext.prDetails', - `PR #${prId}: ${_pr.title ?? ''}`, - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: webviewAssetRoots(_context) - } - ); - - this._panel.onDidDispose(() => this._dispose(), null, this._disposables); - - this._panel.webview.onDidReceiveMessage( - async (msg) => this._handleMessage(msg), - null, - this._disposables - ); - + this.onMessage(msg => this._handleMessage(msg as PrDetailsMessage)); PrDetailsPanel._panels.set(panelKey, this); - void this._refresh(_client, _config, _pr); + void this._refresh(client, config, _pr); } private async _refresh( @@ -842,12 +824,9 @@ export class PrDetailsPanel { vote === PullRequestReviewVotes.rejected; } - private _dispose(): void { + override dispose(): void { PrDetailsPanel._panels.delete(this._panelKey); - for (const d of this._disposables) { - d.dispose(); - } - this._disposables = []; + super.dispose(); } private static panelKey(prId: number, organization?: string, project?: string): string { diff --git a/src/views/workItemDetailsPanel.ts b/src/views/workItemDetailsPanel.ts index 1f8e4b2..539830f 100644 --- a/src/views/workItemDetailsPanel.ts +++ b/src/views/workItemDetailsPanel.ts @@ -5,7 +5,8 @@ import type { ConfigManager } from '../config/configManager'; import { showErrorMessage, showInformationMessage, showWarningMessage } from '../utils/notifications'; import { bundledWorkItemTypeIconFile, normalizeWorkItemTypeName } from '../utils/workItemTypeIcons'; import { buildSummaryData } from './buildSummaryHtml'; -import { buildWebviewDocument, webviewAssetRoots } from './webviewHtml'; +import { buildWebviewDocument } from './webviewHtml'; +import { PanelBase } from './panelBase'; import type { WorkItemDetailsMessage, WorkItemDetailsViewModel } from './webviewTypes'; export interface WorkItemPanelScope { @@ -36,15 +37,13 @@ interface LinkedItem { * discussion) in a VS Code webview panel. The user can add comments * without leaving VS Code. */ -export class WorkItemDetailsPanel { +export class WorkItemDetailsPanel extends PanelBase { private static _panels = new Map(); - private readonly _panel: vscode.WebviewPanel; private readonly _workItemId: number; private readonly _panelKey: string; private readonly _organization?: string; private readonly _project?: string; - private _disposables: vscode.Disposable[] = []; private _allowedStates: string[] = []; private _linkedItems: LinkedItem[] = []; private _workItemTypeIconUrl: string | undefined; @@ -79,43 +78,25 @@ export class WorkItemDetailsPanel { } private constructor( - private readonly _context: vscode.ExtensionContext, - private readonly _client: AdoClient, - private readonly _config: ConfigManager, + context: vscode.ExtensionContext, + client: AdoClient, + config: ConfigManager, private _workItem: WorkItem, workItemId: number, panelKey: string, scope: WorkItemPanelScope ) { + const id = workItemId; + const title = (_workItem.fields?.['System.Title'] as string | undefined) ?? ''; + const wiType = (_workItem.fields?.['System.WorkItemType'] as string | undefined) ?? 'Work Item'; + super(context, client, config, 'adoext.workItemDetails', `${wiType} #${id}: ${title}`); this._workItemId = workItemId; this._panelKey = panelKey; this._organization = scope.organization; this._project = scope.project; - const id = this._workItemId; - const title = (_workItem.fields?.['System.Title'] as string | undefined) ?? ''; - const wiType = (_workItem.fields?.['System.WorkItemType'] as string | undefined) ?? 'Work Item'; - - this._panel = vscode.window.createWebviewPanel( - 'adoext.workItemDetails', - `${wiType} #${id}: ${title}`, - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: webviewAssetRoots(_context) - } - ); - - this._panel.onDidDispose(() => this._dispose(), null, this._disposables); - - this._panel.webview.onDidReceiveMessage( - async (msg) => this._handleMessage(msg), - null, - this._disposables - ); - + this.onMessage(msg => this._handleMessage(msg as WorkItemDetailsMessage)); WorkItemDetailsPanel._panels.set(panelKey, this); - void this._refresh(this._client, this._config, this._workItem); + void this._refresh(client, config, this._workItem); } private async _refresh( @@ -505,12 +486,9 @@ export class WorkItemDetailsPanel { return items; } - private _dispose(): void { + override dispose(): void { WorkItemDetailsPanel._panels.delete(this._panelKey); - for (const d of this._disposables) { - d.dispose(); - } - this._disposables = []; + super.dispose(); } private static panelKey(id: number, organization?: string, project?: string): string { From 09344f0f86c3c9e9dc61b3b37409d255192b0562 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Thu, 4 Jun 2026 11:00:46 +0200 Subject: [PATCH 3/3] fix(hover): encode command URI args as array --- src/providers/hoverProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/hoverProvider.ts b/src/providers/hoverProvider.ts index 0a2e959..5a89e6f 100644 --- a/src/providers/hoverProvider.ts +++ b/src/providers/hoverProvider.ts @@ -48,7 +48,7 @@ function prStatusLabel(status: number | undefined): string { function commandUri(command: string, args: unknown): vscode.Uri { return vscode.Uri.parse( - `command:${command}?${encodeURIComponent(JSON.stringify(args))}` + `command:${command}?${encodeURIComponent(JSON.stringify([args]))}` ); }