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

rokucommunity / vscode-brightscript-language / 30128190245

24 Jul 2026 09:34PM UTC coverage: 60.923% (+1.0%) from 59.908%
30128190245

Pull #862

github

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

2714 of 4855 branches covered (55.9%)

Branch coverage included in aggregate %.

296 of 325 new or added lines in 7 files covered. (91.08%)

11 existing lines in 3 files now uncovered.

4163 of 6433 relevant lines covered (64.71%)

47.42 hits per line

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

35.0
/src/BrightScriptCommands.ts
1
import * as request from 'postman-request';
1✔
2
import * as vscode from 'vscode';
1✔
3
import BrightScriptFileUtils from './BrightScriptFileUtils';
1✔
4
import { GlobalStateManager } from './GlobalStateManager';
1✔
5
import { brighterScriptPreviewCommand } from './commands/BrighterScriptPreviewCommand';
1✔
6
import { captureScreenshotCommand } from './commands/CaptureScreenshotCommand';
1✔
7
import { rekeyAndPackageCommand } from './commands/RekeyAndPackageCommand';
1✔
8
import { languageServerInfoCommand } from './commands/LanguageServerInfoCommand';
1✔
9
import { util } from './util';
1✔
10
import { util as rokuDebugUtil } from 'roku-debug/dist/util';
1✔
11
import type { RemoteControlManager, RemoteControlModeInitiator } from './managers/RemoteControlManager';
12
import type { WhatsNewManager } from './managers/WhatsNewManager';
13
import type { ConfiguredDevice, DeviceManager, HostWithDeviceInfo, RokuDevice } from './deviceDiscovery/DeviceManager';
14
import * as xml2js from 'xml2js';
1✔
15
import { firstBy } from 'thenby';
1✔
16
import type { UserInputManager } from './managers/UserInputManager';
17
import { clearNpmPackageCacheCommand } from './commands/ClearNpmPackageCacheCommand';
1✔
18
import type { LocalPackageManager } from './managers/LocalPackageManager';
19
import { profilingCommands } from './commands/ProfilingCommands';
1✔
20
import { vscodeContextManager } from './managers/VscodeContextManager';
1✔
21
import type { CredentialStore } from './managers/CredentialStore';
22
import type { DevicesViewProvider } from './viewProviders/DevicesViewProvider';
23
import { DEVICE_FILTER_KEYS } from './deviceFilters';
1✔
24
import { rokuDeploy } from 'roku-deploy';
1✔
25

