Skip to content

Commit 609ce3a

Browse files
axosoft-raminteamodio
authored andcommitted
Updates Launchpad search UX
1 parent cbf418f commit 609ce3a

File tree

1 file changed

+135
-42
lines changed

1 file changed

+135
-42
lines changed

src/plus/launchpad/launchpad.ts

Lines changed: 135 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -95,24 +95,50 @@ export interface LaunchpadItemQuickPickItem extends QuickPickItemOfT<LaunchpadIt
9595
type ConnectMoreIntegrationsItem = QuickPickItem & {
9696
item: undefined;
9797
group: undefined;
98+
search: undefined;
9899
};
99100
const connectMoreIntegrationsItem: ConnectMoreIntegrationsItem = {
100101
label: 'Connect an Additional Integration...',
101102
detail: 'Connect additional integrations to view their pull requests in Launchpad',
102103
item: undefined,
103104
group: undefined,
105+
search: undefined,
104106
};
105107
function isConnectMoreIntegrationsItem(item: unknown): item is ConnectMoreIntegrationsItem {
106108
return item === connectMoreIntegrationsItem;
107109
}
108110

111+
type ToggleSearchItem = QuickPickItem & {
112+
item: undefined;
113+
group: undefined;
114+
search: boolean;
115+
};
116+
const toggleSearchOnItem: ToggleSearchItem = {
117+
label: 'Search for Other Pull Requests...',
118+
item: undefined,
119+
group: undefined,
120+
search: true,
121+
alwaysShow: true,
122+
};
123+
const toggleSearchOffItem: ToggleSearchItem = {
124+
label: 'Stop Searching',
125+
item: undefined,
126+
group: undefined,
127+
search: false,
128+
alwaysShow: true,
129+
};
130+
function isToggleSearchItem(item: unknown): item is ToggleSearchItem {
131+
return item === toggleSearchOnItem || item === toggleSearchOffItem;
132+
}
133+
109134
interface Context {
110135
result: LaunchpadCategorizedResult;
111136

112137
title: string;
113138
collapsed: Map<LaunchpadGroup, boolean>;
114139
telemetryContext: LaunchpadTelemetryContext | undefined;
115140
connectedIntegrations: Map<IntegrationId, boolean>;
141+
isSearching: boolean;
116142
}
117143

