• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / vscode-brightscript-language / 30292899614

27 Jul 2026 06:14PM UTC coverage: 60.918% (+1.0%) from 59.908%
30292899614

Pull #862

github

web-flow
Merge 80e9cd822 into e465804e3
Pull Request #862: Device discovery simplification

2713 of 4855 branches covered (55.88%)

Branch coverage included in aggregate %.

299 of 327 new or added lines in 7 files covered. (91.44%)

12 existing lines in 3 files now uncovered.

4164 of 6434 relevant lines covered (64.72%)

47.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.74
/src/managers/UserInputManager.ts
1
import { Deferred } from 'brighterscript';
1✔
2
import type { DeviceInfoRaw } from 'roku-deploy';
3
import type {
4
    Disposable,
5
    QuickPickItem
6
} from 'vscode';
7
import * as vscode from 'vscode';
1✔
8
import type { ConfiguredDevice, DeviceManager, HostWithDeviceInfo, RokuDevice } from '../deviceDiscovery/DeviceManager';
9
import type { CredentialStore } from './CredentialStore';
10
import { icons } from '../icons';
1✔
11
import { vscodeContextManager } from './VscodeContextManager';
1✔
12
import { util } from '../util';
1✔
13
import {
1✔
14
    DEFAULT_DEVICE_FILTERS,
15
    DEVICE_FILTER_GROUPS,
16
    DEVICE_FILTER_KEYS,
17
    DEVICE_FILTER_LABELS,
18
    applyDeviceFilters,
19
    loadDeviceFilters,
20
    type DeviceFilters
21
} from '../deviceFilters';
22

23
const DEVICE_QUICK_PICK_FILTERS_SECTION = 'brightscript.deviceQuickPick.filters';
1✔
24

25
/**
26
 * An id to represent the "Enter manually" option in the host picker
27
 */
28
export const manualHostItemId = `${Number.MAX_SAFE_INTEGER}`;
1✔
29
const manualLabel = 'Enter manually';
1✔
30
/**
31
 * An id to represent the "Scan for devices" option in the host picker
32
 */
33
export const scanForDevicesItemId = `${Number.MAX_SAFE_INTEGER - 1}`;
1✔
34
const scanForDevicesLabel = 'Scan for devices';
1✔
35

36
/**
37
 * The outcome of resolving a developer password for a device.
38
 */
39
export type DevicePasswordResolution =
40
    | { status: 'ok'; password: string }
41
    | { status: 'unreachable' }
42
    | { status: 'cancelled' };
43