26
export class BrightScriptCommands {
1✔
27

28
    constructor(
29
        private remoteControlManager: RemoteControlManager,
82✔
30
        private whatsNewManager: WhatsNewManager,
82✔
31
        private context: vscode.ExtensionContext,
82✔
32
        private deviceManager: DeviceManager,
82✔
33
        private userInputManager: UserInputManager,
82✔
34
        private localPackageManager: LocalPackageManager,
82✔
35
        private credentialStore: CredentialStore
82✔
36
    ) {
37
        this.fileUtils = new BrightScriptFileUtils();
82✔
38
    }
39

40
    private fileUtils: BrightScriptFileUtils;
41
    public host: string;
42
    public password: string;
43
    public workspacePath: string;
44
    private keypressNotifiers = [] as ((key: string, literalCharacter: boolean) => void)[];
82✔
45

46
    public registerCommands() {
47

48
        brighterScriptPreviewCommand.register(this.context);
20✔
49
        languageServerInfoCommand.register(this.context, this.localPackageManager);
20✔
50
        captureScreenshotCommand.register(this.context, this);
20✔
51
        rekeyAndPackageCommand.register(this.context, this, this.userInputManager);
20✔
52
        clearNpmPackageCacheCommand.register(this.context, this.localPackageManager);
20✔
53
        profilingCommands.register(this.context);
20✔
54

55
        this.registerGeneralCommands();
20✔
56

57
        this.registerCommand('sendRemoteCommand', async (key: string) => {
20✔
58
            await this.sendRemoteCommand(key);
×
59
        });
60

61
        //the "Refresh" button in the Devices list. Submits orders rather than scanning directly —
62
        //a visible view fulfills them immediately (forced, since the reason is refresh-clicked)
63
        this.registerCommand('refreshDeviceList', (key: string) => {
20✔
64
            this.deviceManager.submitBroadcast('refresh-clicked');
1✔
65
            this.deviceManager.submitReconcile('refresh-clicked');
1✔
66
        });
67

68
        this.registerCommand('rescanDevices', () => {
20✔
69
            this.deviceManager.submitBroadcast('refresh-clicked');
1✔
70
            this.deviceManager.submitReconcile('refresh-clicked');
1✔
71
        });
72

73
        // Refresh a single device (inline button on hover in devices panel).
74
        // item.key is the encoded tree key ("s:{serial}" or "i:{ip}") — decode it via getDevice
75
        this.registerCommand('refreshDevice', async (item: { key: string }) => {
20✔
76
            const device = this.deviceManager.getDevice(item.key);
2✔
77
            if (device) {
2✔
78
                await this.deviceManager.healthCheckDevice(device, true);
1✔
79
            }
80
        });
81

82
        this.registerCommand('sendRemoteText', async () => {
20✔
83
            let items: vscode.QuickPickItem[] = [];
×
84
            for (const item of new GlobalStateManager(this.context).sendRemoteTextHistory) {
×
85
                items.push({ label: item });
×
86
            }
87

88
            const stuffUserTyped = await util.showQuickPickInputBox({
×
89
                placeholder: 'Press enter to send all typed characters to the Roku',
90
                items: items
91
            });
92
            console.log('userInput', stuffUserTyped);
×
93

94
            if (stuffUserTyped) {
×
95
                new GlobalStateManager(this.context).addTextHistory(stuffUserTyped);
×
96
                let fallbackToHttp = true;
×
97
                await this.getRemoteHost();
×
98
                //TODO fix SceneGraphDebugCommandController to not timeout so quickly
99
                // try {
100
                //     let commandController = new SceneGraphDebugCommandController(this.host);
101
                //     let response = await commandController.type(stuffUserTyped);
102
                //     if (!response.error) {
103
                //         fallbackToHttp = false;
104
                //     }
105
                // } catch (error) {
106
                //     console.error(error);
107
                //     // Let this fallback to the old HTTP based logic
108
                // }
109

110
                if (fallbackToHttp) {
×
111
                    for (let character of stuffUserTyped) {
×
112
                        await this.sendAsciiToDevice(character);
×
113
                    }
114
                }
115
            }
116
            await vscode.commands.executeCommand('workbench.action.focusPanel');
×
117
        });
118

119
        this.registerCommand('toggleRemoteControlMode', (initiator: RemoteControlModeInitiator) => {
20✔
120
            return this.remoteControlManager.toggleRemoteControlMode(initiator);
×
121
        });
122

123
        this.registerCommand('enableRemoteControlMode', () => {
20✔
124
            return this.remoteControlManager.setRemoteControlMode(true, 'command');
×
125
        });
126

127
        this.registerCommand('disableRemoteControlMode', () => {
20✔
128
            return this.remoteControlManager.setRemoteControlMode(false, 'command');
×
129
        });
130

131
        this.registerCommand('pressBackButton', async () => {
20✔
132
            await this.sendRemoteCommand('Back');
×
133
        });
134

135
        this.registerCommand('pressBackspaceButton', async () => {
20✔
136
            await this.sendRemoteCommand('Backspace');
×
137
        });
138

139
        this.registerCommand('pressHomeButton', async () => {
20✔
140
            await this.sendRemoteCommand('Home');
×
141
        });
142

143
        this.registerCommand('restartDevApplication', async () => {
20✔
144
            await this.restartDevApplication();
×
145
        });
146

147
        this.registerCommand('pressUpButton', async () => {
20✔
148
            await this.sendRemoteCommand('Up');
×
149
        });
150

151
        this.registerCommand('pressDownButton', async () => {
20✔
152
            await this.sendRemoteCommand('Down');
×
153
        });
154

155
        this.registerCommand('pressRightButton', async () => {
20✔
156
            await this.sendRemoteCommand('Right');
×
157
        });
158

159
        this.registerCommand('pressLeftButton', async () => {
20✔
160
            await this.sendRemoteCommand('Left');
×
161
        });
162

163
        this.registerCommand('pressSelectButton', async () => {
20✔
164
            await this.sendRemoteCommand('Select');
×
165
        });
166

167
        this.registerCommand('pressPlayButton', async () => {
20✔
168
            await this.sendRemoteCommand('Play');
×
169
        });
170

171
        this.registerCommand('pressRevButton', async () => {
20✔
172
            await this.sendRemoteCommand('Rev');
×
173
        });
174

175
        this.registerCommand('pressFwdButton', async () => {
20✔
176
            await this.sendRemoteCommand('Fwd');
×
177
        });
178

179
        this.registerCommand('pressStarButton', async () => {
20✔
180
            await this.sendRemoteCommand('Info');
×
181
        });
182

183
        this.registerCommand('pressInstantReplayButton', async () => {
20✔
184
            await this.sendRemoteCommand('InstantReplay');
×
185
        });
186

187
        this.registerCommand('pressSearchButton', async () => {
20✔
188
            await this.sendRemoteCommand('Search');
×
189
        });
190

191
        this.registerCommand('pressEnterButton', async () => {
20✔
192
            await this.sendRemoteCommand('Enter');
×
193
        });
194

195
        this.registerCommand('pressFindRemote', async () => {
20✔
196
            await this.sendRemoteCommand('FindRemote');
×
197
        });
198

199
        this.registerCommand('pressVolumeDown', async () => {
20✔
200
            await this.sendRemoteCommand('VolumeDown');
×
201
        });
202

203
        this.registerCommand('pressVolumeMute', async () => {
20✔
204
            await this.sendRemoteCommand('VolumeMute');
×
205
        });
206

207
        this.registerCommand('pressVolumeUp', async () => {
20✔
208
            await this.sendRemoteCommand('VolumeUp');
×
209
        });
210

211
        this.registerCommand('setVolume', async () => {
20✔
212
            let result = await vscode.window.showInputBox({
×
213
                placeHolder: 'The target volume level (0-100)',
214
                value: '',
215
                validateInput: (text: string) => {
216
                    const num = Number(text);
×
217
                    if (isNaN(num)) {
×
218
                        return 'Value must be a number';
×
219
                    } else if (num < 0 || num > 100) {
×
220
                        return 'Please enter a number between 0 and 100';
×
221
                    }
222
                    return null;
×
223
                }
224
            });
225
            const targetVolume = Number(result);
×
226

227
            if (!isNaN(targetVolume)) {
×
228
                await vscode.window.withProgress({
×
229
                    location: vscode.ProgressLocation.Notification,
230
                    title: 'Setting volume'
231
                }, async (progress) => {
232
                    const totalCommands = 100 + targetVolume;
×
233
                    const incrementValue = 100 / totalCommands;
×
234
                    let executedCommands = 0;
×
235

236
                    for (let i = 0; i < 100; i++) {
×
237
                        await this.sendRemoteCommand('VolumeDown');
×
238
                        executedCommands++;
×
239
                        progress.report({ increment: incrementValue, message: `decreasing volume - ${Math.round((executedCommands / totalCommands) * 100)}%` });
×
240
                    }
241

242
                    for (let i = 0; i < targetVolume; i++) {
×
243
                        await this.sendRemoteCommand('VolumeUp');
×
244
                        executedCommands++;
×
245
                        progress.report({ increment: incrementValue, message: `increasing volume - ${Math.round((executedCommands / totalCommands) * 100)}%` });
×
246
                    }
247
                });
248
            }
249
        });
250

251
        this.registerCommand('pressPowerOff', async () => {
20✔
252
            await this.sendRemoteCommand('PowerOff');
×
253
        });
254

255
        this.registerCommand('pressPowerOn', async () => {
20✔
256
            await this.sendRemoteCommand('PowerOn');
×
257
        });
258

259
        this.registerCommand('pressChannelUp', async () => {
20✔
260
            await this.sendRemoteCommand('ChannelUp');
×
261
        });
262

263
        this.registerCommand('pressChannelDown', async () => {
20✔
264
            await this.sendRemoteCommand('ChannelDown');
×
265
        });
266

267
        this.registerCommand('pressBlue', async () => {
20✔
268
            await this.sendRemoteCommand('Blue');
×
269
        });
270

271
        this.registerCommand('pressGreen', async () => {
20✔
272
            await this.sendRemoteCommand('Green');
×
273
        });
274

275
        this.registerCommand('pressRed', async () => {
20✔
276
            await this.sendRemoteCommand('Red');
×
277
        });
278

279
        this.registerCommand('pressYellow', async () => {
20✔
280
            await this.sendRemoteCommand('Yellow');
×
281
        });
282

283
        this.registerCommand('pressExit', async () => {
20✔
284
            await this.sendRemoteCommand('Exit');
×
285
        });
286

287
        this.registerCommand('changeTvInput', async (host?: string) => {
20✔
288
            const selectedInput = await vscode.window.showQuickPick([
×
289
                'InputHDMI1',
290
                'InputHDMI2',
291
                'InputHDMI3',
292
                'InputHDMI4',
293
                'InputAV1',
294
                'InputTuner'
295
            ]);
296

297
            if (selectedInput) {
×
298
                await this.sendRemoteCommand(selectedInput, host);
×
299
            }
300
        });
301

302
        this.registerKeyboardInputs();
20✔
303
    }
304

305
    /**
306
     * Registers all the commands for a-z, A-Z, 0-9, and all the primary character such as !, @, #, ', ", etc...
307
     */
308
    private registerKeyboardInputs() {
309
        // Get all the keybindings from our package.json
310
        const extension = vscode.extensions.getExtension('RokuCommunity.brightscript');
20✔
311
        const keybindings = (extension.packageJSON.contributes.keybindings as Array<{
20✔
312
            key: string;
313
            command: string;
314
            when: string;
315
            args: any;
316
        }>);
317

318
        for (let keybinding of keybindings) {
20✔
319
            // Find every keybinding that is related to sending text characters to the device
320
            if (keybinding.command.includes('.sendAscii+')) {
2,900✔
321

322
                if (!keybinding.args) {
1,920!
323
                    throw new Error(`Can not register command: ${keybinding.command}. Missing Arguments.`);
×
324
                }
325

326
                // Dynamically register the the command defined in the keybinding
327
                this.registerCommand(keybinding.command, async (character: string) => {
1,920✔
328
                    await this.sendAsciiToDevice(character);
×
329
                });
330
            }
331
        }
332
    }
333

334
    private registerGeneralCommands() {
335
        //a command that does absolutely nothing. It's here to allow us to absorb unsupported keypresses when in **remote control mode**.
336
        this.registerCommand('doNothing', () => { });
20✔
337

338
        this.registerCommand('toggleXML', async () => {
20✔
339
            await this.onToggleXml();
×
340
        });
341

342
        this.registerCommand('goToParentComponent', async () => {
20✔
343
            await this.onGoToParentComponent();
×
344
        });
345

346
        this.registerCommand('clearGlobalState', async () => {
20✔
347
            new GlobalStateManager(this.context).clear();
×
348
            await vscode.window.showInformationMessage('BrightScript Language extension global state cleared');
×
349
        });
350

351
        this.registerCommand('clearCurrentDeviceList', async () => {
20✔
352
            const toatsPromise = util.showTimedNotification('Clearing device list');
×
NEW
353
            this.deviceManager.clearCurrentDeviceList();
×
354
            await toatsPromise;
×
355
        });
356

357
        this.registerCommand('enableDeviceDiscovery', async () => {
20✔
358
            await util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', true);
×
359
        });
360

361
        this.registerCommand('disableDeviceDiscovery', async () => {
20✔
362
            await util.setConfigurationValueAtUserOrClosestScope('brightscript.deviceDiscovery.enabled', false);
×
363
        });
364

365
        this.registerCommand('clearDeviceCache', async () => {
20✔
366
            this.deviceManager.clearAllCache();
×
367
            await util.showTimedNotification('Clearing device cache');
×
368
        });
369

370
        this.registerCommand('clearLastSeenDevices', async () => {
20✔
371
            new GlobalStateManager(this.context).clearLastSeenDevices();
×
372
            await vscode.window.showInformationMessage('Last seen devices cleared');
×
373
        });
374

375
        this.registerCommand('copyToClipboard', async (value: string) => {
20✔
376
            try {
×
377
                if (util.isNullish(value)) {
×
378
                    throw new Error('Cannot copy ${value} to clipboard');
×
379
                }
380
                await vscode.env.clipboard.writeText(value?.toString());
×
381
                await vscode.window.showInformationMessage(`Copied to clipboard: ${value}`);
×
382
            } catch (error) {
383
                await vscode.window.showErrorMessage(`Could not copy value to clipboard`);
×
384
            }
385
        });
386

387
        this.registerCommand('openUrl', async (url: string) => {
20✔
388
            try {
×
389
                await vscode.env.openExternal(vscode.Uri.parse(url));
×
390
            } catch (error) {
391
                await vscode.window.showErrorMessage(`Tried to open url but failed: ${url}`);
×
392
            }
393
        });
394

395
        this.registerCommand('openRegistryInBrowser', async (host: string) => {
20✔
396
            if (!host) {
×
397
                host = (await this.userInputManager.promptForHost())?.host;
×
398
            }
399

400
            let responseText = await util.spinAsync('Fetching app list', async () => {
×
401
                return (await util.httpGet(`http://${host}:8060/query/apps`, { timeout: 4_000 })).body as string;
×
402
            });
403

404
            const parsed = await xml2js.parseStringPromise(responseText);
×
405

406
            //convert the items to QuickPick items
407
            const items: Array<vscode.QuickPickItem & { appId?: string }> = parsed.apps.app.map((appData: any) => {
×
408
                return {
×
409
                    label: appData._,
410
                    detail: `ID: ${appData.$.id}`,
411
                    description: `${appData.$.version}`,
412
                    appId: `${appData.$.id}`
413
                } as vscode.QuickPickItem;
414
                //sort the items alphabetically
415
            }).sort(firstBy('label'));
416

417
            //move the dev app to the top (and add a label/section to differentiate it)
418
            const devApp = items.find(x => x.appId === 'dev');
×
419
            if (devApp) {
×
420
                items.splice(items.indexOf(devApp), 1);
×
421
                items.unshift(
×
422
                    { kind: vscode.QuickPickItemKind.Separator, label: 'dev' },
423
                    devApp,
424
                    { kind: vscode.QuickPickItemKind.Separator, label: ' ' }
425
                );
426
            }
427

428
            const selectedApp: typeof items[0] = await vscode.window.showQuickPick(items, { placeHolder: 'Which app would you like to see the registry for?' });
×
429

430
            if (selectedApp) {
×
431
                const appId = selectedApp.appId;
×
432
                let url = `http://${host}:8060/query/registry/${appId}`;
×
433
                try {
×
434
                    await vscode.env.openExternal(vscode.Uri.parse(url));
×
435
                } catch (error) {
436
                    await vscode.window.showErrorMessage(`Tried to open url but failed: ${url}`);
×
437
                }
438
            }
439
        });
440

441
        this.registerCommand('setActiveDevice', async (deviceOrItem: string | { key: string }) => {
20✔
442
            let ip: string;
443
            if (typeof deviceOrItem === 'object' && deviceOrItem?.key) {
×
444
                ip = this.deviceManager.getDevice(deviceOrItem.key)?.ip;
×
445
            } else if (typeof deviceOrItem === 'string') {
×
446
                ip = deviceOrItem;
×
447
            }
448
            if (!ip) {
×
449
                ip = (await this.userInputManager.promptForHost())?.host;
×
450
            }
451
            if (!ip) {
×
452
                throw new Error('Tried to set active device but failed.');
×
453
            } else {
454
                await this.deviceManager.setActiveDevice(ip);
×
455
                await util.showTimedNotification(`'${ip}' set as active device`);
×
456
            }
457
        });
458

459
        this.registerCommand('editDeviceInUserSettings', async (deviceOrItem: { key: string }) => {
20✔
460
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
461
            await this.openSettingsJsonAtDevice(device, 'user');
×
462
        });
463

464
        this.registerCommand('editDeviceInWorkspaceSettings', async (deviceOrItem: { key: string }) => {
20✔
465
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
466
            await this.openSettingsJsonAtDevice(device, 'workspace');
×
467
        });
468

469
        this.registerCommand('addDeviceToUserSettings', async (deviceOrItem: { key: string }) => {
20✔
470
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
471
            if (!device) {
×
472
                void vscode.window.showErrorMessage('Could not find device to add to settings.');
×
473
                return;
×
474
            }
475

476
            const config = vscode.workspace.getConfiguration('brightscript');
×
477
            const inspection = config.inspect<ConfiguredDevice[]>('devices');
×
478
            const userDevices = inspection?.globalValue || [];
×
479

480
            if (userDevices.some(d => d.host === device.ip || (device.serialNumber && d.serialNumber === device.serialNumber))) {
×
481
                void vscode.window.showInformationMessage('Device is already in your user settings.');
×
482
                return;
×
483
            }
484

485
            // Copy any cred-store-cached password into the settings entry so the device
486
            // is portable across machines via Settings Sync. The cred store keeps its own
487
            // copy — it's a running cache of validated passwords that gets refreshed on
488
            // each successful password validation.
489
            const storedPassword = device.serialNumber
×
490
                ? await this.credentialStore.getPassword(device.serialNumber)
491
                : undefined;
492

493
            const newDevice = {
×
494
                host: device.ip,
495
                ...(device.serialNumber && { serialNumber: device.serialNumber }),
×
496
                ...(storedPassword && { password: storedPassword })
×
497
            };
498
            userDevices.push(newDevice);
×
499

500
            await config.update('devices', userDevices, vscode.ConfigurationTarget.Global);
×
501
            const displayName = device.deviceInfo['user-device-name'] || device.deviceInfo['default-device-name'] || device.ip;
×
502
            void vscode.window.showInformationMessage(`Added "${displayName}" to user settings.`);
×
503
        });
504

505
        this.registerCommand('addDeviceToWorkspaceSettings', async (deviceOrItem: { key: string }) => {
20✔
506
            const device = this.deviceManager.getDevice(deviceOrItem?.key);
×
507
            if (!device) {
×
508
                void vscode.window.showErrorMessage('Could not find device to add to settings.');
×
509
                return;
×
510
            }
511

512
            const config = vscode.workspace.getConfiguration('brightscript');
×
513
            const inspection = config.inspect<ConfiguredDevice[]>('devices');
×
514
            const workspaceDevices = inspection?.workspaceValue || [];
×
515

516
            if (workspaceDevices.some(d => (device.serialNumber && d.serialNumber === device.serialNumber))) {
×
517
                void vscode.window.showInformationMessage('Device is already in your workspace settings.');
×
518
                return;
×
519
            }
520

521
            const storedPassword = device.serialNumber
×
522
                ? await this.credentialStore.getPassword(device.serialNumber)
523
                : undefined;
524

525
            const newDevice = {
×
526
                host: device.ip,
527
                ...(device.serialNumber && { serialNumber: device.serialNumber }),
×
528
                ...(storedPassword && { password: storedPassword })
×
529
            };
530
            workspaceDevices.push(newDevice);
×
531

532
            await config.update('devices', workspaceDevices, vscode.ConfigurationTarget.Workspace);
×
533
            const displayName = device.deviceInfo['user-device-name'] || device.deviceInfo['default-device-name'] || device.ip;
×
534
            void vscode.window.showInformationMessage(`Added "${displayName}" to workspace settings.`);
×
535
        });
536

537
        this.registerCommand('clearDefaultDevicePassword', async () => {
20✔
538
            await vscode.workspace.getConfiguration('brightscript').update('defaultDevicePassword', undefined, vscode.ConfigurationTarget.Global);
2✔
539
            await util.showTimedNotification('Default device password cleared.');
2✔
540
        });
541

542
        this.registerCommand('setDefaultDevicePassword', async () => {
20✔
543
            const currentValue = vscode.workspace.getConfiguration('brightscript').get<string>('defaultDevicePassword') ?? '';
3!
544

545
            const password = await vscode.window.showInputBox({
3✔
546
                placeHolder: 'Enter the default developer password (applied to devices without their own password)',
547
                password: true,
548
                value: currentValue,
549
                prompt: 'Set default device password'
550
            });
551

552
            if (password === undefined) {
3✔
553
                return;
1✔
554
            }
555

556
            //this value is only supported at the global level, so just always write it there
557
            await vscode.workspace.getConfiguration('brightscript').update('defaultDevicePassword', password, vscode.ConfigurationTarget.Global);
2✔
558
        });
559

560
        this.registerCommand('setDevicePassword', async (serialNumber: string) => {
20✔
561
            if (!serialNumber) {
×
562
                throw new Error('Device serial number is required to set password.');
×
563
            }
564

565
            const device = this.deviceManager.getDevice({ serialNumber: serialNumber });
×
566
            const displayName = device?.deviceInfo?.['user-device-name'] || device?.deviceInfo?.['default-device-name'] || device?.ip || serialNumber;
×
567

568
            const password = await vscode.window.showInputBox({
×
569
                placeHolder: 'Enter the developer account password for this device',
570
                password: true,
571
                prompt: `Set password for device: ${displayName}`,
572
                // Roku's own webserver UI enforces the same 4-character minimum.
573
                validateInput: (value) => {
574
                    return value.length < 4 ? 'Password must be at least 4 characters' : undefined;
×
575
                }
576
            });
577

578
            if (password !== undefined) {
×
579
                await this.setDevicePassword(serialNumber, password);
×
580
                await vscode.window.showInformationMessage(`Password set for device: ${displayName}`);
×
581
            }
582
        });
583

584
        this.registerCommand('clearDevicePassword', async (serialNumber: string) => {
20✔
585
            if (!serialNumber) {
×
586
                throw new Error('Device serial number is required to clear password.');
×
587
            }
588

589
            const device = this.deviceManager.getDevice({ serialNumber: serialNumber });
×
590
            const displayName = device?.deviceInfo?.['user-device-name'] || device?.deviceInfo?.['default-device-name'] || device?.ip || serialNumber;
×
591

592
            await this.setDevicePassword(serialNumber, '');
×
593
            await vscode.window.showInformationMessage(`Password cleared for device: ${displayName}`);
×
594
        });
595

596
        this.registerCommand('clearActiveDevice', async () => {
20✔
597
            await this.deviceManager.clearActiveDevice();
×
598
            await util.showTimedNotification('Active device cleared');
×
599
        });
600

601
        this.registerCommand('showReleaseNotes', () => {
20✔
602
            this.whatsNewManager.showReleaseNotes();
×
603
        });
604
    }
605

606
    public async openFile(filename: string, range: vscode.Range = null, preview = false): Promise<boolean> {
×
607
        let uri = vscode.Uri.file(filename);
×
608
        try {
×
609
            let doc = await vscode.workspace.openTextDocument(uri); // calls back into the provider
×
610
            await vscode.window.showTextDocument(doc, { preview: preview });
×
611
            if (range) {
×
612
                await this.gotoRange(range);
×
613
            }
614
        } catch (e) {
615
            return false;
×
616
        }
617
        return true;
×
618
    }
619

620
    private async gotoRange(range: vscode.Range) {
621
        let editor = vscode.window.activeTextEditor;
×
622
        editor.selection = new vscode.Selection(
×
623
            range.start.line,
624
            range.start.character,
625
            range.start.line,
626
            range.start.character
627
        );
628
        await vscode.commands.executeCommand('revealLine', {
×
629
            lineNumber: range.start.line,
630
            at: 'center'
631
        });
632
    }
633

634
    public async onToggleXml() {
635
        if (vscode.window.activeTextEditor) {
3✔
636
            const currentDocument = vscode.window.activeTextEditor.document;
2✔
637
            let alternateFileName = this.fileUtils.getAlternateFileName(currentDocument.fileName);
2✔
638
            if (alternateFileName) {
2✔
639
                if (
1!
640
                    !await this.openFile(alternateFileName) &&
2✔
641
                    alternateFileName.toLowerCase().endsWith('.brs')
642
                ) {
643
                    await this.openFile(this.fileUtils.getBsFileName(alternateFileName));
×
644
                }
645
            }
646
        }
647
    }
648

649
    public async onGoToParentComponent() {
650
        const editor = vscode.window.activeTextEditor;
4✔
651
        if (!editor) {
4✔
652
            return;
1✔
653
        }
654
        const currentDocument = editor.document;
3✔
655
        const fileName = currentDocument.fileName;
3✔
656
        const lowerFileName = fileName.toLowerCase();
3✔
657
        const isXml = lowerFileName.endsWith('.xml');
3✔
658
        const isBrs = lowerFileName.endsWith('.brs') || lowerFileName.endsWith('.bs');
3✔
659

660
        if (!isXml && !isBrs) {
3✔
661
            return;
1✔
662
        }
663

664
        // Get or open the XML document
665
        let xmlDoc: vscode.TextDocument;
666
        if (isXml) {
2!
667
            xmlDoc = currentDocument;
2✔
668
        } else {
669
            const xmlFileName = this.fileUtils.getAlternateFileName(fileName);
×
670
            if (!xmlFileName) {
×
671
                return;
×
672
            }
673
            try {
×
674
                xmlDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(xmlFileName));
×
675
            } catch (e) {
676
                return;
×
677
            }
678
        }
679

680
        const xmlContent = xmlDoc.getText();
2✔
681
        const parentName = this.fileUtils.getParentComponentName(xmlContent);
2✔
682
        if (!parentName) {
2✔
683
            await vscode.window.showInformationMessage('No parent component found');
1✔
684
            return;
1✔
685
        }
686

687
        const extendsPosition = this.getExtendsValuePosition(xmlContent, xmlDoc);
1✔
688
        if (!extendsPosition) {
1!
689
            return;
×
690
        }
691

692
        // Delegate to the definition provider via the LSP
693
        const locations = await vscode.commands.executeCommand<vscode.Location[]>(
1✔
694
            'vscode.executeDefinitionProvider',
695
            xmlDoc.uri,
696
            extendsPosition
697
        );
698

699
        if (!locations || locations.length === 0) {
1!
700
            await vscode.window.showInformationMessage(`Could not find parent component: ${parentName}`);
×
701
            return;
×
702
        }
703

704
        const parentXmlPath = locations[0].uri.fsPath;
1✔
705

706
        if (isBrs) {
1!
707
            const parentBrsPath = this.fileUtils.getAlternateFileName(parentXmlPath);
×
708
            if (parentBrsPath && !await this.openFile(parentBrsPath)) {
×
709
                await this.openFile(this.fileUtils.getBsFileName(parentBrsPath));
×
710
            }
711
        } else {
712
            await this.openFile(parentXmlPath);
1✔
713
        }
714
    }
