From 168da191a5c578d840a5579794984c45831ae1e7 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 19 Sep 2024 16:04:10 +0200 Subject: [PATCH 01/11] Wraps some code of complex funcs to subfunctions to improve readability (#3543, #3684) --- src/plus/launchpad/launchpad.ts | 162 +++++++++++++++++--------------- 1 file changed, 85 insertions(+), 77 deletions(-) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 024824bfc74c4..a5b9f439074d8 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -370,6 +370,89 @@ export class LaunchpadCommand extends QuickCommand { { picked, selectTopItem }: { picked?: string; selectTopItem?: boolean }, ): StepResultGenerator { const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); + + 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, + ): LaunchpadItemQuickPickItem => { + 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); + } + + 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, + }; + }; + const getItems = (result: LaunchpadCategorizedResult) => { const items: (LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem)[] = []; @@ -385,86 +468,11 @@ 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, - ); - } - }, - }), - ); + items.push(...buildGroupHeading(ui, groupItems.length)); 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); - } - - 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))); } } From e84fa8f30db9b149dcc4fdc59a8a0d5cd5552221 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 20 Sep 2024 16:37:02 +0200 Subject: [PATCH 02/11] Shows the item that matches the entered URL (if exists) (#3543, #3684) --- src/git/models/__tests__/pullRequest.test.ts | 84 ++++++++++++++++++++ src/git/models/pullRequest.ts | 58 ++++++++++++++ src/plus/launchpad/launchpad.ts | 45 +++++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/git/models/__tests__/pullRequest.test.ts 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/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index a5b9f439074d8..8d8af8ed6b1b4 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -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'; @@ -540,12 +544,53 @@ export class LaunchpadCommand extends QuickCommand { RefreshQuickInputButton, ], onDidChangeValue: quickpick => { + const { value } = quickpick; const hideGroups = Boolean(quickpick.value?.length); if (groupsHidden !== hideGroups) { groupsHidden = hideGroups; quickpick.items = hideGroups ? items.filter(i => !isDirectiveQuickPickItem(i)) : items; } + const activeLaunchpadItems = quickpick.activeItems.filter( + (i): i is LaunchpadItemQuickPickItem => 'item' in i && !i.alwaysShow, + ); + + let updated = false; + for (const item of quickpick.items) { + if (item.alwaysShow) { + item.alwaysShow = false; + updated = true; + } + } + if (updated) { + // Force quickpick to update by changing the items object: + quickpick.items = [...quickpick.items]; + } + + if (!value?.length || activeLaunchpadItems.length) { + // Nothing to search + return true; + } + + const prUrlIdentity = getPullRequestIdentityValuesFromSearch(value); + if (prUrlIdentity.prNumber != null) { + 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]; + } + } + } return true; }, From f6586dec574c499c9bc86a7d0780dc1d21572b45 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 14 Oct 2024 16:01:09 +0200 Subject: [PATCH 03/11] Searches GitHub PR by the entered URL (#3543, #3684) --- docs/telemetry-events.md | 2 +- src/constants.telemetry.ts | 7 ++- src/plus/launchpad/launchpad.ts | 64 +++++++++++++++---------- src/plus/launchpad/launchpadProvider.ts | 52 +++++++++++++++++--- 4 files changed, 91 insertions(+), 34 deletions(-) diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 7fbc0ce45385b..4d692310a67c9 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' | 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' | 'getCodeSuggestionCounts', 'duration': number } ``` diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index e98a1b7922ad9..df588dec0a2eb 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -294,7 +294,12 @@ 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' + | 'getMyPullRequests' + | 'getCodeSuggestions' + | 'getEnrichedItems' + | 'getCodeSuggestionCounts'; duration: number; }; diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 8d8af8ed6b1b4..19d512a2535a8 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -419,6 +419,7 @@ export class LaunchpadCommand extends QuickCommand { i: LaunchpadItem, ui: LaunchpadGroup, topItem: LaunchpadItem | undefined, + alwaysShow: boolean | undefined, ): LaunchpadItemQuickPickItem => { const buttons = []; @@ -449,6 +450,7 @@ export class LaunchpadCommand extends QuickCommand { 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, @@ -457,7 +459,7 @@ export class LaunchpadCommand extends QuickCommand { }; }; - const getItems = (result: LaunchpadCategorizedResult) => { + const getItems = (result: LaunchpadCategorizedResult, isSearching?: boolean) => { const items: (LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem)[] = []; if (result.items?.length) { @@ -472,18 +474,21 @@ export class LaunchpadCommand extends QuickCommand { for (const [ui, groupItems] of uiGroups) { if (!groupItems.length) continue; - items.push(...buildGroupHeading(ui, groupItems.length)); - - if (context.collapsed.get(ui)) continue; + if (!isSearching) { + items.push(...buildGroupHeading(ui, groupItems.length)); + if (context.collapsed.get(ui)) { + continue; + } + } - items.push(...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem))); + 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 (${ @@ -506,17 +511,18 @@ export class LaunchpadCommand extends QuickCommand { return { placeholder: 'Choose an item to focus on', - items: getItems(context.result), + items: getItems(context.result, isSearching), }; } const updateItems = async ( quickpick: QuickPick, ) => { + const search = quickpick.value; quickpick.busy = true; try { - await updateContextItems(this.container, context, { force: true }); + await updateContextItems(this.container, context, { force: true, search: search }); const { items, placeholder } = getItemsAndPlaceholder(); quickpick.placeholder = placeholder; @@ -527,8 +533,7 @@ export class LaunchpadCommand extends QuickCommand { }; const { items, placeholder } = getItemsAndPlaceholder(); - - let groupsHidden = false; + const nonGroupedItems = items.filter(i => !isDirectiveQuickPickItem(i)); const step = createPickStep({ title: context.title, @@ -543,29 +548,27 @@ export class LaunchpadCommand extends QuickCommand { LaunchpadSettingsQuickInputButton, RefreshQuickInputButton, ], - onDidChangeValue: quickpick => { + onDidChangeValue: async quickpick => { const { value } = quickpick; - const hideGroups = Boolean(quickpick.value?.length); - - if (groupsHidden !== hideGroups) { - groupsHidden = hideGroups; - quickpick.items = hideGroups ? items.filter(i => !isDirectiveQuickPickItem(i)) : items; - } - const activeLaunchpadItems = quickpick.activeItems.filter( - (i): i is LaunchpadItemQuickPickItem => 'item' in i && !i.alwaysShow, - ); + const hideGroups = Boolean(value?.length); + const consideredItems = hideGroups ? nonGroupedItems : items; let updated = false; - for (const item of quickpick.items) { + for (const item of consideredItems) { if (item.alwaysShow) { item.alwaysShow = false; updated = true; } } - if (updated) { - // Force quickpick to update by changing the items object: - quickpick.items = [...quickpick.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; + + const activeLaunchpadItems = quickpick.activeItems.filter( + (i): i is LaunchpadItemQuickPickItem => 'item' in i, + ); if (!value?.length || activeLaunchpadItems.length) { // Nothing to search @@ -588,7 +591,12 @@ export class LaunchpadCommand extends QuickCommand { 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: + return true; } + // Nothing is found above, so let's perform search in the API: + await updateItems(quickpick); } } @@ -1377,7 +1385,11 @@ function getIntegrationTitle(integrationId: string): string { } } -async function updateContextItems(container: Container, context: Context, options?: { force?: boolean }) { +async function updateContextItems( + container: Container, + context: Context, + options?: { force?: boolean; search?: string }, +) { context.result = await container.launchpad.getCategorizedItems(options); if (container.telemetry.enabled) { updateTelemetryContext(context); diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index c7712e0278546..63ad9659afeb5 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, @@ -318,6 +320,34 @@ export class LaunchpadProvider implements Disposable { return { prs: prs, suggestionCounts: suggestionCounts }; } + private async getSearchedPullRequests(search: string) { + const { ownerAndRepo, prNumber } = getPullRequestIdentityValuesFromSearch(search); + let result: TimedResult | undefined; + + if (prNumber != null) { + if (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 }; + } + } + } + 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 +648,12 @@ 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 fireRefresh = options?.force || this._prs == null; + const fireRefresh = !options?.search && (options?.force || this._prs == null); const ignoredRepositories = new Set( (configuration.get('launchpad.ignoredRepositories') ?? []).map(r => r.toLowerCase()), @@ -644,7 +674,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 }), + options?.search + ? this.getSearchedPullRequests(options.search) + : this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), ]); if (cancellation?.isCancellationRequested) throw new CancellationError(); @@ -758,7 +790,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'; @@ -794,7 +826,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 +1100,12 @@ const slowEventTimeout = 1000 * 30; // 30 seconds function withDurationAndSlowEventOnTimeout( promise: Promise, - name: 'getMyPullRequests' | 'getCodeSuggestionCounts' | 'getCodeSuggestions' | 'getEnrichedItems', + name: + | 'getPullRequest' + | 'getMyPullRequests' + | 'getCodeSuggestionCounts' + | 'getCodeSuggestions' + | 'getEnrichedItems', container: Container, ): Promise> { return timedWithSlowThreshold(promise, { From 473ff0faeb5d66469a825ff744a85a4e1f29b730 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 18 Oct 2024 15:34:58 +0200 Subject: [PATCH 04/11] Simplifies old syncronous debouncing function (#3543, #3684) --- src/system/function.ts | 8 -------- 1 file changed, 8 deletions(-) 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); } From c70f39cca94c763b4ffafeab5c62437e6732b5f1 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 10 Oct 2024 16:28:29 +0200 Subject: [PATCH 05/11] Debounces user's typing (#3543, #3684) --- src/plus/launchpad/launchpad.ts | 21 +++-- src/system/vscode/asyncDebouncer.ts | 136 ++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/system/vscode/asyncDebouncer.ts diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 19d512a2535a8..6a134800bab3b 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -54,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'; @@ -149,6 +150,7 @@ const instanceCounter = getScopedCounter(); const defaultCollapsedGroups: LaunchpadGroup[] = ['draft', 'other', 'snoozed']; export class LaunchpadCommand extends QuickCommand { + private readonly updateItemsDebouncer = createAsyncDebouncer(500); private readonly source: Source; private readonly telemetryContext: LaunchpadTelemetryContext | undefined; @@ -520,13 +522,16 @@ export class LaunchpadCommand extends QuickCommand { ) => { const search = quickpick.value; quickpick.busy = true; - try { - await updateContextItems(this.container, context, { force: true, search: search }); - - const { items, placeholder } = getItemsAndPlaceholder(); - quickpick.placeholder = placeholder; - quickpick.items = items; + await this.updateItemsDebouncer(async cancellationToken => { + await updateContextItems(this.container, context, { force: true, search: search }); + if (cancellationToken.isCancellationRequested) { + return; + } + const { items, placeholder } = getItemsAndPlaceholder(Boolean(search)); + quickpick.placeholder = placeholder; + quickpick.items = items; + }); } finally { quickpick.busy = false; } @@ -572,6 +577,7 @@ export class LaunchpadCommand extends QuickCommand { if (!value?.length || activeLaunchpadItems.length) { // Nothing to search + this.updateItemsDebouncer.cancel(); return true; } @@ -593,13 +599,14 @@ export class LaunchpadCommand extends QuickCommand { 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; } // Nothing is found above, so let's perform search in the API: await updateItems(quickpick); } } - + this.updateItemsDebouncer.cancel(); return true; }, onDidClickButton: async (quickpick, button) => { 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; +} From 58fa19ff683c3ff5deb9397b27c8c83f32386429 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 17 Oct 2024 16:16:54 +0200 Subject: [PATCH 06/11] Passes debouncer's cancelation token to the `getCategorizedItems` method (#3543, #3684) --- src/plus/launchpad/launchpad.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 6a134800bab3b..d8cf89bae132d 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 { @@ -524,7 +524,12 @@ export class LaunchpadCommand extends QuickCommand { quickpick.busy = true; try { await this.updateItemsDebouncer(async cancellationToken => { - await updateContextItems(this.container, context, { force: true, search: search }); + await updateContextItems( + this.container, + context, + { force: true, search: search }, + cancellationToken, + ); if (cancellationToken.isCancellationRequested) { return; } @@ -1396,8 +1401,9 @@ async function updateContextItems( container: Container, context: Context, options?: { force?: boolean; search?: string }, + cancellation?: CancellationToken, ) { - context.result = await container.launchpad.getCategorizedItems(options); + context.result = await container.launchpad.getCategorizedItems(options, cancellation); if (container.telemetry.enabled) { updateTelemetryContext(context); } From 06569f3998217e87b37271bfe7f6f600b14c30a2 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 18 Oct 2024 18:25:22 +0200 Subject: [PATCH 07/11] Adds todo-anchors for simplifying the continuation of the current work (#3543, #3684) --- src/plus/launchpad/launchpad.ts | 6 ++++++ src/plus/launchpad/launchpadProvider.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index d8cf89bae132d..d5dfdfec3e35a 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -150,6 +150,7 @@ 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; @@ -586,6 +587,11 @@ export class LaunchpadCommand extends QuickCommand { 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) { const launchpadItems = quickpick.items.filter((i): i is LaunchpadItemQuickPickItem => 'item' in i); diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index 63ad9659afeb5..12986ca569b6f 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -321,6 +321,10 @@ export class LaunchpadProvider implements Disposable { } private async getSearchedPullRequests(search: string) { + // 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; From b39596a7e2415279df0c8f69e1e04fd1add1cb9c Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 29 Oct 2024 18:40:36 +0100 Subject: [PATCH 08/11] Fixes github provider search function (#3543, #3684) --- src/plus/integrations/providers/github/github.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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); From 21e956c181a5bec1873695f46965466b93570a67 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 29 Oct 2024 00:53:25 +0100 Subject: [PATCH 09/11] Searches by a text query using API (#3543, #3684) --- CHANGELOG.md | 4 ++ docs/telemetry-events.md | 2 +- src/constants.telemetry.ts | 1 + src/plus/launchpad/launchpad.ts | 39 ++++++++++++------- src/plus/launchpad/launchpadProvider.ts | 52 +++++++++++++++---------- 5 files changed, 63 insertions(+), 35 deletions(-) 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 4d692310a67c9..4805527f54c16 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -1305,7 +1305,7 @@ void ```typescript { 'timeout': number, - 'operation': 'getPullRequest' | '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 df588dec0a2eb..597f103911a41 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -296,6 +296,7 @@ export type TelemetryEvents = { timeout: number; operation: | 'getPullRequest' + | 'searchPullRequests' | 'getMyPullRequests' | 'getCodeSuggestions' | 'getEnrichedItems' diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index d5dfdfec3e35a..6d6d1c79c5d5a 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -518,6 +518,22 @@ export class LaunchpadCommand extends QuickCommand { }; } + 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, ) => { @@ -536,7 +552,7 @@ export class LaunchpadCommand extends QuickCommand { } const { items, placeholder } = getItemsAndPlaceholder(Boolean(search)); quickpick.placeholder = placeholder; - quickpick.items = items; + quickpick.items = search ? combineQuickpickItemsWithSearchResults(quickpick.items, items) : items; }); } finally { quickpick.busy = false; @@ -577,11 +593,7 @@ export class LaunchpadCommand extends QuickCommand { quickpick.items = updated && quickpick.items === consideredItems ? [...consideredItems] : consideredItems; - const activeLaunchpadItems = quickpick.activeItems.filter( - (i): i is LaunchpadItemQuickPickItem => 'item' in i, - ); - - if (!value?.length || activeLaunchpadItems.length) { + if (!value?.length) { // Nothing to search this.updateItemsDebouncer.cancel(); return true; @@ -593,7 +605,9 @@ export class LaunchpadCommand extends QuickCommand { // 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 @@ -608,16 +622,15 @@ export class LaunchpadCommand extends QuickCommand { 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; } - // Nothing is found above, so let's perform search in the API: - await updateItems(quickpick); + // 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; } } - this.updateItemsDebouncer.cancel(); + + await updateItems(quickpick); return true; }, onDidClickButton: async (quickpick, button) => { diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index 12986ca569b6f..4aaac0a63e40e 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -320,7 +320,7 @@ export class LaunchpadProvider implements Disposable { return { prs: prs, suggestionCounts: suggestionCounts }; } - private async getSearchedPullRequests(search: string) { + 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 } @@ -328,25 +328,34 @@ export class LaunchpadProvider implements Disposable { const { ownerAndRepo, prNumber } = getPullRequestIdentityValuesFromSearch(search); let result: TimedResult | undefined; - if (prNumber != null) { - if (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 }; - } + 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 }; @@ -679,7 +688,7 @@ export class LaunchpadProvider implements Disposable { this.container.git.isDiscoveringRepositories, this.getEnrichedItems({ force: options?.force, cancellation: cancellation }), options?.search - ? this.getSearchedPullRequests(options.search) + ? this.getSearchedPullRequests(options.search, cancellation) : this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), ]); @@ -1106,6 +1115,7 @@ function withDurationAndSlowEventOnTimeout( promise: Promise, name: | 'getPullRequest' + | 'searchPullRequests' | 'getMyPullRequests' | 'getCodeSuggestionCounts' | 'getCodeSuggestions' From 6b29c69b82ce9171f0c96c0489548e9499518343 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 5 Nov 2024 17:39:09 +0100 Subject: [PATCH 10/11] Updates text of the placeholder so it reflects the new features (#3543, #3684) --- src/plus/launchpad/launchpad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 6d6d1c79c5d5a..84f7c597444a6 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -513,7 +513,7 @@ export class LaunchpadCommand extends QuickCommand { } return { - placeholder: 'Choose an item to focus on', + placeholder: 'Choose an item, type a term to search, or paste in a PR URL', items: getItems(context.result, isSearching), }; } From 6460b406ce4d8a25d3ad7619f4cef804e2ad0725 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 6 Nov 2024 15:54:43 +0100 Subject: [PATCH 11/11] Removes Pin and Snooze action buttons from "additional" items (#3543, #3684) --- src/plus/launchpad/launchpad.ts | 18 ++++++++++++------ src/plus/launchpad/launchpadProvider.ts | 7 +++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 84f7c597444a6..f4751e7505aa7 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -430,10 +430,12 @@ export class LaunchpadCommand extends QuickCommand { buttons.push(MergeQuickInputButton); } - buttons.push( - i.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, - i.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, - ); + if (!i.isSearched) { + buttons.push( + i.viewer.pinned ? UnpinQuickInputButton : PinQuickInputButton, + i.viewer.snoozed ? UnsnoozeQuickInputButton : SnoozeQuickInputButton, + ); + } buttons.push(...getOpenOnGitProviderQuickInputButtons(i.provider.id)); @@ -752,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', diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index 4aaac0a63e40e..ca58018f53c96 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -190,6 +190,7 @@ export type LaunchpadItem = LaunchpadPullRequest & { codeSuggestionsCount: number; codeSuggestions?: TimedResult; isNew: boolean; + isSearched: boolean; actionableCategory: LaunchpadActionCategory; suggestedActions: LaunchpadAction[]; openRepository?: OpenRepository; @@ -665,8 +666,9 @@ export class LaunchpadProvider implements Disposable { cancellation?: CancellationToken, ): Promise { const scope = getLogScope(); + const isSearching = ((o?: { search?: string }): o is { search: string } => Boolean(o?.search))(options); - const fireRefresh = !options?.search && (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()), @@ -687,7 +689,7 @@ export class LaunchpadProvider implements Disposable { const [_, enrichedItemsResult, prsWithCountsResult] = await Promise.allSettled([ this.container.git.isDiscoveringRepositories, this.getEnrichedItems({ force: options?.force, cancellation: cancellation }), - options?.search + isSearching ? this.getSearchedPullRequests(options.search, cancellation) : this.getPullRequestsWithSuggestionCounts({ force: options?.force, cancellation: cancellation }), ]); @@ -821,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,