44
export class UserInputManager {
1✔
45

46
    public constructor(
47
        private deviceManager: DeviceManager,
103✔
48
        private credentialStore: CredentialStore
103✔
49
    ) { }
50

51
    /**
52
     * How long the picker waits after open with no broadcast before fulfilling any pending
53
     * order (including `stale`) as a routine freshness fallback. Overridable for tests.
54
     */
55
    private scanTimeoutMs = 7_000;
103✔
56

57
    public async promptForHostManual(): Promise<HostWithDeviceInfo | undefined> {
58
        while (true) {
2✔
59
            const value = await vscode.window.showInputBox({
2✔
60
                placeHolder: 'Please enter the IP address of your Roku device',
61
                value: ''
62
            });
63
            if (!value) {
2✔
64
                return undefined;
1✔
65
            }
66
            const probed = await vscode.window.withProgress(
1✔
67
                { location: vscode.ProgressLocation.Notification, title: `Contacting ${value}...` },
68
                async () => {
69
                    return this.deviceManager.validateAndAddDevice(value);
1✔
70
                }
71
            );
72
            if (probed) {
1!
73
                //probing gathers the device info the same way as the picker; return it alongside the host
74
                return { host: probed.ip, deviceInfo: probed.deviceInfo };
1✔
75
            }
76
            await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${value}. Check the IP and confirm developer mode is enabled.`);
×
77
        }
78
    }
79

80
    /**
81
     * Resolve a developer password that the device at `host` accepts.
82
     *
83
     * Every known candidate is tried in order (stored credential, configured
84
     * `brightscript.devices[].password`, the default device password, and any caller-provided
85
     * `extraCandidates`), each validated against the device. The first accepted candidate wins.
86
     * If none are accepted, the user is prompted, re-prompting after each rejection until they
87
     * enter a working password or cancel. An accepted password refreshes the credential store
88
     * entry when one already exists; callers that keep a global password fallback persist that
89
     * themselves.
90
     *
91
     * @returns `ok` with the accepted password, `unreachable` when the device can't be contacted,
92
     *          or `cancelled` when the user dismisses the prompt.
93
     */
94
    public async resolveDevicePassword(options: { host: string; serialNumber: string | undefined; extraCandidates?: Array<string | undefined> }): Promise<DevicePasswordResolution> {
95
        const { host, serialNumber } = options;
34✔
96
        const candidates = await this.collectDevicePasswordCandidates(serialNumber, options.extraCandidates);
34✔
97

98
        for (const candidate of candidates) {
34✔
99
            const validation = await this.deviceManager.validateDevicePassword(host, candidate);
37✔
100
            if (validation === 'ok') {
37✔
101
                await this.persistDevicePassword(serialNumber, candidate);
24✔
102
                return { status: 'ok', password: candidate };
24✔
103
            }
104
            if (validation === 'unreachable') {
13✔
105
                return { status: 'unreachable' };
2✔
106
            }
107
            // 'bad-password' — fall through to the next candidate
108
        }
109

110
        // No stored / configured candidate was accepted. Prompt, re-prompting after each
111
        // bad-password attempt until the user enters a working one or cancels (empty / Esc).
112
        let placeholder = candidates.length > 0
8✔
113
            ? 'The password was rejected by the device. Try again, or press Esc to cancel.'
114
            : 'The Roku development webserver password.';
115
        while (true) {
8✔
116
            const value = await this.promptForDevicePassword(placeholder);
11✔
117
            if (!value) {
11✔
118
                return { status: 'cancelled' };
3✔
119
            }
120
            const validation = await this.deviceManager.validateDevicePassword(host, value);
8✔
121
            if (validation === 'ok') {
8✔
122
                await this.persistDevicePassword(serialNumber, value);
5✔
123
                return { status: 'ok', password: value };
5✔
124
            }
125
            if (validation === 'unreachable') {
3!
126
                return { status: 'unreachable' };
×
127
            }
128
            placeholder = 'The password was rejected by the device. Try again, or press Esc to cancel.';
3✔
129
        }
130
    }
131

132
    /**
133
     * Build the ordered, de-duplicated list of candidate passwords to try when resolving
134
     * credentials for a device. Variable placeholders and empty values are filtered out so the
135
     * validation loop only sees real passwords. `extraCandidates` are appended after the
136
     * standard sources (e.g. launch-config values for a debug session).
137
     */
138
    private async collectDevicePasswordCandidates(serialNumber: string | undefined, extraCandidates?: Array<string | undefined>): Promise<string[]> {
139
        const candidates: string[] = [];
42✔
140
        const addCandidate = (value: string | undefined | null) => {
42✔
141
            const trimmed = value?.trim();
148✔
142
            // eslint-disable-next-line no-template-curly-in-string
143
            if (!trimmed || trimmed === '${promptForPassword}' || trimmed === '${activeHostPassword}') {
148✔
144
                return;
87✔
145
            }
146
            candidates.push(trimmed);
61✔
147
        };
148

149
        if (serialNumber) {
42✔
150
            addCandidate(await this.credentialStore.getPassword(serialNumber));
29✔
151

152
            const scanScope = (devices: ConfiguredDevice[] | undefined) => {
29✔
153
                for (const entry of devices ?? []) {
58✔
154
                    if (entry.serialNumber === serialNumber) {
1!
155
                        addCandidate(entry.password);
1✔
156
                    }
157
                }
158
            };
159
            const rootInspection = vscode.workspace.getConfiguration('brightscript').inspect<ConfiguredDevice[]>('devices');
29✔
160
            scanScope(rootInspection?.globalValue);
29!
161
            scanScope(rootInspection?.workspaceValue);
29!
162
            for (const folder of vscode.workspace.workspaceFolders ?? []) {
29!
163
                const folderInspection = vscode.workspace.getConfiguration('brightscript', folder.uri).inspect<ConfiguredDevice[]>('devices');
×
164
                scanScope(folderInspection?.workspaceFolderValue);
×
165
            }
166
        }
167

168
        addCandidate(this.deviceManager.getDefaultPassword());
42✔
169
        for (const extra of extraCandidates ?? []) {
42✔
170
            addCandidate(extra);
76✔
171
        }
172

173
        // Dedupe while preserving insertion order so a password referenced by multiple
174
        // sources is only validated once.
175
        return Array.from(new Set(candidates));
42✔
176
    }
177

178
    /**
179
     * Persist an accepted password by refreshing the credential store, but only when an entry
180
     * already exists for this serial (storing a brand-new entry is an explicit opt-in elsewhere).
181
     */
182
    private async persistDevicePassword(serialNumber: string | undefined, password: string): Promise<void> {
183
        if (serialNumber && (await this.credentialStore.getPassword(serialNumber)) !== undefined) {
29✔
184
            await this.credentialStore.setPassword(serialNumber, password);
4✔
185
        }
186
    }
187

188
    /**
189
     * Password input dialog. Returns the typed value, or undefined on Esc / hide.
190
     */
191
    private async promptForDevicePassword(placeholder: string): Promise<string | undefined> {
192
        const input = vscode.window.createInputBox();
×
193
        input.placeholder = placeholder;
×
194
        input.password = true;
×
195
        try {
×
196
            return await new Promise<string | undefined>(resolve => {
×
197
                input.onDidAccept(() => {
×
198
                    resolve(input.value);
×
199
                    input.hide();
×
200
                });
201
                input.onDidHide(() => {
×
202
                    resolve(undefined);
×
203
                });
204
                input.show();
×
205
            });
206
        } finally {
207
            input.dispose();
×
208
        }
209
    }
210

211
    /**
212
     * Prompt the user to pick a host from a list of devices
213
     */
214
    public async promptForHost(options?: { defaultValue?: string }): Promise<HostWithDeviceInfo | undefined> {
215

216
        const deferred = new Deferred<{ ip: string; deviceInfo: DeviceInfoRaw; manual?: false } | { manual: true }>();
9✔
217
        const disposables: Array<Disposable> = [];
9✔
218

219
        //create the quickpick item
220
        const quickPick = vscode.window.createQuickPick();
9✔
221
        disposables.push(quickPick);
9✔
222
        quickPick.placeholder = `Please Select a Roku or manually type an IP address`;
9✔
223
        quickPick.keepScrollPosition = true;
9✔
224

225
        // Track multiple busy sources (scan, health check) with a counter
226
        let busyCount = 0;
9✔
227
        const setBusy = (isBusy: boolean) => {
9✔
228
            busyCount += isBusy ? 1 : -1;
2✔
229
            busyCount = Math.max(0, busyCount); // Prevent negative
2✔
230
            quickPick.busy = busyCount > 0;
2✔
231
        };
232

233
        // Subscribe to scan events before triggering a broadcast so we catch the scan-started event
234
        this.deviceManager.on('scan-started', () => {
9✔
235
            setBusy(true);
×
236
        }, disposables);
237

238
        this.deviceManager.on('scan-ended', () => {
9✔
239
            setBusy(false);
×
240
        }, disposables);
241

242
        let scanTimeoutId: NodeJS.Timeout | null = null;
9✔
243

244
        // On open: fulfill a queued real order (network/sleep/refresh-clicked/...), forced. A
245
        // queued `stale` order is deliberately left alone — routine freshness is the 7s
246
        // fallback's job (below), so opening the picker never scans the network by itself.
247
        // The picker never submits reconcile orders of its own, but it does fulfill
248
        // queued/live ones (below) so work ordered while no view was visible isn't left waiting.
249
        let hasScanned = this.deviceManager.fulfillPendingBroadcast({ except: ['stale'] });
9✔
250

251
        // On open, also fulfill a queued reconcile order (`stale` stays queued)
252
        this.deviceManager.fulfillPendingReconcile({ except: ['stale'] });
9✔
253

254
        this.deviceManager.on('broadcast-ordered', (order) => {
9✔
NEW
255
            if (order.reason === 'stale') {
×
NEW
256
                return;
×
257
            }
258
            // Suppress the 7s fallback even if another visible consumer fulfills this order —
259
            // a scan is happening either way
260
            hasScanned = true;
×
261
            if (scanTimeoutId) {
×
262
                clearTimeout(scanTimeoutId);
×
263
                scanTimeoutId = null;
×
264
            }
NEW
265
            this.deviceManager.fulfillPendingBroadcast({ except: ['stale'] });
×
266
        }, disposables);
267

268
        this.deviceManager.on('reconcile-ordered', () => {
9✔
269
            this.deviceManager.fulfillPendingReconcile({ except: ['stale'] });
1✔
270
        }, disposables);
271

272
        scanTimeoutId = setTimeout(() => {
9✔
273
            if (hasScanned) {
2!
UNCOV
274
                return;
×
275
            }
276
            this.deviceManager.fulfillPendingBroadcast();
2✔
277
        }, this.scanTimeoutMs);
278

279
        function dispose() {
280
            // The fallback timer must not outlive the picker — a leaked timer would fire a
281
            // broadcast on a picker that's already closed
282
            if (scanTimeoutId) {
9!
283
                clearTimeout(scanTimeoutId);
9✔
284
                scanTimeoutId = null;
9✔
285
            }
286
            for (const disposable of disposables) {
9✔
287
                disposable.dispose();
63✔
288
            }
289
        }
290

291
        //detect if the user types an IP address into the picker and presses enter.
292
        let selectedDevice: vscode.QuickPickItem | undefined;
293
        quickPick.onDidAccept(async () => {
9✔
294
            if (selectedDevice) {
2✔
295
                if (selectedDevice.kind !== vscode.QuickPickItemKind.Separator) {
1!
296
                    if (selectedDevice.label === manualLabel) {
1!
297
                        deferred.resolve({ manual: true });
1✔
298
                    } else if (selectedDevice.label === scanForDevicesLabel) {
×
299
                        //an explicit "scan" click is the refresh-clicked trigger — submit orders;
300
                        //this picker (or another visible view) fulfills them immediately
NEW
301
                        this.deviceManager.submitBroadcast('refresh-clicked');
×
NEW
302
                        this.deviceManager.submitReconcile('refresh-clicked');
×
UNCOV
303
                        return;
×
304
                    } else {
305
                        const device = (selectedDevice as any).device as RokuDevice;
×
306
                        // if the selected device isn't healthy, show an error and keep the picker open so they can select a different device
307
                        setBusy(true);
×
308
                        const isHealthy = await this.deviceManager.healthCheckDevice(device, true, false);
×
309
                        setBusy(false);
×
310
                        if (!isHealthy) {
×
311
                            await vscode.window.showErrorMessage(`The selected device (${device.ip}) is not responding.`);
×
312
                            return;
×
313
                        }
314
                        this.deviceManager.setLastUsedDeviceIp(device.ip);
×
315
                        deferred.resolve({ ip: device.ip, deviceInfo: device.deviceInfo });
×
316
                    }
317
                    quickPick.dispose();
1✔
318
                }
319
                selectedDevice = undefined;
1✔
320
                // If the user has typed a value, probe the IP before resolving so
321
                // the caller only ever receives a reachable device.
322
            } else if (quickPick.value) {
1!
323
                const typedValue = quickPick.value;
1✔
324
                setBusy(true);
1✔
325
                const probed = await this.deviceManager.validateAndAddDevice(typedValue);
1✔
326
                setBusy(false);
1✔
327
                if (!probed) {
1!
328
                    await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${typedValue}. Check the IP and confirm developer mode is enabled.`);
×
329
                    return;
×
330
                }
331
                this.deviceManager.setLastUsedDeviceIp(probed.ip);
1✔
332
                deferred.resolve({ ip: probed.ip, deviceInfo: probed.deviceInfo });
1✔
333
                quickPick.dispose();
1✔
334
            }