715

716
    private getExtendsValuePosition(xmlContent: string, xmlDoc: vscode.TextDocument): vscode.Position | undefined {
717
        // Match extends="VALUE" capturing the VALUE portion; [^>]+ spans across lines since [^>] matches \n
718
        const match = /<component[^>]+extends\s*=\s*["']([^"']+)/i.exec(xmlContent);
1✔
719
        if (!match) {
1!
720
            return undefined;
×
721
        }
722
        // Offset to first character of the value (after the opening quote)
723
        const valueOffset = match.index + match[0].length - match[1].length;
1✔
724
        return xmlDoc.positionAt(valueOffset);
1✔
725
    }
726

727
    public async restartDevApplication() {
728
        await this.getRemoteHost();
3✔
729
        const host = this.host;
3✔
730
        if (!host) {
3!
731
            return;
×
732
        }
733

734
        await util.spinAsync('Restarting dev app', async () => {
3✔
735
            const appsResponse = await util.httpGet(`http://${host}:8060/query/apps`, { timeout: 5_000 });
3✔
736
            const appsParsed = await xml2js.parseStringPromise(appsResponse.body as string);
3✔
737
            const appList: Array<{ $?: { id?: string } }> = appsParsed?.apps?.app ?? [];
3!
738
            const hasDev = appList.some(entry => entry.$?.id === 'dev');
3!
739
            if (!hasDev) {
3✔
740
                await vscode.window.showErrorMessage(`No dev channel sideloaded on ${host}. Sideload your project before restarting.`);
1✔
741
                return;
1✔
742
            }
743

744
            // `/true` forces a full terminate even if the channel is suspended in the background via Instant Resume.
745
            // Harmless if dev isn't running — the device just returns FAILED in the body.
746
            await this.ecpPost(host, 'exit-app/dev/true');
2✔
747

748
            const launchResponse = await this.ecpPost(host, 'launch/dev');
2✔
749
            if (launchResponse.statusCode !== 200) {
2!
750
                await vscode.window.showErrorMessage(`Failed to launch dev channel on ${host} (HTTP ${launchResponse.statusCode}).`);
×
751
                return;
×
752
            }
753

754
            // give a little bit of time to let the app boot up before checking its status
755
            await util.sleep(1000);
2✔
756
            const verifyResponse = await util.httpGet(`http://${host}:8060/query/active-app`, { timeout: 5_000 });
2✔
757
            const verifyParsed = await xml2js.parseStringPromise(verifyResponse.body as string);
2✔
758
            const verifyAppId: string | undefined = verifyParsed?.['active-app']?.app?.[0]?.$?.id;
2!
759
            if (verifyAppId === 'dev') {
2✔
760
                void util.showTimedNotification('Dev app restarted', 2000);
1✔
761
            } else {
762
                await vscode.window.showWarningMessage(`Sent the dev launch command, but the foreground app is "${verifyAppId ?? 'unknown'}". The dev app may still be loading.`);
1!
763
            }
764
        });
765
    }
766

767
    private ecpPost(host: string, path: string) {
768
        return new Promise<request.Response>((resolve, reject) => {
×
769
            request.post(`http://${host}:8060/${path}`, (err: Error | null, response: request.Response) => {
×
770
                return err ? reject(err) : resolve(response);
×
771
            });
772
        });
773
    }
774

775
    /**
776
     * Restart a Roku device. When `host` is omitted (e.g. invoked from the command palette)
777
     * a device picker is shown. Resolves and validates the developer password the same way a
778
     * debug launch does before issuing the reboot.
779
     */
780
    public async restartDevice(host?: string): Promise<void> {
781
        const target = await this.resolveDeviceHost(host);
8✔
782
        if (!target) {
8✔
783
            return;
1✔
784
        }
785

786
        const confirm = await vscode.window.showWarningMessage(
7✔
787
            `Restart Device?`,
788
            {
789
                detail: `Any running apps or processes will be terminated.\n\n${target.label}`,
790
                modal: true
791
            },
792
            'Restart'
793
        );
794
        if (confirm !== 'Restart') {
7✔
795
            return;
1✔
796
        }
797

798
        const password = await this.resolveValidatedPassword(target.host, target.serialNumber);
6✔
799
        if (password === undefined) {
6✔
800
            return;
2✔
801
        }
802

803
        try {
4✔
804
            await vscode.window.withProgress({
4✔
805
                location: vscode.ProgressLocation.Notification,
806
                title: `Requesting restarting ${target.label}`
807
            }, () => rokuDeploy.rebootDevice({ host: target.host, password: password, timeout: 10000 }));
4✔
808
        } catch (e) {
809
            void vscode.window.showErrorMessage(`Failed to restart device: ${e.message}`);
1✔
810
        }
811
    }
812

813
    /**
814
     * Ask a Roku device to check for and install any available software updates. Host and
815
     * password are resolved the same way as `restartDevice`.
816
     */
817
    public async checkForUpdates(host?: string): Promise<void> {
818
        const target = await this.resolveDeviceHost(host);
2✔
819
        if (!target) {
2!
820
            return;
×
821
        }
822

823
        const confirm = await vscode.window.showInformationMessage(
2✔
824
            `Check for Updates?`,
825
            {
826
                detail: `Device will check for app and Roku OS updates.\n\nAny running apps or processes will be terminated.\n\n${target.label}`,
827
                modal: true
828
            },
829
            `Check for Updates`
830
        );
831
        if (confirm !== 'Check for Updates') {
2✔
832
            return;
1✔
833
        }
834

835
        const password = await this.resolveValidatedPassword(target.host, target.serialNumber);
1✔
836
        if (password === undefined) {
1!
837
            return;
×
838
        }
839

840
        try {
1✔
841
            await vscode.window.withProgress({
1✔
842
                location: vscode.ProgressLocation.Notification,
843
                title: `Checking for updates: ${target.label}`
844
            }, () => rokuDeploy.checkForUpdate({ host: target.host, password: password, timeout: 10000 }));
1✔
845
        } catch (e) {
846
            void vscode.window.showErrorMessage(`Failed to check for updates: ${e.message}`);
×
847
        }
848
    }
849

850
    /**
851
     * Resolve the device a device-targeted command should act on.
852
     *
853
     * When `host` is provided (e.g. from a Devices view tree item) it is used directly.
854
     * Otherwise the device picker is always shown; these are device-specific actions, so we
855
     * never silently fall back to the active device. The resolved host is probed so the caller
856
     * has a fresh serial number for password lookup and a friendly display label for prompts.
857
     *
858
     * Returns undefined when the user cancels device selection.
859
     */
860
    private async resolveDeviceHost(host?: string): Promise<{ host: string; serialNumber: string | undefined; label: string } | undefined> {
861
        if (!host) {
10✔
862
            try {
2✔
863
                host = (await this.userInputManager.promptForHost())?.host;
2!
864
            } catch {
865
                // promptForHost rejects when the user dismisses the picker; treat as a cancel.
866
                return undefined;
1✔
867
            }
868
        }
869
        if (!host) {
9!
870
            return undefined;
×
871
        }
872

873
        const device = await this.deviceManager.validateAndAddDevice(host);
9✔
874
        const label = device ? this.deviceManager.getDeviceDisplayName(device, true) : host;
9!
875
        return { host: host, serialNumber: device?.serialNumber, label: label };
9!
876
    }
877

878
    /**
879
     * Resolve a developer password the device accepts, delegating the candidate/validate/prompt/
880
     * persist flow to the shared resolver. The global `remotePassword` fallback is offered as an
881
     * extra candidate. Shows an error and returns undefined when the device is unreachable, and
882
     * returns undefined when the user cancels the prompt.
883
     */
884
    private async resolveValidatedPassword(host: string, serialNumber: string | undefined): Promise<string | undefined> {
885
        const resolution = await this.userInputManager.resolveDevicePassword({
7✔
886
            host: host,
887
            serialNumber: serialNumber,
888
            extraCandidates: [await this.context.workspaceState.get('remotePassword')]
889
        });
890
        if (resolution.status === 'unreachable') {
7✔
891
            void vscode.window.showErrorMessage(`Device at ${host} is unreachable.`);
1✔
892
            return undefined;
1✔
893
        }
894
        if (resolution.status === 'cancelled') {
6✔
895
            return undefined;
1✔
896
        }
897
        return resolution.password;
5✔
898
    }
899

900
    public async sendRemoteCommand(key: string, host?: string, literalCharacter = false) {
×
901
        for (const notifier of this.keypressNotifiers) {
×
902
            notifier(key, literalCharacter);
×
903
        }
904

905
        if (literalCharacter) {
×
906
            key = 'Lit_' + encodeURIComponent(key);
×
907
        }
908

909
        // do we have a temporary override?
910
        if (!host) {
×
911
            // Get the long lived host ip
912
            await this.getRemoteHost();
×
913
            host = this.host;
×
914
        }
915

916
        if (host) {
×
917
            let clickUrl = `http://${host}:8060/keypress/${key}`;
×
918
            console.log(`send ${clickUrl}`);
×
919
            return new Promise((resolve, reject) => {
×
920
                request.post(clickUrl, (err, response) => {
×
921
                    if (err) {
×
922
                        return reject(err);
×
923
                    }
924
                    return resolve(response);
×
925
                });
926
            });
927
        }
928
    }
929

930
    public async getRemoteHost(showPrompt = true) {
×
931
        this.host = await this.context.workspaceState.get('remoteHost');
×
932
        if (!this.host) {
×
933
            let config = util.getConfiguration('brightscript.remoteControl');
×
934
            this.host = config.get('host');
×
935
            // eslint-disable-next-line no-template-curly-in-string
936
            if ((!this.host || this.host === '${promptForHost}') && showPrompt) {
×
937
                this.host = (await this.userInputManager.promptForHost())?.host;
×
938
            }
939
        }
940
        if (!this.host) {
×
941
            throw new Error('Can\'t send command: host is required.');
×
942
        } else {
943
            await this.context.workspaceState.update('remoteHost', this.host);
×
944
        }
945
        if (this.host) {
×
946
            //try resolving the hostname. (sometimes it fails for no reason, so just ignore the crash if it does)
947
            try {
×
948
                this.host = await rokuDebugUtil.dnsLookup(this.host);
×
949
            } catch (e) {
950
                console.error('Error doing dns lookup for host ', this.host, e);
×
951
            }
952
        }
953
        return this.host;
×
954
    }
955

956
    public async getRemotePassword(showPrompt = true) {
×
957
        this.password = await this.context.workspaceState.get('remotePassword');
×
958
        if (!this.password) {
×
959
            let config = util.getConfiguration('brightscript.remoteControl');
×
960
            this.password = config.get('password');
×
961
            // eslint-disable-next-line no-template-curly-in-string
962
            if ((!this.password || this.password === '${promptForPassword}') && showPrompt) {
×
963
                this.password = await vscode.window.showInputBox({
×
964
                    placeHolder: 'The developer account password for your Roku device',
965
                    value: ''
966
                });
967
            }
968
        }
969
        if (!this.password) {
×
970
            throw new Error(`Can't send command: password is required.`);
×
971
        } else {
972
            await this.context.workspaceState.update('remotePassword', this.password);
×
973
        }
974
        return this.password;
×
975
    }
976

977
    public async getWorkspacePath() {
978
        this.workspacePath = await this.context.workspaceState.get('workspacePath');
×
979
        //let folderUri: vscode.Uri;
980
        if (!this.workspacePath) {
×
981
            if (vscode.workspace.workspaceFolders?.length === 1) {
×
982
                this.workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath;
×
983
            } else {
984
                //there are multiple workspaces, ask the user to specify which one they want to use
985
                let workspaceFolder = await vscode.window.showWorkspaceFolderPick();
×
986
                if (workspaceFolder) {
×
987
                    this.workspacePath = workspaceFolder.uri.fsPath;
×
988
                }
989
            }
990
        }
991
        return this.workspacePath;
×
992
    }
993

994
    /**
995
     * Store a password for a specific device, keyed by serial number.
996
     * An empty password clears the stored entry.
997
     *
998
     * Writes the password to every `brightscript.devices[]` settings entry that
999
     * matches the SN (user/workspace/workspace-folder scopes) and also keeps the
1000
     * cred store updated. The cred store acts as a running cache of passwords —
1001
     * kept fresh alongside settings here, and refreshed on each successful
1002
     * password validation elsewhere.
1003
     */
1004
    public async setDevicePassword(serialNumber: string, password: string) {
1005
        await this.writeDevicePasswordToSettings(serialNumber, password);
×
1006
        if (password) {
×
1007
            await this.credentialStore.setPassword(serialNumber, password);
×
1008
        } else {
1009
            await this.credentialStore.clearPassword(serialNumber);
×
1010
        }
1011
    }
1012

1013
    /**
1014
     * Update/clear the `password` field on any `brightscript.devices[]` entries
1015
     * whose `serialNumber` matches. Writes to every writable scope that contains
1016
     * a matching entry — user (Global), workspace, and each workspace folder.
1017
     * The default (package.json) scope is read-only and is never written.
1018
     * Empty password removes the field rather than writing an empty string.
1019
     * Returns true when at least one scope contained a matching entry.
1020
     */
1021
    private async writeDevicePasswordToSettings(serialNumber: string, password: string): Promise<boolean> {
1022
        if (!serialNumber) {
×
1023
            return false;
×
1024
        }
1025

1026
        const scopeHasMatch = (devices: ConfiguredDevice[] | undefined): boolean => !!devices?.some(entry => entry.serialNumber === serialNumber);
×
1027

1028
        const rewriteEntries = (devices: ConfiguredDevice[]): ConfiguredDevice[] => devices.map(entry => {
×
1029
            if (entry.serialNumber !== serialNumber) {
×
1030
                return entry;
×
1031
            }
1032
            if (password) {
×
1033
                return { ...entry, password: password };
×
1034
            }
1035
            const { password: _existingPassword, ...entryWithoutPassword } = entry;
×
1036
            return entryWithoutPassword;
×
1037
        });
1038

1039
        let found = false;
×
1040

1041
        // User (Global) + workspace scopes — resource-agnostic
1042
        const rootConfig = vscode.workspace.getConfiguration('brightscript');
×
1043
        const rootInspection = rootConfig.inspect<ConfiguredDevice[]>('devices');
×
1044

1045
        if (scopeHasMatch(rootInspection?.globalValue)) {
×
1046
            found = true;
×
1047
            await rootConfig.update('devices', rewriteEntries(rootInspection.globalValue), vscode.ConfigurationTarget.Global);
×
1048
        }
1049
        if (scopeHasMatch(rootInspection?.workspaceValue)) {
×
1050
            found = true;
×
1051
            await rootConfig.update('devices', rewriteEntries(rootInspection.workspaceValue), vscode.ConfigurationTarget.Workspace);
×
1052
        }
1053

1054
        // Workspace-folder scope — one setting per folder in multi-root workspaces
1055
        const workspaceFolders = vscode.workspace.workspaceFolders ?? [];
×
1056
        for (const folder of workspaceFolders) {
×
1057
            const folderConfig = vscode.workspace.getConfiguration('brightscript', folder.uri);
×
1058
            const folderInspection = folderConfig.inspect<ConfiguredDevice[]>('devices');
×
1059
            if (scopeHasMatch(folderInspection?.workspaceFolderValue)) {
×
1060
                found = true;
×
1061
                await folderConfig.update('devices', rewriteEntries(folderInspection.workspaceFolderValue), vscode.ConfigurationTarget.WorkspaceFolder);
×
1062
            }
1063
        }
1064

1065
        return found;
×
1066
    }
1067

1068
    /**
1069
     * Get the stored password for a specific device by serial number.
1070
     */
1071
    public async getDevicePassword(serialNumber: string | undefined): Promise<string | undefined> {
1072
        return this.credentialStore.getPassword(serialNumber);
×
1073
    }
1074

1075
    /**
1076
     * Get the password for the currently active device.
1077
     * Resolves the active host's IP to a serial number via DeviceManager,
1078
     * then reads from the SN-keyed credential store. Falls back to the
1079
     * workspace-global password when no per-device entry exists.
1080
     */
1081
    public async getActiveHostPassword(): Promise<string | undefined> {
1082
        const activeHost = this.context.workspaceState.get<string>('remoteHost');
×
1083
        if (activeHost && typeof activeHost === 'string') {
×
1084
            const serialNumber = this.deviceManager.getDevice({ ip: activeHost })?.serialNumber;
×
1085
            const devicePassword = await this.credentialStore.getPassword(serialNumber);
×
1086
            if (devicePassword) {
×
1087
                return devicePassword;
×
1088
            }
1089
        }
1090
        return this.getRemotePassword(false);
×
1091
    }
1092

1093
    /**
1094
     * Return the active host (paired with its raw device-info) if one is set and passes a health
1095
     * check; otherwise undefined. The health check refreshes the device in the device manager, so
1096
     * the device-info is read back from there without an extra request.
1097
     */
1098
    public async getHealthyActiveHost(): Promise<HostWithDeviceInfo | undefined> {
1099
        const activeHost = vscodeContextManager.get<string>('activeHost');
4✔
1100
        if (!activeHost) {
4✔
1101
            return undefined;
1✔
1102
        }
1103
        const isHealthy = await this.deviceManager.healthCheckDevice({ ip: activeHost }, true, false);
3✔
1104
        if (!isHealthy) {
3✔
1105
            return undefined;
1✔
1106
        }
1107
        const deviceInfo = this.deviceManager.getDevice({ ip: activeHost })?.deviceInfo;
2✔
1108
        //deviceInfo is required on HostWithDeviceInfo, so if we couldn't read it back, report no healthy active host
1109
        if (!deviceInfo) {
2✔
1110
            return undefined;
1✔
1111
        }
1112
        return { host: activeHost, deviceInfo: deviceInfo };
1✔
1113
    }
1114

1115
    /**
1116
     * Open the settings JSON file and position cursor at the specified device entry
1117
     */
1118
    private async openSettingsJsonAtDevice(device: RokuDevice | undefined, scope: 'user' | 'workspace'): Promise<void> {
1119
        // Open the appropriate settings JSON file
1120
        const command = scope === 'user'
×
1121
            ? 'workbench.action.openSettingsJson'
1122
            : 'workbench.action.openWorkspaceSettingsFile';
1123
        await vscode.commands.executeCommand(command);
×
1124

1125
        // Get the active editor (should be the settings file we just opened)
1126
        const editor = vscode.window.activeTextEditor;
×
1127
        if (!editor || !device) {
×
1128
            return;
×
1129
        }
1130

1131
        const text = editor.document.getText();
×
1132

1133
        // Search for the device by IP or serial number
1134
        const searchTerms = [device.ip];
×
1135
        if (device.serialNumber) {
×
1136
            searchTerms.push(device.serialNumber);
×
1137
        }
1138

1139
        let matchIndex = -1;
×
1140
        for (const term of searchTerms) {
×
1141
            const index = text.indexOf(`"${term}"`);
×
1142
            if (index !== -1) {
×
1143
                matchIndex = index;
×
1144
                break;
×
1145
            }
1146
        }
1147

1148
        if (matchIndex !== -1) {
×
1149
            const position = editor.document.positionAt(matchIndex + 1); // +1 to skip opening quote
×
1150
            const selection = new vscode.Selection(position, position);
×
1151
            editor.selection = selection;
×
1152
            editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter);
×
1153
        }
1154
    }
