Skip to content

Commit f7500e3

Browse files
committed
Debounces user's typing
(#3543)
1 parent 5954bc9 commit f7500e3

File tree

2 files changed

+154
-12
lines changed

2 files changed

+154
-12
lines changed

src/plus/launchpad/launchpad.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { getScopedCounter } from '../../system/counter';
5050
import { fromNow } from '../../system/date';
5151
import { some } from '../../system/iterable';
5252
import { interpolate, pluralize } from '../../system/string';
53+
import { createAsyncDebouncer } from '../../system/vscode/asyncDebouncer';
5354
import { executeCommand } from '../../system/vscode/command';
5455
import { configuration } from '../../system/vscode/configuration';
5556
import { openUrl } from '../../system/vscode/utils';
@@ -149,6 +150,7 @@ const instanceCounter = getScopedCounter();
149150
const defaultCollapsedGroups: LaunchpadGroup[] = ['draft', 'other', 'snoozed'];
150151

151152
export class LaunchpadCommand extends QuickCommand<State> {
153+
private readonly updateItemsDebouncer = createAsyncDebouncer(500);
152154
private readonly source: Source;
153155
private readonly telemetryContext: LaunchpadTelemetryContext | undefined;
154156

@@ -458,7 +460,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
458460
};
459461
};
460462

