Skip to content

Commit dd89917

Browse files
committed
Debounces user's typing
(#3543, #3684)
1 parent 63741ed commit dd89917

File tree

2 files changed

+150
-7
lines changed

2 files changed

+150
-7
lines changed

src/plus/launchpad/launchpad.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { getScopedCounter } from '../../system/counter';
5454
import { fromNow } from '../../system/date';
5555
import { some } from '../../system/iterable';
5656
import { interpolate, pluralize } from '../../system/string';
57+
import { createAsyncDebouncer } from '../../system/vscode/asyncDebouncer';
5758
import { executeCommand } from '../../system/vscode/command';
5859
import { configuration } from '../../system/vscode/configuration';
5960
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

@@ -520,13 +522,16 @@ export class LaunchpadCommand extends QuickCommand<State> {
520522
) => {
521523
const search = quickpick.value;
522524
quickpick.busy = true;
523-
524525
try {
525-
await updateContextItems(this.container, context, { force: true, search: search });
526-
527-
const { items, placeholder } = getItemsAndPlaceholder();
528-
quickpick.placeholder = placeholder;
529-
quickpick.items = items;
526+
await this.updateItemsDebouncer(async cancellationToken => {
527+
await updateContextItems(this.container, context, { force: true, search: search });
528+
if (cancellationToken.isCancellationRequested) {
529+
return;
530+
}
531+
const { items, placeholder } = getItemsAndPlaceholder(Boolean(search));
532+
quickpick.placeholder = placeholder;
533+
quickpick.items = items;
534+
});
530535
} finally {
531536
quickpick.busy = false;
532537
}
@@ -572,6 +577,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
572577

573578
if (!value?.length || activeLaunchpadItems.length) {
574579
// Nothing to search
580+
this.updateItemsDebouncer.cancel();
575581
return true;
576582
}
577583

@@ -593,13 +599,14 @@ export class LaunchpadCommand extends QuickCommand<State> {
593599
quickpick.items = [...quickpick.items];
594600
// We have found an item that matches to the URL.
595601
// Now it will be displayed as the found item and we exit this function now without sending any requests to API:
602+
this.updateItemsDebouncer.cancel();
596603
return true;
597604
}
598605
// Nothing is found above, so let's perform search in the API:
599606
await updateItems(quickpick);
600607
}
601608
}
602-
609+
this.updateItemsDebouncer.cancel();
603610
return true;
604611
},
605612
onDidClickButton: async (quickpick, button) => {

src/system/vscode/asyncDebouncer.ts

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)