From a34948595e4cf2baecabffb88edf7fd421755820 Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Tue, 10 Dec 2024 14:27:25 -0700 Subject: [PATCH 1/5] Updates Launchpad search UX --- src/plus/launchpad/launchpad.ts | 177 ++++++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 42 deletions(-) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 2eedeb7915a59..1f4583e428e93 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -95,17 +95,42 @@ export interface LaunchpadItemQuickPickItem extends QuickPickItemOfT; telemetryContext: LaunchpadTelemetryContext | undefined; connectedIntegrations: Map; + isSearching: boolean; } interface GroupedLaunchpadItem extends LaunchpadItem { @@ -151,6 +177,7 @@ export class LaunchpadCommand extends QuickCommand { private readonly updateItemsDebouncer = createAsyncDebouncer(500); private readonly source: Source; private readonly telemetryContext: LaunchpadTelemetryContext | undefined; + private savedSearch: string | undefined; constructor(container: Container, args?: LaunchpadCommandArgs) { super(container, 'launchpad', 'launchpad', `GitLens Launchpad\u00a0\u00a0${proBadge}`, { @@ -223,6 +250,7 @@ export class LaunchpadCommand extends QuickCommand { collapsed: collapsed, telemetryContext: this.telemetryContext, connectedIntegrations: await this.container.launchpad.getConnectedIntegrations(), + isSearching: false, }; let opened = false; @@ -302,6 +330,12 @@ export class LaunchpadCommand extends QuickCommand { newlyConnected = Boolean(connected); await updateContextItems(this.container, context, { force: newlyConnected }); continue; + } else if (isToggleSearchItem(result)) { + context.isSearching = result.search; + if (!context.isSearching) { + this.updateItemsDebouncer.cancel(); + } + continue; } state.item = result; @@ -372,7 +406,7 @@ export class LaunchpadCommand extends QuickCommand { state: StepState, context: Context, { picked, selectTopItem }: { picked?: string; selectTopItem?: boolean }, - ): StepResultGenerator { + ): StepResultGenerator { const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); const buildGroupHeading = ( @@ -466,6 +500,7 @@ export class LaunchpadCommand extends QuickCommand { | LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem + | ToggleSearchItem )[] = []; if (items.length) { @@ -477,7 +512,11 @@ export class LaunchpadCommand extends QuickCommand { uiGroups.get('blocked')?.[0] || uiGroups.get('follow-up')?.[0] || uiGroups.get('needs-review')?.[0]; - for (const [ui, groupItems] of uiGroups) { + for (let [ui, groupItems] of uiGroups) { + if (context.isSearching) { + groupItems = groupItems.filter(i => i.isSearched); + } + if (!groupItems.length) continue; if (!isSearching) { @@ -488,7 +527,7 @@ export class LaunchpadCommand extends QuickCommand { } groupedAndSorted.push( - ...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, isSearching)), + ...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, context.isSearching)), ); } } @@ -497,6 +536,8 @@ export class LaunchpadCommand extends QuickCommand { }; function getItemsAndPlaceholder(isSearching?: boolean) { + const toggleSearchItem = context.isSearching ? toggleSearchOffItem : toggleSearchOnItem; + if (context.result.error != null) { return { placeholder: `Unable to load items (${ @@ -513,34 +554,22 @@ export class LaunchpadCommand extends QuickCommand { if (!context.result.items.length) { return { placeholder: 'All done! Take a vacation', - items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], + items: [toggleSearchItem], }; } return { - placeholder: 'Choose an item, type a term to search, or paste in a PR URL', - items: getLaunchpadQuickPickItems(context.result.items, isSearching), + placeholder: context.isSearching + ? 'Type a term to search' + : 'Choose an item or paste in a pull request URL to search', + items: [toggleSearchItem, ...getLaunchpadQuickPickItems(context.result.items, 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, + quickpick: QuickPick< + LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem | ToggleSearchItem + >, force?: boolean, ) => { const search = quickpick.value; @@ -550,7 +579,7 @@ export class LaunchpadCommand extends QuickCommand { await updateContextItems( this.container, context, - { force: force, search: search }, + { force: force, search: context.isSearching ? search : undefined }, cancellationToken, ); if (cancellationToken.isCancellationRequested) { @@ -558,7 +587,7 @@ export class LaunchpadCommand extends QuickCommand { } const { items, placeholder } = getItemsAndPlaceholder(Boolean(search)); quickpick.placeholder = placeholder; - quickpick.items = search ? combineQuickpickItemsWithSearchResults(quickpick.items, items) : items; + quickpick.items = items; }); } finally { quickpick.busy = false; @@ -568,7 +597,9 @@ export class LaunchpadCommand extends QuickCommand { // Should only be used for optimistic update of the list when some UI property (like pinned, snoozed) changed with an // item. For all other cases, use updateItems. const optimisticallyUpdateItems = ( - quickpick: QuickPick, + quickpick: QuickPick< + LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem | ToggleSearchItem + >, ) => { quickpick.items = getLaunchpadQuickPickItems( context.result.items, @@ -577,7 +608,6 @@ export class LaunchpadCommand extends QuickCommand { }; const { items, placeholder } = getItemsAndPlaceholder(); - const nonGroupedItems = items.filter(i => !isDirectiveQuickPickItem(i)); const step = createPickStep({ title: context.title, @@ -592,30 +622,72 @@ export class LaunchpadCommand extends QuickCommand { LaunchpadSettingsQuickInputButton, RefreshQuickInputButton, ], + onDidActivate: quickpick => { + if (this.savedSearch == null || this.savedSearch.length === 0) return; + if (context.isSearching) { + quickpick.value = this.savedSearch; + } + + this.savedSearch = undefined; + }, onDidChangeValue: async quickpick => { const { value } = quickpick; - const hideGroups = Boolean(value?.length); - const consideredItems = hideGroups ? nonGroupedItems : items; + this.savedSearch = value; - let updated = false; - for (const item of consideredItems) { + // Clear alwaysShow for items which we matched by PR number or other identity + for (const item of quickpick.items.filter( + i => !isDirectiveQuickPickItem(i) && !isToggleSearchItem(i), + )) { if (item.alwaysShow) { item.alwaysShow = false; - updated = true; } } - // 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(); + // Restore original list if search mode was active and input was cleared + if (context.isSearching) { + context.isSearching = false; + quickpick.busy = true; + quickpick.items = []; + quickpick.placeholder = 'Restoring your pull requests...'; + await updateItems(quickpick); + quickpick.busy = false; + // Restore category/divider items if not in search mode and input was cleared + } else { + const { items, placeholder } = getItemsAndPlaceholder(); + quickpick.placeholder = placeholder; + quickpick.items = items; + } + + return true; + } + + // In API search mode + if (context.isSearching) { + if (quickpick.activeItems.length === 0) { + // Show just the option to toggle search off if nothing found + quickpick.items = [toggleSearchOnItem]; + } else { + // Search the API and update the quickpick + await updateItems(quickpick, true); + } + + return true; + } + + // Out of API search mode + // This effectively hides the category/divider items + quickpick.items = getLaunchpadQuickPickItems(context.result.items, true); + + // Show just the option to toggle search on if nothing found + if (quickpick.activeItems.length === 0 && !isSupportedLaunchpadPullRequestUrl(value)) { + quickpick.items = [toggleSearchOnItem]; return true; } + // Match on some special cases like owner/repo, PR number, URL, etc. // 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 } @@ -625,7 +697,9 @@ export class LaunchpadCommand extends QuickCommand { 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); + const launchpadItems = quickpick.items.filter( + (i): i is LaunchpadItemQuickPickItem => 'item' in i && i.item?.id != null, + ); let item = launchpadItems.find(i => // perform strict match first doesPullRequestSatisfyRepositoryURLIdentity(i.item, prUrlIdentity), @@ -640,14 +714,21 @@ export class LaunchpadCommand extends QuickCommand { // 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: + // We have found an item that matches as a special case. this.updateItemsDebouncer.cancel(); return true; } } - await updateItems(quickpick, true); + // If a supported PR URL was entered but no existing items match outside of search mode, turn on search mode and search the API. + if (isSupportedLaunchpadPullRequestUrl(value)) { + context.isSearching = true; + await updateItems(quickpick, true); + } else if (quickpick.activeItems.length === 0 || quickpick.activeItems[0] === toggleSearchOnItem) { + // Show just the option to toggle search on if nothing found + quickpick.items = [...quickpick.items, toggleSearchOnItem]; + } + return true; }, onDidClickButton: async (quickpick, button) => { @@ -766,7 +847,7 @@ export class LaunchpadCommand extends QuickCommand { return StepResultBreak; } const element = selection[0]; - if (isConnectMoreIntegrationsItem(element)) { + if (isConnectMoreIntegrationsItem(element) || isToggleSearchItem(element)) { return element; } return { ...element.item, group: element.group }; @@ -1510,3 +1591,15 @@ function updateTelemetryContext(context: Context) { function isLaunchpadTargetActionQuickPickItem(item: any): item is QuickPickItemOfT { return item?.item?.action != null && item?.item?.target != null; } + +function isGitHubPullRequestUrl(search: string) { + return search.includes('github.com') && search.includes('/pull/'); +} + +function isGitLabPullRequestUrl(search: string) { + return search.includes('gitlab.com') && search.includes('/merge_requests/'); +} + +function isSupportedLaunchpadPullRequestUrl(search: string) { + return isGitHubPullRequestUrl(search) || isGitLabPullRequestUrl(search); +} From 0e313886344f65899722f18e4cd7b1a95fb9c624 Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Tue, 10 Dec 2024 15:27:46 -0700 Subject: [PATCH 2/5] Improves PR url checks --- src/plus/launchpad/launchpad.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 1f4583e428e93..8202f2c96a237 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -682,7 +682,7 @@ export class LaunchpadCommand extends QuickCommand { quickpick.items = getLaunchpadQuickPickItems(context.result.items, true); // Show just the option to toggle search on if nothing found - if (quickpick.activeItems.length === 0 && !isSupportedLaunchpadPullRequestUrl(value)) { + if (quickpick.activeItems.length === 0 && !isSupportedLaunchpadPullRequestSearchUrl(value)) { quickpick.items = [toggleSearchOnItem]; return true; } @@ -721,7 +721,7 @@ export class LaunchpadCommand extends QuickCommand { } // If a supported PR URL was entered but no existing items match outside of search mode, turn on search mode and search the API. - if (isSupportedLaunchpadPullRequestUrl(value)) { + if (isSupportedLaunchpadPullRequestSearchUrl(value)) { context.isSearching = true; await updateItems(quickpick, true); } else if (quickpick.activeItems.length === 0 || quickpick.activeItems[0] === toggleSearchOnItem) { @@ -1593,13 +1593,23 @@ function isLaunchpadTargetActionQuickPickItem(item: any): item is QuickPickItemO } function isGitHubPullRequestUrl(search: string) { - return search.includes('github.com') && search.includes('/pull/'); + try { + const url = new URL(search); + return url.host === 'github.com' && url.pathname.includes('/pull/'); + } catch { + return false; + } } function isGitLabPullRequestUrl(search: string) { - return search.includes('gitlab.com') && search.includes('/merge_requests/'); + try { + const url = new URL(search); + return url.host === 'gitlab.com' && url.pathname.includes('/merge_requests/'); + } catch { + return false; + } } -function isSupportedLaunchpadPullRequestUrl(search: string) { +function isSupportedLaunchpadPullRequestSearchUrl(search: string) { return isGitHubPullRequestUrl(search) || isGitLabPullRequestUrl(search); } From 137d5517754c45ae1c81f82913e6a1b6d8d919f7 Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Tue, 10 Dec 2024 15:37:31 -0700 Subject: [PATCH 3/5] Moves util functions to better locations --- .../integrations/providers/github/models.ts | 9 ++++++++ .../integrations/providers/gitlab/models.ts | 9 ++++++++ src/plus/launchpad/launchpad.ts | 23 +------------------ src/plus/launchpad/utils.ts | 6 +++++ 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/plus/integrations/providers/github/models.ts b/src/plus/integrations/providers/github/models.ts index 47017a55c1f5f..afbf165ac769b 100644 --- a/src/plus/integrations/providers/github/models.ts +++ b/src/plus/integrations/providers/github/models.ts @@ -508,3 +508,12 @@ export function fromCommitFileStatus( } return undefined; } + +export function isGitHubPullRequestUrl(search: string): boolean { + try { + const url = new URL(search); + return url.host === 'github.com' && url.pathname.includes('/pull/'); + } catch { + return false; + } +} diff --git a/src/plus/integrations/providers/gitlab/models.ts b/src/plus/integrations/providers/gitlab/models.ts index 846588c795540..40ed396937abd 100644 --- a/src/plus/integrations/providers/gitlab/models.ts +++ b/src/plus/integrations/providers/gitlab/models.ts @@ -150,3 +150,12 @@ export function fromGitLabMergeRequestProvidersApi(pr: ProviderPullRequest, prov }; return fromProviderPullRequest(wrappedPr, provider); } + +export function isGitLabPullRequestUrl(search: string): boolean { + try { + const url = new URL(search); + return url.host === 'gitlab.com' && url.pathname.includes('/merge_requests/'); + } catch { + return false; + } +} diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 8202f2c96a237..6b1bd6c46581a 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -73,6 +73,7 @@ import { launchpadGroups, supportedLaunchpadIntegrations, } from './launchpadProvider'; +import { isSupportedLaunchpadPullRequestSearchUrl } from './utils'; const actionGroupMap = new Map([ ['mergeable', ['Ready to Merge', 'Ready to merge']], @@ -1591,25 +1592,3 @@ function updateTelemetryContext(context: Context) { function isLaunchpadTargetActionQuickPickItem(item: any): item is QuickPickItemOfT { return item?.item?.action != null && item?.item?.target != null; } - -function isGitHubPullRequestUrl(search: string) { - try { - const url = new URL(search); - return url.host === 'github.com' && url.pathname.includes('/pull/'); - } catch { - return false; - } -} - -function isGitLabPullRequestUrl(search: string) { - try { - const url = new URL(search); - return url.host === 'gitlab.com' && url.pathname.includes('/merge_requests/'); - } catch { - return false; - } -} - -function isSupportedLaunchpadPullRequestSearchUrl(search: string) { - return isGitHubPullRequestUrl(search) || isGitLabPullRequestUrl(search); -} diff --git a/src/plus/launchpad/utils.ts b/src/plus/launchpad/utils.ts index 9c0b8566ad16e..de333e7360ae6 100644 --- a/src/plus/launchpad/utils.ts +++ b/src/plus/launchpad/utils.ts @@ -1,5 +1,7 @@ import type { Container } from '../../container'; import { configuration } from '../../system/vscode/configuration'; +import { isGitHubPullRequestUrl } from '../integrations/providers/github/models'; +import { isGitLabPullRequestUrl } from '../integrations/providers/gitlab/models'; import type { LaunchpadSummaryResult } from './launchpadIndicator'; import { generateLaunchpadSummary } from './launchpadIndicator'; import type { LaunchpadGroup } from './launchpadProvider'; @@ -16,3 +18,7 @@ export async function getLaunchpadSummary(container: Container): Promise Date: Mon, 16 Dec 2024 00:38:34 -0500 Subject: [PATCH 4/5] Refactors pull request searching in Launchpad Separates search in 3 modes: filtering, url search, & search mode - Introduces dedicated search mode UI with clearer user feedback - Consolidates GitHub/GitLab PR URL parsing into a single utility - Reduces UI flicker during searches with optimistic updates --- src/git/models/pullRequest.ts | 6 +- src/git/models/pullRequest.utils.ts | 10 +- .../integrations/providers/github/models.ts | 24 +- .../integrations/providers/gitlab/models.ts | 22 +- src/plus/launchpad/launchpad.ts | 439 ++++++++++-------- src/plus/launchpad/launchpadProvider.ts | 19 +- src/plus/launchpad/utils.ts | 26 +- src/system/vscode/asyncDebouncer.ts | 7 +- 8 files changed, 330 insertions(+), 223 deletions(-) diff --git a/src/git/models/pullRequest.ts b/src/git/models/pullRequest.ts index 0245b9ff340e4..365b6255e44ac 100644 --- a/src/git/models/pullRequest.ts +++ b/src/git/models/pullRequest.ts @@ -7,10 +7,10 @@ import { formatDate, fromNow } from '../../system/date'; import { memoize } from '../../system/decorators/memoize'; import type { LeftRightCommitCountResult } from '../gitProvider'; import type { IssueOrPullRequest, IssueRepository, IssueOrPullRequestState as PullRequestState } from './issue'; -import type { PullRequestURLIdentity } from './pullRequest.utils'; +import type { PullRequestUrlIdentity } from './pullRequest.utils'; import type { ProviderReference } from './remoteProvider'; import type { Repository } from './repository'; -import { createRevisionRange , shortenRevision } from './revision.utils'; +import { createRevisionRange, shortenRevision } from './revision.utils'; export type { PullRequestState }; @@ -420,7 +420,7 @@ export async function getOpenedPullRequestRepo( export function doesPullRequestSatisfyRepositoryURLIdentity( pr: EnrichablePullRequest | undefined, - { ownerAndRepo, prNumber }: PullRequestURLIdentity, + { ownerAndRepo, prNumber }: PullRequestUrlIdentity, ): boolean { if (pr == null) { return false; diff --git a/src/git/models/pullRequest.utils.ts b/src/git/models/pullRequest.utils.ts index 0f6bf01d819b3..28e9ee2353d3f 100644 --- a/src/git/models/pullRequest.utils.ts +++ b/src/git/models/pullRequest.utils.ts @@ -2,12 +2,16 @@ // To avoid this file has been created that can collect more simple functions which // don't require Container and can be tested. -export type PullRequestURLIdentity = { +import type { HostingIntegrationId } from '../../constants.integrations'; + +export interface PullRequestUrlIdentity { + provider?: HostingIntegrationId; + ownerAndRepo?: string; prNumber?: string; -}; +} -export function getPullRequestIdentityValuesFromSearch(search: string): PullRequestURLIdentity { +export function getPullRequestIdentityValuesFromSearch(search: string): PullRequestUrlIdentity { let ownerAndRepo: string | undefined = undefined; let prNumber: string | undefined = undefined; diff --git a/src/plus/integrations/providers/github/models.ts b/src/plus/integrations/providers/github/models.ts index afbf165ac769b..3777745550420 100644 --- a/src/plus/integrations/providers/github/models.ts +++ b/src/plus/integrations/providers/github/models.ts @@ -1,4 +1,5 @@ import type { Endpoints } from '@octokit/types'; +import { HostingIntegrationId } from '../../../../constants.integrations'; import { GitFileIndexStatus } from '../../../../git/models/file'; import type { IssueLabel } from '../../../../git/models/issue'; import { Issue, RepositoryAccessLevel } from '../../../../git/models/issue'; @@ -10,6 +11,7 @@ import { PullRequestReviewState, PullRequestStatusCheckRollupState, } from '../../../../git/models/pullRequest'; +import type { PullRequestUrlIdentity } from '../../../../git/models/pullRequest.utils'; import type { Provider } from '../../../../git/models/remoteProvider'; export interface GitHubBlame { @@ -509,11 +511,19 @@ export function fromCommitFileStatus( return undefined; } -export function isGitHubPullRequestUrl(search: string): boolean { - try { - const url = new URL(search); - return url.host === 'github.com' && url.pathname.includes('/pull/'); - } catch { - return false; - } +const prUrlRegex = /^(?:https?:\/\/)?(?:github\.com\/)?([^/]+\/[^/]+)\/pull\/(\d+)/i; + +export function isMaybeGitHubPullRequestUrl(url: string): boolean { + if (url == null) return false; + + return prUrlRegex.test(url); +} + +export function getGitHubPullRequestIdentityFromMaybeUrl(url: string): RequireSome { + if (url == null) return { prNumber: undefined, ownerAndRepo: undefined, provider: HostingIntegrationId.GitHub }; + + const match = prUrlRegex.exec(url); + if (match == null) return { prNumber: undefined, ownerAndRepo: undefined, provider: HostingIntegrationId.GitHub }; + + return { prNumber: match[2], ownerAndRepo: match[1], provider: HostingIntegrationId.GitHub }; } diff --git a/src/plus/integrations/providers/gitlab/models.ts b/src/plus/integrations/providers/gitlab/models.ts index 40ed396937abd..86a6f16a39b1c 100644 --- a/src/plus/integrations/providers/gitlab/models.ts +++ b/src/plus/integrations/providers/gitlab/models.ts @@ -1,5 +1,7 @@ +import { HostingIntegrationId } from '../../../../constants.integrations'; import type { PullRequestState } from '../../../../git/models/pullRequest'; import { PullRequest } from '../../../../git/models/pullRequest'; +import type { PullRequestUrlIdentity } from '../../../../git/models/pullRequest.utils'; import type { Provider } from '../../../../git/models/remoteProvider'; import type { Integration } from '../../integration'; import type { ProviderPullRequest } from '../models'; @@ -151,11 +153,17 @@ export function fromGitLabMergeRequestProvidersApi(pr: ProviderPullRequest, prov return fromProviderPullRequest(wrappedPr, provider); } -export function isGitLabPullRequestUrl(search: string): boolean { - try { - const url = new URL(search); - return url.host === 'gitlab.com' && url.pathname.includes('/merge_requests/'); - } catch { - return false; - } +const prUrlRegex = /^(?:https?:\/\/)?(?:gitlab\.com\/)?(.+?)\/-\/merge_requests\/(\d+)/i; + +export function isMaybeGitLabPullRequestUrl(url: string): boolean { + return prUrlRegex.test(url); +} + +export function getGitLabPullRequestIdentityFromMaybeUrl(url: string): RequireSome { + if (url == null) return { prNumber: undefined, ownerAndRepo: undefined, provider: HostingIntegrationId.GitLab }; + + const match = prUrlRegex.exec(url); + if (match == null) return { prNumber: undefined, ownerAndRepo: undefined, provider: HostingIntegrationId.GitLab }; + + return { prNumber: match[2], ownerAndRepo: match[1], provider: HostingIntegrationId.GitLab }; } diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 6b1bd6c46581a..9149aa2b6cf00 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -1,5 +1,5 @@ import type { CancellationToken, QuickInputButton, QuickPick, QuickPickItem } from 'vscode'; -import { commands, ThemeIcon, Uri } from 'vscode'; +import { commands, QuickInputButtons, ThemeIcon, Uri } from 'vscode'; import { getAvatarUri } from '../../avatars'; import type { AsyncStepResultGenerator, @@ -41,8 +41,6 @@ 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 } from '../../git/models/pullRequest'; -import { getPullRequestIdentityValuesFromSearch } from '../../git/models/pullRequest.utils'; import type { QuickPickItemOfT } from '../../quickpicks/items/common'; import { createQuickPickItemOfT, createQuickPickSeparator } from '../../quickpicks/items/common'; import type { DirectiveQuickPickItem } from '../../quickpicks/items/directive'; @@ -73,7 +71,7 @@ import { launchpadGroups, supportedLaunchpadIntegrations, } from './launchpadProvider'; -import { isSupportedLaunchpadPullRequestSearchUrl } from './utils'; +import { isMaybeSupportedLaunchpadPullRequestSearchUrl } from './utils'; const actionGroupMap = new Map([ ['mergeable', ['Ready to Merge', 'Ready to merge']], @@ -89,61 +87,69 @@ const actionGroupMap = new Map([ ['other', ['Other', `Opened by \${author} \${createdDateRelative}`]], ]); -export interface LaunchpadItemQuickPickItem extends QuickPickItemOfT { - group: LaunchpadGroup; +export interface LaunchpadItemQuickPickItem extends QuickPickItem { + readonly type: 'item'; + readonly item: LaunchpadItem; + readonly group: LaunchpadGroup; + + searchModeEnabled?: never; } -type ConnectMoreIntegrationsItem = QuickPickItem & { - item: undefined; - group: undefined; - search: undefined; -}; -const connectMoreIntegrationsItem: ConnectMoreIntegrationsItem = { - label: 'Connect an Additional Integration...', - detail: 'Connect additional integrations to view their pull requests in Launchpad', - item: undefined, - group: undefined, - search: undefined, -}; -function isConnectMoreIntegrationsItem(item: unknown): item is ConnectMoreIntegrationsItem { - return item === connectMoreIntegrationsItem; +interface ConnectMoreIntegrationsQuickPickItem extends QuickPickItem { + readonly type: 'integrations'; + readonly item?: never; + readonly group?: never; + + searchModeEnabled?: never; } -type ToggleSearchItem = QuickPickItem & { - item: undefined; - group: undefined; - search: boolean; -}; -const toggleSearchOnItem: ToggleSearchItem = { - label: 'Search for Other Pull Requests...', - item: undefined, - group: undefined, - search: true, - alwaysShow: true, -}; -const toggleSearchOffItem: ToggleSearchItem = { - label: 'Stop Searching', - item: undefined, - group: undefined, - search: false, - alwaysShow: true, -}; -function isToggleSearchItem(item: unknown): item is ToggleSearchItem { - return item === toggleSearchOnItem || item === toggleSearchOffItem; +interface ToggleSearchModeQuickPickItem extends QuickPickItem { + readonly type: 'searchMode'; + readonly item?: never; + readonly group?: never; + + searchMode: boolean; } +type LaunchpadQuickPickItem = + | LaunchpadItemQuickPickItem + | ConnectMoreIntegrationsQuickPickItem + | ToggleSearchModeQuickPickItem + | DirectiveQuickPickItem; + +function isConnectMoreIntegrationsItem(item: LaunchpadQuickPickItem): item is ConnectMoreIntegrationsQuickPickItem { + return !isDirectiveQuickPickItem(item) && item?.type === 'integrations'; +} + +// function isLaunchpadItem(item: LaunchpadQuickPickItem): item is LaunchpadItemQuickPickItem { +// return !isDirectiveQuickPickItem(item) && item?.type === 'item'; +// } + +function isToggleSearchModeItem(item: LaunchpadQuickPickItem): item is ToggleSearchModeQuickPickItem { + return !isDirectiveQuickPickItem(item) && item?.type === 'searchMode'; +} + +const connectMoreIntegrationsItem: ConnectMoreIntegrationsQuickPickItem = { + type: 'integrations', + label: 'Connect an Additional Integration...', + detail: 'Connect additional integrations to view their pull requests in Launchpad', +}; + interface Context { result: LaunchpadCategorizedResult; + searchResult: LaunchpadCategorizedResult | undefined; title: string; collapsed: Map; telemetryContext: LaunchpadTelemetryContext | undefined; connectedIntegrations: Map; - isSearching: boolean; + + inSearch: boolean | 'mode'; + updateItemsDebouncer: ReturnType; } interface GroupedLaunchpadItem extends LaunchpadItem { - group: LaunchpadGroup; + readonly group: LaunchpadGroup; } interface State { @@ -174,8 +180,6 @@ 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; private savedSearch: string | undefined; @@ -245,19 +249,31 @@ export class LaunchpadCommand extends QuickCommand { } } + using updateItemsDebouncer = createAsyncDebouncer(500); + const context: Context = { result: { items: [] }, + searchResult: undefined, title: this.title, collapsed: collapsed, telemetryContext: this.telemetryContext, connectedIntegrations: await this.container.launchpad.getConnectedIntegrations(), - isSearching: false, + inSearch: false, + updateItemsDebouncer: updateItemsDebouncer, + }; + + const toggleSearchMode = (enabled: boolean) => { + context.inSearch = enabled ? 'mode' : false; + context.searchResult = undefined; + context.updateItemsDebouncer.cancel(); + state.item = undefined; }; let opened = false; while (this.canStepsContinue(state)) { context.title = this.title; + context.updateItemsDebouncer.cancel(); let newlyConnected = false; const hasConnectedIntegrations = [...context.connectedIntegrations.values()].some(c => c); @@ -316,9 +332,14 @@ export class LaunchpadCommand extends QuickCommand { picked: state.item?.graphQLId, selectTopItem: state.selectTopItem, }); - if (result === StepResultBreak) continue; + if (result === StepResultBreak) { + toggleSearchMode(false); + continue; + } if (isConnectMoreIntegrationsItem(result)) { + toggleSearchMode(false); + const isUsingCloudIntegrations = configuration.get('cloudIntegrations.enabled', undefined, false); const result = isUsingCloudIntegrations ? yield* this.confirmCloudIntegrationsConnectStep(state, context) @@ -330,16 +351,19 @@ export class LaunchpadCommand extends QuickCommand { const connected = result.connected; newlyConnected = Boolean(connected); await updateContextItems(this.container, context, { force: newlyConnected }); + + state.counter--; continue; - } else if (isToggleSearchItem(result)) { - context.isSearching = result.search; - if (!context.isSearching) { - this.updateItemsDebouncer.cancel(); - } + } + + if (isToggleSearchModeItem(result)) { + toggleSearchMode(result.searchMode); + + state.counter--; continue; } - state.item = result; + state.item = { ...result.item, group: result.group }; } assertsLaunchpadStepState(state); @@ -407,7 +431,9 @@ export class LaunchpadCommand extends QuickCommand { state: StepState, context: Context, { picked, selectTopItem }: { picked?: string; selectTopItem?: boolean }, - ): StepResultGenerator { + ): StepResultGenerator< + LaunchpadItemQuickPickItem | ConnectMoreIntegrationsQuickPickItem | ToggleSearchModeQuickPickItem + > { const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c); const buildGroupHeading = ( @@ -476,6 +502,7 @@ export class LaunchpadCommand extends QuickCommand { } return { + type: 'item', 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 ${ @@ -496,12 +523,12 @@ export class LaunchpadCommand extends QuickCommand { }; }; - const getLaunchpadQuickPickItems = (items: LaunchpadItem[] = [], isSearching?: boolean) => { + const getLaunchpadQuickPickItems = (items: LaunchpadItem[] = [], isFiltering?: boolean) => { const groupedAndSorted: ( | LaunchpadItemQuickPickItem | DirectiveQuickPickItem - | ConnectMoreIntegrationsItem - | ToggleSearchItem + | ConnectMoreIntegrationsQuickPickItem + | ToggleSearchModeQuickPickItem )[] = []; if (items.length) { @@ -514,13 +541,13 @@ export class LaunchpadCommand extends QuickCommand { uiGroups.get('follow-up')?.[0] || uiGroups.get('needs-review')?.[0]; for (let [ui, groupItems] of uiGroups) { - if (context.isSearching) { + if (context.inSearch) { groupItems = groupItems.filter(i => i.isSearched); } if (!groupItems.length) continue; - if (!isSearching) { + if (!isFiltering) { groupedAndSorted.push(...buildGroupHeading(ui, groupItems.length)); if (context.collapsed.get(ui)) { continue; @@ -528,7 +555,7 @@ export class LaunchpadCommand extends QuickCommand { } groupedAndSorted.push( - ...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, context.isSearching)), + ...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, Boolean(context.inSearch))), ); } } @@ -536,60 +563,139 @@ export class LaunchpadCommand extends QuickCommand { return groupedAndSorted; }; - function getItemsAndPlaceholder(isSearching?: boolean) { - const toggleSearchItem = context.isSearching ? toggleSearchOffItem : toggleSearchOnItem; + function getItemsAndQuickpickProps(isFiltering?: boolean) { + const result = context.inSearch ? context.searchResult : context.result; - if (context.result.error != null) { + if (result?.error != null) { return { + title: `${context.title} \u00a0\u2022\u00a0 Unable to Load Items`, placeholder: `Unable to load items (${ - context.result.error.name === 'HttpError' && - 'status' in context.result.error && - typeof context.result.error.status === 'number' - ? `${context.result.error.status}: ${String(context.result.error)}` - : String(context.result.error) + result.error.name === 'HttpError' && + 'status' in result.error && + typeof result.error.status === 'number' + ? `${result.error.status}: ${String(result.error)}` + : String(result.error) })`, items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], }; } - if (!context.result.items.length) { + if (!result?.items.length) { + if (context.inSearch === 'mode') { + return { + title: `Search For Pull Request \u00a0\u2022\u00a0 ${context.title}`, + placeholder: 'Enter a term to search for a pull request to act on', + items: [ + { + type: 'searchMode', + searchMode: false, + label: 'Cancel Searching', + detail: isFiltering ? 'No pull requests found' : 'Go back to Launchpad', + alwaysShow: true, + picked: true, + } satisfies ToggleSearchModeQuickPickItem, + ], + }; + } + return { + title: context.title, placeholder: 'All done! Take a vacation', - items: [toggleSearchItem], + items: [ + { + type: 'searchMode', + searchMode: true, + label: 'Search for Pull Request...', + alwaysShow: true, + picked: true, + } satisfies ToggleSearchModeQuickPickItem, + ], + }; + } + + const items = getLaunchpadQuickPickItems(result.items, isFiltering); + const hasPicked = items.some(i => i.picked); + if (context.inSearch === 'mode') { + const offItem: ToggleSearchModeQuickPickItem = { + type: 'searchMode', + searchMode: false, + label: 'Cancel Searching', + detail: 'Go back to Launchpad', + alwaysShow: true, + picked: !isFiltering && !hasPicked, + }; + + return { + title: `Search For Pull Request \u00a0\u2022\u00a0 ${context.title}`, + placeholder: 'Enter a term to search for a pull request to act on', + items: isFiltering ? [...items, offItem] : [offItem, ...items], }; } + const onItem: ToggleSearchModeQuickPickItem = { + type: 'searchMode', + searchMode: true, + label: 'Search for Pull Request...', + alwaysShow: true, + picked: !isFiltering && !hasPicked, + }; + return { - placeholder: context.isSearching - ? 'Type a term to search' - : 'Choose an item or paste in a pull request URL to search', - items: [toggleSearchItem, ...getLaunchpadQuickPickItems(context.result.items, isSearching)], + title: context.title, + placeholder: 'Choose a pull request or paste a pull request URL to act on', + items: isFiltering + ? [...items, onItem] + : [onItem, ...getLaunchpadQuickPickItems(result.items, isFiltering)], }; } const updateItems = async ( quickpick: QuickPick< - LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem | ToggleSearchItem + | LaunchpadItemQuickPickItem + | DirectiveQuickPickItem + | ConnectMoreIntegrationsQuickPickItem + | ToggleSearchModeQuickPickItem >, - force?: boolean, + options?: { force?: boolean; immediate?: boolean }, ) => { - const search = quickpick.value; + const search = context.inSearch ? quickpick.value : undefined; quickpick.busy = true; try { - await this.updateItemsDebouncer(async cancellationToken => { + const update = async (cancellationToken: CancellationToken | undefined) => { + if (context.inSearch === 'mode') { + quickpick.items = [ + { + type: 'searchMode', + searchMode: false, + label: `Searching for "${quickpick.value}"...`, + detail: 'Click to cancel searching', + alwaysShow: true, + picked: true, + } satisfies ToggleSearchModeQuickPickItem, + ]; + } + await updateContextItems( this.container, context, - { force: force, search: context.isSearching ? search : undefined }, + { force: options?.force, search: search }, cancellationToken, ); - if (cancellationToken.isCancellationRequested) { - return; - } - const { items, placeholder } = getItemsAndPlaceholder(Boolean(search)); + + if (cancellationToken?.isCancellationRequested) return; + + const { items, placeholder, title } = getItemsAndQuickpickProps(Boolean(search)); + quickpick.title = title; quickpick.placeholder = placeholder; quickpick.items = items; - }); + }; + + if (options?.immediate) { + context.updateItemsDebouncer.cancel(); + await update(undefined); + } else { + await context.updateItemsDebouncer(update); + } } finally { quickpick.busy = false; } @@ -599,33 +705,40 @@ export class LaunchpadCommand extends QuickCommand { // item. For all other cases, use updateItems. const optimisticallyUpdateItems = ( quickpick: QuickPick< - LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem | ToggleSearchItem + | LaunchpadItemQuickPickItem + | DirectiveQuickPickItem + | ConnectMoreIntegrationsQuickPickItem + | ToggleSearchModeQuickPickItem >, ) => { - quickpick.items = getLaunchpadQuickPickItems( - context.result.items, - quickpick.value != null && quickpick.value.length > 0, - ); + const { items, placeholder, title } = getItemsAndQuickpickProps(Boolean(quickpick.value)); + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.items = items; }; - const { items, placeholder } = getItemsAndPlaceholder(); + const { items, placeholder, title } = getItemsAndQuickpickProps(); + const inSearchMode = context.inSearch === 'mode'; const step = createPickStep({ - title: context.title, + title: title, placeholder: placeholder, - matchOnDescription: true, - matchOnDetail: true, + matchOnDescription: !inSearchMode, + matchOnDetail: !inSearchMode, items: items, buttons: [ + ...(inSearchMode ? [QuickInputButtons.Back] : []), // FeedbackQuickInputButton, OpenOnWebQuickInputButton, ...(hasDisconnectedIntegrations ? [ConnectIntegrationButton] : []), LaunchpadSettingsQuickInputButton, RefreshQuickInputButton, ], + // TODO@axosoft-ramint why is this needed? onDidActivate: quickpick => { - if (this.savedSearch == null || this.savedSearch.length === 0) return; - if (context.isSearching) { + if (!this.savedSearch?.length) return; + + if (context.inSearch === 'mode') { quickpick.value = this.savedSearch; } @@ -635,99 +748,47 @@ export class LaunchpadCommand extends QuickCommand { const { value } = quickpick; this.savedSearch = value; - // Clear alwaysShow for items which we matched by PR number or other identity - for (const item of quickpick.items.filter( - i => !isDirectiveQuickPickItem(i) && !isToggleSearchItem(i), - )) { - if (item.alwaysShow) { - item.alwaysShow = false; - } - } - + // Nothing to search if (!value?.length) { - // Nothing to search - this.updateItemsDebouncer.cancel(); - // Restore original list if search mode was active and input was cleared - if (context.isSearching) { - context.isSearching = false; - quickpick.busy = true; - quickpick.items = []; - quickpick.placeholder = 'Restoring your pull requests...'; - await updateItems(quickpick); - quickpick.busy = false; - // Restore category/divider items if not in search mode and input was cleared - } else { - const { items, placeholder } = getItemsAndPlaceholder(); - quickpick.placeholder = placeholder; - quickpick.items = items; - } + context.updateItemsDebouncer.cancel(); - return true; - } - - // In API search mode - if (context.isSearching) { - if (quickpick.activeItems.length === 0) { - // Show just the option to toggle search off if nothing found - quickpick.items = [toggleSearchOnItem]; - } else { - // Search the API and update the quickpick - await updateItems(quickpick, true); + context.searchResult = undefined; + if (context.inSearch === true) { + context.inSearch = false; } + // Restore category/divider items + const { items, placeholder, title } = getItemsAndQuickpickProps(); + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.items = items; + return true; } - // Out of API search mode - // This effectively hides the category/divider items - quickpick.items = getLaunchpadQuickPickItems(context.result.items, true); + // In API search mode, search the API and update the quickpick + if (context.inSearch || isMaybeSupportedLaunchpadPullRequestSearchUrl(value)) { + let immediate = false; + if (!context.inSearch) { + immediate = value.length >= 3; - // Show just the option to toggle search on if nothing found - if (quickpick.activeItems.length === 0 && !isSupportedLaunchpadPullRequestSearchUrl(value)) { - quickpick.items = [toggleSearchOnItem]; - return true; - } + // Hide the category/divider items quickly + const { items } = getItemsAndQuickpickProps(true); + quickpick.items = items; - // Match on some special cases like owner/repo, PR number, URL, etc. - // 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 && i.item?.id != null, - ); - 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); + context.inSearch = true; } - 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 as a special case. - this.updateItemsDebouncer.cancel(); - return true; - } - } - // If a supported PR URL was entered but no existing items match outside of search mode, turn on search mode and search the API. - if (isSupportedLaunchpadPullRequestSearchUrl(value)) { - context.isSearching = true; - await updateItems(quickpick, true); - } else if (quickpick.activeItems.length === 0 || quickpick.activeItems[0] === toggleSearchOnItem) { - // Show just the option to toggle search on if nothing found - quickpick.items = [...quickpick.items, toggleSearchOnItem]; + await updateItems(quickpick, { force: true, immediate: immediate }); + } else { + // Out of API search mode + context.updateItemsDebouncer.cancel(); + + // Hide the category/divider items quickly + const { items } = getItemsAndQuickpickProps(true); + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.items = items; } return true; @@ -755,7 +816,7 @@ export class LaunchpadCommand extends QuickCommand { case RefreshQuickInputButton: this.sendTitleActionTelemetry('refresh', context); - await updateItems(quickpick, true); + await updateItems(quickpick, { force: true, immediate: true }); break; } return undefined; @@ -844,14 +905,9 @@ export class LaunchpadCommand extends QuickCommand { }); const selection: StepSelection = yield step; - if (!canPickStepContinue(step, state, selection)) { - return StepResultBreak; - } - const element = selection[0]; - if (isConnectMoreIntegrationsItem(element) || isToggleSearchItem(element)) { - return element; - } - return { ...element.item, group: element.group }; + if (!canPickStepContinue(step, state, selection)) return StepResultBreak; + + return selection[0]; } private *confirmStep( @@ -1552,9 +1608,14 @@ async function updateContextItems( options?: { force?: boolean; search?: string }, cancellation?: CancellationToken, ) { - context.result = await container.launchpad.getCategorizedItems(options, cancellation); - if (container.telemetry.enabled) { - updateTelemetryContext(context); + const result = await container.launchpad.getCategorizedItems(options, cancellation); + if (context.inSearch) { + context.searchResult = result; + } else { + context.result = result; + if (container.telemetry.enabled) { + updateTelemetryContext(context); + } } context.connectedIntegrations = await container.launchpad.getConnectedIntegrations(); } diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index ac3b2df27fe69..3214099a9ba0d 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -21,7 +21,6 @@ import { getOrOpenPullRequestRepository, getRepositoryIdentityForPullRequest, } from '../../git/models/pullRequest'; -import { getPullRequestIdentityValuesFromSearch } from '../../git/models/pullRequest.utils'; import type { GitRemote } from '../../git/models/remote'; import type { Repository } from '../../git/models/repository'; import type { CodeSuggestionCounts, Draft } from '../../gk/models/drafts'; @@ -43,6 +42,7 @@ 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 { GitLabRepositoryDescriptor } from '../integrations/providers/gitlab'; import type { EnrichablePullRequest, ProviderActionablePullRequest } from '../integrations/providers/models'; import { fromProviderPullRequest, @@ -51,6 +51,7 @@ import { } from '../integrations/providers/models'; import type { EnrichableItem, EnrichedItem } from './enrichmentService'; import { convertRemoteProviderIdToEnrichProvider, isEnrichableRemoteProviderId } from './enrichmentService'; +import { getPullRequestIdentityFromMaybeUrl } from './utils'; export const launchpadActionCategories = [ 'mergeable', @@ -326,14 +327,14 @@ export class LaunchpadProvider implements Disposable { // 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); + const { ownerAndRepo, prNumber, provider } = getPullRequestIdentityFromMaybeUrl(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); + if (provider != null && prNumber != null && ownerAndRepo != null) { + // TODO: This needs to be generalized to work outside of GitHub/GitLab + const integration = await this.container.integrations.get(provider); const [owner, repo] = ownerAndRepo.split('/', 2); - const descriptor: GitHubRepositoryDescriptor = { + const descriptor: GitHubRepositoryDescriptor | GitLabRepositoryDescriptor = { key: ownerAndRepo, owner: owner, name: repo, @@ -666,8 +667,10 @@ 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 isSearching = ((o): o is RequireSome, 'search'> => Boolean(o?.search))( + options, + ); const fireRefresh = !isSearching && (options?.force || this._prs == null); const ignoredRepositories = new Set( @@ -822,7 +825,7 @@ export class LaunchpadProvider implements Disposable { ...item, currentViewer: myAccounts.get(item.provider.id)!, codeSuggestionsCount: codeSuggestionsCount, - isNew: this.isItemNewInGroup(item, actionableCategory), + isNew: isSearching ? false : this.isItemNewInGroup(item, actionableCategory), isSearched: isSearching, actionableCategory: actionableCategory, suggestedActions: suggestedActions, diff --git a/src/plus/launchpad/utils.ts b/src/plus/launchpad/utils.ts index de333e7360ae6..35f9c2b249ca5 100644 --- a/src/plus/launchpad/utils.ts +++ b/src/plus/launchpad/utils.ts @@ -1,7 +1,14 @@ import type { Container } from '../../container'; +import type { PullRequestUrlIdentity } from '../../git/models/pullRequest.utils'; import { configuration } from '../../system/vscode/configuration'; -import { isGitHubPullRequestUrl } from '../integrations/providers/github/models'; -import { isGitLabPullRequestUrl } from '../integrations/providers/gitlab/models'; +import { + getGitHubPullRequestIdentityFromMaybeUrl, + isMaybeGitHubPullRequestUrl, +} from '../integrations/providers/github/models'; +import { + getGitLabPullRequestIdentityFromMaybeUrl, + isMaybeGitLabPullRequestUrl, +} from '../integrations/providers/gitlab/models'; import type { LaunchpadSummaryResult } from './launchpadIndicator'; import { generateLaunchpadSummary } from './launchpadIndicator'; import type { LaunchpadGroup } from './launchpadProvider'; @@ -19,6 +26,17 @@ export async function getLaunchpadSummary(container: Container): Promise { * * 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> { +export function createAsyncDebouncer( + delay: number, +): Disposable & CodeDisposable & Deferrable<(task: AsyncTask) => Promise> { let lastTask: AsyncTask | undefined; let timer: ReturnType | undefined; let curDeferred: Deferred | undefined; @@ -130,6 +132,7 @@ export function createAsyncDebouncer(delay: number): Disposable & Deferrable< debounce.cancel = cancel; debounce.dispose = dispose; + debounce[Symbol.dispose] = dispose; debounce.flush = flush; debounce.pending = pending; return debounce; From a41055105f8c1dd1dbb0ed38d3ee1cf706ba5d84 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Mon, 16 Dec 2024 00:39:09 -0500 Subject: [PATCH 5/5] Ensure active selection in Launchpad --- src/plus/launchpad/launchpadIndicator.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/plus/launchpad/launchpadIndicator.ts b/src/plus/launchpad/launchpadIndicator.ts index 25d6c73e17f6b..61854c91bc4cc 100644 --- a/src/plus/launchpad/launchpadIndicator.ts +++ b/src/plus/launchpad/launchpadIndicator.ts @@ -368,7 +368,7 @@ export class LaunchpadIndicator implements Disposable { source: 'launchpad-indicator', state: { initialGroup: 'mergeable', - selectTopItem: labelType === 'item', + selectTopItem: true, }, } satisfies Omit), )} "Open Ready to Merge in Launchpad")`, @@ -429,7 +429,10 @@ export class LaunchpadIndicator implements Disposable { }](command:gitlens.showLaunchpad?${encodeURIComponent( JSON.stringify({ source: 'launchpad-indicator', - state: { initialGroup: 'blocked', selectTopItem: labelType === 'item' }, + state: { + initialGroup: 'blocked', + selectTopItem: true, + }, } satisfies Omit), )} "Open Blocked in Launchpad")`, ); @@ -465,7 +468,7 @@ export class LaunchpadIndicator implements Disposable { source: 'launchpad-indicator', state: { initialGroup: 'follow-up', - selectTopItem: labelType === 'item', + selectTopItem: true, }, } satisfies Omit), )} "Open Follow-Up in Launchpad")`, @@ -488,7 +491,7 @@ export class LaunchpadIndicator implements Disposable { source: 'launchpad-indicator', state: { initialGroup: 'needs-review', - selectTopItem: labelType === 'item', + selectTopItem: true, }, } satisfies Omit), )} "Open Needs Your Review in Launchpad")`,