diff --git a/CHANGELOG.md b/CHANGELOG.md index f772269055fbd..4e36baa405c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- Adds new ability to search for a GitHub PR in the _Launchpad;_ closes [#3543](https://github.com/gitkraken/vscode-gitlens/issues/3543) + ## [15.6.2] - 2024-10-17 ### Fixed diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 7fbc0ce45385b..4805527f54c16 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -1305,7 +1305,7 @@ void ```typescript { 'timeout': number, - 'operation': 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' | 'getCodeSuggestionCounts', + 'operation': 'getPullRequest' | 'searchPullRequests' | 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' | 'getCodeSuggestionCounts', 'duration': number } ``` diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index e98a1b7922ad9..597f103911a41 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -294,7 +294,13 @@ export type TelemetryEvents = { /** Sent when a launchpad operation is taking longer than a set timeout to complete */ 'launchpad/operation/slow': { timeout: number; - operation: 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' | 'getCodeSuggestionCounts'; + operation: + | 'getPullRequest' + | 'searchPullRequests' + | 'getMyPullRequests' + | 'getCodeSuggestions' + | 'getEnrichedItems' + | 'getCodeSuggestionCounts'; duration: number; }; diff --git a/src/git/models/__tests__/pullRequest.test.ts b/src/git/models/__tests__/pullRequest.test.ts new file mode 100644 index 0000000000000..a2da2e0606edb --- /dev/null +++ b/src/git/models/__tests__/pullRequest.test.ts @@ -0,0 +1,84 @@ +import * as assert from 'assert'; +import { suite, test } from 'mocha'; +import { getPullRequestIdentityValuesFromSearch } from '../pullRequest'; + +suite('Test GitHub PR URL parsing to identity: getPullRequestIdentityValuesFromSearch()', () => { + function t(message: string, query: string, prNumber: string | undefined, ownerAndRepo?: string) { + assert.deepStrictEqual( + getPullRequestIdentityValuesFromSearch(query), + { + ownerAndRepo: ownerAndRepo, + prNumber: prNumber, + }, + `${message} (${JSON.stringify(query)})`, + ); + } + + test('full URL or without protocol but with domain, should parse to ownerAndRepo and prNumber', () => { + t('full URL', 'https://github.com/eamodio/vscode-gitlens/pull/1', '1', 'eamodio/vscode-gitlens'); + t( + 'with suffix', + 'https://github.com/eamodio/vscode-gitlens/pull/1/files?diff=unified#hello', + '1', + 'eamodio/vscode-gitlens', + ); + t( + 'with query', + 'https://github.com/eamodio/vscode-gitlens/pull/1?diff=unified#hello', + '1', + 'eamodio/vscode-gitlens', + ); + + t('with anchor', 'https://github.com/eamodio/vscode-gitlens/pull/1#hello', '1', 'eamodio/vscode-gitlens'); + t('a weird suffix', 'https://github.com/eamodio/vscode-gitlens/pull/1-files', '1', 'eamodio/vscode-gitlens'); + t('numeric repo name', 'https://github.com/sergeibbb/1/pull/16', '16', 'sergeibbb/1'); + + t('no protocol with leading slash', '/github.com/sergeibbb/1/pull/16?diff=unified', '16', 'sergeibbb/1'); + t('no protocol without leading slash', 'github.com/sergeibbb/1/pull/16/files', '16', 'sergeibbb/1'); + }); + + test('no domain, should parse to ownerAndRepo and prNumber', () => { + t('with leading slash', '/sergeibbb/1/pull/16#hello', '16', 'sergeibbb/1'); + t('words in repo name', 'eamodio/vscode-gitlens/pull/1?diff=unified#hello', '1', 'eamodio/vscode-gitlens'); + t('numeric repo name', 'sergeibbb/1/pull/16/files', '16', 'sergeibbb/1'); + }); + + test('domain vs. no domain', () => { + t( + 'with anchor', + 'https://github.com/eamodio/vscode-gitlens/pull/1#hello/sergeibbb/1/pull/16', + '1', + 'eamodio/vscode-gitlens', + ); + }); + + test('has "pull/" fragment', () => { + t('with leading slash', '/pull/16/files#hello', '16'); + t('without leading slash', 'pull/16?diff=unified#hello', '16'); + t('with numeric repo name', '1/pull/16?diff=unified#hello', '16'); + t('with double slash', '1//pull/16?diff=unified#hello', '16'); + }); + + test('has "/" fragment', () => { + t('with leading slash', '/16/files#hello', '16'); + }); + + test('is a number', () => { + t('just a number', '16', '16'); + t('with a hash', '#16', '16'); + }); + + test('does not match', () => { + t('without leading slash', '16?diff=unified#hello', undefined); + t('with leading hash', '/#16/files#hello', undefined); + t('number is a part of a word', 'hello16', undefined); + t('number is a part of a word', '16hello', undefined); + + t('with a number', '1/16?diff=unified#hello', '16'); + t('with a number and slash', '/1/16?diff=unified#hello', '1'); + t('with a word', 'anything/16?diff=unified#hello', '16'); + + t('with a wrong character leading to pull', 'sergeibbb/1/-pull/16?diff=unified#hello', '1'); + t('with a wrong character leading to pull', 'sergeibbb/1-pull/16?diff=unified#hello', '1'); + }); +}); diff --git a/src/git/models/pullRequest.ts b/src/git/models/pullRequest.ts index 688de1e6c747e..637f9eb14b52b 100644 --- a/src/git/models/pullRequest.ts +++ b/src/git/models/pullRequest.ts @@ -2,6 +2,7 @@ import { Uri, window } from 'vscode'; import { Schemes } from '../../constants'; import { Container } from '../../container'; import type { RepositoryIdentityDescriptor } from '../../gk/models/repositoryIdentities'; +import type { EnrichablePullRequest } from '../../plus/integrations/providers/models'; import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import type { LeftRightCommitCountResult } from '../gitProvider'; @@ -415,3 +416,60 @@ export async function getOpenedPullRequestRepo( const repo = await getOrOpenPullRequestRepository(container, pr, { promptIfNeeded: true }); return repo; } + +export type PullRequestURLIdentity = { + ownerAndRepo?: string; + prNumber?: string; +}; + +export function getPullRequestIdentityValuesFromSearch(search: string): PullRequestURLIdentity { + let ownerAndRepo: string | undefined = undefined; + let prNumber: string | undefined = undefined; + + let match = search.match(/([^/]+\/[^/]+)\/pull\/(\d+)/); // with org and rep name + if (match != null) { + ownerAndRepo = match[1]; + prNumber = match[2]; + } + + if (prNumber == null) { + match = search.match(/(?:\/|^)pull\/(\d+)/); // without repo name + if (match != null) { + prNumber = match[1]; + } + } + + if (prNumber == null) { + match = search.match(/(?:\/)(\d+)/); // any number starting with "/" + if (match != null) { + prNumber = match[1]; + } + } + + if (prNumber == null) { + match = search.match(/^#?(\d+)$/); // just a number or with a leading "#" + if (match != null) { + prNumber = match[1]; + } + } + + return { ownerAndRepo: ownerAndRepo, prNumber: prNumber }; +} + +export function doesPullRequestSatisfyRepositoryURLIdentity( + pr: EnrichablePullRequest | undefined, + { ownerAndRepo, prNumber }: PullRequestURLIdentity, +): boolean { + if (pr == null) { + return false; + } + const satisfiesPrNumber = prNumber != null && pr.number === parseInt(prNumber, 10); + if (!satisfiesPrNumber) { + return false; + } + const satisfiesOwnerAndRepo = ownerAndRepo != null && pr.repoIdentity.name === ownerAndRepo; + if (!satisfiesOwnerAndRepo) { + return false; + } + return true; +} diff --git a/src/plus/integrations/providers/github/github.ts b/src/plus/integrations/providers/github/github.ts index f69b1f6a38b3b..50aa36f73b523 100644 --- a/src/plus/integrations/providers/github/github.ts +++ b/src/plus/integrations/providers/github/github.ts @@ -3040,7 +3040,9 @@ export class GitHubApi implements Disposable { const scope = getLogScope(); interface SearchResult { - nodes: GitHubPullRequest[]; + search: { + nodes: GitHubPullRequest[]; + }; } try { @@ -3048,7 +3050,7 @@ export class GitHubApi implements Disposable { $searchQuery: String! $avatarSize: Int ) { - search(first: 100, query: $searchQuery, type: ISSUE) { + search(first: 10, query: $searchQuery, type: ISSUE) { nodes { ...on PullRequest { ${gqlPullRequestFragment} @@ -3082,7 +3084,7 @@ export class GitHubApi implements Disposable { ); if (rsp == null) return []; - const results = rsp.nodes.map(pr => fromGitHubPullRequest(pr, provider)); + const results = rsp.search.nodes.map(pr => fromGitHubPullRequest(pr, provider)); return results; } catch (ex) { throw this.handleException(ex, provider, scope); diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 024824bfc74c4..f4751e7505aa7 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -1,4 +1,4 @@ -import type { QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; +import type { CancellationToken, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; import { commands, ThemeIcon, Uri } from 'vscode'; import { getAvatarUri } from '../../avatars'; import type { @@ -42,6 +42,10 @@ import { HostingIntegrationId, SelfHostedIntegrationId } from '../../constants.i import type { LaunchpadTelemetryContext, Source, Sources, TelemetryEvents } from '../../constants.telemetry'; import type { Container } from '../../container'; import { PlusFeatures } from '../../features'; +import { + doesPullRequestSatisfyRepositoryURLIdentity, + getPullRequestIdentityValuesFromSearch, +} from '../../git/models/pullRequest'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickItemOfT, createQuickPickSeparator } from '../../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; @@ -50,6 +54,7 @@ import { getScopedCounter } from '../../system/counter'; import { fromNow } from '../../system/date'; import { some } from '../../system/iterable'; import { interpolate, pluralize } from '../../system/string'; +import { createAsyncDebouncer } from '../../system/vscode/asyncDebouncer'; import { executeCommand } from '../../system/vscode/command'; import { configuration } from '../../system/vscode/configuration'; import { openUrl } from '../../system/vscode/utils'; @@ -145,6 +150,8 @@ const instanceCounter = getScopedCounter(); const defaultCollapsedGroups: LaunchpadGroup[] = ['draft', 'other', 'snoozed']; export class LaunchpadCommand extends QuickCommand { + // TODO: The debouncer needs to be cancelled when the step is changed when the quickpick is closed + private readonly updateItemsDebouncer = createAsyncDebouncer(500); private readonly source: Source; private readonly telemetryContext: LaunchpadTelemetryContext | undefined; @@ -370,7 +377,94 @@ export class LaunchpadCommand extends QuickCommand { { picked, selectTopItem }: { picked?: string; selectTopItem?: boolean }, ): StepResultGenerator { const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); - const getItems = (result: LaunchpadCategorizedResult) => { + + const buildGroupHeading = ( + ui: LaunchpadGroup, + groupLength: number, + ): [DirectiveQuickPickItem, DirectiveQuickPickItem] => { + return [ + createQuickPickSeparator(groupLength ? groupLength.toString() : undefined), + createDirectiveQuickPickItem(Directive.Reload, false, { + label: `$(${ + context.collapsed.get(ui) ? 'chevron-down' : 'chevron-up' + })\u00a0\u00a0${launchpadGroupIconMap.get(ui)!}\u00a0\u00a0${launchpadGroupLabelMap + .get(ui) + ?.toUpperCase()}`, //'\u00a0', + //detail: groupMap.get(group)?.[0].toUpperCase(), + onDidSelect: () => { + const collapsed = !context.collapsed.get(ui); + context.collapsed.set(ui, collapsed); + if (state.initialGroup == null) { + void this.container.storage.store( + 'launchpad:groups:collapsed', + Array.from(context.collapsed.keys()).filter(g => context.collapsed.get(g)), + ); + } + + if (this.container.telemetry.enabled) { + updateTelemetryContext(context); + this.container.telemetry.sendEvent( + 'launchpad/groupToggled', + { + ...context.telemetryContext!, + group: ui, + collapsed: collapsed, + }, + this.source, + ); + } + }, + }), + ]; + }; + + const buildLaunchpadQuickPickItem = ( + i: LaunchpadItem, + ui: LaunchpadGroup, + topItem: LaunchpadItem | undefined, + alwaysShow: boolean | undefined, + ): LaunchpadItemQuickPickItem => { + const buttons = []; + + if (i.actionableCategory === 'mergeable') { + buttons.push(MergeQuickInputButton); + } + + if (!i.isSearched) { + buttons.push( + i.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, + i.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, + ); + } + + buttons.push(...getOpenOnGitProviderQuickInputButtons(i.provider.id)); + + if (!i.openRepository?.localBranch?.current) { + buttons.push(OpenWorktreeInNewWindowQuickInputButton); + } + + return { + label: i.title.length > 60 ? `${i.title.substring(0, 60)}...` : i.title, + // description: `${i.repoAndOwner}#${i.id}, by @${i.author}`, + description: `\u00a0 ${i.repository.owner.login}/${i.repository.name}#${i.id} \u00a0 ${ + i.codeSuggestionsCount > 0 ? ` $(gitlens-code-suggestion) ${i.codeSuggestionsCount}` : '' + } \u00a0 ${i.isNew ? '(New since last view)' : ''}`, + detail: ` ${i.viewer.pinned ? '$(pinned) ' : ''}${ + i.isDraft && ui !== 'draft' ? '$(git-pull-request-draft) ' : '' + }${ + i.actionableCategory === 'other' ? '' : `${actionGroupMap.get(i.actionableCategory)![0]} \u2022 ` + }${fromNow(i.updatedDate)} by @${i.author!.username}`, + + alwaysShow: alwaysShow, + buttons: buttons, + iconPath: i.author?.avatarUrl != null ? Uri.parse(i.author.avatarUrl) : undefined, + item: i, + picked: i.graphQLId === picked || i.graphQLId === topItem?.graphQLId, + group: ui, + }; + }; + + const getItems = (result: LaunchpadCategorizedResult, isSearching?: boolean) => { const items: (LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem)[] = []; if (result.items?.length) { @@ -385,93 +479,21 @@ export class LaunchpadCommand extends QuickCommand { for (const [ui, groupItems] of uiGroups) { if (!groupItems.length) continue; - items.push( - createQuickPickSeparator(groupItems.length ? groupItems.length.toString() : undefined), - createDirectiveQuickPickItem(Directive.Reload, false, { - label: `$(${ - context.collapsed.get(ui) ? 'chevron-down' : 'chevron-up' - })\u00a0\u00a0${launchpadGroupIconMap.get(ui)!}\u00a0\u00a0${launchpadGroupLabelMap - .get(ui) - ?.toUpperCase()}`, //'\u00a0', - //detail: groupMap.get(group)?.[0].toUpperCase(), - onDidSelect: () => { - const collapsed = !context.collapsed.get(ui); - context.collapsed.set(ui, collapsed); - if (state.initialGroup == null) { - void this.container.storage.store( - 'launchpad:groups:collapsed', - Array.from(context.collapsed.keys()).filter(g => context.collapsed.get(g)), - ); - } - - if (this.container.telemetry.enabled) { - updateTelemetryContext(context); - this.container.telemetry.sendEvent( - 'launchpad/groupToggled', - { - ...context.telemetryContext!, - group: ui, - collapsed: collapsed, - }, - this.source, - ); - } - }, - }), - ); - - if (context.collapsed.get(ui)) continue; - - items.push( - ...groupItems.map(i => { - const buttons = []; - - if (i.actionableCategory === 'mergeable') { - buttons.push(MergeQuickInputButton); - } - - buttons.push( - i.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, - i.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, - ); - - buttons.push(...getOpenOnGitProviderQuickInputButtons(i.provider.id)); - - if (!i.openRepository?.localBranch?.current) { - buttons.push(OpenWorktreeInNewWindowQuickInputButton); - } + if (!isSearching) { + items.push(...buildGroupHeading(ui, groupItems.length)); + if (context.collapsed.get(ui)) { + continue; + } + } - return { - label: i.title.length > 60 ? `${i.title.substring(0, 60)}...` : i.title, - // description: `${i.repoAndOwner}#${i.id}, by @${i.author}`, - description: `\u00a0 ${i.repository.owner.login}/${i.repository.name}#${i.id} \u00a0 ${ - i.codeSuggestionsCount > 0 - ? ` $(gitlens-code-suggestion) ${i.codeSuggestionsCount}` - : '' - } \u00a0 ${i.isNew ? '(New since last view)' : ''}`, - detail: ` ${i.viewer.pinned ? '$(pinned) ' : ''}${ - i.isDraft && ui !== 'draft' ? '$(git-pull-request-draft) ' : '' - }${ - i.actionableCategory === 'other' - ? '' - : `${actionGroupMap.get(i.actionableCategory)![0]} \u2022 ` - }${fromNow(i.updatedDate)} by @${i.author!.username}`, - - buttons: buttons, - iconPath: i.author?.avatarUrl != null ? Uri.parse(i.author.avatarUrl) : undefined, - item: i, - picked: i.graphQLId === picked || i.graphQLId === topItem?.graphQLId, - group: ui, - }; - }), - ); + items.push(...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, isSearching))); } } return items; }; - function getItemsAndPlaceholder() { + function getItemsAndPlaceholder(isSearching?: boolean) { if (context.result.error != null) { return { placeholder: `Unable to load items (${ @@ -493,30 +515,54 @@ export class LaunchpadCommand extends QuickCommand { } return { - placeholder: 'Choose an item to focus on', - items: getItems(context.result), + placeholder: 'Choose an item, type a term to search, or paste in a PR URL', + items: getItems(context.result, isSearching), }; } + const combineQuickpickItemsWithSearchResults = ( + arr: readonly T[], + items: T[], + ) => { + const ids: Set = new Set( + arr.map(i => 'item' in i && i.item?.id).filter(id => typeof id === 'string'), + ); + const result = [...arr]; + for (const item of items) { + if ('item' in item && item.item?.id && !ids.has(item.item.id)) { + result.push(item); + } + } + return result; + }; + const updateItems = async ( quickpick: QuickPick, ) => { + const search = quickpick.value; quickpick.busy = true; - try { - await updateContextItems(this.container, context, { force: true }); - - const { items, placeholder } = getItemsAndPlaceholder(); - quickpick.placeholder = placeholder; - quickpick.items = items; + await this.updateItemsDebouncer(async cancellationToken => { + await updateContextItems( + this.container, + context, + { force: true, search: search }, + cancellationToken, + ); + if (cancellationToken.isCancellationRequested) { + return; + } + const { items, placeholder } = getItemsAndPlaceholder(Boolean(search)); + quickpick.placeholder = placeholder; + quickpick.items = search ? combineQuickpickItemsWithSearchResults(quickpick.items, items) : items; + }); } finally { quickpick.busy = false; } }; const { items, placeholder } = getItemsAndPlaceholder(); - - let groupsHidden = false; + const nonGroupedItems = items.filter(i => !isDirectiveQuickPickItem(i)); const step = createPickStep({ title: context.title, @@ -531,14 +577,62 @@ export class LaunchpadCommand extends QuickCommand { LaunchpadSettingsQuickInputButton, RefreshQuickInputButton, ], - onDidChangeValue: quickpick => { - const hideGroups = Boolean(quickpick.value?.length); + onDidChangeValue: async quickpick => { + const { value } = quickpick; + const hideGroups = Boolean(value?.length); + const consideredItems = hideGroups ? nonGroupedItems : items; + + let updated = false; + for (const item of consideredItems) { + if (item.alwaysShow) { + item.alwaysShow = false; + updated = true; + } + } - if (groupsHidden !== hideGroups) { - groupsHidden = hideGroups; - quickpick.items = hideGroups ? items.filter(i => !isDirectiveQuickPickItem(i)) : items; + // By doing the following we make sure we operate with the PRs that belong to Launchpad initially. + // Also, when we re-create the array, we make sure that `alwaysShow` updates are applied. + quickpick.items = + updated && quickpick.items === consideredItems ? [...consideredItems] : consideredItems; + + if (!value?.length) { + // Nothing to search + this.updateItemsDebouncer.cancel(); + return true; + } + + // TODO: This needs to be generalized to work outside of GitHub, + // The current idea is that we should iterate the connected integrations and apply their parsing. + // Probably we even want to build a map like this: { integrationId: identity } + // Then when we iterate local items we can check them to corresponding identitie according to the item's repo type. + // Same with API: we iterate connected integrations and search in each of them with the corresponding identity. + const prUrlIdentity = getPullRequestIdentityValuesFromSearch(value); + + if (prUrlIdentity.prNumber != null) { + // We can identify the PR number, so let's try to find it locally: + const launchpadItems = quickpick.items.filter((i): i is LaunchpadItemQuickPickItem => 'item' in i); + let item = launchpadItems.find(i => + // perform strict match first + doesPullRequestSatisfyRepositoryURLIdentity(i.item, prUrlIdentity), + ); + if (item == null) { + // Haven't found full match, so let's at least find something with the same pr number + item = launchpadItems.find(i => i.item.id === prUrlIdentity.prNumber); + } + if (item != null) { + if (!item.alwaysShow) { + item.alwaysShow = true; + // Force quickpick to update by changing the items object: + quickpick.items = [...quickpick.items]; + } + // We have found an item that matches to the URL. + // Now it will be displayed as the found item and we exit this function now without sending any requests to API: + this.updateItemsDebouncer.cancel(); + return true; + } } + await updateItems(quickpick); return true; }, onDidClickButton: async (quickpick, button) => { @@ -660,8 +754,12 @@ export class LaunchpadCommand extends QuickCommand { state.item.author?.avatarUrl != null ? Uri.parse(state.item.author.avatarUrl) : undefined, buttons: [ ...gitProviderWebButtons, - state.item.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, - state.item.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, + ...(state.item.isSearched + ? [] + : [ + state.item.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, + state.item.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, + ]), ], }, 'soft-open', @@ -1324,8 +1422,13 @@ function getIntegrationTitle(integrationId: string): string { } } -async function updateContextItems(container: Container, context: Context, options?: { force?: boolean }) { - context.result = await container.launchpad.getCategorizedItems(options); +async function updateContextItems( + container: Container, + context: Context, + options?: { force?: boolean; search?: string }, + cancellation?: CancellationToken, +) { + context.result = await container.launchpad.getCategorizedItems(options, cancellation); if (container.telemetry.enabled) { updateTelemetryContext(context); } diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index c7712e0278546..ca58018f53c96 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -19,6 +19,7 @@ import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequ import { getComparisonRefsForPullRequest, getOrOpenPullRequestRepository, + getPullRequestIdentityValuesFromSearch, getRepositoryIdentityForPullRequest, } from '../../git/models/pullRequest'; import type { GitRemote } from '../../git/models/remote'; @@ -41,6 +42,7 @@ import { showInspectView } from '../../webviews/commitDetails/actions'; import type { ShowWipArgs } from '../../webviews/commitDetails/protocol'; import type { IntegrationResult } from '../integrations/integration'; import type { ConnectionStateChangeEvent } from '../integrations/integrationService'; +import type { GitHubRepositoryDescriptor } from '../integrations/providers/github'; import type { EnrichablePullRequest, ProviderActionablePullRequest } from '../integrations/providers/models'; import { fromProviderPullRequest, @@ -188,6 +190,7 @@ export type LaunchpadItem = LaunchpadPullRequest & { codeSuggestionsCount: number; codeSuggestions?: TimedResult; isNew: boolean; + isSearched: boolean; actionableCategory: LaunchpadActionCategory; suggestedActions: LaunchpadAction[]; openRepository?: OpenRepository; @@ -318,6 +321,47 @@ export class LaunchpadProvider implements Disposable { return { prs: prs, suggestionCounts: suggestionCounts }; } + private async getSearchedPullRequests(search: string, cancellation?: CancellationToken) { + // TODO: This needs to be generalized to work outside of GitHub, + // The current idea is that we should iterate the connected integrations and apply their parsing. + // Probably we even want to build a map like this: { integrationId: identity } + // Then we iterate connected integrations and search in each of them with the corresponding identity. + const { ownerAndRepo, prNumber } = getPullRequestIdentityValuesFromSearch(search); + let result: TimedResult | undefined; + + if (prNumber != null && ownerAndRepo != null) { + // TODO: This needs to be generalized to work outside of GitHub + const integration = await this.container.integrations.get(HostingIntegrationId.GitHub); + const [owner, repo] = ownerAndRepo.split('/', 2); + const descriptor: GitHubRepositoryDescriptor = { + key: ownerAndRepo, + owner: owner, + name: repo, + }; + const pr = await withDurationAndSlowEventOnTimeout( + integration?.getPullRequest(descriptor, prNumber), + 'getPullRequest', + this.container, + ); + if (pr?.value != null) { + result = { value: [{ pullRequest: pr.value, reasons: [] }], duration: pr.duration }; + return { prs: result, suggestionCounts: undefined }; + } + } else { + const integration = await this.container.integrations.get(HostingIntegrationId.GitHub); + const prs = await withDurationAndSlowEventOnTimeout( + integration?.searchPullRequests(search, undefined, cancellation), + 'searchPullRequests', + this.container, + ); + if (prs != null) { + result = { value: prs.value?.map(pr => ({ pullRequest: pr, reasons: [] })), duration: prs.duration }; + return { prs: result, suggestionCounts: undefined }; + } + } + return { prs: undefined, suggestionCounts: undefined }; + } + private _enrichedItems: CachedLaunchpadPromise> | undefined; @debug({ args: { 0: o => `force=${o?.force}` } }) private async getEnrichedItems(options?: { cancellation?: CancellationToken; force?: boolean }) { @@ -618,12 +662,13 @@ export class LaunchpadProvider implements Disposable { @gate(o => `${o?.force ?? false}`) @log({ args: { 0: o => `force=${o?.force}`, 1: false } }) async getCategorizedItems( - options?: { force?: boolean }, + options?: { force?: boolean; search?: string }, cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); + const isSearching = ((o?: { search?: string }): o is { search: string } => Boolean(o?.search))(options); - const fireRefresh = options?.force || this._prs == null; + const fireRefresh = !isSearching && (options?.force || this._prs == null); const ignoredRepositories = new Set( (configuration.get('launchpad.ignoredRepositories') ?? []).map(r => r.toLowerCase()), @@ -644,7 +689,9 @@ export class LaunchpadProvider implements Disposable { const [_, enrichedItemsResult, prsWithCountsResult] = await Promise.allSettled([ this.container.git.isDiscoveringRepositories, this.getEnrichedItems({ force: options?.force, cancellation: cancellation }), - this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), + isSearching + ? this.getSearchedPullRequests(options.search, cancellation) + : this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), ]); if (cancellation?.isCancellationRequested) throw new CancellationError(); @@ -758,7 +805,7 @@ export class LaunchpadProvider implements Disposable { item.suggestedActionCategory, )!; // category overrides - if (staleDate != null && item.updatedDate.getTime() < staleDate.getTime()) { + if (!options?.search && staleDate != null && item.updatedDate.getTime() < staleDate.getTime()) { actionableCategory = 'other'; } else if (codeSuggestionsCount > 0 && item.viewer.isAuthor) { actionableCategory = 'code-suggestions'; @@ -776,6 +823,7 @@ export class LaunchpadProvider implements Disposable { currentViewer: myAccounts.get(item.provider.id)!, codeSuggestionsCount: codeSuggestionsCount, isNew: this.isItemNewInGroup(item, actionableCategory), + isSearched: isSearching, actionableCategory: actionableCategory, suggestedActions: suggestedActions, openRepository: openRepository, @@ -794,7 +842,10 @@ export class LaunchpadProvider implements Disposable { }; return result; } finally { - this.updateGroupedIds(result?.items ?? []); + if (!options?.search) { + this.updateGroupedIds(result?.items ?? []); + } + if (result != null && fireRefresh) { this._onDidRefresh.fire(result); } @@ -1065,7 +1116,13 @@ const slowEventTimeout = 1000 * 30; // 30 seconds function withDurationAndSlowEventOnTimeout( promise: Promise, - name: 'getMyPullRequests' | 'getCodeSuggestionCounts' | 'getCodeSuggestions' | 'getEnrichedItems', + name: + | 'getPullRequest' + | 'searchPullRequests' + | 'getMyPullRequests' + | 'getCodeSuggestionCounts' + | 'getCodeSuggestions' + | 'getEnrichedItems', container: Container, ): Promise> { return timedWithSlowThreshold(promise, { diff --git a/src/system/function.ts b/src/system/function.ts index ed94c1d6048c3..2eae8546cf7ff 100644 --- a/src/system/function.ts +++ b/src/system/function.ts @@ -86,7 +86,6 @@ export function debounce ReturnType>( function debounced(this: any, ...args: Parameters) { const time = Date.now(); - const isInvoking = shouldInvoke(time); if (aggregator != null && lastArgs) { lastArgs = aggregator(lastArgs, args); @@ -98,13 +97,6 @@ export function debounce ReturnType>( lastThis = this; lastCallTime = time; - if (isInvoking) { - if (timer == null) { - // Start the timer for the trailing edge. - timer = setTimeout(timerExpired, wait); - return result; - } - } if (timer == null) { timer = setTimeout(timerExpired, wait); } diff --git a/src/system/vscode/asyncDebouncer.ts b/src/system/vscode/asyncDebouncer.ts new file mode 100644 index 0000000000000..7214cdc579593 --- /dev/null +++ b/src/system/vscode/asyncDebouncer.ts @@ -0,0 +1,136 @@ +import type { CancellationToken, Disposable } from 'vscode'; +import { CancellationTokenSource } from 'vscode'; +import { CancellationError } from '../../errors'; +import type { Deferrable } from '../function'; +import type { Deferred } from '../promise'; +import { defer } from '../promise'; + +export interface AsyncTask { + (cancelationToken: CancellationToken): T | Promise; +} + +/** + * This is similar to `src/system/function.ts: debounce` but it's for async tasks. + * The old `debounce` function does not awaits for promises, so it's not suitable for async tasks. + * + * This function cannot be part of `src/system/function.ts` because it relies on `CancellationTokenSource` from `vscode`. + * + * Here the debouncer returns a promise that awaits task for completion. + * Also we can let tasks know if they are cancelled by passing a cancellation token. + * + * Despite being able to accept synchronous tasks, we always return a promise here. It's implemeted this way for simplicity. + */ +export function createAsyncDebouncer(delay: number): Disposable & Deferrable<(task: AsyncTask) => Promise> { + let lastTask: AsyncTask | undefined; + let timer: ReturnType | undefined; + let curDeferred: Deferred | undefined; + let curCancellation: CancellationTokenSource | undefined; + + /** + * Cancels the timer and current execution without cancelling the promise + */ + function cancelCurrentExecution(): void { + if (timer != null) { + clearTimeout(timer); + timer = undefined; + } + if (curCancellation != null && !curCancellation.token.isCancellationRequested) { + curCancellation.cancel(); + } + } + + function cancel() { + cancelCurrentExecution(); + if (curDeferred?.pending) { + curDeferred.cancel(new CancellationError()); + } + lastTask = undefined; + } + + function dispose() { + cancel(); + curCancellation?.dispose(); + curCancellation = undefined; + } + + function flush(): Promise | undefined { + if (lastTask != null) { + cancelCurrentExecution(); + void invoke(); + } + if (timer != null) { + clearTimeout(timer); + } + return curDeferred?.promise; + } + + function pending(): boolean { + return curDeferred?.pending ?? false; + } + + async function invoke(): Promise { + if (curDeferred == null || lastTask == null) { + return; + } + cancelCurrentExecution(); + + const task = lastTask; + const deferred = curDeferred; + lastTask = undefined; + const cancellation = (curCancellation = new CancellationTokenSource()); + + try { + const result = await task(cancellation.token); + if (!cancellation.token.isCancellationRequested) { + // Default successful line: current task has completed without interruptions by another task + if (deferred !== curDeferred && deferred.pending) { + deferred.fulfill(result); + } + if (curDeferred.pending) { + curDeferred.fulfill(result); + } + } else { + throw new CancellationError(); + } + } catch (e) { + if (cancellation.token.isCancellationRequested) { + // The current execution has been cancelled so we don't want to reject the main promise, + // because that's expected that it can be fullfilled by the next task. + // (If the whole task is cancelled, the main promise will be rejected in the cancel() method) + if (curDeferred !== deferred && deferred.pending) { + // Unlikely we get here, but if the local `deferred` is different from the main one, then we cancel it to not let the clients hang. + deferred.cancel(e); + } + } else { + // The current execution hasn't been cancelled, so just reject the promise with the error + if (deferred !== curDeferred && deferred.pending) { + deferred.cancel(e); + } + if (curDeferred?.pending) { + curDeferred.cancel(e); + } + } + } finally { + cancellation.dispose(); + } + } + + function debounce(this: any, task: AsyncTask): Promise { + lastTask = task; + cancelCurrentExecution(); // cancelling the timer or current execution without cancelling the promise + + if (!curDeferred?.pending) { + curDeferred = defer(); + } + + timer = setTimeout(invoke, delay); + + return curDeferred.promise; + } + + debounce.cancel = cancel; + debounce.dispose = dispose; + debounce.flush = flush; + debounce.pending = pending; + return debounce; +}