461-
const getItems = (result: LaunchpadCategorizedResult) => {
463+
const getItems = (result: LaunchpadCategorizedResult, treatAllGroupAsExpanded?: boolean) => {
462464
const items: (LaunchpadItemQuickPickItem | DirectiveQuickPickItem | ConnectMoreIntegrationsItem)[] = [];
463465

464466
if (result.items?.length) {
@@ -475,7 +477,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
475477

476478
items.push(...buildGroupHeading(ui, groupItems.length));
477479

478-
if (context.collapsed.get(ui)) continue;
480+
if (!treatAllGroupAsExpanded && context.collapsed.get(ui)) continue;
479481

480482
items.push(...groupItems.map(i => buildLaunchpadQuickPickItem(i, ui, topItem)));
481483
}
@@ -484,7 +486,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
484486
return items;
485487
};
486488

487-
function getItemsAndPlaceholder() {
489+
function getItemsAndPlaceholder(treatAllGroupAsExpanded?: boolean) {
488490
if (context.result.error != null) {
489491
return {
490492
placeholder: `Unable to load items (${
@@ -507,7 +509,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
507509

508510
return {
509511
placeholder: 'Choose an item to focus on',
510-
items: getItems(context.result),
512+
items: getItems(context.result, treatAllGroupAsExpanded),
511513
};
512514
}
513515

@@ -516,13 +518,16 @@ export class LaunchpadCommand extends QuickCommand<State> {
516518
search?: string,
517519
) => {
518520
quickpick.busy = true;
519-
520521
try {
521-
await updateContextItems(this.container, context, { force: true, search: search });
522-
523-
const { items, placeholder } = getItemsAndPlaceholder();
524-
quickpick.placeholder = placeholder;
525-
quickpick.items = items;
522+
await this.updateItemsDebouncer(async cancellationToken => {
523+
await updateContextItems(this.container, context, { force: true, search: search });
524+
if (cancellationToken.isCancellationRequested) {
525+
return;
526+
}
527+
const { items, placeholder } = getItemsAndPlaceholder(Boolean(search));
528+
quickpick.placeholder = placeholder;
529+
quickpick.items = items;
530+
});
526531
} finally {
527532
quickpick.busy = false;
528533
}
@@ -591,6 +596,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
591596
}
592597
// We have found an item that matches to the URL.
593598
// Now it will be displayed as the found item and we exit this function now without sending any requests to API:
599+
this.updateItemsDebouncer.cancel();
594600
return true;
595601
}
596602
// Nothing is found above, so let's perform search in the API:
@@ -599,7 +605,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
599605
groupsHidden = true;
600606
}
601607
}
602-
608+
this.updateItemsDebouncer.cancel();
603609
return true;
604610
},
605611
onDidClickButton: async (quickpick, button) => {
@@ -1340,7 +1346,7 @@ async function updateContextItems(
13401346
options?: { force?: boolean; search?: string },
13411347
) {
13421348
const result = await container.launchpad.getCategorizedItems(options);
1343-
if (options?.search != null) {
1349+
if (options?.search) {
13441350
context.result = container.launchpad.mergeSearchedCategorizedItems(context.result, result);
13451351
} else {
13461352
context.result = result;
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { CancellationToken, Disposable } from 'vscode';
2+
import { CancellationTokenSource } from 'vscode';
3+
import { CancellationError } from '../../errors';
4+
import type { Deferrable } from '../function';
5+
import type { Deferred } from '../promise';
6+
import { defer } from '../promise';
7+
8+
export interface AsyncTask<T> {
9+
(cancelationToken: CancellationToken): T | Promise<T>;
10+
}
11+
12+
/**
13+
* This is similar to `src/system/function.ts: debounce` but it's for async tasks.
14+
* The old `debounce` function does not awaits for promises, so it's not suitable for async tasks.
15+
*
16+
* This function cannot be part of `src/system/function.ts` because it relies on `CancellationTokenSource` from `vscode`.
17+
*
18+
* Here the debouncer returns a promise that awaits task for completion.
19+
* Also we can let tasks know if they are cancelled by passing a cancellation token.
20+
*
21+
* Despite being able to accept synchronous tasks, we always return a promise here. It's implemeted this way for simplicity.
22+
*/
23+
export function createAsyncDebouncer<T>(delay: number): Disposable & Deferrable<(task: AsyncTask<T>) => Promise<T>> {
24+
let lastTask: AsyncTask<T> | undefined;
25+
let timer: ReturnType<typeof setTimeout> | undefined;
26+
let curDeferred: Deferred<T> | undefined;
27+
let curCancellation: CancellationTokenSource | undefined;
28+
29+
/**
30+
* Cancels the timer and current execution without cancelling the promise
31+
*/
32+
function cancelCurrentExecution(): void {
33+
if (timer != null) {
34+
clearTimeout(timer);
35+
timer = undefined;
36+
}
37+
if (curCancellation != null && !curCancellation.token.isCancellationRequested) {
38+
curCancellation.cancel();
39+
}
40+
}
41+
42+
function cancel() {
43+
cancelCurrentExecution();
44+
if (curDeferred?.pending) {
45+
curDeferred.cancel(new CancellationError());
46+
}
47+
lastTask = undefined;
48+
}
49+
50+
function dispose() {
51+
cancel();
52+
curCancellation?.dispose();
53+
curCancellation = undefined;
54+
}
55+
56+
function flush(): Promise<T> | undefined {
57+
if (lastTask != null) {
58+
cancelCurrentExecution();
59+
void invoke();
60+
}
61+
if (timer != null) {
62+
clearTimeout(timer);
63+
}
64+
return curDeferred?.promise;
65+
}
66+
67+
function pending(): boolean {
68+
return curDeferred?.pending ?? false;
69+
}
70+
71+
async function invoke(): Promise<void> {
72+
if (curDeferred == null || lastTask == null) {
73+
return;
74+
}
75+
cancelCurrentExecution();
76+
77+
const task = lastTask;
78+
const deferred = curDeferred;
79+
lastTask = undefined;
80+
const cancellation = (curCancellation = new CancellationTokenSource());
81+
82+
try {
83+
const result = await task(cancellation.token);
84+
if (!cancellation.token.isCancellationRequested) {
85+
// Default successful line: current task has completed without interruptions by another task
86+
if (deferred !== curDeferred && deferred.pending) {
87+
deferred.fulfill(result);
88+
}
89+
if (curDeferred.pending) {
90+
curDeferred.fulfill(result);
91+
}
92+
} else {
93+
throw new CancellationError();
94+
}
95+
} catch (e) {
96+
if (cancellation.token.isCancellationRequested) {
97+
// The current execution has been cancelled so we don't want to reject the main promise,
98+
// because that's expected that it can be fullfilled by the next task.
99+
// (If the whole task is cancelled, the main promise will be rejected in the cancel() method)
100+
if (curDeferred !== deferred && deferred.pending) {
101+
// Unlikely we get here, but if the local `deferred` is different from the main one, then we cancel it to not let the clients hang.
102+
deferred.cancel(e);
103+
}
104+
} else {
105+
// The current execution hasn't been cancelled, so just reject the promise with the error
106+
if (deferred !== curDeferred && deferred.pending) {
107+
deferred.cancel(e);
108+
}
109+
if (curDeferred?.pending) {
110+
curDeferred.cancel(e);
111+
}
112+
}
113+
} finally {
114+
cancellation.dispose();
115+
}
116+
}
117+
118+
function debounce(this: any, task: AsyncTask<T>): Promise<T> {
119+
lastTask = task;
120+
cancelCurrentExecution(); // cancelling the timer or current execution without cancelling the promise
121+
122+
if (!curDeferred?.pending) {
123+
curDeferred = defer<T>();
124+
}
125+
126+
timer = setTimeout(invoke, delay);
127+
128+
return curDeferred.promise;
129+
}
130+
131+
debounce.cancel = cancel;
132+
debounce.dispose = dispose;
133+
debounce.flush = flush;
134+
debounce.pending = pending;
135+
return debounce;
136+
}

0 commit comments

Comments
 (0)