335
        });
336

337
        quickPick.onDidChangeSelection((selection) => {
9✔
338
            // only save the selectedDevice if the user explicitly clicks on an item
339
            // use the selected device in onDidAccept
340
            selectedDevice = selection[0];
1✔
341
        });
342

343
        let activeChangesSinceRefresh = 0;
9✔
344
        let activeItem: QuickPickItem;
345

346
        // remember the currently active item so we can maintain active selection when refreshing the list
347
        quickPick.onDidChangeActive((items) => {
9✔
348
            // reset our activeChanges tracker since users cannot cause items.length to be 0 (meaning a refresh has just happened)
349
            if (items.length === 0) {
×
350
                activeChangesSinceRefresh = 0;
×
351
                return;
×
352
            }
353
            if (activeChangesSinceRefresh > 0) {
×
354
                activeItem = items[0];
×
355
            }
356
            activeChangesSinceRefresh++;
×
357
        });
358

359
        const itemCache = new Map<string, QuickPickHostItem>();
9✔
360
        if (options?.defaultValue) {
9!
361
            quickPick.value = options?.defaultValue;
×
362
        }
363
        quickPick.show();
9✔
364

365
        //set a timeout to automatically start scanning for devices after a short delay
366
        const SCAN_FOR_DEVICES = 'Scan for Devices';
