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

rokucommunity / vscode-brightscript-language / 26249605670

21 May 2026 07:54PM UTC coverage: 56.236% (+0.7%) from 55.501%
26249605670

Pull #790

github

web-flow
Merge 5f9127bce into b9f6aae1a
Pull Request #790: Add filter dropdown to the Devices view

2275 of 4461 branches covered (51.0%)

Branch coverage included in aggregate %.

105 of 172 new or added lines in 7 files covered. (61.05%)

3 existing lines in 1 file now uncovered.

3645 of 6066 relevant lines covered (60.09%)

40.17 hits per line

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

21.41
/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 { DeviceManager, RokuDevice } from '../deviceDiscovery/DeviceManager';
8
import { icons } from '../icons';
1✔
9
import { vscodeContextManager } from './VscodeContextManager';
1✔
10
import { util } from '../util';
1✔
11
import {
1✔
12
    DEFAULT_DEVICE_FILTERS,
13
    DEVICE_FILTER_GROUPS,
14
    DEVICE_FILTER_KEYS,
15
    DEVICE_FILTER_LABELS,
16
    applyDeviceFilters,
17
    loadDeviceFilters,
18
    type DeviceFilters
19
} from '../deviceFilters';
20

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

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

34
export class UserInputManager {
1✔
35

36
    public constructor(
37
        private deviceManager: DeviceManager
74✔
38
    ) { }
39

40
    public async promptForHostManual(): Promise<string | undefined> {
41
        while (true) {
×
42
            const value = await vscode.window.showInputBox({
×
43
                placeHolder: 'Please enter the IP address of your Roku device',
44
                value: ''
45
            });
46
            if (!value) {
×
47
                return undefined;
×
48
            }
49
            const probed = await vscode.window.withProgress(
×
50
                { location: vscode.ProgressLocation.Notification, title: `Contacting ${value}...` },
51
                async () => {
52
                    return this.deviceManager.validateAndAddDevice(value);
×
53
                }
54
            );
55
            if (probed) {
×
56
                return probed.ip;
×
57
            }
58
            await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${value}. Check the IP and confirm developer mode is enabled.`);
×
59
        }
60
    }
61

62
    /**
63
     * Prompt the user to pick a host from a list of devices
64
     */
65
    public async promptForHost(options?: { defaultValue?: string }) {
66

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

70
        //create the quickpick item
71
        const quickPick = vscode.window.createQuickPick();
1✔
72
        disposables.push(quickPick);
1✔
73
        quickPick.placeholder = `Please Select a Roku or manually type an IP address`;
1✔
74
        quickPick.keepScrollPosition = true;
1✔
75

76
        // Track multiple busy sources (scan, health check) with a counter
77
        let busyCount = 0;
1✔
78
        const setBusy = (isBusy: boolean) => {
1✔
79
            busyCount += isBusy ? 1 : -1;
×
80
            busyCount = Math.max(0, busyCount); // Prevent negative
×
81
            quickPick.busy = busyCount > 0;
×
82
        };
83

84
        // Subscribe to scan events before triggering refresh so we catch the scan-started event
85
        this.deviceManager.on('scan-started', () => {
1✔
86
            setBusy(true);
×
87
        }, disposables);
88

89
        this.deviceManager.on('scan-ended', () => {
1✔
90
            setBusy(false);
×
91
        }, disposables);
92

93
        const scanTimeoutMs = 7_000;
1✔
94
        let scanTimeoutId: NodeJS.Timeout | null = null;
1✔
95
        let hasScanned = this.deviceManager.scan();
1✔
96
        this.deviceManager.on('scanNeeded-changed', () => {
1✔
97
            hasScanned = true;
×
98
            if (scanTimeoutId) {
×
99
                clearTimeout(scanTimeoutId);
×
100
                scanTimeoutId = null;
×
101
            }
102
            this.deviceManager.scan();
×
103
        }, disposables);
104
        scanTimeoutId = setTimeout(() => {
1✔
105
            if (hasScanned) {
1!
106
                return;
1✔
107
            }
108
            this.deviceManager.scan();
×
109
        }, scanTimeoutMs);
110

111
        function dispose() {
112
            for (const disposable of disposables) {
×
113
                disposable.dispose();
×
114
            }
115
        }
116

117
        //detect if the user types an IP address into the picker and presses enter.
118
        let selectedDevice: vscode.QuickPickItem | undefined;
119
        quickPick.onDidAccept(async () => {
1✔
120
            if (selectedDevice) {
×
121
                if (selectedDevice.kind !== vscode.QuickPickItemKind.Separator) {
×
122
                    if (selectedDevice.label === manualLabel) {
×
123
                        deferred.resolve({ manual: true });
×
124
                    } else if (selectedDevice.label === scanForDevicesLabel) {
×
125
                        this.deviceManager.refresh(true);
×
126
                        return;
×
127
                    } else {
128
                        const device = (selectedDevice as any).device as RokuDevice;
×
129
                        // if the selected device isn't healthy, show an error and keep the picker open so they can select a different device
130
                        setBusy(true);
×
131
                        const isHealthy = await this.deviceManager.healthCheckDevice(device, true, false);
×
132
                        setBusy(false);
×
133
                        if (!isHealthy) {
×
134
                            await vscode.window.showErrorMessage(`The selected device (${device.ip}) is not responding.`);
×
135
                            return;
×
136
                        }
137
                        this.deviceManager.setLastUsedDeviceIp(device.ip);
×
138
                        deferred.resolve(device);
×
139
                    }
140
                    quickPick.dispose();
×
141
                }
142
                selectedDevice = undefined;
×
143
                // If the user has typed a value, probe the IP before resolving so
144
                // the caller only ever receives a reachable device.
145
            } else if (quickPick.value) {
×
146
                const typedValue = quickPick.value;
×
147
                setBusy(true);
×
148
                const probed = await this.deviceManager.validateAndAddDevice(typedValue);
×
149
                setBusy(false);
×
150
                if (!probed) {
×
151
                    await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${typedValue}. Check the IP and confirm developer mode is enabled.`);
×
152
                    return;
×
153
                }
154
                this.deviceManager.setLastUsedDeviceIp(probed.ip);
×
155
                deferred.resolve({ ip: probed.ip });
×
156
                quickPick.dispose();
×
157
            }
158
        });
159

160
        quickPick.onDidChangeSelection((selection) => {
1✔
161
            // only save the selectedDevice if the user explicitly clicks on an item
162
            // use the selected device in onDidAccept
163
            selectedDevice = selection[0];
×
164
        });
165

166
        let activeChangesSinceRefresh = 0;
1✔
167
        let activeItem: QuickPickItem;
168

169
        // remember the currently active item so we can maintain active selection when refreshing the list
170
        quickPick.onDidChangeActive((items) => {
1✔
171
            // reset our activeChanges tracker since users cannot cause items.length to be 0 (meaning a refresh has just happened)
172
            if (items.length === 0) {
×
173
                activeChangesSinceRefresh = 0;
×
174
                return;
×
175
            }
176
            if (activeChangesSinceRefresh > 0) {
×
177
                activeItem = items[0];
×
178
            }
179
            activeChangesSinceRefresh++;
×
180
        });
181

182
        const itemCache = new Map<string, QuickPickHostItem>();
×
183
        if (options?.defaultValue) {
×
184
            quickPick.value = options?.defaultValue;
×
185
        }
186
        quickPick.show();
×
187

188
        //set a timeout to automatically start scanning for devices after a short delay
189
        const SCAN_FOR_DEVICES = 'Scan for Devices';
×
190
        const CLEAR_DEVICE_LIST = 'Clear Device List';
×
191
        const ENABLE_DEVICE_DISCOVERY = 'Enable Device Discovery';
×
192
        const DISABLE_DEVICE_DISCOVERY = 'Disable Device Discovery';
×
NEW
193
        const FILTER_DEVICES = 'Filter Devices';
×
194

195
        const refreshList = () => {
×
NEW
196
            const filters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
UNCOV
197
            const items = this.createHostQuickPickList(
×
198
                applyDeviceFilters(this.deviceManager.getAllDevices(), filters),
199
                this.deviceManager.getLastUsedDeviceIp(),
200
                itemCache
201
            );
202
            quickPick.items = items;
×
203
            const discoveryEnabled = vscodeContextManager.get('brightscript.deviceDiscovery.enabled') === true;
×
204
            // Buttons render left-to-right; the rightmost button is the most prominent.
205
            quickPick.buttons = [
×
206
                {
207
                    iconPath: new vscode.ThemeIcon('filter'),
208
                    tooltip: FILTER_DEVICES
209
                },
210
                {
211
                    iconPath: discoveryEnabled ? icons.radioTower : icons.radioTowerOff,
×
212
                    tooltip: discoveryEnabled ? DISABLE_DEVICE_DISCOVERY : ENABLE_DEVICE_DISCOVERY
×
213
                },
214
                {
215
                    iconPath: new vscode.ThemeIcon('clear-all'),
216
                    tooltip: CLEAR_DEVICE_LIST
217
                },
218
                {
219
                    iconPath: new vscode.ThemeIcon('refresh'),
220
                    tooltip: SCAN_FOR_DEVICES
221
                }
222
            ];
223

224
            // clear the activeItem if we can't find it in the list
225
            if (!quickPick.items.includes(activeItem)) {
×
226
                activeItem = undefined;
×
227
            }
228

229
            // if the user manually selected an item, re-focus that item now that we refreshed the list
230
            if (activeItem) {
×
231
                quickPick.activeItems = [activeItem];
×
232
            }
233
            // quickPick.show();
234
        };
235

236
        //anytime the device list changes, update the list
237
        this.deviceManager.on('devices-changed', refreshList, disposables);
×
238

239
        //refresh the list when the toggle icon's source setting changes, or when any of the
240
        //device-quick-pick filter facets change (so other windows toggling a filter affect this picker)
UNCOV
241
        disposables.push(
×
242
            vscode.workspace.onDidChangeConfiguration(e => {
NEW
243
                if (
×
244
                    e.affectsConfiguration('brightscript.deviceDiscovery.enabled') ||
×
245
                    e.affectsConfiguration(DEVICE_QUICK_PICK_FILTERS_SECTION)
246
                ) {
UNCOV
247
                    refreshList();
×
248
                }
249
            })
250
        );
251

252
        //while the filter submenu is showing, the parent picker briefly hides — don't treat that as a dismissal
NEW
253
        let filterSubmenuOpen = false;
×
254
        quickPick.onDidHide(() => {
×
NEW
255
            if (filterSubmenuOpen) {
×
NEW
256
                return;
×
257
            }
258
            dispose();
×
259
            deferred.reject(new Error('No host was selected'));
×
260
        });
261

NEW
262
        const openFilterSubmenu = () => {
×
NEW
263
            filterSubmenuOpen = true;
×
NEW
264
            this.showFilterSubmenu().finally(() => {
×
NEW
265
                filterSubmenuOpen = false;
×
266
                // Re-render items before re-showing — without this the parent picker
267
                // appears empty after a hide/show cycle when no settings changed during the submenu.
NEW
268
                refreshList();
×
NEW
269
                quickPick.show();
×
270
            });
271
        };
272

273
        quickPick.onDidTriggerButton(button => {
×
274
            if (button.tooltip === SCAN_FOR_DEVICES) {
×
275
                this.deviceManager.refresh(true);
×
276
            } else if (button.tooltip === CLEAR_DEVICE_LIST) {
×
277
                this.deviceManager.clearCurrentDeviceList().catch(() => { });
×
278
                void util.showTimedNotification('Clearing device list');
×
279
            } else if (button.tooltip === ENABLE_DEVICE_DISCOVERY) {
×
280
                void util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', true);
×
281
            } else if (button.tooltip === DISABLE_DEVICE_DISCOVERY) {
×
282
                void util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', false);
×
NEW
283
            } else if (button.tooltip === FILTER_DEVICES) {
×
NEW
284
                openFilterSubmenu();
×
285
            }
286
        });
287

288
        //run the list refresh once to show the popup
289
        refreshList();
×
290
        const result = await deferred.promise;
×
291
        dispose();
×
292
        if (result?.manual === true) {
×
293
            return this.promptForHostManual();
×
294
        } else {
295
            return result?.ip;
×
296
        }
297
    }
298

299
    /**
300
     * Generate the item list for the `this.promptForHost()` call
301
     */
302
    private createHostQuickPickList(
303
        devices: RokuDevice[],
304
        lastUsedDeviceIp: string | undefined,
305
        cache = new Map<string, QuickPickHostItem>()
7✔
306
    ) {
307
        //the collection of items we will eventually return
308
        let items: QuickPickHostItem[] = [];
7✔
309

310
        //find the lastUsedDevice from the devices list
311
        const lastUsedDevice = lastUsedDeviceIp ? devices.find(x => x.ip === lastUsedDeviceIp) : undefined;
7✔
312
        //remove the lastUsedDevice from the devices list so we can more easily reason with the rest of the list
313
        devices = devices.filter(x => x.ip !== lastUsedDeviceIp);
11✔
314

315
        // Ensure the most recently used device is at the top of the list
316
        if (lastUsedDevice) {
7✔
317
            //add a separator for "last used"
318
            items.push({
3✔
319
                label: 'last used',
320
                kind: vscode.QuickPickItemKind.Separator
321
            });
322

323
            //add the device
324
            items.push({
3✔
325
                label: this.deviceManager.getDeviceDisplayName(lastUsedDevice, true),
326
                device: lastUsedDevice,
327
                iconPath: this.deviceManager.getIconPath(lastUsedDevice)
328
            } as any);
329
        }
330

331
        //add all other devices
332
        if (devices.length > 0) {
7✔
333
            items.push({
4✔
334
                label: lastUsedDevice ? 'other devices' : 'devices',
4✔
335
                kind: vscode.QuickPickItemKind.Separator
336
            });
337

338
            //add each device
339
            for (const device of devices) {
4✔
340
                //add the device
341
                items.push({
8✔
342
                    label: this.deviceManager.getDeviceDisplayName(device, true),
343
                    device: device,
344
                    iconPath: this.deviceManager.getIconPath(device)
345
                });
346
            }
347
        }
348

349
        //include a divider between devices and "manual" option (only if we have devices)
350
        if (lastUsedDevice || devices.length) {
7✔
351
            items.push({ label: ' ', kind: vscode.QuickPickItemKind.Separator });
5✔
352
        }
353

354
        // allow user to manually type an IP address
355
        items.push(
7✔
356
            {
357
                label: manualLabel,
358
                device: { id: manualHostItemId },
359
                iconPath: new vscode.ThemeIcon('keyboard')
360
            } as any,
361
            {
362
                label: scanForDevicesLabel,
363
                device: { id: scanForDevicesItemId },
364
                iconPath: new vscode.ThemeIcon('radio-tower')
365
            } as any
366
        );
367

368
        // replace items with their cached versions if found (to maintain references)
369
        for (let i = 0; i < items.length; i++) {
7✔
370
            const item = items[i];
37✔
371
            if (cache.has(item.label)) {
37!
372
                items[i] = cache.get(item.label);
×
373
                items[i].device = item.device;
×
374
                items[i].iconPath = item.iconPath;
×
375
            } else {
376
                cache.set(item.label, item);
37✔
377
            }
378
        }
379

380
        return items;
7✔
381
    }
382

383
    /**
384
     * Open a checkbox-style quick pick (canSelectMany) listing each filter facet.
385
     * Follows VS Code's standard multi-select pattern: Space toggles checkboxes,
386
     * Enter commits the current selection to user settings, Escape cancels. A title-bar
387
     * Reset button resets the picker's selection to defaults (still committed on Enter).
388
     */
389
    private showFilterSubmenu(): Promise<void> {
NEW
390
        return new Promise<void>((resolve) => {
×
NEW
391
            const RESET_FILTERS = 'Reset Filters';
×
NEW
392
            const filterPick = vscode.window.createQuickPick<QuickPickFilterItem>();
×
NEW
393
            filterPick.title = 'Filter Devices';
×
NEW
394
            filterPick.placeholder = 'Space to toggle, Enter to apply, Escape to cancel';
×
NEW
395
            filterPick.canSelectMany = true;
×
NEW
396
            filterPick.buttons = [{
×
397
                iconPath: new vscode.ThemeIcon('discard'),
398
                tooltip: RESET_FILTERS
399
            }];
400

NEW
401
            const buildItems = (filters: DeviceFilters): QuickPickFilterItem[] => {
×
NEW
402
                const result: QuickPickFilterItem[] = [];
×
NEW
403
                for (let groupIndex = 0; groupIndex < DEVICE_FILTER_GROUPS.length; groupIndex++) {
×
NEW
404
                    if (groupIndex > 0) {
×
NEW
405
                        result.push({ label: '', kind: vscode.QuickPickItemKind.Separator });
×
406
                    }
NEW
407
                    for (const facetKey of DEVICE_FILTER_GROUPS[groupIndex]) {
×
NEW
408
                        result.push({
×
409
                            label: DEVICE_FILTER_LABELS[facetKey],
410
                            picked: filters[facetKey],
411
                            facetKey: facetKey
412
                        });
413
                    }
414
                }
NEW
415
                return result;
×
416
            };
417

418
            // Initial load — render items from current settings and pre-select the picked ones
NEW
419
            const initialFilters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
NEW
420
            const items = buildItems(initialFilters);
×
NEW
421
            filterPick.items = items;
×
NEW
422
            filterPick.selectedItems = items.filter(i => i.picked);
×
423

NEW
424
            filterPick.onDidTriggerButton((button) => {
×
NEW
425
                if (button.tooltip !== RESET_FILTERS) {
×
NEW
426
                    return;
×
427
                }
428
                // Reset the picker's selection to the in-code defaults — user still has to
429
                // press Enter to commit or Escape to discard, matching the rest of the flow.
NEW
430
                filterPick.selectedItems = items.filter(item => {
×
NEW
431
                    return item.facetKey ? DEFAULT_DEVICE_FILTERS[item.facetKey] : false;
×
432
                });
433
            });
434

NEW
435
            filterPick.onDidAccept(async () => {
×
NEW
436
                const selectedFacets = new Set<keyof DeviceFilters>();
×
NEW
437
                for (const item of filterPick.selectedItems) {
×
NEW
438
                    if (item.facetKey) {
×
NEW
439
                        selectedFacets.add(item.facetKey);
×
440
                    }
441
                }
NEW
442
                const currentFilters = loadDeviceFilters(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
NEW
443
                const config = vscode.workspace.getConfiguration(DEVICE_QUICK_PICK_FILTERS_SECTION);
×
NEW
444
                const writes: Thenable<unknown>[] = [];
×
NEW
445
                for (const facetKey of DEVICE_FILTER_KEYS) {
×
NEW
446
                    const nextValue = selectedFacets.has(facetKey);
×
NEW
447
                    if (nextValue === currentFilters[facetKey]) {
×
NEW
448
                        continue;
×
449
                    }
NEW
450
                    const valueToWrite = nextValue === DEFAULT_DEVICE_FILTERS[facetKey] ? undefined : nextValue;
×
NEW
451
                    writes.push(config.update(facetKey, valueToWrite, vscode.ConfigurationTarget.Global));
×
452
                }
NEW
453
                if (writes.length > 0) {
×
NEW
454
                    try {
×
NEW
455
                        await Promise.all(writes);
×
456
                    } catch {
457
                        // best-effort persistence
458
                    }
459
                }
NEW
460
                filterPick.hide();
×
461
            });
462

NEW
463
            filterPick.onDidHide(() => {
×
NEW
464
                filterPick.dispose();
×
NEW
465
                resolve();
×
466
            });
467

NEW
468
            filterPick.show();
×
469
        });
470
    }
471
}
472

473
type QuickPickFilterItem = QuickPickItem & { facetKey?: keyof DeviceFilters };
474

475
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