diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index 569014a9..8b16f1d9 100644 --- a/src/DebugConfigurationProvider.spec.ts +++ b/src/DebugConfigurationProvider.spec.ts @@ -4,13 +4,15 @@ 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 { BrightScriptDebugConfigurationProvider } from './DebugConfigurationProvider'; import { vscode } from './mockVscode.spec'; import { standardizePath as s } from 'brighterscript'; import * as fsExtra from 'fs-extra'; -import type { ActiveDeviceManager } from './ActiveDeviceManager'; +import type { RokuDeviceDetails } from './ActiveDeviceManager'; +import { ActiveDeviceManager } from './ActiveDeviceManager'; const sinon = createSandbox(); const Module = require('module'); @@ -49,10 +51,16 @@ describe('BrightScriptConfigurationProvider', () => { index: 0 }; - let activeDeviceManager = { - getActiveDevices: () => [] - } as any as ActiveDeviceManager; - 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(() => { @@ -322,4 +330,135 @@ 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: Number.MAX_SAFE_INTEGER + } + }]); + }); + + 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: Number.MAX_SAFE_INTEGER + } + }] + ); + }); + + 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], ' (searching ...)').map(x => x.label) + ).to.eql([ + 'last used', + label(devices[1]), + 'other devices', + label(devices[0]), + label(devices[2]), + '(searching ...)', + 'Enter manually' + ]); + }); + + it('includes the spinner text if "devices" separator is present', () => { + expect( + configProvider['createHostQuickPickList'](devices, null, ' (searching ...)').map(x => x.label) + ).to.eql([ + 'devices', + label(devices[0]), + label(devices[1]), + label(devices[2]), + '(searching ...)', + 'Enter manually' + ]); + }); + + it('includes the spinner text if only "last used" separator is present', () => { + expect( + configProvider['createHostQuickPickList']([devices[0]], devices[0], ' (searching ...)').map(x => x.label) + ).to.eql([ + 'last used', + label(devices[0]), + '(searching ...)', + 'Enter manually' + ]); + }); + + it('includes the spinner text when no other device entries are present', () => { + expect( + configProvider['createHostQuickPickList']([], null, ' (searching ...)').map(x => x.label) + ).to.eql([ + '(searching ...)', + 'Enter manually' + ]); + }); + + }); }); diff --git a/src/DebugConfigurationProvider.ts b/src/DebugConfigurationProvider.ts index 3d32a109..14ef66b5 100644 --- a/src/DebugConfigurationProvider.ts +++ b/src/DebugConfigurationProvider.ts @@ -459,49 +459,18 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio const refreshListDebounced = debounce(() => refreshList(true), 400); const refreshList = (updateSpinnerText = false) => { - const devices = this.activeDeviceManager.getActiveDevices(); - let itemsRefreshed: Array = devices.map(device => ({ - label: `${device.ip} | ${device.deviceInfo['user-device-name']} - ${device.deviceInfo['serial-number']} - ${device.deviceInfo['model-number']}`, - device: device - })); - - const devicesLabel: QuickPickItem = { - label: this.activeDeviceManager.lastUsedDevice ? 'other devices' : 'devices', - kind: vscode.QuickPickItemKind.Separator - }; - itemsRefreshed.unshift(devicesLabel); - - //move the the most recently used device to the top - if (this.activeDeviceManager.lastUsedDevice) { - const idx = itemsRefreshed.findIndex(x => x.device?.id === this.activeDeviceManager.lastUsedDevice?.id); - const [item] = itemsRefreshed.splice(idx, 1); - itemsRefreshed.unshift(item); - - itemsRefreshed.unshift({ - label: 'last used', - kind: vscode.QuickPickItemKind.Separator - }); - } - + const { activeItems } = quickPick; + let spinnerText = ''; if (this.activeDeviceManager.timeSinceLastDiscoveredDevice < discoveryTime) { - devicesLabel.label += ` (searching ${generateSpinnerText(updateSpinnerText)})`; + spinnerText = ` (searching ${generateSpinnerText(updateSpinnerText)})`; refreshListDebounced(); } - - // allow user to manually type an IP address - itemsRefreshed.push( - { label: ' ', kind: vscode.QuickPickItemKind.Separator }, - { label: manualLabel, device: { id: Number.MAX_SAFE_INTEGER } } as any + quickPick.items = this.createHostQuickPickList( + this.activeDeviceManager.getActiveDevices(), + this.activeDeviceManager.lastUsedDevice, + spinnerText ); - - //find the active item from our list (if there is one) - const activeItem = itemsRefreshed.find(x => { - return x.device?.id === ((quickPick.activeItems?.[0] as any)?.device as RokuDeviceDetails)?.id; - }); - quickPick.items = itemsRefreshed; - if (activeItem) { - quickPick.activeItems = [activeItem]; - } + quickPick.activeItems = activeItems; quickPick.show(); }; @@ -543,6 +512,66 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio return result; } + 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, spinnerText: string) { + //the collection of items we will eventually return + let items: Array = []; + + //yank the last used device out of the list so we can think about the remaining list more easily + lastUsedDevice = devices.find(x => x.id === lastUsedDevice?.id); + //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 + }); + } + } + + //include a divider between devices and "manual" option (only if we have devices) + if (spinnerText || lastUsedDevice || devices.length) { + items.push({ label: spinnerText.trim() || ' ', kind: vscode.QuickPickItemKind.Separator }); + } + + // allow user to manually type an IP address + items.push( + { label: 'Enter manually', device: { id: Number.MAX_SAFE_INTEGER } } as any + ); + return items; + } + /** * Validates the password parameter in the config and opens an input ui if set to ${promptForPassword} * @param config current config object 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,