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

rokucommunity / vscode-brightscript-language / 28182920295

25 Jun 2026 03:53PM UTC coverage: 56.485% (+0.3%) from 56.169%
28182920295

push

github

web-flow
Add restart device and software update commands to Devices panel (#829)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Christopher Dwyer-Perkins <chris@inverted-solutions.com>

2316 of 4518 branches covered (51.26%)

Branch coverage included in aggregate %.

100 of 122 new or added lines in 6 files covered. (81.97%)

1 existing line in 1 file now uncovered.

3703 of 6138 relevant lines covered (60.33%)

40.81 hits per line

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

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

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

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

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

43
export class UserInputManager {
1✔
44

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

50
    public async promptForHostManual(): Promise<string | undefined> {
51
        while (true) {
×
52
            const value = await vscode.window.showInputBox({
×
53
                placeHolder: 'Please enter the IP address of your Roku device',
54
                value: ''
55
            });
56
            if (!value) {
×
57
                return undefined;
×
58
            }
59
            const probed = await vscode.window.withProgress(
×
60
                { location: vscode.ProgressLocation.Notification, title: `Contacting ${value}...` },
61
                async () => {
62
                    return this.deviceManager.validateAndAddDevice(value);
×
63
                }
64
            );
65
            if (probed) {
×
66
                return probed.ip;
×
67
            }
68
            await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${value}. Check the IP and confirm developer mode is enabled.`);
×
69
        }
70
    }
71

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

90
        for (const candidate of candidates) {
31✔
91
            const validation = await this.deviceManager.validateDevicePassword(host, candidate);
34✔
92
            if (validation === 'ok') {
34✔
93
                await this.persistDevicePassword(serialNumber, candidate);
21✔
94
                return { status: 'ok', password: candidate };
21✔
95
            }
96
            if (validation === 'unreachable') {
13✔
97
                return { status: 'unreachable' };
2✔
98
            }
99
            // 'bad-password' — fall through to the next candidate
100
        }
101

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

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

141
        if (serialNumber) {
39✔
142
            addCandidate(await this.credentialStore.getPassword(serialNumber));
18✔
143

144
            const scanScope = (devices: ConfiguredDevice[] | undefined) => {
18✔
145
                for (const entry of devices ?? []) {
36✔
146
                    if (entry.serialNumber === serialNumber) {
1!
147
                        addCandidate(entry.password);
1✔
148
                    }
149
                }
150
            };
151
            const rootInspection = vscode.workspace.getConfiguration('brightscript').inspect<ConfiguredDevice[]>('devices');
18✔
152
            scanScope(rootInspection?.globalValue);
18!
153
            scanScope(rootInspection?.workspaceValue);
18!
154
            for (const folder of vscode.workspace.workspaceFolders ?? []) {
18!
NEW
155
                const folderInspection = vscode.workspace.getConfiguration('brightscript', folder.uri).inspect<ConfiguredDevice[]>('devices');
×
NEW
156
                scanScope(folderInspection?.workspaceFolderValue);
×
157
            }
158
        }
159

160
        addCandidate(this.deviceManager.getDefaultPassword());
39✔
161
        for (const extra of extraCandidates ?? []) {
39✔
162
            addCandidate(extra);
70✔
163
        }
164

165
        // Dedupe while preserving insertion order so a password referenced by multiple
166
        // sources is only validated once.
167
        return Array.from(new Set(candidates));
39✔
168
    }
169

170
    /**
171
     * Persist an accepted password by refreshing the credential store, but only when an entry
172
     * already exists for this serial (storing a brand-new entry is an explicit opt-in elsewhere).
173
     */
174
    private async persistDevicePassword(serialNumber: string | undefined, password: string): Promise<void> {
175
        if (serialNumber && (await this.credentialStore.getPassword(serialNumber)) !== undefined) {
26✔
176
            await this.credentialStore.setPassword(serialNumber, password);
4✔
177
        }
178
    }
179

180
    /**
181
     * Password input dialog. Returns the typed value, or undefined on Esc / hide.
182
     */
183
    private async promptForDevicePassword(placeholder: string): Promise<string | undefined> {
NEW
184
        const input = vscode.window.createInputBox();
×
NEW
185
        input.placeholder = placeholder;
×
NEW
186
        input.password = true;
×
NEW
187
        try {
×
NEW
188
            return await new Promise<string | undefined>(resolve => {
×
NEW
189
                input.onDidAccept(() => {
×
NEW
190
                    resolve(input.value);
×
NEW
191
                    input.hide();
×
192
                });
NEW
193
                input.onDidHide(() => {
×
NEW
194
                    resolve(undefined);
×
195
                });
NEW
196
                input.show();
×
197
            });
198
        } finally {
NEW
199
            input.dispose();
×
200
        }
201
    }
202

203
    /**
204
     * Prompt the user to pick a host from a list of devices
205
     */
206
    public async promptForHost(options?: { defaultValue?: string }) {
207

208
        const deferred = new Deferred<{ ip: string; manual?: boolean } | { ip?: string; manual: true }>();
1✔
209
        const disposables: Array<Disposable> = [];
1✔
210

211
        //create the quickpick item
212
        const quickPick = vscode.window.createQuickPick();
1✔
213
        disposables.push(quickPick);
1✔
214
        quickPick.placeholder = `Please Select a Roku or manually type an IP address`;
1✔
215
        quickPick.keepScrollPosition = true;
1✔
216

217
        // Track multiple busy sources (scan, health check) with a counter
218
        let busyCount = 0;
1✔
219
        const setBusy = (isBusy: boolean) => {
1✔
220
            busyCount += isBusy ? 1 : -1;
×
221
            busyCount = Math.max(0, busyCount); // Prevent negative
×
222
            quickPick.busy = busyCount > 0;
×
223
        };
224

225
        // Subscribe to scan events before triggering refresh so we catch the scan-started event
226
        this.deviceManager.on('scan-started', () => {
1✔
227
            setBusy(true);
×
228
        }, disposables);
229

230
        this.deviceManager.on('scan-ended', () => {
1✔
231
            setBusy(false);
×
232
        }, disposables);
233

234
        const scanTimeoutMs = 7_000;
1✔
235
        let scanTimeoutId: NodeJS.Timeout | null = null;
1✔
236
        let hasScanned = this.deviceManager.scan();
1✔
237
        this.deviceManager.on('scanNeeded-changed', () => {
1✔
238
            hasScanned = true;
×
239
            if (scanTimeoutId) {
×
240
                clearTimeout(scanTimeoutId);
×
241
                scanTimeoutId = null;
×
242
            }
243
            this.deviceManager.scan();
×
244
        }, disposables);
245
        scanTimeoutId = setTimeout(() => {
1✔
246
            if (hasScanned) {
1!
247
                return;
1✔
248
            }
249
            this.deviceManager.scan();
×
250
        }, scanTimeoutMs);
251

252
        function dispose() {
253
            for (const disposable of disposables) {
×
254
                disposable.dispose();
×
255
            }
256
        }
257

258
        //detect if the user types an IP address into the picker and presses enter.
259
        let selectedDevice: vscode.QuickPickItem | undefined;
260
        quickPick.onDidAccept(async () => {
1✔
261
            if (selectedDevice) {
×
262
                if (selectedDevice.kind !== vscode.QuickPickItemKind.Separator) {
×
263
                    if (selectedDevice.label === manualLabel) {
×
264
                        deferred.resolve({ manual: true });
×
265
                    } else if (selectedDevice.label === scanForDevicesLabel) {
×
266
                        this.deviceManager.refresh(true);
×
267
                        return;
×
268
                    } else {
269
                        const device = (selectedDevice as any).device as RokuDevice;
×
270
                        // if the selected device isn't healthy, show an error and keep the picker open so they can select a different device
271
                        setBusy(true);
×
272
                        const isHealthy = await this.deviceManager.healthCheckDevice(device, true, false);
×
273
                        setBusy(false);
×
274
                        if (!isHealthy) {
×
275
                            await vscode.window.showErrorMessage(`The selected device (${device.ip}) is not responding.`);
×
276
                            return;
×
277
                        }
278
                        this.deviceManager.setLastUsedDeviceIp(device.ip);
×
279
                        deferred.resolve(device);
×
280
                    }
281
                    quickPick.dispose();
×
282
                }
283
                selectedDevice = undefined;
×
284
                // If the user has typed a value, probe the IP before resolving so
285
                // the caller only ever receives a reachable device.
286
            } else if (quickPick.value) {
×
287
                const typedValue = quickPick.value;
×
288
                setBusy(true);
×
289
                const probed = await this.deviceManager.validateAndAddDevice(typedValue);
×
290
                setBusy(false);
×
291
                if (!probed) {
×
292
                    await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${typedValue}. Check the IP and confirm developer mode is enabled.`);
×
293
                    return;
×
294
                }
295
                this.deviceManager.setLastUsedDeviceIp(probed.ip);
×
296
                deferred.resolve({ ip: probed.ip });
×
297
                quickPick.dispose();
×
298
            }
299
        });
