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 47017a55c1f5f..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 { @@ -508,3 +510,20 @@ export function fromCommitFileStatus( } return undefined; } + +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 846588c795540..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'; @@ -150,3 +152,18 @@ export function fromGitLabMergeRequestProvidersApi(pr: ProviderPullRequest, prov }; return fromProviderPullRequest(wrappedPr, provider); } + +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 2eedeb7915a59..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,6 +71,7 @@ import { launchpadGroups, supportedLaunchpadIntegrations, } from './launchpadProvider'; +import { isMaybeSupportedLaunchpadPullRequestSearchUrl } from './utils'; const actionGroupMap = new Map([ ['mergeable', ['Ready to Merge', 'Ready to merge']], @@ -88,35 +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; -}; -const connectMoreIntegrationsItem: ConnectMoreIntegrationsItem = { +interface ConnectMoreIntegrationsQuickPickItem extends QuickPickItem { + readonly type: 'integrations'; + readonly item?: never; + readonly group?: never; + + searchModeEnabled?: never; +} + +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', - item: undefined, - group: undefined, }; -function isConnectMoreIntegrationsItem(item: unknown): item is ConnectMoreIntegrationsItem { - return item === connectMoreIntegrationsItem; -} interface Context { result: LaunchpadCategorizedResult; + searchResult: LaunchpadCategorizedResult | undefined; title: string; collapsed: Map; telemetryContext: LaunchpadTelemetryContext | undefined; connectedIntegrations: Map; + + inSearch: boolean | 'mode'; + updateItemsDebouncer: ReturnType; } interface GroupedLaunchpadItem extends LaunchpadItem { - group: LaunchpadGroup; + readonly group: LaunchpadGroup; } interface State { @@ -147,10 +180,9 @@ 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; constructor(container: Container, args?: LaunchpadCommandArgs) { super(container, 'launchpad', 'launchpad', `GitLens Launchpad\u00a0\u00a0${proBadge}`, { @@ -217,18 +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(), + 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); @@ -287,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) @@ -301,10 +351,19 @@ export class LaunchpadCommand extends QuickCommand { const connected = result.connected; newlyConnected = Boolean(connected); await updateContextItems(this.container, context, { force: newlyConnected }); + + state.counter--; + continue; + } + + if (isToggleSearchModeItem(result)) { + toggleSearchMode(result.searchMode); + + state.counter--; continue; } - state.item = result; + state.item = { ...result.item, group: result.group }; } assertsLaunchpadStepState(state); @@ -372,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 = ( @@ -441,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 ${ @@ -461,11 +523,12 @@ export class LaunchpadCommand extends QuickCommand { }; }; - const getLaunchpadQuickPickItems = (items: LaunchpadItem[] = [], isSearching?: boolean) => { + const getLaunchpadQuickPickItems = (items: LaunchpadItem[] = [], isFiltering?: boolean) => { const groupedAndSorted: ( | LaunchpadItemQuickPickItem | DirectiveQuickPickItem - | ConnectMoreIntegrationsItem + | ConnectMoreIntegrationsQuickPickItem + | ToggleSearchModeQuickPickItem )[] = []; if (items.length) { @@ -477,10 +540,14 @@ 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.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; @@ -488,7 +555,7 @@ export class LaunchpadCommand extends QuickCommand { } groupedAndSorted.push( - ...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, isSearching)), + ...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, Boolean(context.inSearch))), ); } } @@ -496,70 +563,139 @@ export class LaunchpadCommand extends QuickCommand { return groupedAndSorted; }; - function getItemsAndPlaceholder(isSearching?: boolean) { - if (context.result.error != null) { + function getItemsAndQuickpickProps(isFiltering?: boolean) { + const result = context.inSearch ? context.searchResult : context.result; + + 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: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })], + 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: 'Choose an item, type a term to search, or paste in a PR URL', - items: 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 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, - force?: boolean, + quickpick: QuickPick< + | LaunchpadItemQuickPickItem + | DirectiveQuickPickItem + | ConnectMoreIntegrationsQuickPickItem + | ToggleSearchModeQuickPickItem + >, + 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: search }, + { 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 = search ? combineQuickpickItemsWithSearchResults(quickpick.items, items) : items; - }); + quickpick.items = items; + }; + + if (options?.immediate) { + context.updateItemsDebouncer.cancel(); + await update(undefined); + } else { + await context.updateItemsDebouncer(update); + } } finally { quickpick.busy = false; } @@ -568,86 +704,93 @@ 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 + | 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 nonGroupedItems = items.filter(i => !isDirectiveQuickPickItem(i)); + 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?.length) return; + + if (context.inSearch === 'mode') { + quickpick.value = this.savedSearch; + } + + this.savedSearch = undefined; + }, onDidChangeValue: async quickpick => { const { value } = quickpick; - const hideGroups = Boolean(value?.length); - const consideredItems = hideGroups ? nonGroupedItems : items; - - let updated = false; - for (const item of consideredItems) { - if (item.alwaysShow) { - item.alwaysShow = false; - updated = true; + this.savedSearch = value; + + // Nothing to search + if (!value?.length) { + context.updateItemsDebouncer.cancel(); + + context.searchResult = undefined; + if (context.inSearch === true) { + context.inSearch = false; } - } - // 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; + // Restore category/divider items + const { items, placeholder, title } = getItemsAndQuickpickProps(); + quickpick.title = title; + quickpick.placeholder = placeholder; + quickpick.items = items; - if (!value?.length) { - // Nothing to search - this.updateItemsDebouncer.cancel(); return true; } - // TODO: This needs to be generalized to work outside of GitHub, - // The current idea is that we should iterate the connected integrations and apply their parsing. - // Probably we even want to build a map like this: { integrationId: identity } - // Then when we iterate local items we can check them to corresponding identitie according to the item's repo type. - // Same with API: we iterate connected integrations and search in each of them with the corresponding identity. - const prUrlIdentity = getPullRequestIdentityValuesFromSearch(value); - - if (prUrlIdentity.prNumber != null) { - // We can identify the PR number, so let's try to find it locally: - const launchpadItems = quickpick.items.filter((i): i is LaunchpadItemQuickPickItem => 'item' in i); - let item = launchpadItems.find(i => - // perform strict match first - doesPullRequestSatisfyRepositoryURLIdentity(i.item, prUrlIdentity), - ); - if (item == null) { - // Haven't found full match, so let's at least find something with the same pr number - item = launchpadItems.find(i => i.item.id === prUrlIdentity.prNumber); - } - if (item != null) { - if (!item.alwaysShow) { - item.alwaysShow = true; - // Force quickpick to update by changing the items object: - quickpick.items = [...quickpick.items]; - } - // We have found an item that matches to the URL. - // Now it will be displayed as the found item and we exit this function now without sending any requests to API: - this.updateItemsDebouncer.cancel(); - return true; + // 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; + + // Hide the category/divider items quickly + const { items } = getItemsAndQuickpickProps(true); + quickpick.items = items; + + context.inSearch = true; } + + 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; } - await updateItems(quickpick, true); return true; }, onDidClickButton: async (quickpick, button) => { @@ -673,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; @@ -762,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)) { - return element; - } - return { ...element.item, group: element.group }; + if (!canPickStepContinue(step, state, selection)) return StepResultBreak; + + return selection[0]; } private *confirmStep( @@ -1470,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/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")`, 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 9c0b8566ad16e..35f9c2b249ca5 100644 --- a/src/plus/launchpad/utils.ts +++ b/src/plus/launchpad/utils.ts @@ -1,5 +1,14 @@ import type { Container } from '../../container'; +import type { PullRequestUrlIdentity } from '../../git/models/pullRequest.utils'; import { configuration } from '../../system/vscode/configuration'; +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'; @@ -16,3 +25,18 @@ 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;