mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-06-06 20:26:34 +00:00
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
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<string>;
|
|
readonly onAppStateDidChange: Event<FrontendApplicationState>;
|
|
}
|
|
|
|
export interface State extends Config {
|
|
searchResults: Array<BoardWithPackage>;
|
|
knownPorts: Port[];
|
|
showAllPorts: boolean;
|
|
query: string;
|
|
}
|
|
}
|
|
|
|
export abstract class Item<T> 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 (
|
|
<div
|
|
onClick={this.onClick}
|
|
className={classNames.join(' ')}
|
|
title={`${label}${!details ? '' : details}`}
|
|
>
|
|
<div className="label">{label}</div>
|
|
{!details ? '' : <div className="details">{details}</div>}
|
|
{!selected ? (
|
|
''
|
|
) : (
|
|
<div className="selected-icon">
|
|
<i className="fa fa-check" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLInputElement> | 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<Array<BoardWithPackage>> => {
|
|
return this.props.boardsServiceProvider.searchBoards(options);
|
|
};
|
|
|
|
protected get availablePorts(): MaybePromise<Port[]> {
|
|
return this.props.boardsServiceProvider.availableBoards
|
|
.map(({ port }) => port)
|
|
.filter(notEmpty);
|
|
}
|
|
|
|
protected get availableBoards(): AvailableBoard[] {
|
|
return this.props.boardsServiceProvider.availableBoards;
|
|
}
|
|
|
|
protected queryPorts = async (
|
|
availablePorts: MaybePromise<Port[]> = 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 (
|
|
<div className="body">
|
|
{this.renderContainer('boards', this.renderBoards.bind(this))}
|
|
{this.renderContainer(
|
|
'ports',
|
|
this.renderPorts.bind(this),
|
|
this.renderPortsFooter.bind(this)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
protected renderContainer(
|
|
title: string,
|
|
contentRenderer: () => React.ReactNode,
|
|
footerRenderer?: () => React.ReactNode
|
|
): React.ReactNode {
|
|
return (
|
|
<div className="container">
|
|
<div className="content">
|
|
<div className="title">{title}</div>
|
|
{contentRenderer()}
|
|
<div className="footer">{footerRenderer ? footerRenderer() : ''}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string, Board.Detailed>();
|
|
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 (
|
|
<React.Fragment>
|
|
<div className="search">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
className="theia-input"
|
|
placeholder="SEARCH BOARD"
|
|
onChange={this.updateBoards}
|
|
ref={this.focusNodeSet}
|
|
/>
|
|
<i className="fa fa-search"></i>
|
|
</div>
|
|
<div className="boards list">
|
|
{Array.from(distinctBoards.values()).map((board) => (
|
|
<Item<BoardWithPackage>
|
|
key={toKey(board)}
|
|
item={board}
|
|
label={board.name}
|
|
details={board.details}
|
|
selected={board.selected}
|
|
onClick={this.selectBoard}
|
|
missing={board.missing}
|
|
/>
|
|
))}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
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 ? (
|
|
<div className="loading noselect">No ports discovered</div>
|
|
) : (
|
|
<div className="ports list">
|
|
{ports.map((port) => (
|
|
<Item<Port>
|
|
key={`${port.id}`}
|
|
item={port}
|
|
label={Port.toString(port)}
|
|
selected={Port.sameAs(this.state.selectedPort, port)}
|
|
onClick={this.selectPort}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
protected renderPortsFooter(): React.ReactNode {
|
|
return (
|
|
<div className="noselect">
|
|
<label
|
|
title={nls.localize(
|
|
'arduino/board/showAllAvailablePorts',
|
|
'Shows all available ports when enabled'
|
|
)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
defaultChecked={this.state.showAllPorts}
|
|
onChange={this.toggleFilterPorts}
|
|
/>
|
|
<span>Show all ports</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|