import * as React from '@theia/core/shared/react'; import { Event } from '@theia/core/lib/common/event'; import { notEmpty } from '@theia/core/lib/common/objects'; import { MaybePromise } from '@theia/core/lib/common/types'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { Board, Port, AttachedBoardsChangeEvent, BoardWithPackage, } from '../../common/protocol/boards-service'; import { NotificationCenter } from '../notification-center'; import { AvailableBoard, BoardsServiceProvider, } from './boards-service-provider'; import { naturalCompare } from '../../common/utils'; import { nls } from '@theia/core/lib/common'; import { FrontendApplicationState } from '@theia/core/lib/common/frontend-application-state'; export namespace BoardsConfig { export interface Config { selectedBoard?: Board; selectedPort?: Port; } export interface Props { readonly boardsServiceProvider: BoardsServiceProvider; readonly notificationCenter: NotificationCenter; readonly onConfigChange: (config: Config) => void; readonly onFocusNodeSet: (element: HTMLElement | undefined) => void; readonly onFilteredTextDidChangeEvent: Event; readonly onAppStateDidChange: Event; } export interface State extends Config { searchResults: Array; knownPorts: Port[]; showAllPorts: boolean; query: string; } } export abstract class Item extends React.Component<{ item: T; label: string; selected: boolean; onClick: (item: T) => void; missing?: boolean; details?: string; }> { override render(): React.ReactNode { const { selected, label, missing, details } = this.props; const classNames = ['item']; if (selected) { classNames.push('selected'); } if (missing === true) { classNames.push('missing'); } return (
{label}
{!details ? '' :
{details}
} {!selected ? ( '' ) : (
)}
); } protected onClick = () => { this.props.onClick(this.props.item); }; } export class BoardsConfig extends React.Component< BoardsConfig.Props, BoardsConfig.State > { protected toDispose = new DisposableCollection(); constructor(props: BoardsConfig.Props) { super(props); const { boardsConfig } = props.boardsServiceProvider; this.state = { searchResults: [], knownPorts: [], showAllPorts: false, query: '', ...boardsConfig, }; } override componentDidMount(): void { this.toDispose.pushAll([ this.props.onAppStateDidChange((state) => { if (state === 'ready') { this.updateBoards(); this.updatePorts( this.props.boardsServiceProvider.availableBoards .map(({ port }) => port) .filter(notEmpty) ); } }), this.props.notificationCenter.onAttachedBoardsDidChange((event) => this.updatePorts( event.newState.ports, AttachedBoardsChangeEvent.diff(event).detached.ports ) ), this.props.boardsServiceProvider.onBoardsConfigChanged( ({ selectedBoard, selectedPort }) => { this.setState({ selectedBoard, selectedPort }, () => this.fireConfigChanged() ); } ), this.props.notificationCenter.onPlatformDidInstall(() => this.updateBoards(this.state.query) ), this.props.notificationCenter.onPlatformDidUninstall(() => this.updateBoards(this.state.query) ), this.props.notificationCenter.onIndexDidUpdate(() => this.updateBoards(this.state.query) ), this.props.notificationCenter.onDaemonDidStart(() => this.updateBoards(this.state.query) ), this.props.notificationCenter.onDaemonDidStop(() => this.setState({ searchResults: [] }) ), this.props.onFilteredTextDidChangeEvent((query) => this.setState({ query }, () => this.updateBoards(this.state.query)) ), ]); } override componentWillUnmount(): void { this.toDispose.dispose(); } protected fireConfigChanged(): void { const { selectedBoard, selectedPort } = this.state; this.props.onConfigChange({ selectedBoard, selectedPort }); } protected updateBoards = ( eventOrQuery: React.ChangeEvent | string = '' ) => { const query = typeof eventOrQuery === 'string' ? eventOrQuery : eventOrQuery.target.value.toLowerCase(); this.setState({ query }); this.queryBoards({ query }).then((searchResults) => this.setState({ searchResults }) ); }; protected updatePorts = (ports: Port[] = [], removedPorts: Port[] = []) => { this.queryPorts(Promise.resolve(ports)).then(({ knownPorts }) => { let { selectedPort } = this.state; // If the currently selected port is not available anymore, unset the selected port. if (removedPorts.some((port) => Port.sameAs(port, selectedPort))) { selectedPort = undefined; } this.setState({ knownPorts, selectedPort }, () => this.fireConfigChanged() ); }); }; protected queryBoards = ( options: { query?: string } = {} ): Promise> => { return this.props.boardsServiceProvider.searchBoards(options); }; protected get availablePorts(): MaybePromise { return this.props.boardsServiceProvider.availableBoards .map(({ port }) => port) .filter(notEmpty); } protected get availableBoards(): AvailableBoard[] { return this.props.boardsServiceProvider.availableBoards; } protected queryPorts = async ( availablePorts: MaybePromise = this.availablePorts ) => { // Available ports must be sorted in this order: // 1. Serial with recognized boards // 2. Serial with guessed boards // 3. Serial with incomplete boards // 4. Network with recognized boards // 5. Other protocols with recognized boards const ports = (await availablePorts).sort((left: Port, right: Port) => { if (left.protocol === 'serial' && right.protocol !== 'serial') { return -1; } else if (left.protocol !== 'serial' && right.protocol === 'serial') { return 1; } else if (left.protocol === 'network' && right.protocol !== 'network') { return -1; } else if (left.protocol !== 'network' && right.protocol === 'network') { return 1; } else if (left.protocol === right.protocol) { // We show ports, including those that have guessed // or unrecognized boards, so we must sort those too. const leftBoard = this.availableBoards.find( (board) => board.port === left ); const rightBoard = this.availableBoards.find( (board) => board.port === right ); if (leftBoard && !rightBoard) { return -1; } else if (!leftBoard && rightBoard) { return 1; } else if (leftBoard?.state! < rightBoard?.state!) { return -1; } else if (leftBoard?.state! > rightBoard?.state!) { return 1; } } return naturalCompare(left.address, right.address); }); return { knownPorts: ports }; }; protected toggleFilterPorts = () => { this.setState({ showAllPorts: !this.state.showAllPorts }); }; protected selectPort = (selectedPort: Port | undefined) => { this.setState({ selectedPort }, () => this.fireConfigChanged()); }; protected selectBoard = (selectedBoard: BoardWithPackage | undefined) => { this.setState({ selectedBoard }, () => this.fireConfigChanged()); }; protected focusNodeSet = (element: HTMLElement | null) => { this.props.onFocusNodeSet(element || undefined); }; override render(): React.ReactNode { return (
{this.renderContainer('boards', this.renderBoards.bind(this))} {this.renderContainer( 'ports', this.renderPorts.bind(this), this.renderPortsFooter.bind(this) )}
); } protected renderContainer( title: string, contentRenderer: () => React.ReactNode, footerRenderer?: () => React.ReactNode ): React.ReactNode { return (
{title}
{contentRenderer()}
{footerRenderer ? footerRenderer() : ''}
); } protected renderBoards(): React.ReactNode { const { selectedBoard, searchResults, query } = this.state; // Board names are not unique per core https://github.com/arduino/arduino-pro-ide/issues/262#issuecomment-661019560 // It is tricky when the core is not yet installed, no FQBNs are available. const distinctBoards = new Map(); const toKey = ({ name, packageName, fqbn }: Board.Detailed) => !!fqbn ? `${name}-${packageName}-${fqbn}` : `${name}-${packageName}`; for (const board of Board.decorateBoards(selectedBoard, searchResults)) { const key = toKey(board); if (!distinctBoards.has(key)) { distinctBoards.set(key, board); } } return (
{Array.from(distinctBoards.values()).map((board) => ( key={toKey(board)} item={board} label={board.name} details={board.details} selected={board.selected} onClick={this.selectBoard} missing={board.missing} /> ))}
); } protected renderPorts(): React.ReactNode { let ports = [] as Port[]; if (this.state.showAllPorts) { ports = this.state.knownPorts; } else { ports = this.state.knownPorts.filter((port) => { if (port.protocol === 'serial') { return true; } // All other ports with different protocol are // only shown if there is a recognized board // connected for (const board of this.availableBoards) { if (board.port?.address === port.address) { return true; } } }); } return !ports.length ? (
No ports discovered
) : (
{ports.map((port) => ( key={`${port.id}`} item={port} label={Port.toString(port)} selected={Port.sameAs(this.state.selectedPort, port)} onClick={this.selectPort} /> ))}
); } protected renderPortsFooter(): React.ReactNode { return (
); } } export namespace BoardsConfig { export namespace Config { export function sameAs(config: Config, other: Config | Board): boolean { const { selectedBoard, selectedPort } = config; if (Board.is(other)) { return ( !!selectedBoard && Board.equals(other, selectedBoard) && Port.sameAs(selectedPort, other.port) ); } return sameAs(config, other); } export function equals(left: Config, right: Config): boolean { return ( left.selectedBoard === right.selectedBoard && left.selectedPort === right.selectedPort ); } export function toString( config: Config, options: { default: string } = { default: '' } ): string { const { selectedBoard, selectedPort: port } = config; if (!selectedBoard) { return options.default; } const { name } = selectedBoard; return `${name}${port ? ` at ${port.address}` : ''}`; } export function setConfig( config: Config | undefined, urlToAttachTo: URL ): URL { const copy = new URL(urlToAttachTo.toString()); if (!config) { copy.searchParams.delete('boards-config'); return copy; } const selectedBoard = config.selectedBoard ? { name: config.selectedBoard.name, fqbn: config.selectedBoard.fqbn, } : undefined; const selectedPort = config.selectedPort ? { protocol: config.selectedPort.protocol, address: config.selectedPort.address, } : undefined; const jsonConfig = JSON.stringify({ selectedBoard, selectedPort }); copy.searchParams.set('boards-config', encodeURIComponent(jsonConfig)); return copy; } export function getConfig(url: URL): Config | undefined { const encoded = url.searchParams.get('boards-config'); if (!encoded) { return undefined; } try { const raw = decodeURIComponent(encoded); const candidate = JSON.parse(raw); if (typeof candidate === 'object') { return candidate; } console.warn( `Expected candidate to be an object. It was ${typeof candidate}. URL was: ${url}` ); return undefined; } catch (e) { console.log(`Could not get board config from URL: ${url}.`, e); return undefined; } } } }