diff --git a/package-lock.json b/package-lock.json index b8f0072a..d8165d47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "brighterscript-formatter": "^1.6.34", "debounce": "^1.2.0", "dotenv": "^6.2.0", + "eventemitter3": "^5.0.1", "fast-xml-parser": "^3.12.16", "fs-extra": "^7.0.1", "get-port": "^5.0.0", @@ -2570,6 +2571,11 @@ "node": ">=0.8.0" } }, + "node_modules/brighterscript/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/brighterscript/node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -4511,9 +4517,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "node_modules/expand-template": { "version": "2.0.3", @@ -5772,6 +5778,11 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8865,6 +8876,11 @@ "xml2js": "^0.5.0" } }, + "node_modules/roku-debug/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/roku-debug/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -13143,6 +13159,11 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -14676,9 +14697,9 @@ } }, "eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "expand-template": { "version": "2.0.3", @@ -15622,6 +15643,13 @@ "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" + }, + "dependencies": { + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + } } }, "http-proxy-middleware": { @@ -17993,6 +18021,11 @@ "xml2js": "^0.5.0" }, "dependencies": { + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", diff --git a/package.json b/package.json index 942c35da..50e4b69d 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "brighterscript-formatter": "^1.6.34", "debounce": "^1.2.0", "dotenv": "^6.2.0", + "eventemitter3": "^5.0.1", "fast-xml-parser": "^3.12.16", "fs-extra": "^7.0.1", "get-port": "^5.0.0", diff --git a/src/ActiveDeviceManager.ts b/src/ActiveDeviceManager.ts index 8d292c72..7f03ca4f 100644 --- a/src/ActiveDeviceManager.ts +++ b/src/ActiveDeviceManager.ts @@ -1,5 +1,5 @@ import * as backoff from 'backoff'; -import { EventEmitter } from 'events'; +import { EventEmitter } from 'eventemitter3'; import * as xmlParser from 'fast-xml-parser'; import * as http from 'http'; import * as NodeCache from 'node-cache'; @@ -8,13 +8,14 @@ import { Client } from 'node-ssdp'; import { URL } from 'url'; import { util } from './util'; import * as vscode from 'vscode'; +import { firstBy } from 'thenby'; +import type { Disposable } from 'vscode'; const DEFAULT_TIMEOUT = 10000; -export class ActiveDeviceManager extends EventEmitter { +export class ActiveDeviceManager { constructor() { - super(); this.isRunning = false; this.firstRequestForDevices = true; @@ -38,23 +39,76 @@ export class ActiveDeviceManager extends EventEmitter { this.deviceCache = new NodeCache({ stdTTL: 3600, checkperiod: 120 }); //anytime a device leaves the cache (either expired or manually deleted) this.deviceCache.on('del', (deviceId, device) => { - this.emit('expiredDevice', deviceId, device); + void this.emit('device-expired', device); }); this.processEnabledState(); } + private emitter = new EventEmitter(); + + public on(eventName: 'device-expired', handler: (device: RokuDeviceDetails) => void, disposables?: Disposable[]): () => void; + public on(eventName: 'device-found', handler: (device: RokuDeviceDetails) => void, disposables?: Disposable[]): () => void; + public on(eventName: string, handler: (payload: any) => void, disposables?: Disposable[]): () => void { + this.emitter.on(eventName, handler); + const unsubscribe = () => { + if (this.emitter !== undefined) { + this.emitter.removeListener(eventName, handler); + } + }; + + disposables?.push({ + dispose: unsubscribe + }); + + return unsubscribe; + } + + private async emit(eventName: 'device-expired', device: RokuDeviceDetails); + private async emit(eventName: 'device-found', device: RokuDeviceDetails); + private async emit(eventName: string, data?: any) { + //emit these events on next tick, otherwise they will be processed immediately which could cause issues + await util.sleep(0); + this.emitter?.emit(eventName, data); + } + public firstRequestForDevices: boolean; - public lastUsedDevice: string; - private enabled: boolean; + public lastUsedDevice: RokuDeviceDetails; + public enabled: boolean; private showInfoMessages: boolean; private deviceCache: NodeCache; private exponentialBackoff: any; private isRunning: boolean; - // Returns an object will all the active devices by device id - public getActiveDevices() { + /** + * Get a list of all devices discovered on the network + */ + public getActiveDevices(): RokuDeviceDetails[] { this.firstRequestForDevices = false; - return this.deviceCache.mget(this.deviceCache.keys()); + const devices = Object.values( + this.deviceCache.mget(this.deviceCache.keys()) as Record + ).sort(firstBy((a: RokuDeviceDetails, b: RokuDeviceDetails) => { + return this.getPriorityForDeviceFormFactor(a) - this.getPriorityForDeviceFormFactor(b); + }).thenBy((a: RokuDeviceDetails, b: RokuDeviceDetails) => { + if (a.id < b.id) { + return -1; + } + if (a.id > b.id) { + return 1; + } + // ids must be equal + return 0; + })); + return devices; + } + + private getPriorityForDeviceFormFactor(device: RokuDeviceDetails): number { + if (device.deviceInfo['is-stick']) { + return 0; + } + if (device.deviceInfo['is-tv']) { + return 2; + } + return 1; } // Returns the device cache statistics. @@ -114,6 +168,18 @@ export class ActiveDeviceManager extends EventEmitter { } } + /** + * The number of milliseconds since a new device was discovered + */ + public get timeSinceLastDiscoveredDevice() { + if (!this.lastDiscoveredDeviceDate) { + return 0; + } + return Date.now() - this.lastDiscoveredDeviceDate.getTime(); + } + private lastDiscoveredDeviceDate: Date; + + // Discover all Roku devices on the network and watch for new ones that connect private discoverAll(timeout: number = DEFAULT_TIMEOUT): Promise { return new Promise((resolve, reject) => { @@ -122,13 +188,16 @@ export class ActiveDeviceManager extends EventEmitter { finder.on('found', (device: RokuDeviceDetails) => { if (!devices.includes(device.id)) { - if (this.showInfoMessages && this.deviceCache.get(device.id) === undefined) { - // New device found - void vscode.window.showInformationMessage(`Device found: ${device.deviceInfo['default-device-name']}`); + if (this.deviceCache.get(device.id) === undefined) { + this.lastDiscoveredDeviceDate = new Date(); + if (this.showInfoMessages) { + // New device found + void vscode.window.showInformationMessage(`Device found: ${device.deviceInfo['default-device-name']}`); + } } this.deviceCache.set(device.id, device); devices.push(device.id); - this.emit('foundDevice', device.id, device); + this.emit('device-found', device); } }); @@ -192,7 +261,7 @@ class RokuFinder extends EventEmitter { const device: RokuDeviceDetails = { location: url.origin, ip: url.hostname, - id: info['device-info']['device-id'], + id: info['device-info']['device-id']?.toString?.(), deviceInfo: info['device-info'] }; this.emit('found', device); diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index 3a3f55a9..efd53e4c 100644 --- a/src/DebugConfigurationProvider.spec.ts +++ b/src/DebugConfigurationProvider.spec.ts @@ -4,12 +4,16 @@ import { assert, expect } from 'chai'; import * as path from 'path'; import { createSandbox } from 'sinon'; import type { WorkspaceFolder } from 'vscode'; +import { QuickPickItemKind } from 'vscode'; import Uri from 'vscode-uri'; import type { BrightScriptLaunchConfiguration } from './DebugConfigurationProvider'; +import { manualHostItemId } from './DebugConfigurationProvider'; import { BrightScriptDebugConfigurationProvider } from './DebugConfigurationProvider'; import { vscode } from './mockVscode.spec'; import { standardizePath as s } from 'brighterscript'; import * as fsExtra from 'fs-extra'; +import type { RokuDeviceDetails } from './ActiveDeviceManager'; +import { ActiveDeviceManager } from './ActiveDeviceManager'; const sinon = createSandbox(); const Module = require('module'); @@ -48,10 +52,16 @@ describe('BrightScriptConfigurationProvider', () => { index: 0 }; - let activeDeviceManager = { - getActiveDevices: () => [] - }; - configProvider = new BrightScriptDebugConfigurationProvider(context, activeDeviceManager, null, vscode.window.createOutputChannel('Extension')); + //prevent the 'start' method from actually running + sinon.stub(ActiveDeviceManager.prototype as any, 'start').callsFake(() => { }); + let activeDeviceManager = new ActiveDeviceManager(); + + configProvider = new BrightScriptDebugConfigurationProvider( + context, + activeDeviceManager, + null, + vscode.window.createOutputChannel('Extension') + ); }); afterEach(() => { @@ -321,4 +331,134 @@ describe('BrightScriptConfigurationProvider', () => { expect(config.rootDir).to.eql('./somePath/123'); }); }); + + describe('createHostQuickPickList', () => { + const devices: Array = [{ + deviceInfo: { + 'user-device-name': 'roku1', + 'serial-number': 'alpha', + 'model-number': 'model1' + }, + id: '1', + ip: '1.1.1.1', + location: '???' + }, { + deviceInfo: { + 'user-device-name': 'roku2', + 'serial-number': 'beta', + 'model-number': 'model2' + }, + id: '2', + ip: '1.1.1.2', + location: '???' + }, { + deviceInfo: { + 'user-device-name': 'roku3', + 'serial-number': 'charlie', + 'model-number': 'model3' + }, + id: '3', + ip: '1.1.1.3', + location: '???' + }]; + function label(device: RokuDeviceDetails) { + return `${device.ip} | ${device.deviceInfo['user-device-name']} - ${device.deviceInfo['serial-number']} - ${device.deviceInfo['model-number']}`; + } + + it('includes "manual', () => { + expect( + configProvider['createHostQuickPickList']([], undefined) + ).to.eql([{ + label: 'Enter manually', + device: { + id: manualHostItemId + } + }]); + }); + + it('includes separators for devices and manual options', () => { + expect( + configProvider['createHostQuickPickList']([devices[0]], undefined) + ).to.eql([ + { + kind: QuickPickItemKind.Separator, + label: 'devices' + }, + { + label: '1.1.1.1 | roku1 - alpha - model1', + device: devices[0] + }, + { + kind: QuickPickItemKind.Separator, + label: ' ' + }, { + label: 'Enter manually', + device: { + id: manualHostItemId + } + }] + ); + }); + + it('moves active device to the top', () => { + expect( + configProvider['createHostQuickPickList']([devices[0], devices[1], devices[2]], devices[1]).map(x => x.label) + ).to.eql([ + 'last used', + label(devices[1]), + 'other devices', + label(devices[0]), + label(devices[2]), + ' ', + 'Enter manually' + ]); + }); + + it('includes the spinner text when "last used" and "other devices" separators are both present', () => { + expect( + configProvider['createHostQuickPickList'](devices, devices[1]).map(x => x.label) + ).to.eql([ + 'last used', + label(devices[1]), + 'other devices', + label(devices[0]), + label(devices[2]), + ' ', + 'Enter manually' + ]); + }); + + it('includes the spinner text if "devices" separator is present', () => { + expect( + configProvider['createHostQuickPickList'](devices, null).map(x => x.label) + ).to.eql([ + 'devices', + label(devices[0]), + label(devices[1]), + label(devices[2]), + ' ', + 'Enter manually' + ]); + }); + + it('includes the spinner text if only "last used" separator is present', () => { + expect( + configProvider['createHostQuickPickList']([devices[0]], devices[0]).map(x => x.label) + ).to.eql([ + 'last used', + label(devices[0]), + ' ', + 'Enter manually' + ]); + }); + + it('includes the spinner text when no other device entries are present', () => { + expect( + configProvider['createHostQuickPickList']([], null).map(x => x.label) + ).to.eql([ + 'Enter manually' + ]); + }); + + }); }); diff --git a/src/DebugConfigurationProvider.ts b/src/DebugConfigurationProvider.ts index c4d681a4..279ea0ef 100644 --- a/src/DebugConfigurationProvider.ts +++ b/src/DebugConfigurationProvider.ts @@ -1,4 +1,4 @@ -import { util as bslangUtil } from 'brighterscript'; +import { Deferred, util as bslangUtil } from 'brighterscript'; import * as dotenv from 'dotenv'; import * as path from 'path'; import * as fsExtra from 'fs-extra'; @@ -7,7 +7,9 @@ import * as rta from 'roku-test-automation'; import type { CancellationToken, DebugConfigurationProvider, + Disposable, ExtensionContext, + QuickPickItem, WorkspaceFolder } from 'vscode'; import * as vscode from 'vscode'; @@ -15,12 +17,20 @@ import type { LaunchConfiguration } from 'roku-debug'; import { fileUtils } from 'roku-debug'; import { util } from './util'; import type { TelemetryManager } from './managers/TelemetryManager'; +import type { ActiveDeviceManager, RokuDeviceDetails } from './ActiveDeviceManager'; +import { debounce } from 'debounce'; + +/** + * An id to represent the "Enter manually" option in the host picker + */ +export const manualHostItemId = `${Number.MAX_SAFE_INTEGER}`; +const manualLabel = 'Enter manually'; export class BrightScriptDebugConfigurationProvider implements DebugConfigurationProvider { public constructor( private context: ExtensionContext, - private activeDeviceManager: any, + private activeDeviceManager: ActiveDeviceManager, private telemetryManager: TelemetryManager, private extensionOutputChannel: vscode.OutputChannel ) { @@ -390,81 +400,217 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio return config; } + private async promptForHostManual() { + return this.openInputBox('The IP address of your Roku device'); + } + /** * Validates the host parameter in the config and opens an input ui if set to ${promptForHost} * @param config current config object */ private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise { - let showInputBox = false; - if (config.host.trim() === '${promptForHost}' || (config?.deepLinkUrl?.includes('${promptForHost}'))) { if (this.activeDeviceManager.enabled) { - if (this.activeDeviceManager.firstRequestForDevices && !this.activeDeviceManager.getCacheStats().keys) { - let deviceWaitTime = 5000; - if (this.showDeviceInfoMessages) { - await vscode.window.showInformationMessage(`Device Info: Allowing time for device discovery (${deviceWaitTime} ms)`); - } + config.host = await this.promptForHost(); + } else { + config.host = await this.promptForHostManual(); + } + } - await util.delay(deviceWaitTime); - } + //check the host and throw error if not provided or update the workspace to set last host + if (!config.host) { + throw new Error('Debug session terminated: host is required.'); + } else { + await this.context.workspaceState.update('remoteHost', config.host); + } + + return config; + } + + /** + * Prompt the user to pick a host from a list of devices + */ + private async promptForHost() { + const deferred = new Deferred<{ ip: string; manual?: boolean } | { ip?: string; manual: true }>(); + const disposables: Array = []; - let activeDevices = this.activeDeviceManager.getActiveDevices(); + const discoveryTime = 5_000; - if (activeDevices && Object.keys(activeDevices).length) { - let items = []; + //create the quickpick item + const quickPick = vscode.window.createQuickPick(); + disposables.push(quickPick); + quickPick.placeholder = `Please Select a Roku or manually type an IP address`; + quickPick.keepScrollPosition = true; - // Create the Quick Picker option items - for (const key of Object.keys(activeDevices)) { - let device = activeDevices[key]; - let itemText = `${device.ip} | ${device.deviceInfo['user-device-name']} - ${device.deviceInfo['serial-number']} - ${device.deviceInfo['model-number']}`; + function dispose() { + for (const disposable of disposables) { + disposable.dispose(); + } + } - if (this.activeDeviceManager.lastUsedDevice && device.deviceInfo['default-device-name'] === this.activeDeviceManager.lastUsedDevice) { - items.unshift(itemText); - } else { - items.push(itemText); - } - } + //detect if the user types an IP address into the picker and presses enter. + quickPick.onDidAccept(() => { + deferred.resolve({ + ip: quickPick.value + }); + }); + + let activeChangesSinceRefresh = 0; + let activeItem: QuickPickItem; + + // remember the currently active item so we can maintain active selection when refreshing the list + quickPick.onDidChangeActive((items) => { + // reset our activeChanges tracker since users cannot cause items.length to be 0 (meaning a refresh has just happened) + if (items.length === 0) { + activeChangesSinceRefresh = 0; + return; + } + if (activeChangesSinceRefresh > 0) { + activeItem = items[0]; + } + activeChangesSinceRefresh++; + }); + + const itemCache = new Map(); + quickPick.show(); + const refreshList = () => { + const items = this.createHostQuickPickList( + this.activeDeviceManager.getActiveDevices(), + this.activeDeviceManager.lastUsedDevice, + itemCache + ); + quickPick.items = items; + + // update the busy spinner based on how long it's been since the last discovered device + quickPick.busy = this.activeDeviceManager.timeSinceLastDiscoveredDevice < discoveryTime; + setTimeout(() => { + quickPick.busy = this.activeDeviceManager.timeSinceLastDiscoveredDevice < discoveryTime; + }, discoveryTime - this.activeDeviceManager.timeSinceLastDiscoveredDevice + 20); + + // clear the activeItem if we can't find it in the list + if (!quickPick.items.includes(activeItem)) { + activeItem = undefined; + } - // Give the user the option to type their own IP incase the device they want has not yet been detected on the network - let manualIpOption = 'Other'; - items.push(manualIpOption); - - let host = await vscode.window.showQuickPick(items, { placeHolder: `Please Select a Roku or use the "${manualIpOption}" option to enter a IP` }); - - if (host === manualIpOption) { - showInputBox = true; - } else if (host) { - let defaultDeviceName = host.substring(host.toLowerCase().indexOf(' | ') + 3, host.toLowerCase().lastIndexOf(' - ')); - let deviceIP = host.substring(0, host.toLowerCase().indexOf(' | ')); - if (defaultDeviceName) { - this.activeDeviceManager.lastUsedDevice = defaultDeviceName; - } - config.host = deviceIP; + // if the user manually selected an item, re-focus that item now that we refreshed the list + if (activeItem) { + quickPick.activeItems = [activeItem]; + } + // quickPick.show(); + }; + + //anytime the device picker adds/removes a device, update the list + this.activeDeviceManager.on('device-found', refreshList, disposables); + this.activeDeviceManager.on('device-expired', refreshList, disposables); + + quickPick.onDidHide(() => { + dispose(); + deferred.reject(new Error('No host was selected')); + }); + + quickPick.onDidChangeSelection(selection => { + const selectedItem = selection[0]; + if (selectedItem) { + if (selectedItem.kind === vscode.QuickPickItemKind.Separator) { + // Handle separator selection + } else { + if (selectedItem.label === manualLabel) { + deferred.resolve({ manual: true }); } else { - // User canceled. Give them one more change to enter an ip - showInputBox = true; + const device = (selectedItem as any).device as RokuDeviceDetails; + this.activeDeviceManager.lastUsedDevice = device; + deferred.resolve(device); } - } else { - showInputBox = true; + quickPick.dispose(); } - } else { - showInputBox = true; + } + }); + //run the list refresh once to show the popup + refreshList(); + const result = await deferred.promise; + dispose(); + if (result?.manual === true) { + return this.promptForHostManual(); + } else { + return result?.ip; + } + } + + /** + * Generate the label used when showing "host" entries in a quick picker + * @param device the device containing all the info + * @returns a properly formatted host string + */ + private createHostLabel(device: RokuDeviceDetails) { + return `${device.ip} | ${device.deviceInfo['user-device-name']} - ${device.deviceInfo['serial-number']} - ${device.deviceInfo['model-number']}`; + } + + /** + * Generate the item list for the `this.promptForHost()` call + */ + private createHostQuickPickList(devices: RokuDeviceDetails[], lastUsedDevice: RokuDeviceDetails, cache = new Map()) { + //the collection of items we will eventually return + let items: QuickPickHostItem[] = []; + + //find the lastUsedDevice from the devices list if possible, or use the data from the lastUsedDevice if not + lastUsedDevice = devices.find(x => x.id === lastUsedDevice?.id) ?? lastUsedDevice; + //remove the lastUsedDevice from the devices list so we can more easily reason with the rest of the list + devices = devices.filter(x => x.id !== lastUsedDevice?.id); + + // Ensure the most recently used device is at the top of the list + if (lastUsedDevice) { + //add a separator for "last used" + items.push({ + label: 'last used', + kind: vscode.QuickPickItemKind.Separator + }); + + //add the device + items.push({ + label: this.createHostLabel(lastUsedDevice), + device: lastUsedDevice + }); + } + + //add all other devices + if (devices.length > 0) { + items.push({ + label: lastUsedDevice ? 'other devices' : 'devices', + kind: vscode.QuickPickItemKind.Separator + }); + + //add each device + for (const device of devices) { + //add the device + items.push({ + label: this.createHostLabel(device), + device: device + }); } } - if (showInputBox) { - config.host = await this.openInputBox('The IP address of your Roku device'); + //include a divider between devices and "manual" option (only if we have devices) + if (lastUsedDevice || devices.length) { + items.push({ label: ' ', kind: vscode.QuickPickItemKind.Separator }); } - // #endregion - //check the host and throw error if not provided or update the workspace to set last host - if (!config.host) { - throw new Error('Debug session terminated: host is required.'); - } else { - await this.context.workspaceState.update('remoteHost', config.host); + // allow user to manually type an IP address + items.push( + { label: 'Enter manually', device: { id: manualHostItemId } } as any + ); + + // replace items with their cached versions if found (to maintain references) + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (cache.has(item.label)) { + items[i] = cache.get(item.label); + items[i].device = item.device; + } else { + cache.set(item.label, item); + } } - return config; + return items; } /** @@ -585,3 +731,5 @@ export interface BrightScriptLaunchConfiguration extends LaunchConfiguration { */ remoteControlMode?: { activateOnSessionStart?: boolean; deactivateOnSessionEnd?: boolean }; } + +type QuickPickHostItem = QuickPickItem & { device?: RokuDeviceDetails }; diff --git a/src/mockVscode.spec.ts b/src/mockVscode.spec.ts index 17ba818f..d9c45773 100644 --- a/src/mockVscode.spec.ts +++ b/src/mockVscode.spec.ts @@ -1,4 +1,11 @@ -import type { Command, Range, TreeDataProvider, TreeItemCollapsibleState, Uri, WorkspaceFolder, ConfigurationScope, ExtensionContext, WorkspaceConfiguration, OutputChannel } from 'vscode'; +import { EventEmitter } from 'eventemitter3'; +import type { Command, Range, TreeDataProvider, TreeItemCollapsibleState, Uri, WorkspaceFolder, ConfigurationScope, ExtensionContext, WorkspaceConfiguration, OutputChannel, QuickPickItem } from 'vscode'; + +//copied from vscode to help with unit tests +enum QuickPickItemKind { + Separator = -1, + Default = 0 +} afterEach(() => { delete vscode.workspace.workspaceFile; @@ -20,6 +27,7 @@ export let vscode = { CodeAction: class { }, Diagnostic: class { }, CallHierarchyItem: class { }, + QuickPickItemKind: QuickPickItemKind, StatusBarAlignment: { Left: 1, Right: 2 @@ -144,6 +152,7 @@ export let vscode = { onDidCloseTextDocument: () => { } }, window: { + showInputBox: () => { }, createStatusBarItem: () => { return { clear: () => { }, @@ -151,6 +160,39 @@ export let vscode = { show: () => { } }; }, + createQuickPick: () => { + class QuickPick { + private emitter = new EventEmitter(); + + public placeholder = ''; + + public items: QuickPickItem[]; + public keepScrollPosition = false; + + public show() { } + + public onDidAccept(cb) { + this.emitter.on('didAccept', cb); + } + + public onDidHide(cb) { + this.emitter.on('didHide', cb); + } + + public hide() { + this.emitter.emit('didHide'); + } + + public onDidChangeSelection(cb) { + this.emitter.on('didChangeSelection', cb); + } + + public dispose() { + this.emitter.removeAllListeners(); + } + } + return new QuickPick(); + }, createOutputChannel: function(name?: string) { return { name: name, diff --git a/src/viewProviders/OnlineDevicesViewProvider.ts b/src/viewProviders/OnlineDevicesViewProvider.ts index 4ee6f5a1..91b1e105 100644 --- a/src/viewProviders/OnlineDevicesViewProvider.ts +++ b/src/viewProviders/OnlineDevicesViewProvider.ts @@ -20,21 +20,21 @@ export class OnlineDevicesViewProvider implements vscode.TreeDataProvider { - if (!this.findDeviceById(newDeviceId)) { + this.activeDeviceManager.on('device-found', newDevice => { + if (!this.findDeviceById(newDevice.id)) { // Add the device to the list this.devices.push(newDevice); this._onDidChangeTreeData.fire(null); } else { // Update the device - const foundIndex = this.devices.findIndex(device => device.id === newDeviceId); + const foundIndex = this.devices.findIndex(device => device.id === newDevice.id); this.devices[foundIndex] = newDevice; } }); - this.activeDeviceManager.on('expiredDevice', (deviceId: string, device: RokuDeviceDetails) => { + this.activeDeviceManager.on('device-expired', device => { // Remove the device from the list - const foundIndex = this.devices.findIndex(x => x.id === deviceId); + const foundIndex = this.devices.findIndex(x => x.id === device.id); this.devices.splice(foundIndex, 1); this._onDidChangeTreeData.fire(null); }); @@ -49,38 +49,11 @@ export class OnlineDevicesViewProvider implements vscode.TreeDataProvider; - private getPriorityForDeviceFormFactor(device: RokuDeviceDetails): number { - if (device.deviceInfo['is-stick']) { - return 0; - } - if (device.deviceInfo['is-tv']) { - return 2; - } - return 1; - } - getChildren(element?: DeviceTreeItem | DeviceInfoTreeItem): vscode.ProviderResult { if (!element) { if (this.devices) { - - // Process the root level devices in order by id - - let devices = this.devices.sort( - firstBy((a: RokuDeviceDetails, b: RokuDeviceDetails) => { - return this.getPriorityForDeviceFormFactor(a) - this.getPriorityForDeviceFormFactor(b); - }).thenBy((a: RokuDeviceDetails, b: RokuDeviceDetails) => { - if (a.id < b.id) { - return -1; - } - if (a.id > b.id) { - return 1; - } - // ids must be equal - return 0; - })); - let items: DeviceTreeItem[] = []; - for (const device of devices) { + for (const device of this.devices) { // Make a rook item for each device let treeItem = new DeviceTreeItem( device.deviceInfo['user-device-name'] + ' - ' + this.concealString(device.deviceInfo['serial-number']),