1155

1156
    public registerKeypressNotifier(notifier: (key: string, literalCharacter: boolean) => void) {
1157
        this.keypressNotifiers.push(notifier);
14✔
1158
    }
1159

1160
    private registerCommand(name: string, callback: (...args: any[]) => any, thisArg?: any) {
1161
        const prefix = 'extension.brightscript.';
3,678✔
1162
        const commandName = name.startsWith(prefix) ? name : prefix + name;
3,678✔
1163
        this.context.subscriptions.push(vscode.commands.registerCommand(commandName, callback, thisArg));
3,678✔
1164
    }
1165

1166
    /**
1167
     * Register the per-facet toggle commands plus the reset command backing the Devices
1168
     * view filter submenu. Each facet has two command variants (unchecked + ".active");
1169
     * the submenu picks which to render via a `when` clause on the per-facet context key.
1170
     * Both call the same toggle handler.
1171
     */
1172
    public registerDevicesViewCommands(devicesViewProvider: DevicesViewProvider) {
1173
        for (const key of DEVICE_FILTER_KEYS) {
14✔
1174
            const handler = () => devicesViewProvider.toggleFilter(key);
126✔
1175
            this.registerCommand(`devicesView.toggleFilter.${key}`, handler);
126✔
1176
            this.registerCommand(`devicesView.toggleFilter.${key}.active`, handler);
126✔
1177
        }
1178
        this.registerCommand('devicesView.resetFilters', () => devicesViewProvider.resetFilters());
14✔
1179
        // These also appear in the command palette, where there's no tree element; resolving
1180
        // a missing/unknown key to undefined lets `restartDevice`/`checkForUpdates` fall back
1181
        // to the active device or device picker.
1182
        this.registerCommand('devicesView.restartDevice', (element?: { key?: string }) => {
14✔
1183
            return this.restartDevice(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined);
2!
1184
        });
1185
        this.registerCommand('devicesView.checkAndInstallUpdates', (element?: { key?: string }) => {
14✔
1186
            return this.checkForUpdates(element?.key ? this.deviceManager.getDevice(element.key)?.ip : undefined);
1!
1187
        });
1188

1189
        this.registerDeviceContextMenuCommands();
14✔
1190
    }