300

301
        quickPick.onDidChangeSelection((selection) => {
1✔
302
            // only save the selectedDevice if the user explicitly clicks on an item
303
            // use the selected device in onDidAccept
304
            selectedDevice = selection[0];
×
305
        });
306

307
        let activeChangesSinceRefresh = 0;
1✔
308
        let activeItem: QuickPickItem;
309

310
        // remember the currently active item so we can maintain active selection when refreshing the list
311
        quickPick.onDidChangeActive((items) => {
1✔
312
            // reset our activeChanges tracker since users cannot cause items.length to be 0 (meaning a refresh has just happened)
313
            if (items.length === 0) {
×
314
                activeChangesSinceRefresh = 0;
×
315
                return;
×
316
            }
317
            if (activeChangesSinceRefresh > 0) {
×
318
                activeItem = items[0];
×
319
            }
320
            activeChangesSinceRefresh++;
×
321
        });
322

323
        const itemCache = new Map<string, QuickPickHostItem>();
×
324
        if (options?.defaultValue) {
×
325
            quickPick.value = options?.defaultValue;
×
326
        }
327
        quickPick.show();
×
328

329
        //set a timeout to automatically start scanning for devices after a short delay
330
        const SCAN_FOR_DEVICES = 'Scan for Devices';
×
331
        const CLEAR_DEVICE_LIST = 'Clear Device List';
