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/providers/pipelinesProvider.ts b/src/providers/pipelinesProvider.ts index 3a439f0..2c8f484 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 './projectScopes'; interface ScopedPipelineRun { build: Build; @@ -239,12 +236,19 @@ export class PipelinesProvider implements vscode.TreeDataProvider { + const builds = await this.client.listPipelineRuns( + scope.project, + scope.organization, + { top, filter } + ); + 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 +284,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..0aaeac6 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 './projectScopes'; 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/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 d91b855..587f666 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 './projectScopes'; 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..4717795 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 './projectScopes'; 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,20 @@ export class WorkItemProvider implements vscode.TreeDataProvider { + const workItems = await this.client.getWorkItems( + scope.project, + 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 +220,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/ttlCache.ts b/src/utils/ttlCache.ts new file mode 100644 index 0000000..a957a3e --- /dev/null +++ b/src/utils/ttlCache.ts @@ -0,0 +1,33 @@ +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 { + return this.get(key) !== undefined; + } +} 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; + } +} 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 {