Improved initial board installation experience

This commit is contained in:
Christian Weichel 2019-05-08 15:47:04 +02:00
parent 43ab17fd34
commit 6584b0d5b1
8 changed files with 153 additions and 24 deletions

View File

@ -14,6 +14,8 @@ import { WorkspaceServiceExt } from './workspace-service-ext';
import { ToolOutputServiceClient } from '../common/protocol/tool-output-service'; import { ToolOutputServiceClient } from '../common/protocol/tool-output-service';
import { ConfirmDialog } from '@theia/core/lib/browser'; import { ConfirmDialog } from '@theia/core/lib/browser';
import { QuickPickService } from '@theia/core/lib/common/quick-pick-service'; import { QuickPickService } from '@theia/core/lib/common/quick-pick-service';
import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution';
import { BoardsNotificationService } from './boards-notification-service';
@injectable() @injectable()
@ -37,6 +39,12 @@ export class ArduinoFrontendContribution extends DefaultFrontendApplicationContr
@inject(QuickPickService) @inject(QuickPickService)
protected readonly quickPickService: QuickPickService; protected readonly quickPickService: QuickPickService;
@inject(BoardsListWidgetFrontendContribution)
protected readonly boardsListWidgetFrontendContribution: BoardsListWidgetFrontendContribution;
@inject(BoardsNotificationService)
protected readonly boardsNotificationService: BoardsNotificationService;
@postConstruct() @postConstruct()
protected async init(): Promise<void> { protected async init(): Promise<void> {
// This is a hack. Otherwise, the backend services won't bind. // This is a hack. Otherwise, the backend services won't bind.
@ -60,7 +68,12 @@ export class ArduinoFrontendContribution extends DefaultFrontendApplicationContr
}); });
registry.registerItem({ registry.registerItem({
id: ConnectedBoards.TOOLBAR_ID, id: ConnectedBoards.TOOLBAR_ID,
render: () => <ConnectedBoards boardsService={this.boardService} quickPickService={this.quickPickService} />, render: () => <ConnectedBoards
boardsService={this.boardService}
boardsNotificationService={this.boardsNotificationService}
quickPickService={this.quickPickService}
onNoBoardsInstalled={this.onNoBoardsInstalled.bind(this)}
onUnknownBoard={this.onUnknownBoard.bind(this)} />,
isVisible: widget => this.isArduinoEditor(widget) isVisible: widget => this.isArduinoEditor(widget)
}) })
} }
@ -103,6 +116,25 @@ export class ArduinoFrontendContribution extends DefaultFrontendApplicationContr
}); });
} }
private async onNoBoardsInstalled() {
const action = await this.messageService.info("You have no boards installed. Use the boards mangager to install one.", "Open Boards Manager");
if (!action) {
return;
}
this.boardsListWidgetFrontendContribution.openView({reveal: true});
}
private async onUnknownBoard() {
const action = await this.messageService.warn("There's a board connected for which you need to install software." +
" If this were not a PoC we would offer you the right package now.", "Open Boards Manager");
if (!action) {
return;
}
this.boardsListWidgetFrontendContribution.openView({reveal: true});
}
private isArduinoEditor(maybeEditorWidget: any): boolean { private isArduinoEditor(maybeEditorWidget: any): boolean {
if (maybeEditorWidget instanceof EditorWidget) { if (maybeEditorWidget instanceof EditorWidget) {
return maybeEditorWidget.editor.uri.toString().endsWith('.ino'); return maybeEditorWidget.editor.uri.toString().endsWith('.ino');

View File

@ -22,6 +22,7 @@ import { ToolOutputServiceClient } from '../common/protocol/tool-output-service'
import '../../src/browser/style/index.css'; import '../../src/browser/style/index.css';
import { ToolOutputService } from '../common/protocol/tool-output-service'; import { ToolOutputService } from '../common/protocol/tool-output-service';
import { ToolOutputServiceClientImpl } from './tool-output/client-service-impl'; import { ToolOutputServiceClientImpl } from './tool-output/client-service-impl';
import { BoardsNotificationService } from './boards-notification-service';
export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => {
// Commands and toolbar items // Commands and toolbar items
@ -44,6 +45,10 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un
})); }));
bind(FrontendApplicationContribution).toService(LibraryListWidgetFrontendContribution); bind(FrontendApplicationContribution).toService(LibraryListWidgetFrontendContribution);
// Boards Notification service for updating boards list
// TODO (post-PoC): move this to boards service/backend
bind(BoardsNotificationService).toSelf().inSingletonScope();
// Boards service // Boards service
bind(BoardsService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, BoardsServicePath)).inSingletonScope(); bind(BoardsService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, BoardsServicePath)).inSingletonScope();

View File

@ -0,0 +1,19 @@
import { EventEmitter } from "events";
import { injectable } from "inversify";
// TODO (post-PoC): move this to the backend / BoardsService
@injectable()
export class BoardsNotificationService {
protected readonly emitter = new EventEmitter();
public on(event: 'boards-installed', listener: (...args: any[]) => void): this {
this.emitter.on(event, listener);
return this;
}
public notifyBoardsInstalled() {
this.emitter.emit('boards-installed');
}
}

View File

@ -4,7 +4,8 @@ import { Message } from '@phosphor/messaging';
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 { WindowService } from '@theia/core/lib/browser/window/window-service';
import { FilterableListContainer } from '../components/component-list/filterable-list-container'; import { FilterableListContainer } from '../components/component-list/filterable-list-container';
import { BoardsService } from '../../common/protocol/boards-service'; import { BoardsService, Board, BoardPackage } from '../../common/protocol/boards-service';
import { BoardsNotificationService } from '../boards-notification-service';
@injectable() @injectable()
export abstract class ListWidget extends ReactWidget { export abstract class ListWidget extends ReactWidget {
@ -15,6 +16,9 @@ export abstract class ListWidget extends ReactWidget {
@inject(WindowService) @inject(WindowService)
protected readonly windowService: WindowService; protected readonly windowService: WindowService;
@inject(BoardsNotificationService)
protected readonly boardsNotificationService: BoardsNotificationService;
constructor() { constructor() {
super(); super();
const { id, title, iconClass } = this.widgetProps(); const { id, title, iconClass } = this.widgetProps();
@ -46,8 +50,19 @@ export abstract class ListWidget extends ReactWidget {
} }
render(): React.ReactNode { render(): React.ReactNode {
const boardsServiceDelegate = this.boardsService;
const boardsService: BoardsService = {
getAttachedBoards: () => boardsServiceDelegate.getAttachedBoards(),
selectBoard: (board: Board) => boardsServiceDelegate.selectBoard(board),
getSelectBoard: () => boardsServiceDelegate.getSelectBoard(),
search: (options: { query?: string }) => boardsServiceDelegate.search(options),
install: async (item: BoardPackage) => {
await boardsServiceDelegate.install(item);
this.boardsNotificationService.notifyBoardsInstalled();
}
}
return <FilterableListContainer return <FilterableListContainer
service={this.boardsService} service={boardsService}
windowService={this.windowService} windowService={this.windowService}
/>; />;
} }

View File

@ -2,6 +2,7 @@ import * as React from 'react';
import { BoardsService, Board } from '../../common/protocol/boards-service'; import { BoardsService, Board } from '../../common/protocol/boards-service';
// import { SelectBoardDialog } from './select-board-dialog'; // import { SelectBoardDialog } from './select-board-dialog';
import { QuickPickService } from '@theia/core/lib/common/quick-pick-service'; import { QuickPickService } from '@theia/core/lib/common/quick-pick-service';
import { BoardsNotificationService } from '../boards-notification-service';
export class ConnectedBoards extends React.Component<ConnectedBoards.Props, ConnectedBoards.State> { export class ConnectedBoards extends React.Component<ConnectedBoards.Props, ConnectedBoards.State> {
static TOOLBAR_ID: 'connected-boards-toolbar'; static TOOLBAR_ID: 'connected-boards-toolbar';
@ -9,6 +10,8 @@ export class ConnectedBoards extends React.Component<ConnectedBoards.Props, Conn
constructor(props: ConnectedBoards.Props) { constructor(props: ConnectedBoards.Props) {
super(props); super(props);
this.state = { boardsLoading: false }; this.state = { boardsLoading: false };
props.boardsNotificationService.on('boards-installed', () => this.onBoardsInstalled());
} }
render(): React.ReactNode { render(): React.ReactNode {
@ -44,14 +47,34 @@ export class ConnectedBoards extends React.Component<ConnectedBoards.Props, Conn
this.reloadBoards(); this.reloadBoards();
} }
protected onBoardsInstalled() {
if (!!this.findUnknownBoards()) {
this.reloadBoards();
}
}
protected findUnknownBoards(): Board[] {
if (!this.state || !this.state.boards) {
return [];
}
return this.state.boards.filter(b => !b.fqbn || b.name === "unknown");
}
protected async reloadBoards() { protected async reloadBoards() {
this.setState({ boardsLoading: true, boards: undefined, selection: undefined }); const prevSelection = this.state.selection;
this.setState({ boardsLoading: true, boards: undefined, selection: "loading" });
const { boards } = await this.props.boardsService.getAttachedBoards() const { boards } = await this.props.boardsService.getAttachedBoards()
this.setState({ boards, boardsLoading: false }); this.setState({ boards, boardsLoading: false, selection: prevSelection });
if (boards) { if (boards) {
this.setState({ selection: "0" }); this.setState({ selection: "0" });
await this.props.boardsService.selectBoard(boards[0]); await this.props.boardsService.selectBoard(boards[0]);
const unknownBoards = this.findUnknownBoards();
if (unknownBoards && unknownBoards.length > 0) {
this.props.onUnknownBoard(unknownBoards[0]);
}
} }
} }
@ -85,6 +108,11 @@ export class ConnectedBoards extends React.Component<ConnectedBoards.Props, Conn
const idx = new Map<string, Board>(); const idx = new Map<string, Board>();
items.filter(pkg => !!pkg.installedVersion).forEach(pkg => pkg.boards.forEach(brd => idx.set(`${brd.name}`, brd) )); items.filter(pkg => !!pkg.installedVersion).forEach(pkg => pkg.boards.forEach(brd => idx.set(`${brd.name}`, brd) ));
if (idx.size === 0) {
this.props.onNoBoardsInstalled();
return;
}
const selection = await this.props.quickPickService.show(Array.from(idx.keys())); const selection = await this.props.quickPickService.show(Array.from(idx.keys()));
if (!selection) { if (!selection) {
return; return;
@ -99,7 +127,10 @@ export namespace ConnectedBoards {
export interface Props { export interface Props {
readonly boardsService: BoardsService; readonly boardsService: BoardsService;
readonly boardsNotificationService: BoardsNotificationService;
readonly quickPickService: QuickPickService; readonly quickPickService: QuickPickService;
readonly onNoBoardsInstalled: () => void;
readonly onUnknownBoard: (board: Board) => void;
} }
export interface State { export interface State {

View File

@ -3,6 +3,7 @@ import { BoardsService, AttachedSerialBoard, AttachedNetworkBoard, BoardPackage,
import { PlatformSearchReq, PlatformSearchResp, PlatformInstallReq, PlatformInstallResp, PlatformListReq, PlatformListResp } from './cli-protocol/core_pb'; import { PlatformSearchReq, PlatformSearchResp, PlatformInstallReq, PlatformInstallResp, PlatformListReq, PlatformListResp } from './cli-protocol/core_pb';
import { CoreClientProvider } from './core-client-provider'; import { CoreClientProvider } from './core-client-provider';
import { BoardListReq, BoardListResp } from './cli-protocol/board_pb'; import { BoardListReq, BoardListResp } from './cli-protocol/board_pb';
import { ToolOutputServiceServer } from '../common/protocol/tool-output-service';
@injectable() @injectable()
export class BoardsServiceImpl implements BoardsService { export class BoardsServiceImpl implements BoardsService {
@ -10,6 +11,9 @@ export class BoardsServiceImpl implements BoardsService {
@inject(CoreClientProvider) @inject(CoreClientProvider)
protected readonly coreClientProvider: CoreClientProvider; protected readonly coreClientProvider: CoreClientProvider;
@inject(ToolOutputServiceServer)
protected readonly toolOutputService: ToolOutputServiceServer;
protected selectedBoard: Board | undefined; protected selectedBoard: Board | undefined;
public async getAttachedBoards(): Promise<{ boards: Board[] }> { public async getAttachedBoards(): Promise<{ boards: Board[] }> {
@ -100,8 +104,8 @@ export class BoardsServiceImpl implements BoardsService {
const resp = client.platformInstall(req); const resp = client.platformInstall(req);
resp.on('data', (r: PlatformInstallResp) => { resp.on('data', (r: PlatformInstallResp) => {
const prog = r.getProgress(); const prog = r.getProgress();
if (prog) { if (prog && prog.getFile()) {
console.info(`downloading ${prog.getFile()}: ${prog.getCompleted()}%`) this.toolOutputService.publishNewOutput("board download", `downloading ${prog.getFile()}\n`)
} }
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {

View File

@ -7,6 +7,8 @@ import { FileSystem } from '@theia/filesystem/lib/common';
import URI from '@theia/core/lib/common/uri'; import URI from '@theia/core/lib/common/uri';
import { CoreClientProvider, Client } from './core-client-provider'; import { CoreClientProvider, Client } from './core-client-provider';
import * as PQueue from 'p-queue'; import * as PQueue from 'p-queue';
import { ToolOutputServiceServer } from '../common/protocol/tool-output-service';
import { Instance } from './cli-protocol/common_pb';
@injectable() @injectable()
export class CoreClientProviderImpl implements CoreClientProvider { export class CoreClientProviderImpl implements CoreClientProvider {
@ -19,6 +21,9 @@ export class CoreClientProviderImpl implements CoreClientProvider {
@inject(WorkspaceServiceExt) @inject(WorkspaceServiceExt)
protected readonly workspaceServiceExt: WorkspaceServiceExt; protected readonly workspaceServiceExt: WorkspaceServiceExt;
@inject(ToolOutputServiceServer)
protected readonly toolOutputService: ToolOutputServiceServer;
protected clients = new Map<string, Client>(); protected clients = new Map<string, Client>();
async getClient(workspaceRootOrResourceUri?: string): Promise<Client> { async getClient(workspaceRootOrResourceUri?: string): Promise<Client> {
@ -69,24 +74,20 @@ export class CoreClientProviderImpl implements CoreClientProvider {
throw new Error(`Could not retrieve instance from the initialize response.`); throw new Error(`Could not retrieve instance from the initialize response.`);
} }
// workaround to speed up startup on existing workspaces // in a seperate promise, try and update the index
const updateReq = new UpdateIndexReq(); let succeeded = true;
updateReq.setInstance(instance); for (let i = 0; i < 10; i++) {
const updateResp = client.updateIndex(updateReq); try {
updateResp.on('data', (o: UpdateIndexResp) => { await this.updateIndex(client, instance);
const progress = o.getDownloadProgress(); succeeded = true;
if (progress) { break;
if (progress.getCompleted()) { } catch (e) {
console.log(`Download${progress.getFile() ? ` of ${progress.getFile()}` : ''} completed.`); this.toolOutputService.publishNewOutput("daemon", `Error while updating index in attempt ${i}: ${e}`);
} else {
console.log(`Downloading${progress.getFile() ? ` ${progress.getFile()}:` : ''} ${progress.getDownloaded()}.`);
}
} }
}); }
await new Promise<void>((resolve, reject) => { if (!succeeded) {
updateResp.on('error', reject); this.toolOutputService.publishNewOutput("daemon", `Was unable to update the index. Please restart to try again.`);
updateResp.on('end', resolve); }
});
const result = { const result = {
client, client,
@ -96,4 +97,23 @@ export class CoreClientProviderImpl implements CoreClientProvider {
console.info(` <<< New client has been successfully created and cached for ${rootUri}.`); console.info(` <<< New client has been successfully created and cached for ${rootUri}.`);
return result; return result;
} }
protected async updateIndex(client: ArduinoCoreClient, instance: Instance): Promise<void> {
const updateReq = new UpdateIndexReq();
updateReq.setInstance(instance);
const updateResp = client.updateIndex(updateReq);
updateResp.on('data', (o: UpdateIndexResp) => {
const progress = o.getDownloadProgress();
if (progress) {
if (progress.getCompleted()) {
this.toolOutputService.publishNewOutput("daemon", `Download${progress.getFile() ? ` of ${progress.getFile()}` : ''} completed.\n`);
}
}
});
await new Promise<void>((resolve, reject) => {
updateResp.on('error', reject);
updateResp.on('end', resolve);
});
}
} }

3
known-issues.txt Normal file
View File

@ -0,0 +1,3 @@
Known issues:
- arduino-cli does not get stopped reliably upon app shutdown
- startup time is not as fast we'd like to have it