Merge pull request #49 from bcmi-labs/boards-manager

generalized the boards and the libraries views.
This commit is contained in:
Luca Cipriani 2019-08-14 17:34:55 +02:00 committed by GitHub
commit a936e4c505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 839 additions and 486 deletions

View File

@ -43,4 +43,14 @@ export namespace ArduinoCommands {
id: "arduino-toggle-pro-mode" id: "arduino-toggle-pro-mode"
} }
export const CONNECT_TODO: Command = {
id: 'connect-to-attached-board',
label: 'Connect to Attached Board'
}
export const SEND: Command = {
id: 'send',
label: 'Send a Message to the Connected Board'
}
} }

View File

@ -5,7 +5,7 @@ import { EditorWidget } from '@theia/editor/lib/browser/editor-widget';
import { MessageService } from '@theia/core/lib/common/message-service'; import { MessageService } from '@theia/core/lib/common/message-service';
import { CommandContribution, CommandRegistry, Command } from '@theia/core/lib/common/command'; import { CommandContribution, CommandRegistry, Command } from '@theia/core/lib/common/command';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { BoardsService } from '../common/protocol/boards-service'; import { BoardsService, AttachedSerialBoard } from '../common/protocol/boards-service';
import { ArduinoCommands } from './arduino-commands'; import { ArduinoCommands } from './arduino-commands';
import { CoreService } from '../common/protocol/core-service'; import { CoreService } from '../common/protocol/core-service';
import { WorkspaceServiceExt } from './workspace-service-ext'; import { WorkspaceServiceExt } from './workspace-service-ext';
@ -19,7 +19,18 @@ import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service
import { SketchFactory } from './sketch-factory'; import { SketchFactory } from './sketch-factory';
import { ArduinoToolbar } from './toolbar/arduino-toolbar'; import { ArduinoToolbar } from './toolbar/arduino-toolbar';
import { EditorManager, EditorMainMenu } from '@theia/editor/lib/browser'; import { EditorManager, EditorMainMenu } from '@theia/editor/lib/browser';
import { ContextMenuRenderer, OpenerService, Widget, StatusBar, ShellLayoutRestorer, StatusBarAlignment, LabelProvider } from '@theia/core/lib/browser'; import {
ContextMenuRenderer,
OpenerService,
Widget,
StatusBar,
ShellLayoutRestorer,
StatusBarAlignment,
QuickOpenItem,
QuickOpenMode,
QuickOpenService,
LabelProvider
} from '@theia/core/lib/browser';
import { OpenFileDialogProps, FileDialogService } from '@theia/filesystem/lib/browser/file-dialog'; import { OpenFileDialogProps, FileDialogService } from '@theia/filesystem/lib/browser/file-dialog';
import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { FileSystem, FileStat } from '@theia/filesystem/lib/common';
import { ArduinoToolbarContextMenu } from './arduino-file-menu'; import { ArduinoToolbarContextMenu } from './arduino-file-menu';
@ -34,6 +45,7 @@ import { MaybePromise } from '@theia/core/lib/common/types';
import { BoardsConfigDialog } from './boards/boards-config-dialog'; import { BoardsConfigDialog } from './boards/boards-config-dialog';
import { BoardsToolBarItem } from './boards/boards-toolbar-item'; import { BoardsToolBarItem } from './boards/boards-toolbar-item';
import { BoardsConfig } from './boards/boards-config'; import { BoardsConfig } from './boards/boards-config';
import { MonitorService } from '../common/protocol/monitor-service';
export namespace ArduinoMenus { export namespace ArduinoMenus {
export const SKETCH = [...MAIN_MENU_BAR, '3_sketch']; export const SKETCH = [...MAIN_MENU_BAR, '3_sketch'];
@ -56,6 +68,12 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C
@inject(CoreService) @inject(CoreService)
protected readonly coreService: CoreService; protected readonly coreService: CoreService;
@inject(MonitorService)
protected readonly monitorService: MonitorService;
// TODO: make this better!
protected connectionId: string | undefined;
@inject(WorkspaceServiceExt) @inject(WorkspaceServiceExt)
protected readonly workspaceServiceExt: WorkspaceServiceExt; protected readonly workspaceServiceExt: WorkspaceServiceExt;
@ -115,6 +133,9 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C
@inject(LabelProvider) @inject(LabelProvider)
protected readonly labelProvider: LabelProvider; protected readonly labelProvider: LabelProvider;
@inject(QuickOpenService)
protected readonly quickOpenService: QuickOpenService;
protected boardsToolbarItem: BoardsToolBarItem | null; protected boardsToolbarItem: BoardsToolBarItem | null;
protected wsSketchCount: number = 0; protected wsSketchCount: number = 0;
@ -293,7 +314,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C
this.boardsServiceClient.boardsConfig = boardsConfig; this.boardsServiceClient.boardsConfig = boardsConfig;
} }
} }
}) });
registry.registerCommand(ArduinoCommands.TOGGLE_PRO_MODE, { registry.registerCommand(ArduinoCommands.TOGGLE_PRO_MODE, {
execute: () => { execute: () => {
const oldModeState = ARDUINO_PRO_MODE; const oldModeState = ARDUINO_PRO_MODE;
@ -301,6 +322,65 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C
registry.executeCommand('reset.layout'); registry.executeCommand('reset.layout');
}, },
isToggled: () => ARDUINO_PRO_MODE isToggled: () => ARDUINO_PRO_MODE
});
registry.registerCommand(ArduinoCommands.CONNECT_TODO, {
execute: async () => {
const { boardsConfig } = this.boardsServiceClient;
const { selectedBoard, selectedPort } = boardsConfig;
if (!selectedBoard) {
this.messageService.warn('No boards selected.');
return;
}
const { name } = selectedBoard;
if (!selectedPort) {
this.messageService.warn(`No ports selected for board: '${name}'.`);
return;
}
const attachedBoards = await this.boardsService.getAttachedBoards();
const connectedBoard = attachedBoards.boards.filter(AttachedSerialBoard.is).find(board => BoardsConfig.Config.sameAs(boardsConfig, board));
if (!connectedBoard) {
this.messageService.warn(`The selected '${name}' board is not connected on ${selectedPort}.`);
return;
}
if (this.connectionId) {
console.log('>>> Disposing existing monitor connection before establishing a new one...');
const result = await this.monitorService.disconnect(this.connectionId);
if (!result) {
// TODO: better!!!
console.error(`Could not close connection: ${this.connectionId}. Check the backend logs.`);
} else {
console.log(`<<< Disposed ${this.connectionId} connection.`)
}
}
const { connectionId } = await this.monitorService.connect({ board: selectedBoard, port: selectedPort });
this.connectionId = connectionId;
}
});
registry.registerCommand(ArduinoCommands.SEND, {
isEnabled: () => !!this.connectionId,
execute: async () => {
const { monitorService, connectionId } = this;
const model = {
onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): void {
acceptor([
new QuickOpenItem({
label: "Type your message and press 'Enter' to send it to the board. Escape to cancel.",
run: (mode: QuickOpenMode): boolean => {
if (mode !== QuickOpenMode.OPEN) {
return false;
}
monitorService.send(connectionId!, lookFor + '\n');
return true;
}
})
]);
}
};
const options = {
placeholder: "Your message. The message will be suffixed with a LF ['\\n'].",
};
this.quickOpenService.open(model, options);
}
}) })
} }

View File

@ -13,7 +13,6 @@ import { ArduinoLanguageGrammarContribution } from './language/arduino-language-
import { LibraryService, LibraryServicePath } from '../common/protocol/library-service'; import { LibraryService, LibraryServicePath } from '../common/protocol/library-service';
import { BoardsService, BoardsServicePath, BoardsServiceClient } from '../common/protocol/boards-service'; import { BoardsService, BoardsServicePath, BoardsServiceClient } from '../common/protocol/boards-service';
import { SketchesService, SketchesServicePath } from '../common/protocol/sketches-service'; import { SketchesService, SketchesServicePath } from '../common/protocol/sketches-service';
import { LibraryListWidgetFrontendContribution } from './library/list-widget-frontend-contribution';
import { CoreService, CoreServicePath } from '../common/protocol/core-service'; import { CoreService, CoreServicePath } from '../common/protocol/core-service';
import { BoardsListWidget } from './boards/boards-list-widget'; import { BoardsListWidget } from './boards/boards-list-widget';
import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution'; import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution';
@ -52,6 +51,11 @@ import { ScmContribution } from '@theia/scm/lib/browser/scm-contribution';
import { SilentScmContribution } from './customization/silent-scm-contribution'; import { SilentScmContribution } from './customization/silent-scm-contribution';
import { SearchInWorkspaceFrontendContribution } from '@theia/search-in-workspace/lib/browser/search-in-workspace-frontend-contribution'; import { SearchInWorkspaceFrontendContribution } from '@theia/search-in-workspace/lib/browser/search-in-workspace-frontend-contribution';
import { SilentSearchInWorkspaceContribution } from './customization/silent-search-in-workspace-contribution'; import { SilentSearchInWorkspaceContribution } from './customization/silent-search-in-workspace-contribution';
import { LibraryListWidgetFrontendContribution } from './library/library-widget-frontend-contribution';
import { LibraryItemRenderer } from './library/library-item-renderer';
import { BoardItemRenderer } from './boards/boards-item-renderer';
import { MonitorServiceClientImpl } from './monitor/monitor-service-client-impl';
import { MonitorServicePath, MonitorService, MonitorServiceClient } from '../common/protocol/monitor-service';
const ElementQueries = require('css-element-queries/src/ElementQueries'); const ElementQueries = require('css-element-queries/src/ElementQueries');
if (!ARDUINO_PRO_MODE) { if (!ARDUINO_PRO_MODE) {
@ -87,6 +91,7 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un
createWidget: () => context.container.get(LibraryListWidget) createWidget: () => context.container.get(LibraryListWidget)
})); }));
bind(FrontendApplicationContribution).toService(LibraryListWidgetFrontendContribution); bind(FrontendApplicationContribution).toService(LibraryListWidgetFrontendContribution);
bind(LibraryItemRenderer).toSelf().inSingletonScope();
// Sketch list service // Sketch list service
bind(SketchesService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, SketchesServicePath)).inSingletonScope(); bind(SketchesService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, SketchesServicePath)).inSingletonScope();
@ -113,6 +118,7 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un
createWidget: () => context.container.get(BoardsListWidget) createWidget: () => context.container.get(BoardsListWidget)
})); }));
bind(FrontendApplicationContribution).toService(BoardsListWidgetFrontendContribution); bind(FrontendApplicationContribution).toService(BoardsListWidgetFrontendContribution);
bind(BoardItemRenderer).toSelf().inSingletonScope();
// Board select dialog // Board select dialog
bind(BoardsConfigDialogWidget).toSelf().inSingletonScope(); bind(BoardsConfigDialogWidget).toSelf().inSingletonScope();
@ -145,6 +151,20 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un
return workspaceServiceExt; return workspaceServiceExt;
}); });
// Frontend binding for the monitor service.
bind(MonitorService).toDynamicValue(context => {
const connection = context.container.get(WebSocketConnectionProvider);
const client = context.container.get(MonitorServiceClientImpl);
return connection.createProxy(MonitorServicePath, client);
}).inSingletonScope();
// Monitor service client to receive and delegate notifications from the backend.
bind(MonitorServiceClientImpl).toSelf().inSingletonScope();
bind(MonitorServiceClient).toDynamicValue(context => {
const client = context.container.get(MonitorServiceClientImpl);
WebSocketConnectionProvider.createProxy(context.container, MonitorServicePath, client);
return client;
}).inSingletonScope();
bind(AWorkspaceService).toSelf().inSingletonScope(); bind(AWorkspaceService).toSelf().inSingletonScope();
rebind(WorkspaceService).to(AWorkspaceService).inSingletonScope(); rebind(WorkspaceService).to(AWorkspaceService).inSingletonScope();
bind(SketchFactory).toSelf().inSingletonScope(); bind(SketchFactory).toSelf().inSingletonScope();

View File

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { DisposableCollection } from '@theia/core'; import { DisposableCollection } from '@theia/core';
import { BoardsService, Board, AttachedSerialBoard } from '../../common/protocol/boards-service'; import { BoardsService, Board, AttachedSerialBoard, AttachedBoardsChangeEvent } from '../../common/protocol/boards-service';
import { BoardsServiceClientImpl } from './boards-service-client-impl'; import { BoardsServiceClientImpl } from './boards-service-client-impl';
export namespace BoardsConfig { export namespace BoardsConfig {
@ -18,7 +18,7 @@ export namespace BoardsConfig {
} }
export interface State extends Config { export interface State extends Config {
searchResults: Board[]; searchResults: Array<Board & { packageName: string }>;
knownPorts: string[]; knownPorts: string[];
} }
@ -26,13 +26,15 @@ export namespace BoardsConfig {
export abstract class Item<T> extends React.Component<{ export abstract class Item<T> extends React.Component<{
item: T, item: T,
name: string, label: string,
selected: boolean, selected: boolean,
onClick: (item: T) => void, onClick: (item: T) => void,
missing?: boolean }> { missing?: boolean,
detail?: string
}> {
render(): React.ReactNode { render(): React.ReactNode {
const { selected, name, missing } = this.props; const { selected, label, missing, detail } = this.props;
const classNames = ['item']; const classNames = ['item'];
if (selected) { if (selected) {
classNames.push('selected'); classNames.push('selected');
@ -40,9 +42,12 @@ export abstract class Item<T> extends React.Component<{
if (missing === true) { if (missing === true) {
classNames.push('missing') classNames.push('missing')
} }
return <div onClick={this.onClick} className={classNames.join(' ')}> return <div onClick={this.onClick} className={classNames.join(' ')} title={`${label}${!detail ? '' : detail}`}>
{name} <div className='label'>
{selected ? <i className='fa fa-check'></i> : ''} {label}
</div>
{!detail ? '' : <div className='detail'>{detail}</div>}
{!selected ? '' : <div className='selected-icon'><i className='fa fa-check'/></div>}
</div>; </div>;
} }
@ -72,7 +77,7 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
this.props.boardsService.getAttachedBoards().then(({ boards }) => this.updatePorts(boards)); this.props.boardsService.getAttachedBoards().then(({ boards }) => this.updatePorts(boards));
const { boardsServiceClient: client } = this.props; const { boardsServiceClient: client } = this.props;
this.toDispose.pushAll([ this.toDispose.pushAll([
client.onBoardsChanged(event => this.updatePorts(event.newState.boards)), client.onBoardsChanged(event => this.updatePorts(event.newState.boards, AttachedBoardsChangeEvent.diff(event).detached)),
client.onBoardsConfigChanged(({ selectedBoard, selectedPort }) => { client.onBoardsConfigChanged(({ selectedBoard, selectedPort }) => {
this.setState({ selectedBoard, selectedPort }, () => this.fireConfigChanged()); this.setState({ selectedBoard, selectedPort }, () => this.fireConfigChanged());
}) })
@ -96,23 +101,24 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
this.queryBoards({ query }).then(({ searchResults }) => this.setState({ searchResults })); this.queryBoards({ query }).then(({ searchResults }) => this.setState({ searchResults }));
} }
protected updatePorts = (boards: Board[] = []) => { protected updatePorts = (boards: Board[] = [], detachedBoards: Board[] = []) => {
this.queryPorts(Promise.resolve({ boards })).then(({ knownPorts }) => { this.queryPorts(Promise.resolve({ boards })).then(({ knownPorts }) => {
let { selectedPort } = this.state; let { selectedPort } = this.state;
if (!!selectedPort && knownPorts.indexOf(selectedPort) === -1) { const removedPorts = detachedBoards.filter(AttachedSerialBoard.is).map(({ port }) => port);
if (!!selectedPort && removedPorts.indexOf(selectedPort) !== -1) {
selectedPort = undefined; selectedPort = undefined;
} }
this.setState({ knownPorts, selectedPort }, () => this.fireConfigChanged()); this.setState({ knownPorts, selectedPort }, () => this.fireConfigChanged());
}); });
} }
protected queryBoards = (options: { query?: string } = {}): Promise<{ searchResults: Board[] }> => { protected queryBoards = (options: { query?: string } = {}): Promise<{ searchResults: Array<Board & { packageName: string }> }> => {
const { boardsService } = this.props; const { boardsService } = this.props;
const query = (options.query || '').toLocaleLowerCase(); const query = (options.query || '').toLocaleLowerCase();
return new Promise<{ searchResults: Board[] }>(resolve => { return new Promise<{ searchResults: Array<Board & { packageName: string }> }>(resolve => {
boardsService.search(options) boardsService.search(options)
.then(({ items }) => items .then(({ items }) => items
.map(item => item.boards) .map(item => item.boards.map(board => ({ ...board, packageName: item.name })))
.reduce((acc, curr) => acc.concat(curr), []) .reduce((acc, curr) => acc.concat(curr), [])
.filter(board => board.name.toLocaleLowerCase().indexOf(query) !== -1) .filter(board => board.name.toLocaleLowerCase().indexOf(query) !== -1)
.sort(Board.compare)) .sort(Board.compare))
@ -139,7 +145,7 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
this.setState({ selectedPort }, () => this.fireConfigChanged()); this.setState({ selectedPort }, () => this.fireConfigChanged());
} }
protected selectBoard = (selectedBoard: Board | undefined) => { protected selectBoard = (selectedBoard: Board & { packageName: string } | undefined) => {
this.setState({ selectedBoard }, () => this.fireConfigChanged()); this.setState({ selectedBoard }, () => this.fireConfigChanged());
} }
@ -166,18 +172,40 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
} }
protected renderBoards(): React.ReactNode { protected renderBoards(): React.ReactNode {
const { selectedBoard } = this.state; const { selectedBoard, searchResults } = this.state;
// Board names are not unique. We show the corresponding core name as a detail.
// https://github.com/arduino/arduino-cli/pull/294#issuecomment-513764948
const distinctBoardNames = new Map<string, number>();
for (const { name } of searchResults) {
const counter = distinctBoardNames.get(name) || 0;
distinctBoardNames.set(name, counter + 1);
}
// Due to the non-unique board names, we have to check the package name as well.
const selected = (board: Board & { packageName: string }) => {
if (!!selectedBoard) {
if (Board.equals(board, selectedBoard)) {
if ('packageName' in selectedBoard) {
return board.packageName === (selectedBoard as any).packageName;
}
return true;
}
}
return false;
}
return <React.Fragment> return <React.Fragment>
<div className='search'> <div className='search'>
<input type='search' placeholder='SEARCH BOARD' onChange={this.updateBoards} ref={this.focusNodeSet} /> <input type='search' placeholder='SEARCH BOARD' onChange={this.updateBoards} ref={this.focusNodeSet} />
<i className='fa fa-search'></i> <i className='fa fa-search'></i>
</div> </div>
<div className='boards list'> <div className='boards list'>
{this.state.searchResults.map((board, index) => <Item<Board> {this.state.searchResults.map(board => <Item<Board & { packageName: string }>
key={`${board.name}-${index}`} key={`${board.name}-${board.packageName}`}
item={board} item={board}
name={board.name} label={board.name}
selected={!!selectedBoard && Board.equals(board, selectedBoard)} detail={(distinctBoardNames.get(board.name) || 0) > 1 ? ` - ${board.packageName}` : undefined}
selected={selected(board)}
onClick={this.selectBoard} onClick={this.selectBoard}
missing={!Board.installed(board)} missing={!Board.installed(board)}
/>)} />)}
@ -197,7 +225,7 @@ export class BoardsConfig extends React.Component<BoardsConfig.Props, BoardsConf
{this.state.knownPorts.map(port => <Item<string> {this.state.knownPorts.map(port => <Item<string>
key={port} key={port}
item={port} item={port}
name={port} label={port}
selected={this.state.selectedPort === port} selected={this.state.selectedPort === port}
onClick={this.selectPort} onClick={this.selectPort}
/>)} />)}

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import { injectable } from 'inversify';
import { ListItemRenderer } from '../components/component-list/list-item-renderer';
import { BoardPackage } from '../../common/protocol/boards-service';
@injectable()
export class BoardItemRenderer extends ListItemRenderer<BoardPackage> {
renderItem(item: BoardPackage, install: (item: BoardPackage) => Promise<void>): React.ReactNode {
const name = <span className='name'>{item.name}</span>;
const author = <span className='author'>{item.author}</span>;
const installedVersion = !!item.installedVersion && <div className='version-info'>
<span className='version'>Version {item.installedVersion}</span>
<span className='installed'>INSTALLED</span>
</div>;
const summary = <div className='summary'>{item.summary}</div>;
const description = <div className='summary'>{item.description}</div>;
const moreInfo = !!item.moreInfoLink && <a href={item.moreInfoLink} onClick={this.onClick}>More info</a>;
const installButton = item.installable && !item.installedVersion &&
<button className='install' onClick={install.bind(this, item)}>INSTALL</button>;
const versions = (() => {
const { availableVersions } = item;
if (!!item.installedVersion || availableVersions.length === 0) {
return undefined;
} else if (availableVersions.length === 1) {
return <label>{availableVersions[0]}</label>
} else {
return <select>{item.availableVersions.map(version => <option value={version} key={version}>{version}</option>)}</select>;
}
})();
return <div className='component-list-item noselect'>
<div className='header'>
<span>{name} by {author}</span>
{installedVersion}
</div>
<div className='content'>
{summary}
{description}
</div>
<div className='info'>
{moreInfo}
</div>
<div className='footer'>
{installButton}
{versions}
</div>
</div>;
}
}

View File

@ -0,0 +1,27 @@
import { inject, injectable } from 'inversify';
import { BoardPackage, BoardsService } from '../../common/protocol/boards-service';
import { ListWidget } from '../components/component-list/list-widget';
import { BoardItemRenderer } from './boards-item-renderer';
@injectable()
export class BoardsListWidget extends ListWidget<BoardPackage> {
static WIDGET_ID = 'boards-list-widget';
static WIDGET_LABEL = 'Boards Manager';
constructor(
@inject(BoardsService) protected service: BoardsService,
@inject(BoardItemRenderer) protected itemRenderer: BoardItemRenderer) {
super({
id: BoardsListWidget.WIDGET_ID,
label: BoardsListWidget.WIDGET_LABEL,
iconClass: 'fa fa-microchip',
searchable: service,
installable: service,
itemLabel: (item: BoardPackage) => item.name,
itemRenderer
});
}
}

View File

@ -1,16 +0,0 @@
import { ListWidget } from './list-widget';
export class BoardsListWidget extends ListWidget {
static WIDGET_ID = 'boards-list-widget';
static WIDGET_LABEL = 'Boards Manager';
protected widgetProps(): ListWidget.Props {
return {
id: BoardsListWidget.WIDGET_ID,
title: BoardsListWidget.WIDGET_LABEL,
iconClass: 'fa fa-microchip'
}
}
}

View File

@ -30,11 +30,11 @@ export class BoardsServiceClientImpl implements BoardsServiceClient {
notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void { notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void {
this.logger.info('Attached boards changed: ', JSON.stringify(event)); this.logger.info('Attached boards changed: ', JSON.stringify(event));
const { boards } = event.newState; const detachedBoards = AttachedBoardsChangeEvent.diff(event).detached.filter(AttachedSerialBoard.is).map(({ port }) => port);
const { selectedPort, selectedBoard } = this.boardsConfig; const { selectedPort, selectedBoard } = this.boardsConfig;
this.onAttachedBoardsChangedEmitter.fire(event); this.onAttachedBoardsChangedEmitter.fire(event);
// Dynamically unset the port if there is not corresponding attached boards for it. // Dynamically unset the port if the selected board was an attached one and we detached it.
if (!!selectedPort && boards.filter(AttachedSerialBoard.is).map(({ port }) => port).indexOf(selectedPort) === -1) { if (!!selectedPort && detachedBoards.indexOf(selectedPort) !== -1) {
this.boardsConfig = { this.boardsConfig = {
selectedBoard, selectedBoard,
selectedPort: undefined selectedPort: undefined

View File

@ -1,22 +1,12 @@
import { injectable } from 'inversify'; import { injectable } from 'inversify';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { ListWidget } from './list-widget';
import { BoardsListWidget } from './boards-list-widget';
import { MenuModelRegistry } from '@theia/core'; import { MenuModelRegistry } from '@theia/core';
import { BoardsListWidget } from './boards-list-widget';
import { ArduinoMenus } from '../arduino-frontend-contribution'; import { ArduinoMenus } from '../arduino-frontend-contribution';
import { BoardPackage } from '../../common/protocol/boards-service';
import { ListWidgetFrontendContribution } from '../components/component-list/list-widget-frontend-contribution';
@injectable() @injectable()
export abstract class ListWidgetFrontendContribution extends AbstractViewContribution<ListWidget> implements FrontendApplicationContribution { export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution<BoardPackage> {
async initializeLayout(): Promise<void> {
// await this.openView();
}
}
@injectable()
export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution {
static readonly OPEN_MANAGER = `${BoardsListWidget.WIDGET_ID}:toggle`; static readonly OPEN_MANAGER = `${BoardsListWidget.WIDGET_ID}:toggle`;

View File

@ -1,76 +0,0 @@
import * as React from 'react';
import { inject, injectable, postConstruct } from 'inversify';
import { Message } from '@phosphor/messaging';
import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { BoardsService } from '../../common/protocol/boards-service';
import { FilterableListContainer } from '../components/component-list/filterable-list-container';
import { BoardsServiceClientImpl } from './boards-service-client-impl';
@injectable()
export abstract class ListWidget extends ReactWidget {
@inject(BoardsService)
protected readonly boardsService: BoardsService;
@inject(WindowService)
protected readonly windowService: WindowService;
@inject(BoardsServiceClientImpl)
protected readonly boardsServiceClient: BoardsServiceClientImpl;
constructor() {
super();
const { id, title, iconClass } = this.widgetProps();
this.id = id;
this.title.label = title;
this.title.caption = title;
this.title.iconClass = iconClass;
this.title.closable = true;
this.addClass(ListWidget.Styles.LIST_WIDGET_CLASS);
this.node.tabIndex = 0; // To be able to set the focus on the widget.
}
protected abstract widgetProps(): ListWidget.Props;
@postConstruct()
protected init(): void {
this.update();
}
protected onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.node.focus();
this.render();
}
protected onUpdateRequest(msg: Message): void {
super.onUpdateRequest(msg);
this.render();
}
render(): React.ReactNode {
return <FilterableListContainer
service={this.boardsService}
windowService={this.windowService}
/>;
}
}
export namespace ListWidget {
/**
* Props for customizing the abstract list widget.
*/
export interface Props {
readonly id: string;
readonly title: string;
readonly iconClass: string;
}
export namespace Styles {
export const LIST_WIDGET_CLASS = 'arduino-list-widget'
}
}

View File

@ -1,78 +1,25 @@
import * as React from 'react'; import * as React from 'react';
import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { ListItemRenderer } from './list-item-renderer';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
export class ComponentListItem extends React.Component<ComponentListItem.Props> { export class ComponentListItem<T> extends React.Component<ComponentListItem.Props<T>> {
protected onClick = (event: React.SyntheticEvent<HTMLAnchorElement, Event>) => { protected async install(item: T): Promise<void> {
const { target } = event.nativeEvent;
if (target instanceof HTMLAnchorElement) {
this.props.windowService.openNewWindow(target.href);
event.nativeEvent.preventDefault();
}
}
protected async install(item: ArduinoComponent): Promise<void> {
await this.props.install(item); await this.props.install(item);
} }
render(): React.ReactNode { render(): React.ReactNode {
const { item } = this.props; const { item, itemRenderer, install } = this.props;
return itemRenderer.renderItem(item, install.bind(this));
const style = ComponentListItem.Styles;
const name = <span className={style.NAME_CLASS}>{item.name}</span>;
const author = <span className={style.AUTHOR_CLASS}>{item.author}</span>;
const installedVersion = !!item.installedVersion && <div className={style.VERSION_INFO_CLASS}>
<span className={style.VERSION_CLASS}>Version {item.installedVersion}</span>
<span className={style.INSTALLED_CLASS}>INSTALLED</span>
</div>;
const summary = <div className={style.SUMMARY_CLASS}>{item.summary}</div>;
const moreInfo = !!item.moreInfoLink && <a href={item.moreInfoLink} onClick={this.onClick}>More info</a>;
const install = this.props.install && item.installable && !item.installedVersion &&
<button className={style.INSTALL_BTN_CLASS} onClick={this.install.bind(this, item)}>INSTALL</button>;
return <div className={[style.LIST_ITEM_CLASS, style.NO_SELECT_CLASS].join(' ')}>
<div className={style.HEADER_CLASS}>
<span>{name} by {author}</span>
{installedVersion}
</div>
<div className={style.CONTENT_CLASS}>
{summary}
</div>
<div className={style.FOOTER_CLASS}>
{moreInfo}
{install}
</div>
</div>;
} }
} }
export namespace ComponentListItem { export namespace ComponentListItem {
export interface Props { export interface Props<T> {
readonly item: ArduinoComponent; readonly item: T;
readonly windowService: WindowService; readonly install: (item: T) => Promise<void>;
readonly install: (comp: ArduinoComponent) => Promise<void>; readonly itemRenderer: ListItemRenderer<T>;
}
export namespace Styles {
export const LIST_ITEM_CLASS = 'component-list-item';
export const HEADER_CLASS = 'header';
export const VERSION_INFO_CLASS = 'version-info';
export const CONTENT_CLASS = 'content';
export const FOOTER_CLASS = 'footer';
export const INSTALLED_CLASS = 'installed';
export const NO_SELECT_CLASS = 'noselect';
export const NAME_CLASS = 'name';
export const AUTHOR_CLASS = 'author';
export const VERSION_CLASS = 'version';
export const SUMMARY_CLASS = 'summary';
export const DESCRIPTION_CLASS = 'description';
export const INSTALL_BTN_CLASS = 'install';
} }
} }

View File

@ -1,16 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { ComponentListItem } from './component-list-item'; import { ComponentListItem } from './component-list-item';
import { ArduinoComponent } from '../../../common/protocol/arduino-component'; import { ListItemRenderer } from './list-item-renderer';
export class ComponentList extends React.Component<ComponentList.Props> { export class ComponentList<T> extends React.Component<ComponentList.Props<T>> {
protected container?: HTMLElement; protected container?: HTMLElement;
render(): React.ReactNode { render(): React.ReactNode {
return <div return <div
className={'items-container'} className={'items-container'}
ref={element => this.container = element || undefined}> ref={this.setRef}>
{this.props.items.map(item => this.createItem(item))} {this.props.items.map(item => this.createItem(item))}
</div>; </div>;
} }
@ -21,19 +20,28 @@ export class ComponentList extends React.Component<ComponentList.Props> {
} }
} }
protected createItem(item: ArduinoComponent): React.ReactNode { protected setRef = (element: HTMLElement | null) => {
return <ComponentListItem key={item.name} item={item} windowService={this.props.windowService} install={this.props.install} /> this.container = element || undefined;
}
protected createItem(item: T): React.ReactNode {
return <ComponentListItem<T>
key={this.props.itemLabel(item)}
item={item}
itemRenderer={this.props.itemRenderer}
install={this.props.install} />
} }
} }
export namespace ComponentList { export namespace ComponentList {
export interface Props { export interface Props<T> {
readonly items: ArduinoComponent[]; readonly items: T[];
readonly windowService: WindowService; readonly itemLabel: (item: T) => string;
readonly install: (comp: ArduinoComponent) => Promise<void>; readonly itemRenderer: ListItemRenderer<T>;
readonly resolveContainer?: (element: HTMLElement) => void; readonly install: (item: T) => Promise<void>;
readonly resolveContainer: (element: HTMLElement) => void;
} }
} }

View File

@ -1,23 +1,24 @@
import * as React from 'react'; import * as React from 'react';
import { WindowService } from '@theia/core/lib/browser/window/window-service'; import debounce = require('lodash.debounce');
import { Searchable } from '../../../common/protocol/searchable';
import { Installable } from '../../../common/protocol/installable';
import { InstallationProgressDialog } from '../installation-progress-dialog';
import { SearchBar } from './search-bar'; import { SearchBar } from './search-bar';
import { ComponentList } from './component-list'; import { ComponentList } from './component-list';
import { LibraryService } from '../../../common/protocol/library-service'; import { ListItemRenderer } from './list-item-renderer';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { InstallationProgressDialog } from '../installation-progress-dialog';
export class FilterableListContainer extends React.Component<FilterableListContainer.Props, FilterableListContainer.State> { export class FilterableListContainer<T> extends React.Component<FilterableListContainer.Props<T>, FilterableListContainer.State<T>> {
constructor(props: Readonly<FilterableListContainer.Props>) { constructor(props: Readonly<FilterableListContainer.Props<T>>) {
super(props); super(props);
this.state = { this.state = {
filterText: '', filterText: '',
items: [] items: []
}; };
this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
} }
componentWillMount(): void { componentWillMount(): void {
this.search = debounce(this.search, 500);
this.handleFilterTextChange(''); this.handleFilterTextChange('');
} }
@ -42,36 +43,43 @@ export class FilterableListContainer extends React.Component<FilterableListConta
} }
protected renderComponentList(): React.ReactNode { protected renderComponentList(): React.ReactNode {
return <ComponentList const { itemLabel, resolveContainer, itemRenderer } = this.props;
return <ComponentList<T>
items={this.state.items} items={this.state.items}
itemLabel={itemLabel}
itemRenderer={itemRenderer}
install={this.install.bind(this)} install={this.install.bind(this)}
windowService={this.props.windowService} resolveContainer={resolveContainer}
resolveContainer={this.props.resolveContainer}
/> />
} }
private handleFilterTextChange(filterText: string): void { protected handleFilterTextChange = (filterText: string) => {
const { props } = this.state; this.setState({ filterText });
this.props.service.search({ query: filterText, props }).then(result => { this.search(filterText);
}
protected search (query: string): void {
const { searchable } = this.props;
searchable.search({ query: query.trim() }).then(result => {
const { items } = result; const { items } = result;
this.setState({ this.setState({
filterText,
items: this.sort(items) items: this.sort(items)
}); });
}); });
} }
protected sort(items: ArduinoComponent[]): ArduinoComponent[] { protected sort(items: T[]): T[] {
return items.sort((left, right) => left.name.localeCompare(right.name)); const { itemLabel } = this.props;
return items.sort((left, right) => itemLabel(left).localeCompare(itemLabel(right)));
} }
protected async install(comp: ArduinoComponent): Promise<void> { protected async install(item: T): Promise<void> {
const dialog = new InstallationProgressDialog(comp.name); const { installable, searchable, itemLabel } = this.props;
const dialog = new InstallationProgressDialog(itemLabel(item));
dialog.open(); dialog.open();
try { try {
await this.props.service.install(comp); await installable.install(item);
const { props } = this.state; const { items } = await searchable.search({ query: this.state.filterText });
const { items } = await this.props.service.search({ query: this.state.filterText, props });
this.setState({ items: this.sort(items) }); this.setState({ items: this.sort(items) });
} finally { } finally {
dialog.close(); dialog.close();
@ -82,23 +90,18 @@ export class FilterableListContainer extends React.Component<FilterableListConta
export namespace FilterableListContainer { export namespace FilterableListContainer {
export interface Props { export interface Props<T> {
readonly service: ComponentSource; readonly installable: Installable<T>;
readonly windowService: WindowService; readonly searchable: Searchable<T>;
readonly resolveContainer?: (element: HTMLElement) => void; readonly itemLabel: (item: T) => string;
readonly resolveFocus?: (element: HTMLElement | undefined) => void; readonly itemRenderer: ListItemRenderer<T>;
readonly resolveContainer: (element: HTMLElement) => void;
readonly resolveFocus: (element: HTMLElement | undefined) => void;
} }
export interface State { export interface State<T> {
filterText: string; filterText: string;
items: ArduinoComponent[]; items: T[];
props?: LibraryService.Search.Props;
}
export interface ComponentSource {
search(req: { query: string, props?: LibraryService.Search.Props }): Promise<{ items: ArduinoComponent[] }>
install(board: ArduinoComponent): Promise<void>;
} }
} }

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import { inject, injectable } from 'inversify';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
@injectable()
export abstract class ListItemRenderer<T> {
@inject(WindowService)
protected windowService: WindowService;
protected onClick = (event: React.SyntheticEvent<HTMLAnchorElement, Event>) => {
const { target } = event.nativeEvent;
if (target instanceof HTMLAnchorElement) {
this.windowService.openNewWindow(target.href);
event.nativeEvent.preventDefault();
}
}
abstract renderItem(item: T, install: (item: T) => Promise<void>): React.ReactNode;
}

View File

@ -0,0 +1,12 @@
import { injectable } from 'inversify';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { ListWidget } from './list-widget';
@injectable()
export abstract class ListWidgetFrontendContribution<T> extends AbstractViewContribution<ListWidget<T>> implements FrontendApplicationContribution {
async initializeLayout(): Promise<void> {
}
}

View File

@ -1,24 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import { inject, injectable, postConstruct } from 'inversify'; import { injectable, postConstruct } from 'inversify';
import { Message } from '@phosphor/messaging'; import { Message } from '@phosphor/messaging';
import { Deferred } from '@theia/core/lib/common/promise-util'; import { Deferred } from '@theia/core/lib/common/promise-util';
import { MaybePromise } from '@theia/core/lib/common/types'; import { MaybePromise } from '@theia/core/lib/common/types';
import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget'; import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget';
import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { Installable } from '../../../common/protocol/installable';
import { LibraryFilterableListContainer } from './library-filterable-list-container'; import { Searchable } from '../../../common/protocol/searchable';
import { LibraryService } from '../../common/protocol/library-service'; import { FilterableListContainer } from './filterable-list-container';
import { ListItemRenderer } from './list-item-renderer';
@injectable() @injectable()
export class LibraryListWidget extends ReactWidget { export abstract class ListWidget<T> extends ReactWidget {
static WIDGET_ID = 'library-list-widget';
static WIDGET_LABEL = 'Library Manager';
@inject(LibraryService)
protected readonly libraryService: LibraryService;
@inject(WindowService)
protected readonly windowService: WindowService;
/** /**
* Do not touch or use it. It is for setting the focus on the `input` after the widget activation. * Do not touch or use it. It is for setting the focus on the `input` after the widget activation.
@ -26,12 +18,13 @@ export class LibraryListWidget extends ReactWidget {
protected focusNode: HTMLElement | undefined; protected focusNode: HTMLElement | undefined;
protected readonly deferredContainer = new Deferred<HTMLElement>(); protected readonly deferredContainer = new Deferred<HTMLElement>();
constructor() { constructor(protected options: ListWidget.Options<T>) {
super(); super();
this.id = LibraryListWidget.WIDGET_ID const { id, label, iconClass } = options;
this.title.label = LibraryListWidget.WIDGET_LABEL; this.id = id;
this.title.caption = LibraryListWidget.WIDGET_LABEL this.title.label = label;
this.title.iconClass = 'library-tab-icon'; this.title.caption = label;
this.title.iconClass = iconClass
this.title.closable = true; this.title.closable = true;
this.addClass('arduino-list-widget'); this.addClass('arduino-list-widget');
this.node.tabIndex = 0; // To be able to set the focus on the widget. this.node.tabIndex = 0; // To be able to set the focus on the widget.
@ -64,25 +57,25 @@ export class LibraryListWidget extends ReactWidget {
} }
render(): React.ReactNode { render(): React.ReactNode {
return <LibraryFilterableListContainer return <FilterableListContainer<T>
resolveContainer={this.deferredContainer.resolve} resolveContainer={this.deferredContainer.resolve}
resolveFocus={this.onFocusResolved} resolveFocus={this.onFocusResolved}
service={this.libraryService} searchable={this.options.searchable}
windowService={this.windowService} installable={this.options.installable}
/>; itemLabel={this.options.itemLabel}
itemRenderer={this.options.itemRenderer} />;
} }
} }
export namespace ListWidget { export namespace ListWidget {
export interface Options<T> {
/**
* Props for customizing the abstract list widget.
*/
export interface Props {
readonly id: string; readonly id: string;
readonly title: string; readonly label: string;
readonly iconClass: string; readonly iconClass: string;
readonly installable: Installable<T>;
readonly searchable: Searchable<T>;
readonly itemLabel: (item: T) => string;
readonly itemRenderer: ListItemRenderer<T>;
} }
} }

View File

@ -1,12 +1,12 @@
import { AbstractDialog } from "@theia/core/lib/browser"; import { AbstractDialog } from '@theia/core/lib/browser';
export class InstallationProgressDialog extends AbstractDialog<undefined> {
export class InstallationProgressDialog extends AbstractDialog<string> { readonly value = undefined;
readonly value: "does-not-matter";
constructor(componentName: string) { constructor(componentName: string) {
super({ title: 'Installation in progress' }); super({ title: 'Installation in progress' });
this.contentNode.textContent = `Installing ${componentName}. Please wait.`; this.contentNode.textContent = `Installing ${componentName}. Please wait.`;
} }
} }

View File

@ -1,50 +0,0 @@
import * as React from 'react';
import { ComponentListItem } from '../components/component-list/component-list-item';
export class LibraryComponentListItem extends ComponentListItem {
render(): React.ReactNode {
const { item } = this.props;
const name = <span className={'name'}>{item.name}</span>;
const author = <span className={'author'}>by {item.author}</span>;
const installedVersion = !!item.installedVersion && <div className={'version-info'}>
<span className={'version'}>Version {item.installedVersion}</span>
<span className={'installed'}>INSTALLED</span>
</div>;
const summary = <div className={'summary'}>{item.summary}</div>;
const moreInfo = !!item.moreInfoLink && <a href={item.moreInfoLink} onClick={this.onClick}>More info</a>;
const install = this.props.install && item.installable && !item.installedVersion &&
<button className={'install'} onClick={this.install.bind(this, item)}>INSTALL</button>;
const versions = (() => {
const { availableVersions } = item;
if (availableVersions.length === 0) {
return undefined;
} else if (availableVersions.length === 1) {
return <label>{availableVersions[0]}</label>
} else {
return <select>{item.availableVersions.map(version => <option value={version} key={version}>{version}</option>)}</select>;
}
})();
return <div className={'component-list-item noselect'}>
<div className={'header'}>
<span>{name} {author}</span>
{installedVersion}
</div>
<div className={'content'}>
{summary}
</div>
<div className={'info'}>
{moreInfo}
</div>
<div className={'footer'}>
{install}
{versions}
</div>
</div>;
}
}

View File

@ -1,17 +0,0 @@
import * as React from 'react';
import { ArduinoComponent } from '../../common/protocol/arduino-component';
import { ComponentList } from '../components/component-list/component-list';
import { LibraryComponentListItem } from './library-component-list-item';
export class LibraryComponentList extends ComponentList {
createItem(item: ArduinoComponent): React.ReactNode {
return <LibraryComponentListItem
key={item.name}
item={item}
windowService={this.props.windowService}
install={this.props.install}
/>
}
}

View File

@ -1,110 +0,0 @@
import * as React from 'react';
import { FilterableListContainer } from '../components/component-list/filterable-list-container';
import { LibraryComponentList } from './library-component-list';
export class LibraryFilterableListContainer extends FilterableListContainer {
constructor(props: Readonly<FilterableListContainer.Props>) {
super(props);
this.state = {
filterText: '',
items: [],
props: {
topic: this.topics[0],
type: this.types[0]
}
};
}
protected renderSearchFilter(): React.ReactNode {
const types = this.types.map(type => <option value={type} key={type}>{type}</option>);
let type = this.types[0];
if (this.state.props) {
const currentType = this.types.find(t => t === this.state.props!.type) || this.types[0];
if (currentType) {
type = currentType;
}
}
const topics = this.topics.map(topic => <option value={topic} key={topic}>{topic}</option>);
let topic = this.topics[0];
if (this.state.props) {
const currentTopic = this.topics.find(t => t === this.state.props!.topic) || this.topics[0];
if (currentTopic) {
topic = currentTopic;
}
}
return <div className={'search-filters'}>
<div className={'filter'}>
<div className={'title'} style={{ minWidth: '32.088px' }}>Type</div> {/** TODO: do `minWidth` better! */}
<select
value={type}
onChange={this.onTypeChange}>
{types}
</select>
</div>
<div className={'filter'}>
<div className={'title'}>Topic</div>
<select
value={topic}
onChange={this.onTopicChange}>
{topics}
</select>
</div>
</div>
}
protected onTypeChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const type = event.target.value;
const props = { ...(this.state.props || {}), ...{ type } };
this.setState({
props
});
}
protected onTopicChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const topic = event.target.value;
const props = { ...(this.state.props || {}), ...{ topic } };
this.setState({
props
});
}
protected renderComponentList(): React.ReactNode {
return <LibraryComponentList
items={this.state.items}
install={this.install.bind(this)}
windowService={this.props.windowService}
resolveContainer={this.props.resolveContainer}
/>
}
private get topics(): string[] {
return [
'All',
'Communication',
'Data Processing',
'Data Storage',
'Device Control',
'Display',
'Other',
'Sensor',
'Signal Input/Output',
'Timing',
'Uncategorized'
];
}
private get types(): string[] {
return [
'All',
'Updatable',
'Installed',
'Arduino',
'Partner',
'Recommended',
'Contributed',
'Retired'
];
}
}

View File

@ -0,0 +1,52 @@
import * as React from 'react';
import { injectable } from 'inversify';
import { Library } from '../../common/protocol/library-service';
import { ListItemRenderer } from '../components/component-list/list-item-renderer';
@injectable()
export class LibraryItemRenderer extends ListItemRenderer<Library> {
renderItem(item: Library, install: (item: Library) => Promise<void>): React.ReactNode {
const name = <span className='name'>{item.name}</span>;
const author = <span className='author'>by {item.author}</span>;
const installedVersion = !!item.installedVersion && <div className='version-info'>
<span className='version'>Version {item.installedVersion}</span>
<span className='installed'>INSTALLED</span>
</div>;
const summary = <div className='summary'>{item.summary}</div>;
const moreInfo = !!item.moreInfoLink && <a href={item.moreInfoLink} onClick={this.onClick}>More info</a>;
const installButton = item.installable && !item.installedVersion &&
<button className='install' onClick={install.bind(this, item)}>INSTALL</button>;
const versions = (() => {
const { availableVersions } = item;
if (!!item.installedVersion || availableVersions.length === 0) {
return undefined;
} else if (availableVersions.length === 1) {
return <label>{availableVersions[0]}</label>
} else {
return <select>{item.availableVersions.map(version => <option value={version} key={version}>{version}</option>)}</select>;
}
})();
return <div className='component-list-item noselect'>
<div className='header'>
<span>{name} {author}</span>
{installedVersion}
</div>
<div className='content'>
{summary}
</div>
<div className='info'>
{moreInfo}
</div>
<div className='footer'>
{installButton}
{versions}
</div>
</div>;
}
}

View File

@ -0,0 +1,27 @@
import { inject, injectable } from 'inversify';
import { Library, LibraryService } from '../../common/protocol/library-service';
import { ListWidget } from '../components/component-list/list-widget';
import { LibraryItemRenderer } from './library-item-renderer';
@injectable()
export class LibraryListWidget extends ListWidget<Library> {
static WIDGET_ID = 'library-list-widget';
static WIDGET_LABEL = 'Library Manager';
constructor(
@inject(LibraryService) protected service: LibraryService,
@inject(LibraryItemRenderer) protected itemRenderer: LibraryItemRenderer) {
super({
id: LibraryListWidget.WIDGET_ID,
label: LibraryListWidget.WIDGET_LABEL,
iconClass: 'library-tab-icon',
searchable: service,
installable: service,
itemLabel: (item: Library) => item.name,
itemRenderer
});
}
}

View File

@ -0,0 +1,23 @@
import { injectable } from 'inversify';
import { Emitter } from '@theia/core/lib/common/event';
import { MonitorServiceClient, MonitorReadEvent, MonitorError } from '../../common/protocol/monitor-service';
@injectable()
export class MonitorServiceClientImpl implements MonitorServiceClient {
protected readonly onReadEmitter = new Emitter<MonitorReadEvent>();
protected readonly onErrorEmitter = new Emitter<MonitorError>();
readonly onRead = this.onReadEmitter.event;
readonly onError = this.onErrorEmitter.event;
notifyRead(event: MonitorReadEvent): void {
this.onReadEmitter.fire(event);
const { connectionId, data } = event;
console.log(`Received data from ${connectionId}: ${data}`);
}
notifyError(error: MonitorError): void {
this.onErrorEmitter.fire(error);
}
}

View File

@ -83,7 +83,20 @@ div#select-board-dialog .selectBoardContainer .body .list .item.selected i{
#select-board-dialog .selectBoardContainer .body .list .item { #select-board-dialog .selectBoardContainer .body .list .item {
padding: 10px 5px 10px 10px; padding: 10px 5px 10px 10px;
display: flex; display: flex;
justify-content: space-between; justify-content: end;
}
#select-board-dialog .selectBoardContainer .body .list .item .selected-icon {
margin-left: auto;
}
#select-board-dialog .selectBoardContainer .body .list .item .detail {
font-size: var(--theia-ui-font-size1);
color: var(--theia-disabled-color0);
width: 155px; /* used heuristics for the calculation */
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
} }
#select-board-dialog .selectBoardContainer .body .list .item.missing { #select-board-dialog .selectBoardContainer .body .list .item.missing {

View File

@ -1,10 +1,27 @@
import { ArduinoComponent } from "./arduino-component"; import { JsonRpcServer } from '@theia/core';
import { JsonRpcServer } from "@theia/core"; import { Searchable } from './searchable';
import { Installable } from './installable';
import { ArduinoComponent } from './arduino-component';
export interface AttachedBoardsChangeEvent { export interface AttachedBoardsChangeEvent {
readonly oldState: Readonly<{ boards: Board[] }>; readonly oldState: Readonly<{ boards: Board[] }>;
readonly newState: Readonly<{ boards: Board[] }>; readonly newState: Readonly<{ boards: Board[] }>;
} }
export namespace AttachedBoardsChangeEvent {
export function diff(event: AttachedBoardsChangeEvent): Readonly<{ attached: Board[], detached: Board[] }> {
const diff = <T>(left: T[], right: T[]) => {
return left.filter(item => right.indexOf(item) === -1);
}
const { boards: newBoards } = event.newState;
const { boards: oldBoards } = event.oldState;
return {
detached: diff(oldBoards, newBoards),
attached: diff(newBoards, oldBoards)
};
}
}
export interface BoardInstalledEvent { export interface BoardInstalledEvent {
readonly pkg: Readonly<BoardPackage>; readonly pkg: Readonly<BoardPackage>;
@ -18,10 +35,8 @@ export interface BoardsServiceClient {
export const BoardsServicePath = '/services/boards-service'; export const BoardsServicePath = '/services/boards-service';
export const BoardsService = Symbol('BoardsService'); export const BoardsService = Symbol('BoardsService');
export interface BoardsService extends JsonRpcServer<BoardsServiceClient> { export interface BoardsService extends Installable<BoardPackage>, Searchable<BoardPackage>, JsonRpcServer<BoardsServiceClient> {
getAttachedBoards(): Promise<{ boards: Board[] }>; getAttachedBoards(): Promise<{ boards: Board[] }>;
search(options: { query?: string }): Promise<{ items: BoardPackage[] }>;
install(item: BoardPackage): Promise<void>;
} }
export interface BoardPackage extends ArduinoComponent { export interface BoardPackage extends ArduinoComponent {
@ -34,10 +49,6 @@ export interface Board {
fqbn?: string fqbn?: string
} }
export interface Port {
port?: string;
}
export namespace Board { export namespace Board {
export function is(board: any): board is Board { export function is(board: any): board is Board {

View File

@ -0,0 +1,3 @@
export interface Installable<T> {
install(item: T): Promise<void>;
}

View File

@ -1,20 +1,13 @@
import { ArduinoComponent } from "./arduino-component"; import { Searchable } from './searchable';
import { Installable } from './installable';
import { ArduinoComponent } from './arduino-component';
export const LibraryServicePath = '/services/library-service'; export const LibraryServicePath = '/services/library-service';
export const LibraryService = Symbol('LibraryService'); export const LibraryService = Symbol('LibraryService');
export interface LibraryService { export interface LibraryService extends Installable<Library>, Searchable<Library> {
search(options: { query?: string, props?: LibraryService.Search.Props }): Promise<{ items: Library[] }>;
install(library: Library): Promise<void>; install(library: Library): Promise<void>;
} }
export namespace LibraryService {
export namespace Search {
export interface Props {
[key: string]: string | undefined;
}
}
}
export interface Library extends ArduinoComponent { export interface Library extends ArduinoComponent {
readonly builtIn?: boolean; readonly builtIn?: boolean;
} }

View File

@ -0,0 +1,43 @@
import { JsonRpcServer } from '@theia/core';
import { Board } from './boards-service';
export interface MonitorError {
readonly message: string;
readonly code: number
}
export interface MonitorReadEvent {
readonly connectionId: string;
readonly data: string;
}
export const MonitorServiceClient = Symbol('MonitorServiceClient');
export interface MonitorServiceClient {
notifyRead(event: MonitorReadEvent): void;
notifyError(event: MonitorError): void;
}
export const MonitorServicePath = '/services/serial-monitor';
export const MonitorService = Symbol('MonitorService');
export interface MonitorService extends JsonRpcServer<MonitorServiceClient> {
connect(config: ConnectionConfig): Promise<{ connectionId: string }>;
disconnect(connectionId: string): Promise<boolean>;
send(connectionId: string, data: string | Uint8Array): Promise<void>;
}
export interface ConnectionConfig {
readonly board: Board;
readonly port: string;
/**
* Defaults to [`SERIAL`](ConnectionType#SERIAL).
*/
readonly type?: ConnectionType;
/**
* Defaults to `9600`.
*/
readonly baudRate?: number;
}
export enum ConnectionType {
SERIAL = 0
}

View File

@ -0,0 +1,11 @@
export interface Searchable<T> {
search(options: Searchable.Options): Promise<{ items: T[] }>;
}
export namespace Searchable {
export interface Options {
/**
* Defaults to empty an empty string.
*/
readonly query?: string;
}
}

View File

@ -19,6 +19,9 @@ import { DefaultWorkspaceServerExt } from './default-workspace-server-ext';
import { WorkspaceServer } from '@theia/workspace/lib/common'; import { WorkspaceServer } from '@theia/workspace/lib/common';
import { SketchesServiceImpl } from './sketches-service-impl'; import { SketchesServiceImpl } from './sketches-service-impl';
import { SketchesService, SketchesServicePath } from '../common/protocol/sketches-service'; import { SketchesService, SketchesServicePath } from '../common/protocol/sketches-service';
import { MonitorServiceImpl } from './monitor/monitor-service-impl';
import { MonitorService, MonitorServicePath, MonitorServiceClient } from '../common/protocol/monitor-service';
import { MonitorClientProvider } from './monitor/monitor-client-provider';
export default new ContainerModule((bind, unbind, isBound, rebind) => { export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ArduinoDaemon).toSelf().inSingletonScope(); bind(ArduinoDaemon).toSelf().inSingletonScope();
@ -104,4 +107,25 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
// If nothing was set previously. // If nothing was set previously.
bind(DefaultWorkspaceServerExt).toSelf().inSingletonScope(); bind(DefaultWorkspaceServerExt).toSelf().inSingletonScope();
rebind(WorkspaceServer).toService(DefaultWorkspaceServerExt); rebind(WorkspaceServer).toService(DefaultWorkspaceServerExt);
// Shared monitor client provider service for the backend.
bind(MonitorClientProvider).toSelf().inSingletonScope();
// Connection scoped service for the serial monitor.
const monitorServiceConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => {
bind(MonitorServiceImpl).toSelf().inSingletonScope();
bind(MonitorService).toService(MonitorServiceImpl);
bindBackendService<MonitorService, MonitorServiceClient>(MonitorServicePath, MonitorService, (service, client) => {
service.setClient(client);
client.onDidCloseConnection(() => service.dispose());
return service;
});
});
bind(ConnectionContainerModule).toConstantValue(monitorServiceConnectionModule);
// Logger for the monitor service.
bind(ILogger).toDynamicValue(ctx => {
const parentLogger = ctx.container.get<ILogger>(ILogger);
return parentLogger.child('monitor-service');
}).inSingletonScope().whenTargetNamed('monitor-service');
}); });

View File

@ -6,7 +6,6 @@ import { PlatformSearchReq, PlatformSearchResp, PlatformInstallReq, PlatformInst
import { CoreClientProvider } from './core-client-provider'; import { CoreClientProvider } from './core-client-provider';
import { BoardListReq, BoardListResp } from './cli-protocol/commands/board_pb'; import { BoardListReq, BoardListResp } from './cli-protocol/commands/board_pb';
import { ToolOutputServiceServer } from '../common/protocol/tool-output-service'; import { ToolOutputServiceServer } from '../common/protocol/tool-output-service';
import { Deferred } from '@theia/core/lib/common/promise-util';
@injectable() @injectable()
export class BoardsServiceImpl implements BoardsService { export class BoardsServiceImpl implements BoardsService {
@ -23,7 +22,6 @@ export class BoardsServiceImpl implements BoardsService {
protected selectedBoard: Board | undefined; protected selectedBoard: Board | undefined;
protected discoveryInitialized = false; protected discoveryInitialized = false;
protected discoveryReady = new Deferred<void>();
protected discoveryTimer: NodeJS.Timeout | undefined; protected discoveryTimer: NodeJS.Timeout | undefined;
/** /**
* Poor man's serial discovery: * Poor man's serial discovery:
@ -41,7 +39,6 @@ export class BoardsServiceImpl implements BoardsService {
this.doGetAttachedBoards().then(({ boards }) => { this.doGetAttachedBoards().then(({ boards }) => {
const update = (oldState: Board[], newState: Board[], message: string) => { const update = (oldState: Board[], newState: Board[], message: string) => {
this._attachedBoards = { boards: newState }; this._attachedBoards = { boards: newState };
this.discoveryReady.resolve();
this.discoveryLogger.info(`${message} - Discovered boards: ${JSON.stringify(newState)}`); this.discoveryLogger.info(`${message} - Discovered boards: ${JSON.stringify(newState)}`);
if (this.client) { if (this.client) {
this.client.notifyAttachedBoardsChanged({ this.client.notifyAttachedBoardsChanged({
@ -91,7 +88,6 @@ export class BoardsServiceImpl implements BoardsService {
} }
async getAttachedBoards(): Promise<{ boards: Board[] }> { async getAttachedBoards(): Promise<{ boards: Board[] }> {
await this.discoveryReady.promise;
return this._attachedBoards; return this._attachedBoards;
} }
@ -163,7 +159,7 @@ export class BoardsServiceImpl implements BoardsService {
let items = resp.getSearchOutputList().map(item => { let items = resp.getSearchOutputList().map(item => {
let installedVersion: string | undefined; let installedVersion: string | undefined;
const matchingPlatform = installedPlatforms.find(ip => ip.getId().startsWith(`${item.getId()}`)); const matchingPlatform = installedPlatforms.find(ip => ip.getId() === item.getId());
if (!!matchingPlatform) { if (!!matchingPlatform) {
installedVersion = matchingPlatform.getInstalled(); installedVersion = matchingPlatform.getInstalled();
} }
@ -172,12 +168,13 @@ export class BoardsServiceImpl implements BoardsService {
id: item.getId(), id: item.getId(),
name: item.getName(), name: item.getName(),
author: item.getMaintainer(), author: item.getMaintainer(),
availableVersions: [item.getInstalled()], availableVersions: [item.getLatest()],
description: item.getBoardsList().map(b => b.getName()).join(", "), description: item.getBoardsList().map(b => b.getName()).join(", "),
installable: true, installable: true,
summary: "Boards included in this package:", summary: "Boards included in this package:",
installedVersion, installedVersion,
boards: item.getBoardsList().map(b => <Board>{ name: b.getName(), fqbn: b.getFqbn() }), boards: item.getBoardsList().map(b => <Board>{ name: b.getName(), fqbn: b.getFqbn() }),
moreInfoLink: item.getWebsite()
} }
return result; return result;
}); });

View File

@ -6,7 +6,9 @@ import {
InitReq, InitReq,
Configuration, Configuration,
UpdateIndexReq, UpdateIndexReq,
UpdateIndexResp UpdateIndexResp,
UpdateLibrariesIndexReq,
UpdateLibrariesIndexResp
} from './cli-protocol/commands/commands_pb'; } from './cli-protocol/commands/commands_pb';
import { WorkspaceServiceExt } from '../browser/workspace-service-ext'; import { WorkspaceServiceExt } from '../browser/workspace-service-ext';
import { FileSystem } from '@theia/filesystem/lib/common'; import { FileSystem } from '@theia/filesystem/lib/common';
@ -111,20 +113,34 @@ export class CoreClientProviderImpl implements CoreClientProvider {
} }
// in a separate promise, try and update the index // in a separate promise, try and update the index
let succeeded = true; let indexUpdateSucceeded = true;
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
try { try {
await this.updateIndex(client, instance); await this.updateIndex(client, instance);
succeeded = true; indexUpdateSucceeded = true;
break; break;
} catch (e) { } catch (e) {
this.toolOutputService.publishNewOutput("daemon", `Error while updating index in attempt ${i}: ${e}`); this.toolOutputService.publishNewOutput("daemon", `Error while updating index in attempt ${i}: ${e}`);
} }
} }
if (!succeeded) { if (!indexUpdateSucceeded) {
this.toolOutputService.publishNewOutput("daemon", `Was unable to update the index. Please restart to try again.`); this.toolOutputService.publishNewOutput("daemon", `Was unable to update the index. Please restart to try again.`);
} }
let libIndexUpdateSucceeded = true;
for (let i = 0; i < 10; i++) {
try {
await this.updateLibraryIndex(client, instance);
libIndexUpdateSucceeded = true;
break;
} catch (e) {
this.toolOutputService.publishNewOutput("daemon", `Error while updating library index in attempt ${i}: ${e}`);
}
}
if (!libIndexUpdateSucceeded) {
this.toolOutputService.publishNewOutput("daemon", `Was unable to update the library index. Please restart to try again.`);
}
const result = { const result = {
client, client,
instance instance
@ -134,6 +150,38 @@ export class CoreClientProviderImpl implements CoreClientProvider {
return result; return result;
} }
protected async updateLibraryIndex(client: ArduinoCoreClient, instance: Instance): Promise<void> {
const req = new UpdateLibrariesIndexReq();
req.setInstance(instance);
const resp = client.updateLibrariesIndex(req);
let file: string | undefined;
resp.on('data', (data: UpdateLibrariesIndexResp) => {
const progress = data.getDownloadProgress();
if (progress) {
if (!file && progress.getFile()) {
file = `${progress.getFile()}`;
}
if (progress.getCompleted()) {
if (file) {
if (/\s/.test(file)) {
this.toolOutputService.publishNewOutput("daemon", `${file} completed.\n`);
} else {
this.toolOutputService.publishNewOutput("daemon", `Download of '${file}' completed.\n'`);
}
} else {
this.toolOutputService.publishNewOutput("daemon", `The library index has been successfully updated.\n'`);
}
file = undefined;
}
}
});
await new Promise<void>((resolve, reject) => {
resp.on('error', reject);
resp.on('end', resolve);
});
}
protected async updateIndex(client: ArduinoCoreClient, instance: Instance): Promise<void> { protected async updateIndex(client: ArduinoCoreClient, instance: Instance): Promise<void> {
const updateReq = new UpdateIndexReq(); const updateReq = new UpdateIndexReq();
updateReq.setInstance(instance); updateReq.setInstance(instance);
@ -165,4 +213,4 @@ export class CoreClientProviderImpl implements CoreClientProvider {
}); });
} }
} }

View File

@ -14,7 +14,7 @@ export class LibraryServiceImpl implements LibraryService {
@inject(ToolOutputServiceServer) @inject(ToolOutputServiceServer)
protected readonly toolOutputService: ToolOutputServiceServer; protected readonly toolOutputService: ToolOutputServiceServer;
async search(options: { query?: string, props: LibraryService.Search.Props }): Promise<{ items: Library[] }> { async search(options: { query?: string }): Promise<{ items: Library[] }> {
const coreClient = await this.coreClientProvider.getClient(); const coreClient = await this.coreClientProvider.getClient();
if (!coreClient) { if (!coreClient) {
return { items: [] }; return { items: [] };

View File

@ -0,0 +1,20 @@
import * as grpc from '@grpc/grpc-js';
import { injectable, postConstruct } from 'inversify';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { MonitorClient } from '../cli-protocol/monitor/monitor_grpc_pb';
@injectable()
export class MonitorClientProvider {
readonly deferred = new Deferred<MonitorClient>();
@postConstruct()
protected init(): void {
this.deferred.resolve(new MonitorClient('localhost:50051', grpc.credentials.createInsecure()));
}
get client(): Promise<MonitorClient> {
return this.deferred.promise;
}
}

View File

@ -0,0 +1,164 @@
import { v4 } from 'uuid';
import * as grpc from '@grpc/grpc-js';
import { TextDecoder, TextEncoder } from 'util';
import { injectable, inject, named } from 'inversify';
import { ILogger, Disposable, DisposableCollection } from '@theia/core';
import { MonitorService, MonitorServiceClient, ConnectionConfig, ConnectionType } from '../../common/protocol/monitor-service';
import { StreamingOpenReq, StreamingOpenResp, MonitorConfig } from '../cli-protocol/monitor/monitor_pb';
import { MonitorClientProvider } from './monitor-client-provider';
export interface MonitorDuplex {
readonly toDispose: Disposable;
readonly duplex: grpc.ClientDuplexStream<StreamingOpenReq, StreamingOpenResp>;
}
type ErrorCode = { code: number };
type MonitorError = Error & ErrorCode;
namespace MonitorError {
export function is(error: Error & Partial<ErrorCode>): error is MonitorError {
return typeof error.code === 'number';
}
/**
* The frontend has refreshed the browser, for instance.
*/
export function isClientCancelledError(error: MonitorError): boolean {
return error.code === 1 && error.message === 'Cancelled on client';
}
/**
* When detaching a physical device when the duplex channel is still opened.
*/
export function isDeviceNotConfiguredError(error: MonitorError): boolean {
return error.code === 2 && error.message === 'device not configured';
}
}
@injectable()
export class MonitorServiceImpl implements MonitorService {
@inject(ILogger)
@named('monitor-service')
protected readonly logger: ILogger;
@inject(MonitorClientProvider)
protected readonly monitorClientProvider: MonitorClientProvider;
protected client?: MonitorServiceClient;
protected readonly connections = new Map<string, MonitorDuplex>();
setClient(client: MonitorServiceClient | undefined): void {
this.client = client;
}
dispose(): void {
for (const [connectionId, duplex] of this.connections.entries()) {
this.doDisconnect(connectionId, duplex);
}
}
async connect(config: ConnectionConfig): Promise<{ connectionId: string }> {
const client = await this.monitorClientProvider.client;
const duplex = client.streamingOpen();
const connectionId = v4();
const toDispose = new DisposableCollection(
Disposable.create(() => this.disconnect(connectionId))
);
duplex.on('error', ((error: Error) => {
if (MonitorError.is(error) && (
MonitorError.isClientCancelledError(error)
|| MonitorError.isDeviceNotConfiguredError(error)
)) {
if (this.client) {
this.client.notifyError(error);
}
}
this.logger.error(`Error occurred for connection ${connectionId}.`, error);
toDispose.dispose();
}).bind(this));
duplex.on('data', ((resp: StreamingOpenResp) => {
if (this.client) {
const raw = resp.getData();
const data = typeof raw === 'string' ? raw : new TextDecoder('utf8').decode(raw);
this.client.notifyRead({ connectionId, data });
}
}).bind(this));
const { type, port } = config;
const req = new StreamingOpenReq();
const monitorConfig = new MonitorConfig();
monitorConfig.setType(this.mapType(type));
monitorConfig.setTarget(port);
if (config.baudRate !== undefined) {
monitorConfig.setAdditionalconfig({ 'BaudRate': config.baudRate });
}
req.setMonitorconfig(monitorConfig);
return new Promise<{ connectionId: string }>(resolve => {
duplex.write(req, () => {
this.connections.set(connectionId, { toDispose, duplex });
resolve({ connectionId });
});
});
}
async disconnect(connectionId: string): Promise<boolean> {
this.logger.info(`>>> Received disconnect request for connection: ${connectionId}`);
const disposable = this.connections.get(connectionId);
if (!disposable) {
this.logger.warn(`<<< No connection was found for ID: ${connectionId}`);
return false;
}
const result = await this.doDisconnect(connectionId, disposable);
if (result) {
this.logger.info(`<<< Successfully disconnected from ${connectionId}.`);
} else {
this.logger.info(`<<< Could not disconnected from ${connectionId}.`);
}
return result;
}
protected async doDisconnect(connectionId: string, duplex: MonitorDuplex): Promise<boolean> {
const { toDispose } = duplex;
this.logger.info(`>>> Disposing monitor connection: ${connectionId}...`);
try {
toDispose.dispose();
this.logger.info(`<<< Connection disposed: ${connectionId}.`);
return true;
} catch (e) {
this.logger.error(`<<< Error occurred when disposing monitor connection: ${connectionId}. ${e}`);
return false;
}
}
async send(connectionId: string, data: string): Promise<void> {
const duplex = this.duplex(connectionId);
if (duplex) {
const req = new StreamingOpenReq();
req.setData(new TextEncoder().encode(data));
return new Promise<void>(resolve => duplex.duplex.write(req, resolve));
} else {
throw new Error(`No connection with ID: ${connectionId}.`);
}
}
protected mapType(type?: ConnectionType): MonitorConfig.TargetType {
switch (type) {
case ConnectionType.SERIAL: return MonitorConfig.TargetType.SERIAL;
default: return MonitorConfig.TargetType.SERIAL;
}
}
protected duplex(connectionId: string): MonitorDuplex | undefined {
const monitorClient = this.connections.get(connectionId);
if (!monitorClient) {
this.logger.warn(`Could not find monitor client for connection ID: ${connectionId}`);
}
return monitorClient;
}
}