118144
interface GroupedLaunchpadItem extends LaunchpadItem {
@@ -151,6 +177,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
151177
private readonly updateItemsDebouncer = createAsyncDebouncer(500);
152178
private readonly source: Source;
153179
private readonly telemetryContext: LaunchpadTelemetryContext | undefined;
180+
private savedSearch: string | undefined;
154181

155182
constructor(container: Container, args?: LaunchpadCommandArgs) {
156183
super(container, 'launchpad', 'launchpad', `GitLens Launchpad\u00a0\u00a0${proBadge}`, {
@@ -223,6 +250,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
223250
collapsed: collapsed,
224251
telemetryContext: this.telemetryContext,
225252
connectedIntegrations: await this.container.launchpad.getConnectedIntegrations(),
253+
isSearching: false,
226254
};
227255

228256
let opened = false;
@@ -302,6 +330,12 @@ export class LaunchpadCommand extends QuickCommand<State> {
302330
newlyConnected = Boolean(connected);
303331
await updateContextItems(this.container, context, { force: newlyConnected });
304332
continue;
333+
} else if (isToggleSearchItem(result)) {
334+
context.isSearching = result.search;
335+
if (!context.isSearching) {
336+
this.updateItemsDebouncer.cancel();
337+
}
338+
continue;
305339
}
306340

307341
state.item = result;
@@ -372,7 +406,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
372406
state: StepState<State>,
373407
context: Context,
374408
{ picked, selectTopItem }: { picked?: string; selectTopItem?: boolean },
375-
): StepResultGenerator<GroupedLaunchpadItem | ConnectMoreIntegrationsItem> {
409+
): StepResultGenerator<GroupedLaunchpadItem | ConnectMoreIntegrationsItem | ToggleSearchItem> {
376410
const hasDisconnectedIntegrations = [...context.connectedIntegrations.values()].some(c => !c);
377411

378412
const buildGroupHeading = (
@@ -466,6 +500,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
466500
| LaunchpadItemQuickPickItem
467501
| DirectiveQuickPickItem
468502
| ConnectMoreIntegrationsItem
503+
| ToggleSearchItem
469504
)[] = [];
470505

471506
if (items.length) {
@@ -477,7 +512,11 @@ export class LaunchpadCommand extends QuickCommand<State> {
477512
uiGroups.get('blocked')?.[0] ||
478513
uiGroups.get('follow-up')?.[0] ||
479514
uiGroups.get('needs-review')?.[0];
480-
for (const [ui, groupItems] of uiGroups) {
515+
for (let [ui, groupItems] of uiGroups) {
516+
if (context.isSearching) {
517+
groupItems = groupItems.filter(i => i.isSearched);
518+
}
519+
481520
if (!groupItems.length) continue;
482521

483522
if (!isSearching) {
@@ -488,7 +527,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
488527
}
489528

490529
groupedAndSorted.push(
491-
...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, isSearching)),
530+
...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem, context.isSearching)),
492531
);
493532
}
494533
}
@@ -497,6 +536,8 @@ export class LaunchpadCommand extends QuickCommand<State> {
497536
};
498537

499538
function getItemsAndPlaceholder(isSearching?: boolean) {
539+
const toggleSearchItem = context.isSearching ? toggleSearchOffItem : toggleSearchOnItem;
540+
500541
if (context.result.error != null) {
501542
return {
502543
placeholder: `Unable to load items (${
@@ -513,34 +554,22 @@ export class LaunchpadCommand extends QuickCommand<State> {
513554
if (!context.result.items.length) {
514555
return {
515556
placeholder: 'All done! Take a vacation',
516-
items: [createDirectiveQuickPickItem(Directive.Cancel, undefined, { label: 'OK' })],
557+
items: [toggleSearchItem],
517558
};
518559
}
519560

520561
return {
521-
placeholder: 'Choose an item, type a term to search, or paste in a PR URL',
522-
items: getLaunchpadQuickPickItems(context.result.items, isSearching),
562+
placeholder: context.isSearching
563+
? 'Type a term to search'
564+
: 'Choose an item or paste in a pull request URL to search',
565+
items: [toggleSearchItem, ...getLaunchpadQuickPickItems(context.result.items, isSearching)],
523566
};
524567
}
525568

526-
const combineQuickpickItemsWithSearchResults = <T extends { item: { id: string } } | object>(
527-
arr: readonly T[],
528-
items: T[],
529-
) => {
530-
const ids: Set<string> = new Set(
531-
arr.map(i => 'item' in i && i.item?.id).filter(id => typeof id === 'string'),
532-
);
533-
const result = [...arr];
534-
for (const item of items) {
535-
if ('item' in item && item.item?.id && !ids.has(item.item.id)) {
536-
result.push(item);
537-
}
538-
}
539-
return result;
540-
};
541-
542569
const updateItems = async (
543-
quickpick: QuickPick<LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem>,
570+
quickpick: QuickPick<
571+
LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem | ToggleSearchItem
572+
>,
544573
force?: boolean,
545574
) => {
546575
const search = quickpick.value;
@@ -550,15 +579,15 @@ export class LaunchpadCommand extends QuickCommand<State> {
550579
await updateContextItems(
551580
this.container,
552581
context,
553-
{ force: force, search: search },
582+
{ force: force, search: context.isSearching ? search : undefined },
554583
cancellationToken,
555584
);
556585
if (cancellationToken.isCancellationRequested) {
557586
return;
558587
}
559588
const { items, placeholder } = getItemsAndPlaceholder(Boolean(search));
560589
quickpick.placeholder = placeholder;
561-
quickpick.items = search ? combineQuickpickItemsWithSearchResults(quickpick.items, items) : items;
590+
quickpick.items = items;
562591
});
563592
} finally {
564593
quickpick.busy = false;
@@ -568,7 +597,9 @@ export class LaunchpadCommand extends QuickCommand<State> {
568597
// Should only be used for optimistic update of the list when some UI property (like pinned, snoozed) changed with an
569598
// item. For all other cases, use updateItems.
570599
const optimisticallyUpdateItems = (
571-
quickpick: QuickPick<LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem>,
600+
quickpick: QuickPick<
601+
LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem | ToggleSearchItem
602+
>,
572603
) => {
573604
quickpick.items = getLaunchpadQuickPickItems(
574605
context.result.items,
@@ -577,7 +608,6 @@ export class LaunchpadCommand extends QuickCommand<State> {
577608
};
578609

579610
const { items, placeholder } = getItemsAndPlaceholder();
580-
const nonGroupedItems = items.filter(i => !isDirectiveQuickPickItem(i));
581611

582612
const step = createPickStep({
583613
title: context.title,
@@ -592,30 +622,72 @@ export class LaunchpadCommand extends QuickCommand<State> {
592622
LaunchpadSettingsQuickInputButton,
593623
RefreshQuickInputButton,
594624
],
625+
onDidActivate: quickpick => {
626+
if (this.savedSearch == null || this.savedSearch.length === 0) return;
627+
if (context.isSearching) {
628+
quickpick.value = this.savedSearch;
629+
}
630+
631+
this.savedSearch = undefined;
632+
},
595633
onDidChangeValue: async quickpick => {
596634
const { value } = quickpick;
597-
const hideGroups = Boolean(value?.length);
598-
const consideredItems = hideGroups ? nonGroupedItems : items;
635+
this.savedSearch = value;
599636

600-
let updated = false;
601-
for (const item of consideredItems) {
637+
// Clear alwaysShow for items which we matched by PR number or other identity
638+
for (const item of quickpick.items.filter(
639+
i => !isDirectiveQuickPickItem(i) && !isToggleSearchItem(i),
640+
)) {
602641
if (item.alwaysShow) {
603642
item.alwaysShow = false;
604-
updated = true;
605643
}
606644
}
607645

608-
// By doing the following we make sure we operate with the PRs that belong to Launchpad initially.
609-
// Also, when we re-create the array, we make sure that `alwaysShow` updates are applied.
610-
quickpick.items =
611-
updated && quickpick.items === consideredItems ? [...consideredItems] : consideredItems;
612-
613646
if (!value?.length) {
614647
// Nothing to search
615648
this.updateItemsDebouncer.cancel();
649+
// Restore original list if search mode was active and input was cleared
650+
if (context.isSearching) {
651+
context.isSearching = false;
652+
quickpick.busy = true;
653+
quickpick.items = [];
654+
quickpick.placeholder = 'Restoring your pull requests...';
655+
await updateItems(quickpick);
656+
quickpick.busy = false;
657+
// Restore category/divider items if not in search mode and input was cleared
658+
} else {
659+
const { items, placeholder } = getItemsAndPlaceholder();
660+
quickpick.placeholder = placeholder;
661+
quickpick.items = items;
662+
}
663+
664+
return true;
665+
}
666+
667+
// In API search mode
668+
if (context.isSearching) {
669+
if (quickpick.activeItems.length === 0) {
670+
// Show just the option to toggle search off if nothing found
671+
quickpick.items = [toggleSearchOnItem];
672+
} else {
673+
// Search the API and update the quickpick
674+
await updateItems(quickpick, true);
675+
}
676+
677+
return true;
678+
}
679+
680+
// Out of API search mode
681+
// This effectively hides the category/divider items
682+
quickpick.items = getLaunchpadQuickPickItems(context.result.items, true);
683+
684+
// Show just the option to toggle search on if nothing found
685+
if (quickpick.activeItems.length === 0 && !isSupportedLaunchpadPullRequestUrl(value)) {
686+
quickpick.items = [toggleSearchOnItem];
616687
return true;
617688
}
618689

690+
// Match on some special cases like owner/repo, PR number, URL, etc.
619691
// TODO: This needs to be generalized to work outside of GitHub,
620692
// The current idea is that we should iterate the connected integrations and apply their parsing.
621693
// Probably we even want to build a map like this: { integrationId: identity }
@@ -625,7 +697,9 @@ export class LaunchpadCommand extends QuickCommand<State> {
625697

626698
if (prUrlIdentity.prNumber != null) {
627699
// We can identify the PR number, so let's try to find it locally:
628-
const launchpadItems = quickpick.items.filter((i): i is LaunchpadItemQuickPickItem => 'item' in i);
700+
const launchpadItems = quickpick.items.filter(
701+
(i): i is LaunchpadItemQuickPickItem => 'item' in i && i.item?.id != null,
702+
);
629703
let item = launchpadItems.find(i =>
630704
// perform strict match first
631705
doesPullRequestSatisfyRepositoryURLIdentity(i.item, prUrlIdentity),
@@ -640,14 +714,21 @@ export class LaunchpadCommand extends QuickCommand<State> {
640714
// Force quickpick to update by changing the items object:
641715
quickpick.items = [...quickpick.items];
642716
}
643-
// We have found an item that matches to the URL.
644-
// Now it will be displayed as the found item and we exit this function now without sending any requests to API:
717+
// We have found an item that matches as a special case.
645718
this.updateItemsDebouncer.cancel();
646719
return true;
647720
}
648721
}
649722

650-
await updateItems(quickpick, true);
723+
// If a supported PR URL was entered but no existing items match outside of search mode, turn on search mode and search the API.
724+
if (isSupportedLaunchpadPullRequestUrl(value)) {
725+
context.isSearching = true;
726+
await updateItems(quickpick, true);
727+
} else if (quickpick.activeItems.length === 0 || quickpick.activeItems[0] === toggleSearchOnItem) {
728+
// Show just the option to toggle search on if nothing found
729+
quickpick.items = [...quickpick.items, toggleSearchOnItem];
730+
}
731+
651732
return true;
652733
},
653734
onDidClickButton: async (quickpick, button) => {
@@ -766,7 +847,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
766847
return StepResultBreak;
767848
}
768849
const element = selection[0];
769-
if (isConnectMoreIntegrationsItem(element)) {
850+
if (isConnectMoreIntegrationsItem(element) || isToggleSearchItem(element)) {
770851
return element;
771852
}
772853
return { ...element.item, group: element.group };
@@ -1510,3 +1591,15 @@ function updateTelemetryContext(context: Context) {
15101591
function isLaunchpadTargetActionQuickPickItem(item: any): item is QuickPickItemOfT<LaunchpadTargetAction> {
15111592
return item?.item?.action != null && item?.item?.target != null;
15121593
}
1594+
1595+
function isGitHubPullRequestUrl(search: string) {
1596+
return search.includes('github.com') && search.includes('/pull/');
1597+
}
1598+
1599+
function isGitLabPullRequestUrl(search: string) {
1600+
return search.includes('gitlab.com') && search.includes('/merge_requests/');
1601+
}
1602+
1603+
function isSupportedLaunchpadPullRequestUrl(search: string) {
1604+
return isGitHubPullRequestUrl(search) || isGitLabPullRequestUrl(search);
1605+
}

0 commit comments

Comments
 (0)