×
332
        const ENABLE_DEVICE_DISCOVERY = 'Enable Device Discovery';
×
333
        const DISABLE_DEVICE_DISCOVERY = 'Disable Device Discovery';
×
334
        const FILTER_DEVICES = 'Filter Devices';
×
335

336
        const refreshList = () => {
×
337
            const filters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
338
            const items = this.createHostQuickPickList(
×
339
                applyDeviceFilters(this.deviceManager.getAllDevices(), filters),
340
                this.deviceManager.getLastUsedDeviceIp(),
341
                itemCache
342
            );
343
            quickPick.items = items;
×
344
            const discoveryEnabled = vscodeContextManager.get('brightscript.deviceDiscovery.enabled') === true;
×
345
            // Buttons render left-to-right; the rightmost button is the most prominent.
346
            quickPick.buttons = [
×
347
                {
348
                    iconPath: new vscode.ThemeIcon('filter'),
349
                    tooltip: FILTER_DEVICES
350
                },
351
                {
352
                    iconPath: discoveryEnabled ? icons.radioTower : icons.radioTowerOff,
×
353
                    tooltip: discoveryEnabled ? DISABLE_DEVICE_DISCOVERY : ENABLE_DEVICE_DISCOVERY
×
354
                },
355
                {
356
                    iconPath: new vscode.ThemeIcon('clear-all'),
357
                    tooltip: CLEAR_DEVICE_LIST
358
                },
359
                {
360
                    iconPath: new vscode.ThemeIcon('refresh'),
361
                    tooltip: SCAN_FOR_DEVICES
362
                }
363
            ];
364

365
            // clear the activeItem if we can't find it in the list
366
            if (!quickPick.items.includes(activeItem)) {
×
367
                activeItem = undefined;
×
368
            }
369

370
            // if the user manually selected an item, re-focus that item now that we refreshed the list
371
            if (activeItem) {
×
372
                quickPick.activeItems = [activeItem];
×
373
            }
374
            // quickPick.show();
375
        };
376

377
        //anytime the device list changes, update the list
378
        this.deviceManager.on('devices-changed', refreshList, disposables);
×
379

380
        //refresh the list when the toggle icon's source setting changes, or when any of the
381
        //device-quick-pick filter facets change (so other windows toggling a filter affect this picker)
382
        disposables.push(
×
383
            vscode.workspace.onDidChangeConfiguration(e => {
384
                if (
×
385
                    e.affectsConfiguration('brightscript.deviceDiscovery.enabled') ||
×
386
                    e.affectsConfiguration(DEVICE_QUICK_PICK_FILTERS_SECTION)
387
                ) {
388
                    refreshList();
×
389
                }
390
            })
391
        );
392

393
        //while the filter submenu is showing, the parent picker briefly hides — don't treat that as a dismissal
394
        let filterSubmenuOpen = false;