1191

1192
    /**
1193
     * Register the commands behind the right-click context menu on a device in the Devices view.
1194
     * Each receives the clicked tree element and resolves it into the argument shape its
1195
     * underlying command expects. These are hidden from the command palette (they only make
1196
     * sense with a tree element), which also lets their titles carry the emoji icons shown
1197
     * in the context menu.
1198
     */
1199
    private registerDeviceContextMenuCommands() {
1200
        const getDevice = (element?: { key?: string }) => {
14✔
1201
            return element?.key ? this.deviceManager.getDevice(element.key) : undefined;
×
1202
        };
1203

1204
        this.registerCommand('devicesView.deviceMenu.setActiveDevice', (element?: { key?: string }) => {
14✔
1205
            return vscode.commands.executeCommand('extension.brightscript.setActiveDevice', element);
×
1206
        });
1207
        this.registerCommand('devicesView.deviceMenu.clearActiveDevice', () => {
14✔
1208
            return vscode.commands.executeCommand('extension.brightscript.clearActiveDevice');
×
1209
        });
1210
        this.registerCommand('devicesView.deviceMenu.captureScreenshot', (element?: { key?: string }) => {
14✔
1211
            return vscode.commands.executeCommand('extension.brightscript.captureScreenshot', getDevice(element)?.ip);
×
1212
        });
1213
        this.registerCommand('devicesView.deviceMenu.switchTvInput', (element?: { key?: string }) => {
14✔
1214
            return vscode.commands.executeCommand('extension.brightscript.changeTvInput', getDevice(element)?.ip);
×
1215
        });
1216
        this.registerCommand('devicesView.deviceMenu.refreshDevice', (element?: { key?: string }) => {
14✔
1217
            return vscode.commands.executeCommand('extension.brightscript.refreshDevice', element);
×
1218
        });
1219
        this.registerCommand('devicesView.deviceMenu.restartDevice', (element?: { key?: string }) => {
14✔
1220
            return this.restartDevice(getDevice(element)?.ip);
×
1221
        });
1222
        this.registerCommand('devicesView.deviceMenu.checkAndInstallUpdates', (element?: { key?: string }) => {
14✔
1223
            return this.checkForUpdates(getDevice(element)?.ip);
×
1224
        });
1225
        this.registerCommand('devicesView.deviceMenu.openWebPortal', (element?: { key?: string }) => {
14✔
1226
            const ip = getDevice(element)?.ip;
×
1227
            if (!ip) {
×
1228
                return vscode.window.showErrorMessage('Could not determine the IP address for this device');
×
1229
            }
1230
            return vscode.commands.executeCommand('extension.brightscript.openUrl', `http://${ip}`);
×
1231
        });
1232
        this.registerCommand('devicesView.deviceMenu.viewRegistry', (element?: { key?: string }) => {
14✔
1233
            return vscode.commands.executeCommand('extension.brightscript.openRegistryInBrowser', getDevice(element)?.ip);
×
1234
        });
1235
        // Set and Change share a handler; the menu shows one or the other based on whether a password is stored
1236
        for (const commandName of ['devicesView.deviceMenu.setDevicePassword', 'devicesView.deviceMenu.changeDevicePassword']) {
14✔
1237
            this.registerCommand(commandName, (element?: { key?: string }) => {
28✔
1238
                return vscode.commands.executeCommand('extension.brightscript.setDevicePassword', getDevice(element)?.serialNumber);
×
1239
            });
1240
        }
1241
        this.registerCommand('devicesView.deviceMenu.clearDevicePassword', (element?: { key?: string }) => {
14✔
1242
            return vscode.commands.executeCommand('extension.brightscript.clearDevicePassword', getDevice(element)?.serialNumber);
×
1243
        });
1244
        this.registerCommand('devicesView.deviceMenu.addToUserSettings', (element?: { key?: string }) => {
14✔
1245
            return vscode.commands.executeCommand('extension.brightscript.addDeviceToUserSettings', element);
×
1246
        });
1247
        this.registerCommand('devicesView.deviceMenu.editInUserSettings', (element?: { key?: string }) => {
14✔
1248
            return vscode.commands.executeCommand('extension.brightscript.editDeviceInUserSettings', element);
×
1249
        });
1250
        this.registerCommand('devicesView.deviceMenu.addToWorkspaceSettings', (element?: { key?: string }) => {
14✔
1251
            return vscode.commands.executeCommand('extension.brightscript.addDeviceToWorkspaceSettings', element);
×
1252
        });
1253
        this.registerCommand('devicesView.deviceMenu.editInWorkspaceSettings', (element?: { key?: string }) => {
14✔
1254
            return vscode.commands.executeCommand('extension.brightscript.editDeviceInWorkspaceSettings', element);
×
1255
        });
1256
    }
1257

1258
    private async sendAsciiToDevice(character: string) {
1259
        await this.sendRemoteCommand(character, undefined, true);
×
1260
    }
1261
}
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