From 3243d4013ef0cf53a97d23dabc233917ba1ed229 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:18:21 +0200 Subject: [PATCH 1/5] perf: add TTL cache for getWorkItemTypes Extract reusable TtlCache class to src/utils/ttlCache.ts. Add 5-minute TTL cache to AdoClient.getWorkItemTypes to avoid redundant API calls when querying work item types for the same project within the cache window. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/api/adoClient.ts | 13 ++++++++++++- src/utils/ttlCache.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/utils/ttlCache.ts diff --git a/src/api/adoClient.ts b/src/api/adoClient.ts index 075846d..8351055 100644 --- a/src/api/adoClient.ts +++ b/src/api/adoClient.ts @@ -12,6 +12,7 @@ import { BuildReason, BuildResult, BuildStatus } from 'azure-devops-node-api/int import { ResultDetails, TestOutcome } from 'azure-devops-node-api/interfaces/TestInterfaces'; import { Operation } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { normalizeWorkItemTypeName, workItemTypeScopeKey } from '../utils/workItemTypeIcons'; +import { TtlCache } from '../utils/ttlCache'; import { formatAdoError } from '../utils/adoErrors'; import type { WorkItem, @@ -154,6 +155,7 @@ export class AdoClient { private _currentUserIds = new Map(); private _workItemStatesByType = new Map(); private _workItemTypeIconsByScope = new Map }>(); + private _workItemTypesCache = new TtlCache(300_000); constructor(private _accessToken: string) {} @@ -166,6 +168,7 @@ export class AdoClient { this._connectionsByOrganization.clear(); this._workItemStatesByType.clear(); this._workItemTypeIconsByScope.clear(); + this._workItemTypesCache.clear(); if (!token.trim()) { this.disconnect(); @@ -200,6 +203,7 @@ export class AdoClient { this._currentUserIds.clear(); this._workItemStatesByType.clear(); this._workItemTypeIconsByScope.clear(); + this._workItemTypesCache.clear(); } private get connection(): azdev.WebApi { @@ -573,9 +577,16 @@ export class AdoClient { project: string, organization?: string ): Promise { + const cacheKey = JSON.stringify([organization ?? this._organization ?? null, project]); + const cached = this._workItemTypesCache.get(cacheKey); + if (cached) { + return cached; + } const witApi: IWorkItemTrackingApi = await this.getConnectionFor(organization).getWorkItemTrackingApi(); const types = await witApi.getWorkItemTypes(project); - return (types ?? []).filter((type): type is WorkItemType => type !== null && !type.isDisabled); + const filtered = (types ?? []).filter((type): type is WorkItemType => type !== null && !type.isDisabled); + this._workItemTypesCache.set(cacheKey, filtered); + return filtered; } async getWorkItemTypeStates( diff --git a/src/utils/ttlCache.ts b/src/utils/ttlCache.ts new file mode 100644 index 0000000..5c0a40f --- /dev/null +++ b/src/utils/ttlCache.ts @@ -0,0 +1,34 @@ +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class TtlCache { + private _map = new Map>(); + + constructor(private _ttlMs: number) {} + + get(key: string): T | undefined { + const entry = this._map.get(key); + if (entry && entry.expiresAt > Date.now()) { + return entry.value; + } + if (entry) { + this._map.delete(key); + } + return undefined; + } + + set(key: string, value: T): void { + this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs }); + } + + clear(): void { + this._map.clear(); + } + + has(key: string): boolean { + const entry = this._map.get(key); + return entry !== undefined && entry.expiresAt > Date.now(); + } +} From 9442c508d6a4adc751d575d6029b5ceb1d023b09 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:18:26 +0200 Subject: [PATCH 2/5] perf: add forEachScope helper and migrate scope-fetch callsites Create forEachScope() in async.ts that combines resolveProjectScopes + mapWithConcurrencyLimit into a single reusable pattern returning { scopes, items }. Migrate workItemProvider, pipelinesProvider, pullRequestProvider, and planningProviders to use the helper, removing the duplicate resolveProjectScopes + mapParallelLimit boilerplate and the MAX_CONCURRENT_SCOPE_REQUESTS constants from each file. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/providers/pipelinesProvider.ts | 26 ++++++++-------------- src/providers/planningProviders.ts | 11 ++++------ src/providers/pullRequestProvider.ts | 33 ++++++++++------------------ src/providers/workItemProvider.ts | 28 ++++++++--------------- src/utils/async.ts | 18 +++++++++++++++ 5 files changed, 51 insertions(+), 65 deletions(-) diff --git a/src/providers/pipelinesProvider.ts b/src/providers/pipelinesProvider.ts index 3a439f0..c3c44e4 100644 --- a/src/providers/pipelinesProvider.ts +++ b/src/providers/pipelinesProvider.ts @@ -3,14 +3,11 @@ import type { AdoClient, Build, Timeline } from '../api/adoClient'; import { BuildResult, BuildStatus } from '../api/adoClient'; import type { ConfigManager, PipelineRunsGroupBy } from '../config/configManager'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; - -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; +import { forEachScope } from '../utils/async'; interface ScopedPipelineRun { build: Build; @@ -239,12 +236,17 @@ export class PipelinesProvider implements vscode.TreeDataProvider { + const builds = await this.client.listPipelineRuns( + scope.project, + scope.organization, + { top: this.config.pipelineRunsTop, filter: this.config.pipelineRunsFilter } + ); + return builds.map(build => ({ build, scope })); + }); if (scopes.length === 0) { return [this.createConfigureNode()]; } - - const scopedRuns = await this.loadRuns(scopes); if (scopedRuns.length === 0) { const node = new vscode.TreeItem('No pipeline runs found', vscode.TreeItemCollapsibleState.None); node.iconPath = new vscode.ThemeIcon('info'); @@ -280,16 +282,6 @@ export class PipelinesProvider implements vscode.TreeDataProvider { - const filter = this.config.pipelineRunsFilter; - const top = this.config.pipelineRunsTop; - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { - const builds = await this.client.listPipelineRuns(scope.project, scope.organization, { top, filter }); - return builds.map(build => ({ build, scope })); - }); - return results.flat(); - } - private async getTimelineChildren(node: PipelineRunNode): Promise { const buildId = node.build.id; if (!buildId) { diff --git a/src/providers/planningProviders.ts b/src/providers/planningProviders.ts index 3cbdc31..c73dba1 100644 --- a/src/providers/planningProviders.ts +++ b/src/providers/planningProviders.ts @@ -4,18 +4,15 @@ import type { AdoClient } from '../api/adoClient'; import type { ConfigManager } from '../config/configManager'; import { WorkItemNode, stateIcon } from './workItemProvider'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; +import { forEachScope } from '../utils/async'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; - interface ScopedWorkItem { workItem: WorkItem; scope: ProjectScope; @@ -350,13 +347,13 @@ async function loadPlanningItems( client: AdoClient, config: ConfigManager ): Promise<{ scopes: ProjectScope[]; items: ScopedWorkItem[] }> { - const scopes = await resolveProjectScopes(client, config); const assignedToMe = config.planningAssignedFilter === 'mine'; - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { + const { scopes, items: rawItems } = await forEachScope(client, config, async scope => { const workItems = await client.getPlanningWorkItems(scope.project, scope.organization, assignedToMe); return workItems.map(workItem => ({ workItem, scope })); }); - let items = results.flat(); + let items = rawItems; + const hideStates = new Set(config.workItemHideStates.map(s => s.toLowerCase())); if (hideStates.size > 0) { diff --git a/src/providers/pullRequestProvider.ts b/src/providers/pullRequestProvider.ts index d91b855..185b068 100644 --- a/src/providers/pullRequestProvider.ts +++ b/src/providers/pullRequestProvider.ts @@ -5,17 +5,14 @@ import type { ConfigManager } from '../config/configManager'; import { isToolIdentity, isSystemThread } from '../utils/prCommentIdentity'; import { isResolvedPullRequestThread } from '../utils/prThreadStatus'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; +import { forEachScope } from '../utils/async'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; - interface ScopedPullRequest { pr: GitPullRequest; scope: ProjectScope; @@ -656,12 +653,20 @@ export class PullRequestProvider implements vscode.TreeDataProvider { try { - const scopes = await resolveProjectScopes(this.client, this.config); + const filter = bucket.filter; + const { scopes, items: prs } = await forEachScope(this.client, this.config, async scope => { + const pulls = await this.client.getPullRequests( + scope.project, + filter, + undefined, + scope.organization + ); + return pulls.map(pr => ({ pr, scope })); + }); if (scopes.length === 0) { return [this.createConfigureNode()]; } - const prs = await this.loadPullRequests(scopes, bucket.filter); this._prCache.set(bucket.bucketId, prs); const forceScopeGrouping = scopes.length > 1; this._bucketScopeGrouping.set(bucket.bucketId, forceScopeGrouping); @@ -740,22 +745,6 @@ export class PullRequestProvider implements vscode.TreeDataProvider { - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { - const prs = await this.client.getPullRequests( - scope.project, - filter, - undefined, - scope.organization - ); - return prs.map(pr => ({ pr, scope })); - }); - return results.flat(); - } - private async loadPrChildren( pr: GitPullRequest, scope?: ProjectScope diff --git a/src/providers/workItemProvider.ts b/src/providers/workItemProvider.ts index 635c745..222b2fe 100644 --- a/src/providers/workItemProvider.ts +++ b/src/providers/workItemProvider.ts @@ -3,19 +3,16 @@ import type { WorkItem } from '../api/adoClient'; import type { AdoClient } from '../api/adoClient'; import type { ConfigManager } from '../config/configManager'; import { - resolveProjectScopes, scopeKey, scopeLabel, type ProjectScope } from './projectScopes'; -import { mapWithConcurrencyLimit } from '../utils/async'; +import { forEachScope } from '../utils/async'; import { bundledWorkItemTypeIconFile } from '../utils/workItemTypeIcons'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; -const MAX_CONCURRENT_SCOPE_REQUESTS = 4; - interface ScopedWorkItem { workItem: WorkItem; scope: ProjectScope; @@ -178,13 +175,19 @@ export class WorkItemProvider implements vscode.TreeDataProvider { + const workItems = await this.client.getWorkItems( + scope.project, + this.config.activeWorkItemQuery.filter, + scope.organization + ); + return workItems.map(workItem => ({ workItem, scope })); + }); if (scopes.length === 0) { return [this.createConfigureNode()]; } await this._iconResolver.loadForScopes(scopes); - const scopedItems = await this.loadWorkItems(scopes); if (scopedItems.length === 0) { const node = new vscode.TreeItem('No work items found', vscode.TreeItemCollapsibleState.None); node.iconPath = new vscode.ThemeIcon('info'); @@ -216,19 +219,6 @@ export class WorkItemProvider implements vscode.TreeDataProvider { - const query = this.config.activeWorkItemQuery; - const results = await mapWithConcurrencyLimit(scopes, MAX_CONCURRENT_SCOPE_REQUESTS, async scope => { - const workItems = await this.client.getWorkItems( - scope.project, - query.filter, - scope.organization - ); - return workItems.map(workItem => ({ workItem, scope })); - }); - return results.flat(); - } - private buildStateGroups(items: ScopedWorkItem[]): WorkItemStateGroup[] { // Apply regex filtering const filtered = items.filter(item => this.matchesFilter(item)); diff --git a/src/utils/async.ts b/src/utils/async.ts index 98ff6fa..2daf477 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -1,3 +1,21 @@ +import type { AdoClient } from '../api/adoClient'; +import type { ConfigManager } from '../config/configManager'; +import { resolveProjectScopes, type ProjectScope } from '../providers/projectScopes'; + +export async function forEachScope( + client: AdoClient, + config: ConfigManager, + fetcher: (scope: ProjectScope) => Promise, + concurrency = 4 +): Promise<{ scopes: ProjectScope[]; items: T[] }> { + const scopes = await resolveProjectScopes(client, config); + if (scopes.length === 0) { + return { scopes, items: [] }; + } + const nested = await mapWithConcurrencyLimit(scopes, concurrency, fetcher); + return { scopes, items: nested.flat() }; +} + export async function mapWithConcurrencyLimit( items: readonly TInput[], concurrencyLimit: number, From 6414848c57ce0831f08983896244d0702a40e871 Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Thu, 21 May 2026 00:30:19 +0200 Subject: [PATCH 3/5] review: address PR 63 feedback - TtlCache.has() now evicts expired entries (delegates to get()) - Move forEachScope from utils/async.ts to providers/projectScopes.ts so unrelated utils/async consumers don't transitively pull in the provider layer - Capture activeWorkItemQuery.filter / pipelineRunsTop / pipelineRunsFilter before the concurrent fetcher to guard against config changes mid-load --- src/providers/pipelinesProvider.ts | 6 ++++-- src/providers/planningProviders.ts | 2 +- src/providers/projectScopes.ts | 16 +++++++++++++++- src/providers/pullRequestProvider.ts | 2 +- src/providers/workItemProvider.ts | 5 +++-- src/utils/async.ts | 18 ------------------ src/utils/ttlCache.ts | 3 +-- 7 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/providers/pipelinesProvider.ts b/src/providers/pipelinesProvider.ts index c3c44e4..2c8f484 100644 --- a/src/providers/pipelinesProvider.ts +++ b/src/providers/pipelinesProvider.ts @@ -7,7 +7,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; interface ScopedPipelineRun { build: Build; @@ -236,11 +236,13 @@ export class PipelinesProvider implements vscode.TreeDataProvider { const builds = await this.client.listPipelineRuns( scope.project, scope.organization, - { top: this.config.pipelineRunsTop, filter: this.config.pipelineRunsFilter } + { top, filter } ); return builds.map(build => ({ build, scope })); }); diff --git a/src/providers/planningProviders.ts b/src/providers/planningProviders.ts index c73dba1..0aaeac6 100644 --- a/src/providers/planningProviders.ts +++ b/src/providers/planningProviders.ts @@ -8,7 +8,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; diff --git a/src/providers/projectScopes.ts b/src/providers/projectScopes.ts index 9a48834..fadf951 100644 --- a/src/providers/projectScopes.ts +++ b/src/providers/projectScopes.ts @@ -1,5 +1,6 @@ import type { AdoClient } from '../api/adoClient'; import { ALL_PROJECTS, type ConfigManager } from '../config/configManager'; +import { mapWithConcurrencyLimit } from '../utils/async'; export interface ProjectScope { organization: string; @@ -38,4 +39,17 @@ export async function resolveProjectScopes( } return scopes; -} \ No newline at end of file +} +export async function forEachScope( + client: AdoClient, + config: ConfigManager, + fetcher: (scope: ProjectScope) => Promise, + concurrency = 4 +): Promise<{ scopes: ProjectScope[]; items: T[] }> { + const scopes = await resolveProjectScopes(client, config); + if (scopes.length === 0) { + return { scopes, items: [] }; + } + const nested = await mapWithConcurrencyLimit(scopes, concurrency, fetcher); + return { scopes, items: nested.flat() }; +} diff --git a/src/providers/pullRequestProvider.ts b/src/providers/pullRequestProvider.ts index 185b068..587f666 100644 --- a/src/providers/pullRequestProvider.ts +++ b/src/providers/pullRequestProvider.ts @@ -9,7 +9,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; import { handleProviderError } from './providerErrors'; diff --git a/src/providers/workItemProvider.ts b/src/providers/workItemProvider.ts index 222b2fe..4717795 100644 --- a/src/providers/workItemProvider.ts +++ b/src/providers/workItemProvider.ts @@ -7,7 +7,7 @@ import { scopeLabel, type ProjectScope } from './projectScopes'; -import { forEachScope } from '../utils/async'; +import { forEachScope } from './projectScopes'; import { bundledWorkItemTypeIconFile } from '../utils/workItemTypeIcons'; import { WorkItemIconResolver } from './workItemIconResolver'; import type { AuthRecoveryHandler } from '../utils/authRecovery'; @@ -175,10 +175,11 @@ export class WorkItemProvider implements vscode.TreeDataProvider { const workItems = await this.client.getWorkItems( scope.project, - this.config.activeWorkItemQuery.filter, + filter, scope.organization ); return workItems.map(workItem => ({ workItem, scope })); diff --git a/src/utils/async.ts b/src/utils/async.ts index 2daf477..98ff6fa 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -1,21 +1,3 @@ -import type { AdoClient } from '../api/adoClient'; -import type { ConfigManager } from '../config/configManager'; -import { resolveProjectScopes, type ProjectScope } from '../providers/projectScopes'; - -export async function forEachScope( - client: AdoClient, - config: ConfigManager, - fetcher: (scope: ProjectScope) => Promise, - concurrency = 4 -): Promise<{ scopes: ProjectScope[]; items: T[] }> { - const scopes = await resolveProjectScopes(client, config); - if (scopes.length === 0) { - return { scopes, items: [] }; - } - const nested = await mapWithConcurrencyLimit(scopes, concurrency, fetcher); - return { scopes, items: nested.flat() }; -} - export async function mapWithConcurrencyLimit( items: readonly TInput[], concurrencyLimit: number, diff --git a/src/utils/ttlCache.ts b/src/utils/ttlCache.ts index 5c0a40f..a957a3e 100644 --- a/src/utils/ttlCache.ts +++ b/src/utils/ttlCache.ts @@ -28,7 +28,6 @@ export class TtlCache { } has(key: string): boolean { - const entry = this._map.get(key); - return entry !== undefined && entry.expiresAt > Date.now(); + return this.get(key) !== undefined; } } From eac2e72b5a55b682984ef52040c0084a08c943ce Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:29:29 +0200 Subject: [PATCH 4/5] 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 c3858cd8cc62cd42aebc10e2916f76cbf3b173bb Mon Sep 17 00:00:00 2001 From: Marc Kassubeck Date: Wed, 20 May 2026 22:29:37 +0200 Subject: [PATCH 5/5] 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 {