Skip to content

Commit 5156ce8

Browse files
feat: User Activity event (#3645)
* rename: throttle() -> debounce() The existing function is a debounce and not a throttle. Update name. Signed-off-by: Nikolas Komonen <[email protected]> * feat: User Activity event This adds a new class which provides an event that triggers upon user activity. Signed-off-by: Nikolas Komonen <[email protected]> --------- Signed-off-by: Nikolas Komonen <[email protected]>
1 parent f7f72b9 commit 5156ce8

File tree

7 files changed

+114
-16
lines changed

7 files changed

+114
-16
lines changed

src/auth/ui/statusBarItem.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { DevSettings } from '../../shared/settings'
1313
import { Auth } from '../auth'
1414
import { getAllConnectionsInUse, onDidChangeConnections } from '../secondaryAuth'
1515
import { codicon, getIcon } from '../../shared/icons'
16-
import { throttle } from '../../shared/utilities/functionUtils'
16+
import { debounce } from '../../shared/utilities/functionUtils'
1717
import { login } from '../utils'
1818

1919
const statusbarPriority = 1
@@ -27,7 +27,7 @@ export async function initializeAwsCredentialsStatusBarItem(
2727
statusBarItem.command = login.build().asCommand({ title: 'Login' })
2828
statusBarItem.show()
2929

30-
const update = throttle(() => updateItem(statusBarItem, devSettings))
30+
const update = debounce(() => updateItem(statusBarItem, devSettings))
3131

3232
update()
3333
context.subscriptions.push(

src/auth/ui/vue/show.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { tryAddCredentials, signout, showRegionPrompter, promptAndUseConnection,
3636
import { Region } from '../../../shared/regions/endpoints'
3737
import { CancellationError } from '../../../shared/utilities/timeoutUtils'
3838
import { validateSsoUrl, validateSsoUrlFormat } from '../../sso/validation'
39-
import { throttle } from '../../../shared/utilities/functionUtils'
39+
import { debounce } from '../../../shared/utilities/functionUtils'
4040
import { AuthError, ServiceItemId, userCancelled } from './types'
4141
import { awsIdSignIn } from '../../../codewhisperer/util/showSsoPrompt'
4242
import { connectToEnterpriseSso } from '../../../codewhisperer/util/getStartUrl'
@@ -314,12 +314,12 @@ export class AuthWebview extends VueWebview {
314314
]
315315

316316
// The event handler in the frontend refreshes all connection statuses
317-
// when triggered, and multiple events can fire at the same time so we throttle.
318-
const throttledFire = throttle(() => this.onDidConnectionUpdate.fire(undefined), 500)
317+
// when triggered, and multiple events can fire at the same time so we debounce.
318+
const debouncedFire = debounce(() => this.onDidConnectionUpdate.fire(undefined), 500)
319319

320320
events.forEach(event =>
321321
event(() => {
322-
throttledFire()
322+
debouncedFire()
323323
})
324324
)
325325
}

src/awsexplorer/localExplorer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import * as vscode from 'vscode'
77
import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceTreeDataProvider'
88
import { isCloud9 } from '../shared/extensionUtilities'
9-
import { throttle } from '../shared/utilities/functionUtils'
9+
import { debounce } from '../shared/utilities/functionUtils'
1010

1111
export interface RootNode<T = unknown> extends TreeNode<T> {
1212
/**
@@ -40,7 +40,7 @@ export function createLocalExplorerView(rootNodes: RootNode[]): vscode.TreeView<
4040
rootNodes.forEach(node => {
4141
// Refreshes are delayed to guard against excessive calls to `getTreeItem` and `getChildren`
4242
// The 10ms delay is arbitrary. A single event loop may be good enough in many scenarios.
43-
const refresh = throttle(() => treeDataProvider.refresh(node), 10)
43+
const refresh = debounce(() => treeDataProvider.refresh(node), 10)
4444
node.onDidChangeTreeItem?.(() => refresh())
4545
node.onDidChangeChildren?.(() => refresh())
4646
})

src/shared/extensionUtilities.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,47 @@ export function getComputeRegion(): string | undefined {
364364

365365
return computeRegion
366366
}
367+
368+
/**
369+
* Provides an {@link vscode.Event} that is triggered when the user interacts with the extension.
370+
* This will help to be notified of user activity.
371+
*/
372+
export class ExtensionUserActivity {
373+
private activityEvent = new vscode.EventEmitter<void>()
374+
/** The event that is triggered when the user interacts with the extension */
375+
onUserActivity = this.activityEvent.event
376+
377+
/** To be efficient we re-used instances that already have the same throttle delay */
378+
private static instances: { [delay: number]: ExtensionUserActivity } = {}
379+
380+
/**
381+
* @param delay The delay until a throttled user activity event is fired in milliseconds.
382+
* @param activityEvents The events that trigger the user activity.
383+
* @returns
384+
*/
385+
static instance(delay: number = 500, activityEvents = ExtensionUserActivity.activityEvents): ExtensionUserActivity {
386+
if (!ExtensionUserActivity.instances[delay]) {
387+
const newInstance = new ExtensionUserActivity(delay, activityEvents)
388+
ExtensionUserActivity.instances[delay] = newInstance
389+
}
390+
return ExtensionUserActivity.instances[delay]
391+
}
392+
393+
private constructor(delay: number, private readonly activityEvents: vscode.Event<any>[]) {
394+
const throttledEvent = _.throttle(() => this.activityEvent.fire(), delay, { leading: true })
395+
this.activityEvents.forEach(event => event(throttledEvent))
396+
}
397+
398+
private static activityEvents = [
399+
vscode.window.onDidChangeActiveTextEditor,
400+
vscode.window.onDidChangeTextEditorSelection,
401+
vscode.window.onDidChangeActiveTerminal,
402+
vscode.window.onDidChangeVisibleTextEditors,
403+
vscode.window.onDidChangeWindowState,
404+
vscode.window.onDidChangeTextEditorOptions,
405+
vscode.window.onDidOpenTerminal,
406+
vscode.window.onDidCloseTerminal,
407+
vscode.window.onDidChangeTextEditorVisibleRanges,
408+
vscode.window.onDidChangeTextEditorViewColumn,
409+
]
410+
}

src/shared/utilities/functionUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ export function memoize<T, U extends any[]>(fn: (...args: U) => T): (...args: U)
7979

8080
/**
8181
* Prevents a function from executing until {@link delay} milliseconds have passed
82-
* since the last invocation. Omitting {@link delay} will throttle the function for
82+
* since the last invocation. Omitting {@link delay} will not execute the function for
8383
* a single event loop.
8484
*
85-
* Multiple calls made during the throttle window will receive references to the
85+
* Multiple calls made during the debounce window will receive references to the
8686
* same Promise similar to {@link shared}. The window will also be 'rolled', delaying
8787
* the execution by another {@link delay} milliseconds.
8888
*/
89-
export function throttle<T>(cb: () => T | Promise<T>, delay: number = 0): () => Promise<T> {
89+
export function debounce<T>(cb: () => T | Promise<T>, delay: number = 0): () => Promise<T> {
9090
let timer: NodeJS.Timeout | undefined
9191
let promise: Promise<T> | undefined
9292

src/test/shared/extensionUtilities.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import * as path from 'path'
1111
import * as sinon from 'sinon'
1212
import { DefaultEc2MetadataClient } from '../../shared/clients/ec2MetadataClient'
1313
import * as vscode from 'vscode'
14-
import { getComputeRegion, initializeComputeRegion, mostRecentVersionKey } from '../../shared/extensionUtilities'
14+
import {
15+
ExtensionUserActivity,
16+
getComputeRegion,
17+
initializeComputeRegion,
18+
mostRecentVersionKey,
19+
} from '../../shared/extensionUtilities'
1520
import {
1621
createQuickStartWebview,
1722
isDifferentVersion,
@@ -22,6 +27,7 @@ import * as filesystemUtilities from '../../shared/filesystemUtilities'
2227
import { FakeExtensionContext } from '../fakeExtensionContext'
2328
import { InstanceIdentity } from '../../shared/clients/ec2MetadataClient'
2429
import { extensionVersion } from '../../shared/vscode/env'
30+
import { sleep } from '../../shared/utilities/timeoutUtils'
2531

2632
describe('extensionUtilities', function () {
2733
describe('safeGet', function () {
@@ -184,3 +190,51 @@ describe('initializeComputeRegion, getComputeRegion', async function () {
184190
await assert.rejects(metadataService.invoke('/bogus/path'))
185191
})
186192
})
193+
194+
describe('ExtensionUserActivity', function () {
195+
let count: number
196+
197+
function onEventTriggered() {
198+
count++
199+
}
200+
201+
before(function () {
202+
count = 0
203+
})
204+
205+
it('triggers twice when multiple user activities are fired in separate intervals', async function () {
206+
// IMPORTANT: This may be flaky in CI, so we may need to increase the intervals for more tolerance
207+
const throttleDelay = 200
208+
const eventsFirst = [delayedFiringEvent(100), delayedFiringEvent(101), delayedFiringEvent(102)]
209+
const eventsSecond = [
210+
delayedFiringEvent(250),
211+
delayedFiringEvent(251),
212+
delayedFiringEvent(251),
213+
delayedFiringEvent(252),
214+
]
215+
const waitFor = 500 // some additional buffer to make sure everything triggers
216+
217+
const instance = ExtensionUserActivity.instance(throttleDelay, [...eventsFirst, ...eventsSecond])
218+
instance.onUserActivity(onEventTriggered)
219+
await sleep(waitFor)
220+
221+
assert.strictEqual(count, 2)
222+
})
223+
224+
it('gives the same instance if the throttle delay is the same', function () {
225+
const instance1 = ExtensionUserActivity.instance(100)
226+
const instance2 = ExtensionUserActivity.instance(100)
227+
const instance3 = ExtensionUserActivity.instance(200)
228+
const instance4 = ExtensionUserActivity.instance(200)
229+
230+
assert.strictEqual(instance1, instance2)
231+
assert.strictEqual(instance3, instance4)
232+
assert.notStrictEqual(instance1, instance3)
233+
})
234+
235+
function delayedFiringEvent(fireInMillis: number): vscode.Event<any> {
236+
const event = new vscode.EventEmitter<void>()
237+
setTimeout(() => event.fire(), fireInMillis)
238+
return event.event
239+
}
240+
})

src/test/shared/utilities/functionUtils.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import * as assert from 'assert'
7-
import { once, onceChanged, throttle } from '../../../shared/utilities/functionUtils'
7+
import { once, onceChanged, debounce } from '../../../shared/utilities/functionUtils'
88
import { installFakeClock } from '../../testUtil'
99

1010
describe('functionUtils', function () {
@@ -50,13 +50,13 @@ describe('functionUtils', function () {
5050
})
5151
})
5252

53-
describe('throttle', function () {
53+
describe('debounce', function () {
5454
let counter: number
5555
let fn: () => Promise<unknown>
5656

5757
beforeEach(function () {
5858
counter = 0
59-
fn = throttle(() => void counter++)
59+
fn = debounce(() => void counter++)
6060
})
6161

6262
it('prevents a function from executing more than once in the `delay` window', async function () {
@@ -86,7 +86,7 @@ describe('throttle', function () {
8686

8787
beforeEach(function () {
8888
clock = installFakeClock()
89-
fn = throttle(() => void counter++, 10)
89+
fn = debounce(() => void counter++, 10)
9090
})
9191

9292
afterEach(function () {

0 commit comments

Comments
 (0)