×
395
        quickPick.onDidHide(() => {
×
396
            if (filterSubmenuOpen) {
×
397
                return;
×
398
            }
399
            dispose();
×
400
            deferred.reject(new Error('No host was selected'));
×
401
        });
402

403
        const openFilterSubmenu = () => {
×
404
            filterSubmenuOpen = true;
×
405
            this.showFilterSubmenu().finally(() => {
×
406
                filterSubmenuOpen = false;
×
407
                // Re-render items before re-showing — without this the parent picker
408
                // appears empty after a hide/show cycle when no settings changed during the submenu.
409
                refreshList();
×
410
                quickPick.show();
×
411
            });
412
        };
413

414
        quickPick.onDidTriggerButton(button => {
×
415
            if (button.tooltip === SCAN_FOR_DEVICES) {
×
416
                this.deviceManager.refresh(true);
×
417
            } else if (button.tooltip === CLEAR_DEVICE_LIST) {
×
418
                this.deviceManager.clearCurrentDeviceList().catch(() => { });
×
419
                void util.showTimedNotification('Clearing device list');
×
420
            } else if (button.tooltip === ENABLE_DEVICE_DISCOVERY) {
×
421
                void util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', true);
×
422
            } else if (button.tooltip === DISABLE_DEVICE_DISCOVERY) {
×
423
                void util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', false);
×
424
            } else if (button.tooltip === FILTER_DEVICES) {
×
425
                openFilterSubmenu();
×
426
            }
427
        });
428

429
        //run the list refresh once to show the popup
430
        refreshList();
×
431
        const result = await deferred.promise;
×
432
        dispose();
×
433
        if (result?.manual === true) {
×
434
            return this.promptForHostManual();
×
435
        } else {
436
            return result?.ip;
×
437
        }
438
    }
439

440
    /**
441
     * Generate the item list for the `this.promptForHost()` call
442
     */
443
    private createHostQuickPickList(
444
        devices: RokuDevice[],
445
        lastUsedDeviceIp: string | undefined,
446
        cache = new Map<string, QuickPickHostItem>()
7✔
447
    ) {
448
        //the collection of items we will eventually return
449
        let items: QuickPickHostItem[] = [];
7✔
450

451
        //find the lastUsedDevice from the devices list
452
        const lastUsedDevice = lastUsedDeviceIp ? devices.find(x => x.ip === lastUsedDeviceIp) : undefined;
7✔
453
        //remove the lastUsedDevice from the devices list so we can more easily reason with the rest of the list
454
        devices = devices.filter(x => x.ip !== lastUsedDeviceIp);
11✔
455

456
        // Ensure the most recently used device is at the top of the list
457
        if (lastUsedDevice) {
7✔
458
            //add a separator for "last used"
459
            items.push({
3✔
460
                label: 'last used',
461
                kind: vscode.QuickPickItemKind.Separator
462
            });
463

464
            //add the device
465
            items.push({
3✔
466
                label: this.deviceManager.getDeviceDisplayName(lastUsedDevice, true),
467
                device: lastUsedDevice,
468
                iconPath: this.deviceManager.getIconPath(lastUsedDevice)
469
            } as any);
470
        }
471

472
        //add all other devices
473
        if (devices.length > 0) {
7✔
474
            items.push({
4✔
475
                label: lastUsedDevice ? 'other devices' : 'devices',
4✔
476
                kind: vscode.QuickPickItemKind.Separator
477
            });
478

479
            //add each device
480
            for (const device of devices) {
4✔
481
                //add the device
482
                items.push({
8✔
483
                    label: this.deviceManager.getDeviceDisplayName(device, true),
484
                    device: device,
485
                    iconPath: this.deviceManager.getIconPath(device)
486
                });
487
            }
488
        }
489

490
        //include a divider between devices and "manual" option (only if we have devices)
491
        if (lastUsedDevice || devices.length) {
7✔
492
            items.push({ label: ' ', kind: vscode.QuickPickItemKind.Separator });
5✔
493
        }
494

495
        // allow user to manually type an IP address
496
        items.push(
7✔
497
            {
498
                label: manualLabel,
499
                device: { id: manualHostItemId },
500
                iconPath: new vscode.ThemeIcon('keyboard')
501
            } as any,
502
            {
503
                label: scanForDevicesLabel,
504
                device: { id: scanForDevicesItemId },
505
                iconPath: new vscode.ThemeIcon('radio-tower')
506
            } as any
507
        );
508

509
        // replace items with their cached versions if found (to maintain references)
510
        for (let i = 0; i < items.length; i++) {
7✔
511
            const item = items[i];
37✔
512
            if (cache.has(item.label)) {
37!
513
                items[i] = cache.get(item.label);
×
514
                items[i].device = item.device;
×
515
                items[i].iconPath = item.iconPath;
×
516
            } else {
517
                cache.set(item.label, item);
37✔
518
            }
519
        }
520

521
        return items;
7✔
522
    }
523

524
    /**
525
     * Open a checkbox-style quick pick (canSelectMany) listing each filter facet.
526
     * Follows VS Code's standard multi-select pattern: Space toggles checkboxes,
527
     * Enter commits the current selection to user settings, Escape cancels. A title-bar
528
     * Reset button resets the picker's selection to defaults (still committed on Enter).
529
     */
530
    private showFilterSubmenu(): Promise<void> {
531
        return new Promise<void>((resolve) => {
×
532
            const RESET_FILTERS = 'Reset Filters';
×
533
            const filterPick = vscode.window.createQuickPick<QuickPickFilterItem>();
×
534
            filterPick.title = 'Filter Devices';
×
535
            filterPick.placeholder = 'Space to toggle, Enter to apply, Escape to cancel';
×
536
            filterPick.canSelectMany = true;
×
537
            filterPick.buttons = [{
×
538
                iconPath: new vscode.ThemeIcon('discard'),
539
                tooltip: RESET_FILTERS
540
            }];
541

542
            const buildItems = (filters: DeviceFilters): QuickPickFilterItem[] => {
×
543
                const result: QuickPickFilterItem[] = [];
×
544
                for (let groupIndex = 0; groupIndex < DEVICE_FILTER_GROUPS.length; groupIndex++) {
×
545
                    if (groupIndex > 0) {
×
546
                        result.push({ label: '', kind: vscode.QuickPickItemKind.Separator });
×
547
                    }
548
                    for (const facetKey of DEVICE_FILTER_GROUPS[groupIndex]) {
×
549
                        result.push({
×
550
                            label: DEVICE_FILTER_LABELS[facetKey],
551
                            picked: filters[facetKey],
552
                            facetKey: facetKey
553
                        });
554
                    }
555
                }
556
                return result;
×
557
            };
558

559
            // Initial load — render items from current settings and pre-select the picked ones
560
            const initialFilters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
561
            const items = buildItems(initialFilters);
×
562
            filterPick.items = items;
×
563
            filterPick.selectedItems = items.filter(i => i.picked);
×
564

565
            filterPick.onDidTriggerButton((button) => {
×
566
                if (button.tooltip !== RESET_FILTERS) {
×
567
                    return;
×
568
                }
569
                // Reset the picker's selection to the in-code defaults — user still has to
570
                // press Enter to commit or Escape to discard, matching the rest of the flow.
571
                filterPick.selectedItems = items.filter(item => {
×
572
                    return item.facetKey ? DEFAULT_DEVICE_FILTERS[item.facetKey] : false;
×
573
                });
574
            });
575

576
            filterPick.onDidAccept(async () => {
×
577
                const selectedFacets = new Set<keyof DeviceFilters>();
×
578
                for (const item of filterPick.selectedItems) {
×
579
                    if (item.facetKey) {
×
580
                        selectedFacets.add(item.facetKey);
×
581
                    }
582
                }
583
                const currentFilters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
584
                const config = vscode.workspace.getConfiguration(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
585
                const writes: Thenable<unknown>[] = [];
×
586
                for (const facetKey of DEVICE_FILTER_KEYS) {
×
587
                    const nextValue = selectedFacets.has(facetKey);
×
588
                    if (nextValue === currentFilters[facetKey]) {
×
589
                        continue;
×
590
                    }
591
                    const valueToWrite = nextValue === DEFAULT_DEVICE_FILTERS[facetKey] ? undefined : nextValue;
×
592
                    writes.push(config.update(facetKey, valueToWrite, vscode.ConfigurationTarget.Global));
×
593
                }
594
                if (writes.length > 0) {
×
595
                    try {
×
596
                        await Promise.all(writes);
×
597
                    } catch {
598
                        // best-effort persistence
599
                    }
600
                }
601
                filterPick.hide();
×
602
            });
603

604
            filterPick.onDidHide(() => {
×
605
                filterPick.dispose();
×
606
                resolve();
×
607
            });
608

609
            filterPick.show();
×
610
        });
611
    }
612
}
613

614
type QuickPickFilterItem = QuickPickItem & { facetKey?: keyof DeviceFilters };
615

616
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 · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc