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

rokucommunity / vscode-brightscript-language / 28807866778

06 Jul 2026 04:44PM UTC coverage: 57.326% (+1.0%) from 56.344%
28807866778

Pull #839

github

web-flow
Merge cfc88ca9b into 2f49ccaa0
Pull Request #839: Reuse probed device-info for launch and host selection

2379 of 4584 branches covered (51.9%)

Branch coverage included in aggregate %.

26 of 33 new or added lines in 5 files covered. (78.79%)

3779 of 6158 relevant lines covered (61.37%)

41.77 hits per line

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

56.97
/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,
95✔
48
        private credentialStore: CredentialStore
95✔
49
    ) { }
50

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

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

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

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

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

143
        if (serialNumber) {
42✔
144
            addCandidate(await this.credentialStore.getPassword(serialNumber));
29✔
145

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

162
        addCandidate(this.deviceManager.getDefaultPassword());
42✔
163
        for (const extra of extraCandidates ?? []) {
42✔
164
            addCandidate(extra);
76✔
165
        }
166

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

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

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

205
    /**
206
     * Prompt the user to pick a host from a list of devices
207
     */
208
    public async promptForHost(options?: { defaultValue?: string }): Promise<HostWithDeviceInfo | undefined> {
209

210
        const deferred = new Deferred<{ ip: string; deviceInfo: DeviceInfoRaw; manual?: false } | { manual: true }>();
3✔
211
        const disposables: Array<Disposable> = [];
3✔
212

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

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

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

232
        this.deviceManager.on('scan-ended', () => {
3✔
233
            setBusy(false);
×
234
        }, disposables);
235

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

254
        function dispose() {
255
            for (const disposable of disposables) {
3✔
256
                disposable.dispose();
18✔
257
            }
258
        }
259

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

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

309
        let activeChangesSinceRefresh = 0;
3✔
310
        let activeItem: QuickPickItem;
311

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

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

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

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

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

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

379
        //anytime the device list changes, update the list
380
        this.deviceManager.on('devices-changed', refreshList, disposables);
3✔
381

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

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

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

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

431
        //run the list refresh once to show the popup
432
        refreshList();
3✔
433
        const result = await deferred.promise;
3✔
434
        dispose();
2✔
435
        if (result.manual === true) {
2✔
436
            return this.promptForHostManual();
1✔
437
        } else {
438
            return { host: result.ip, deviceInfo: result.deviceInfo };
1✔
439
        }
440
    }
441

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

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

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

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

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

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

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

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

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

523
        return items;
10✔
524
    }
525

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

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

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

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

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

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

611
            filterPick.show();
×
612
        });
613
    }
614
}
615

616
type QuickPickFilterItem = QuickPickItem & { facetKey?: keyof DeviceFilters };
617

618
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