9✔
367
        const CLEAR_DEVICE_LIST = 'Clear Device List';
9✔
368
        const ENABLE_DEVICE_DISCOVERY = 'Enable Device Discovery';
9✔
369
        const DISABLE_DEVICE_DISCOVERY = 'Disable Device Discovery';
9✔
370
        const FILTER_DEVICES = 'Filter Devices';
9✔
371

372
        const refreshList = () => {
9✔
373
            const filters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
9✔
374
            const items = this.createHostQuickPickList(
9✔
375
                applyDeviceFilters(this.deviceManager.getAllDevices(), filters),
376
                this.deviceManager.getLastUsedDeviceIp(),
377
                itemCache
378
            );
379
            quickPick.items = items;
9✔
380
            const discoveryEnabled = vscodeContextManager.get('brightscript.deviceDiscovery.enabled') === true;
9✔
381
            // Buttons render left-to-right; the rightmost button is the most prominent.
382
            quickPick.buttons = [
9✔
383
                {
384
                    iconPath: new vscode.ThemeIcon('filter'),
385
                    tooltip: FILTER_DEVICES
386
                },
387
                {
388
                    iconPath: discoveryEnabled ? icons.radioTower : icons.radioTowerOff,
9!
389
                    tooltip: discoveryEnabled ? DISABLE_DEVICE_DISCOVERY : ENABLE_DEVICE_DISCOVERY
9!
390
                },
391
                {
392
                    iconPath: new vscode.ThemeIcon('clear-all'),
393
                    tooltip: CLEAR_DEVICE_LIST
394
                },
395
                {
396
                    iconPath: new vscode.ThemeIcon('refresh'),
397
                    tooltip: SCAN_FOR_DEVICES
398
                }
399
            ];
400

401
            // clear the activeItem if we can't find it in the list
402
            if (!quickPick.items.includes(activeItem)) {
9!
403
                activeItem = undefined;
9✔
404
            }
405

406
            // if the user manually selected an item, re-focus that item now that we refreshed the list
407
            if (activeItem) {
9!
408
                quickPick.activeItems = [activeItem];
×
409
            }
410
            // quickPick.show();
411
        };
412

413
        //anytime the device list changes, update the list
414
        this.deviceManager.on('devices-changed', refreshList, disposables);
9✔
415

416
        //refresh the list when the toggle icon's source setting changes, or when any of the
417
        //device-quick-pick filter facets change (so other windows toggling a filter affect this picker)
418
        disposables.push(
9✔
419
            vscode.workspace.onDidChangeConfiguration(e => {
420
                if (
×
421
                    e.affectsConfiguration('brightscript.deviceDiscovery.enabled') ||
×
422
                    e.affectsConfiguration(DEVICE_QUICK_PICK_FILTERS_SECTION)
423
                ) {
424
                    refreshList();
×
425
                }
426
            })
427
        );
428

429
        //while the filter submenu is showing, the parent picker briefly hides — don't treat that as a dismissal
430
        let filterSubmenuOpen = false;
9✔
431
        quickPick.onDidHide(() => {
9✔
432
            if (filterSubmenuOpen) {
7!
433
                return;
×
434
            }
435
            dispose();
7✔
436
            deferred.reject(new Error('No host was selected'));
7✔
437
        });
438

439
        const openFilterSubmenu = () => {
9✔
440
            filterSubmenuOpen = true;
×
441
            this.showFilterSubmenu().finally(() => {
×
442
                filterSubmenuOpen = false;
×
443
                // Re-render items before re-showing — without this the parent picker
444
                // appears empty after a hide/show cycle when no settings changed during the submenu.
445
                refreshList();
×
446
                quickPick.show();
×
447
            });
448
        };
449

450
        quickPick.onDidTriggerButton(button => {
9✔
451
            if (button.tooltip === SCAN_FOR_DEVICES) {
×
452
                //an explicit "scan" click is the refresh-clicked trigger — submit orders;
453
                //this picker (or another visible view) fulfills them immediately
NEW
454
                this.deviceManager.submitBroadcast('refresh-clicked');
×
NEW
455
                this.deviceManager.submitReconcile('refresh-clicked');
×
456
            } else if (button.tooltip === CLEAR_DEVICE_LIST) {
×
NEW
457
                this.deviceManager.clearCurrentDeviceList();
×
458
                void util.showTimedNotification('Clearing device list');
×
459
            } else if (button.tooltip === ENABLE_DEVICE_DISCOVERY) {
×
460
                void util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', true);
×
461
            } else if (button.tooltip === DISABLE_DEVICE_DISCOVERY) {
×
462
                void util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', false);
×
463
            } else if (button.tooltip === FILTER_DEVICES) {
×
464
                openFilterSubmenu();
×
465
            }
466
        });
467

468
        //run the list refresh once to show the popup
469
        refreshList();
9✔
470
        const result = await deferred.promise;
9✔
471
        dispose();
2✔
472
        if (result.manual === true) {
2✔
473
            return this.promptForHostManual();
1✔
474
        } else {
475
            return { host: result.ip, deviceInfo: result.deviceInfo };
1✔
476
        }
477
    }
478

479
    /**
480
     * Generate the item list for the `this.promptForHost()` call
481
     */
482
    private createHostQuickPickList(
483
        devices: RokuDevice[],
484
        lastUsedDeviceIp: string | undefined,
485
        cache = new Map<string, QuickPickHostItem>()
7✔
486
    ) {
487
        //the collection of items we will eventually return
488
        let items: QuickPickHostItem[] = [];
16✔
489

490
        //find the lastUsedDevice from the devices list
491
        const lastUsedDevice = lastUsedDeviceIp ? devices.find(x => x.ip === lastUsedDeviceIp) : undefined;
16✔
492
        //remove the lastUsedDevice from the devices list so we can more easily reason with the rest of the list
493
        devices = devices.filter(x => x.ip !== lastUsedDeviceIp);
16✔
494

495
        // Ensure the most recently used device is at the top of the list
496
        if (lastUsedDevice) {
16✔
497
            //add a separator for "last used"
498
            items.push({
3✔
499
                label: 'last used',
500
                kind: vscode.QuickPickItemKind.Separator
501
            });
502

503
            //add the device
504
            items.push({
3✔
505
                label: this.deviceManager.getDeviceDisplayName(lastUsedDevice, true),
506
                device: lastUsedDevice,
507
                iconPath: this.deviceManager.getIconPath(lastUsedDevice)
508
            } as any);
509
        }
510

511
        //add all other devices
512
        if (devices.length > 0) {
16✔
513
            items.push({
4✔
514
                label: lastUsedDevice ? 'other devices' : 'devices',
4✔
515
                kind: vscode.QuickPickItemKind.Separator
516
            });
517

518
            //add each device
519
            for (const device of devices) {
4✔
520
                //add the device
521
                items.push({
8✔
522
                    label: this.deviceManager.getDeviceDisplayName(device, true),
523
                    device: device,
524
                    iconPath: this.deviceManager.getIconPath(device)
525
                });
526
            }
527
        }
528

529
        //include a divider between devices and "manual" option (only if we have devices)
530
        if (lastUsedDevice || devices.length) {
16✔
531
            items.push({ label: ' ', kind: vscode.QuickPickItemKind.Separator });
5✔
532
        }
533

534
        // allow user to manually type an IP address
535
        items.push(
16✔
536
            {
537
                label: manualLabel,
538
                device: { id: manualHostItemId },
539
                iconPath: new vscode.ThemeIcon('keyboard')
540
            } as any,
541
            {
542
                label: scanForDevicesLabel,
543
                device: { id: scanForDevicesItemId },
544
                iconPath: new vscode.ThemeIcon('radio-tower')
545
            } as any
546
        );
547

548
        // replace items with their cached versions if found (to maintain references)
549
        for (let i = 0; i < items.length; i++) {
16✔
550
            const item = items[i];
55✔
551
            if (cache.has(item.label)) {
55!
552
                items[i] = cache.get(item.label);
×
553
                items[i].device = item.device;
×
554
                items[i].iconPath = item.iconPath;
×
555
            } else {
556
                cache.set(item.label, item);
55✔
557
            }
558
        }
559

560
        return items;
16✔
561
    }
562

563
    /**
564
     * Open a checkbox-style quick pick (canSelectMany) listing each filter facet.
565
     * Follows VS Code's standard multi-select pattern: Space toggles checkboxes,
566
     * Enter commits the current selection to user settings, Escape cancels. A title-bar
567
     * Reset button resets the picker's selection to defaults (still committed on Enter).
568
     */
569
    private showFilterSubmenu(): Promise<void> {
570
        return new Promise<void>((resolve) => {
×
571
            const RESET_FILTERS = 'Reset Filters';
×
572
            const filterPick = vscode.window.createQuickPick<QuickPickFilterItem>();
×
573
            filterPick.title = 'Filter Devices';
×
574
            filterPick.placeholder = 'Space to toggle, Enter to apply, Escape to cancel';
×
575
            filterPick.canSelectMany = true;
×
576
            filterPick.buttons = [{
×
577
                iconPath: new vscode.ThemeIcon('discard'),
578
                tooltip: RESET_FILTERS
579
            }];
580

581
            const buildItems = (filters: DeviceFilters): QuickPickFilterItem[] => {
×
582
                const result: QuickPickFilterItem[] = [];
×
583
                for (let groupIndex = 0; groupIndex < DEVICE_FILTER_GROUPS.length; groupIndex++) {
×
584
                    if (groupIndex > 0) {
×
585
                        result.push({ label: '', kind: vscode.QuickPickItemKind.Separator });
×
586
                    }
587
                    for (const facetKey of DEVICE_FILTER_GROUPS[groupIndex]) {
×
588
                        result.push({
×
589
                            label: DEVICE_FILTER_LABELS[facetKey],
590
                            picked: filters[facetKey],
591
                            facetKey: facetKey
592
                        });
593
                    }
594
                }
595
                return result;
×
596
            };
597

598
            // Initial load — render items from current settings and pre-select the picked ones
599
            const initialFilters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
600
            const items = buildItems(initialFilters);
×
601
            filterPick.items = items;
×
602
            filterPick.selectedItems = items.filter(i => i.picked);
×
603

604
            filterPick.onDidTriggerButton((button) => {
×
605
                if (button.tooltip !== RESET_FILTERS) {
×
606
                    return;
×
607
                }
608
                // Reset the picker's selection to the in-code defaults — user still has to
609
                // press Enter to commit or Escape to discard, matching the rest of the flow.
610
                filterPick.selectedItems = items.filter(item => {
×
611
                    return item.facetKey ? DEFAULT_DEVICE_FILTERS[item.facetKey] : false;
×
612
                });
613
            });
614

615
            filterPick.onDidAccept(async () => {
×
616
                const selectedFacets = new Set<keyof DeviceFilters>();
×
617
                for (const item of filterPick.selectedItems) {
×
618
                    if (item.facetKey) {
×
619
                        selectedFacets.add(item.facetKey);
×
620
                    }
621
                }
622
                const currentFilters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
623
                const config = vscode.workspace.getConfiguration(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
624
                const writes: Thenable<unknown>[] = [];
×
625
                for (const facetKey of DEVICE_FILTER_KEYS) {
×
626
                    const nextValue = selectedFacets.has(facetKey);
×
627
                    if (nextValue === currentFilters[facetKey]) {
×
628
                        continue;
×
629
                    }
630
                    const valueToWrite = nextValue === DEFAULT_DEVICE_FILTERS[facetKey] ? undefined : nextValue;
×
631
                    writes.push(config.update(facetKey, valueToWrite, vscode.ConfigurationTarget.Global));
×
632
                }
633
                if (writes.length > 0) {
×
634
                    try {
×
635
                        await Promise.all(writes);
×
636
                    } catch {
637
                        // best-effort persistence
638
                    }
639
                }
640
                filterPick.hide();
×
641
            });
642

643
            filterPick.onDidHide(() => {
×
644
                filterPick.dispose();
×
645
                resolve();
×
646
            });
647

648
            filterPick.show();
×
649
        });
650
    }
651
}
652

653
type QuickPickFilterItem = QuickPickItem & { facetKey?: keyof DeviceFilters };
654

655
type QuickPickHostItem = QuickPickItem & { device?: RokuDevice; iconPath?: vscode.ThemeIcon | { light: vscode.Uri; dark: vscode.Uri } };
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc