feat: new UX for the boards/library manager widgets

Closes #19
Closes #781
Closes #1591
Closes #1607
Closes #1697
Closes #1707
Closes #1924
Closes #1941

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta 2023-03-01 18:02:45 +01:00 committed by Akos Kitta
parent 58aac236bf
commit 2aad0e3b16
29 changed files with 1409 additions and 504 deletions

View File

@ -58,7 +58,6 @@
"@types/p-queue": "^2.3.1", "@types/p-queue": "^2.3.1",
"@types/ps-tree": "^1.1.0", "@types/ps-tree": "^1.1.0",
"@types/react-tabs": "^2.3.2", "@types/react-tabs": "^2.3.2",
"@types/react-virtualized": "^9.21.21",
"@types/temp": "^0.8.34", "@types/temp": "^0.8.34",
"@types/which": "^1.3.1", "@types/which": "^1.3.1",
"@vscode/debugprotocol": "^1.51.0", "@vscode/debugprotocol": "^1.51.0",
@ -96,7 +95,6 @@
"react-perfect-scrollbar": "^1.5.8", "react-perfect-scrollbar": "^1.5.8",
"react-select": "^5.6.0", "react-select": "^5.6.0",
"react-tabs": "^3.1.2", "react-tabs": "^3.1.2",
"react-virtualized": "^9.22.3",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"semver": "^7.3.2", "semver": "^7.3.2",
"string-natural-compare": "^2.0.3", "string-natural-compare": "^2.0.3",

View File

@ -79,7 +79,10 @@ import { ProblemManager as TheiaProblemManager } from '@theia/markers/lib/browse
import { ProblemManager } from './theia/markers/problem-manager'; import { ProblemManager } from './theia/markers/problem-manager';
import { BoardsAutoInstaller } from './boards/boards-auto-installer'; import { BoardsAutoInstaller } from './boards/boards-auto-installer';
import { ShellLayoutRestorer } from './theia/core/shell-layout-restorer'; import { ShellLayoutRestorer } from './theia/core/shell-layout-restorer';
import { ListItemRenderer } from './widgets/component-list/list-item-renderer'; import {
ArduinoComponentContextMenuRenderer,
ListItemRenderer,
} from './widgets/component-list/list-item-renderer';
import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution';
import { import {
@ -1021,4 +1024,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(SidebarBottomMenuWidget).toSelf(); bind(SidebarBottomMenuWidget).toSelf();
rebind(TheiaSidebarBottomMenuWidget).toService(SidebarBottomMenuWidget); rebind(TheiaSidebarBottomMenuWidget).toService(SidebarBottomMenuWidget);
bind(ArduinoComponentContextMenuRenderer).toSelf().inSingletonScope();
}); });

View File

@ -174,7 +174,7 @@ export class BoardsAutoInstaller implements FrontendApplicationContribution {
// CLI returns the packages already sorted with the deprecated ones at the end of the list // CLI returns the packages already sorted with the deprecated ones at the end of the list
// in order to ensure the new ones are preferred // in order to ensure the new ones are preferred
const candidates = packagesForBoard.filter( const candidates = packagesForBoard.filter(
({ installable, installedVersion }) => installable && !installedVersion ({ installedVersion }) => !installedVersion
); );
return candidates[0]; return candidates[0];

View File

@ -1,6 +1,6 @@
import * as PQueue from 'p-queue'; import * as PQueue from 'p-queue';
import { inject, injectable } from '@theia/core/shared/inversify'; import { inject, injectable } from '@theia/core/shared/inversify';
import { CommandHandler } from '@theia/core/lib/common/command'; import { CommandHandler, CommandService } from '@theia/core/lib/common/command';
import { import {
MenuPath, MenuPath,
CompositeMenuNode, CompositeMenuNode,
@ -11,7 +11,11 @@ import {
DisposableCollection, DisposableCollection,
} from '@theia/core/lib/common/disposable'; } from '@theia/core/lib/common/disposable';
import { OpenSketch } from './open-sketch'; import { OpenSketch } from './open-sketch';
import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus'; import {
ArduinoMenus,
examplesLabel,
PlaceholderMenuNode,
} from '../menu/arduino-menus';
import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { BoardsServiceProvider } from '../boards/boards-service-provider';
import { ExamplesService } from '../../common/protocol/examples-service'; import { ExamplesService } from '../../common/protocol/examples-service';
import { import {
@ -25,11 +29,73 @@ import {
SketchRef, SketchRef,
SketchContainer, SketchContainer,
SketchesError, SketchesError,
Sketch,
CoreService, CoreService,
SketchesService,
Sketch,
} from '../../common/protocol'; } from '../../common/protocol';
import { nls } from '@theia/core/lib/common'; import { nls } from '@theia/core/lib/common/nls';
import { unregisterSubmenu } from '../menu/arduino-menus'; import { unregisterSubmenu } from '../menu/arduino-menus';
import { MaybePromise } from '@theia/core/lib/common/types';
import { ApplicationError } from '@theia/core/lib/common/application-error';
/**
* Creates a cloned copy of the example sketch and opens it in a new window.
*/
export async function openClonedExample(
uri: string,
services: {
sketchesService: SketchesService;
commandService: CommandService;
},
onError: {
onDidFailClone?: (
err: ApplicationError<
number,
{
uri: string;
}
>,
uri: string
) => MaybePromise<unknown>;
onDidFailOpen?: (
err: ApplicationError<
number,
{
uri: string;
}
>,
sketch: Sketch
) => MaybePromise<unknown>;
} = {}
): Promise<void> {
const { sketchesService, commandService } = services;
const { onDidFailClone, onDidFailOpen } = onError;
try {
const sketch = await sketchesService.cloneExample(uri);
try {
await commandService.executeCommand(
OpenSketch.Commands.OPEN_SKETCH.id,
sketch
);
} catch (openError) {
if (SketchesError.NotFound.is(openError)) {
if (onDidFailOpen) {
await onDidFailOpen(openError, sketch);
return;
}
}
throw openError;
}
} catch (cloneError) {
if (SketchesError.NotFound.is(cloneError)) {
if (onDidFailClone) {
await onDidFailClone(cloneError, uri);
return;
}
}
throw cloneError;
}
}
@injectable() @injectable()
export abstract class Examples extends SketchContribution { export abstract class Examples extends SketchContribution {
@ -94,7 +160,7 @@ export abstract class Examples extends SketchContribution {
// TODO: unregister submenu? https://github.com/eclipse-theia/theia/issues/7300 // TODO: unregister submenu? https://github.com/eclipse-theia/theia/issues/7300
registry.registerSubmenu( registry.registerSubmenu(
ArduinoMenus.FILE__EXAMPLES_SUBMENU, ArduinoMenus.FILE__EXAMPLES_SUBMENU,
nls.localize('arduino/examples/menu', 'Examples'), examplesLabel,
{ {
order: '4', order: '4',
} }
@ -174,47 +240,33 @@ export abstract class Examples extends SketchContribution {
} }
protected createHandler(uri: string): CommandHandler { protected createHandler(uri: string): CommandHandler {
const forceUpdate = () =>
this.update({
board: this.boardsServiceClient.boardsConfig.selectedBoard,
forceRefresh: true,
});
return { return {
execute: async () => { execute: async () => {
const sketch = await this.clone(uri); await openClonedExample(
if (sketch) { uri,
try { {
return this.commandService.executeCommand( sketchesService: this.sketchesService,
OpenSketch.Commands.OPEN_SKETCH.id, commandService: this.commandRegistry,
sketch },
); {
} catch (err) { onDidFailClone: () => {
if (SketchesError.NotFound.is(err)) {
// Do not toast the error message. It's handled by the `Open Sketch` command. // Do not toast the error message. It's handled by the `Open Sketch` command.
this.update({ forceUpdate();
board: this.boardsServiceClient.boardsConfig.selectedBoard, },
forceRefresh: true, onDidFailOpen: (err) => {
}); this.messageService.error(err.message);
} else { forceUpdate();
throw err; },
}
} }
} );
}, },
}; };
} }
private async clone(uri: string): Promise<Sketch | undefined> {
try {
const sketch = await this.sketchesService.cloneExample(uri);
return sketch;
} catch (err) {
if (SketchesError.NotFound.is(err)) {
this.messageService.error(err.message);
this.update({
board: this.boardsServiceClient.boardsConfig.selectedBoard,
forceRefresh: true,
});
} else {
throw err;
}
}
}
} }
@injectable() @injectable()

View File

@ -12,7 +12,10 @@ import {
LibrarySearch, LibrarySearch,
LibraryService, LibraryService,
} from '../../common/protocol/library-service'; } from '../../common/protocol/library-service';
import { ListWidget } from '../widgets/component-list/list-widget'; import {
ListWidget,
UserAbortError,
} from '../widgets/component-list/list-widget';
import { Installable } from '../../common/protocol'; import { Installable } from '../../common/protocol';
import { ListItemRenderer } from '../widgets/component-list/list-item-renderer'; import { ListItemRenderer } from '../widgets/component-list/list-item-renderer';
import { nls } from '@theia/core/lib/common'; import { nls } from '@theia/core/lib/common';
@ -141,6 +144,8 @@ export class LibraryListWidget extends ListWidget<
// All // All
installDependencies = true; installDependencies = true;
} }
} else {
throw new UserAbortError();
} }
} else { } else {
// The lib does not have any dependencies. // The lib does not have any dependencies.
@ -235,6 +240,21 @@ class MessageBoxDialog extends AbstractDialog<MessageBoxDialog.Result> {
this.response = 0; this.response = 0;
super.handleEnter(event); super.handleEnter(event);
} }
protected override onAfterAttach(message: Message): void {
super.onAfterAttach(message);
let buttonToFocus: HTMLButtonElement | undefined = undefined;
for (const child of Array.from(this.controlPanel.children)) {
if (child instanceof HTMLButtonElement) {
if (child.classList.contains('main')) {
buttonToFocus = child;
break;
}
buttonToFocus = child;
}
}
buttonToFocus?.focus();
}
} }
export namespace MessageBoxDialog { export namespace MessageBoxDialog {
export interface Options extends DialogProps { export interface Options extends DialogProps {

View File

@ -1,4 +1,3 @@
import { isOSX } from '@theia/core/lib/common/os';
import { CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution'; import { CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution';
import { import {
MAIN_MENU_BAR, MAIN_MENU_BAR,
@ -7,6 +6,8 @@ import {
MenuPath, MenuPath,
SubMenuOptions, SubMenuOptions,
} from '@theia/core/lib/common/menu'; } from '@theia/core/lib/common/menu';
import { nls } from '@theia/core/lib/common/nls';
import { isOSX } from '@theia/core/lib/common/os';
export namespace ArduinoMenus { export namespace ArduinoMenus {
// Main menu // Main menu
@ -173,6 +174,17 @@ export namespace ArduinoMenus {
'3_sign_out', '3_sign_out',
]; ];
// Context menu from the library and boards manager widget
export const ARDUINO_COMPONENT__CONTEXT = ['arduino-component--context'];
export const ARDUINO_COMPONENT__CONTEXT__INFO_GROUP = [
...ARDUINO_COMPONENT__CONTEXT,
'0_info',
];
export const ARDUINO_COMPONENT__CONTEXT__ACTION_GROUP = [
...ARDUINO_COMPONENT__CONTEXT,
'1_action',
];
// -- ROOT SSL CERTIFICATES // -- ROOT SSL CERTIFICATES
export const ROOT_CERTIFICATES__CONTEXT = [ export const ROOT_CERTIFICATES__CONTEXT = [
'arduino-root-certificates--context', 'arduino-root-certificates--context',
@ -230,3 +242,5 @@ export class PlaceholderMenuNode implements MenuNode {
return [...this.menuPath, 'placeholder'].join('-'); return [...this.menuPath, 'placeholder'].join('-');
} }
} }
export const examplesLabel = nls.localize('arduino/examples/menu', 'Examples');

View File

@ -165,7 +165,7 @@ div#select-board-dialog .selectBoardContainer .list .item.selected i {
border: 1px solid var(--theia-arduino-toolbar-dropdown-border); border: 1px solid var(--theia-arduino-toolbar-dropdown-border);
display: flex; display: flex;
gap: 10px; gap: 10px;
height: 28px; height: var(--arduino-button-height);
margin: 0 4px; margin: 0 4px;
overflow: hidden; overflow: hidden;
padding: 0 10px; padding: 0 10px;

View File

@ -12,7 +12,7 @@
min-width: 424px; min-width: 424px;
max-height: 560px; max-height: 560px;
padding: 0 28px; padding: 0 var(--arduino-button-height);
} }
.p-Widget.dialogOverlay .dialogBlock .dialogTitle { .p-Widget.dialogOverlay .dialogBlock .dialogTitle {
@ -35,7 +35,7 @@
} }
.p-Widget.dialogOverlay .dialogBlock .dialogContent > input { .p-Widget.dialogOverlay .dialogBlock .dialogContent > input {
margin-bottom: 28px; margin-bottom: var(--arduino-button-height);
} }
.p-Widget.dialogOverlay .dialogBlock .dialogContent > div { .p-Widget.dialogOverlay .dialogBlock .dialogContent > div {
@ -43,7 +43,7 @@
} }
.p-Widget.dialogOverlay .dialogBlock .dialogContent .dialogSection { .p-Widget.dialogOverlay .dialogBlock .dialogContent .dialogSection {
margin-top: 28px; margin-top: var(--arduino-button-height);
} }
.p-Widget.dialogOverlay .dialogBlock .dialogContent .dialogSection:first-child { .p-Widget.dialogOverlay .dialogBlock .dialogContent .dialogSection:first-child {
margin-top: 0; margin-top: 0;

View File

@ -1,5 +1,10 @@
@font-face { @font-face {
font-family: 'Open Sans'; font-family: 'Open Sans';
src: url('fonts/OpenSans-Regular-webfont.woff') format('woff');
}
@font-face {
font-family: 'Open Sans Bold';
src: url('fonts/OpenSans-Bold-webfont.woff') format('woff'); src: url('fonts/OpenSans-Bold-webfont.woff') format('woff');
} }

View File

@ -15,7 +15,7 @@
} }
.ide-updater-dialog--logo-container { .ide-updater-dialog--logo-container {
margin-right: 28px; margin-right: var(--arduino-button-height);
} }
.ide-updater-dialog--logo { .ide-updater-dialog--logo {
@ -76,7 +76,7 @@
.ide-updater-dialog .buttons-container { .ide-updater-dialog .buttons-container {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 28px; margin-top: var(--arduino-button-height);
} }
.ide-updater-dialog .buttons-container a.theia-button { .ide-updater-dialog .buttons-container a.theia-button {

View File

@ -20,6 +20,10 @@
@import './progress-bar.css'; @import './progress-bar.css';
@import './settings-step-input.css'; @import './settings-step-input.css';
:root {
--arduino-button-height: 28px;
}
/* Revive of the `--theia-icon-loading`. The variable has been removed from Theia while IDE2 still uses is. */ /* Revive of the `--theia-icon-loading`. The variable has been removed from Theia while IDE2 still uses is. */
/* The SVG icons are still part of Theia (1.31.1) */ /* The SVG icons are still part of Theia (1.31.1) */
/* https://github.com/arduino/arduino-ide/pull/1662#issuecomment-1324997134 */ /* https://github.com/arduino/arduino-ide/pull/1662#issuecomment-1324997134 */
@ -64,9 +68,9 @@ body.theia-dark {
/* Makes the sidepanel a bit wider when opening the widget */ /* Makes the sidepanel a bit wider when opening the widget */
.p-DockPanel-widget { .p-DockPanel-widget {
min-width: 200px; min-width: 220px;
min-height: 20px; min-height: 20px;
height: 200px; height: 220px;
} }
/* Overrule the default Theia CSS button styles. */ /* Overrule the default Theia CSS button styles. */
@ -74,9 +78,9 @@ button.theia-button,
.theia-button { .theia-button {
align-items: center; align-items: center;
display: flex; display: flex;
font-family: 'Open Sans',sans-serif; font-family: 'Open Sans Bold',sans-serif;
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 14px;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
@ -95,7 +99,7 @@ button.theia-button,
} }
button.theia-button { button.theia-button {
height: 28px; height: var(--arduino-button-height);
max-width: none; max-width: none;
} }
@ -154,10 +158,6 @@ button.theia-button.message-box-dialog-button {
font-size: 14px; font-size: 14px;
} }
.uppercase {
text-transform: uppercase;
}
/* High Contrast Theme rules */ /* High Contrast Theme rules */
/* TODO: Remove it when the Theia version is upgraded to 1.27.0 and use Theia APIs to implement it*/ /* TODO: Remove it when the Theia version is upgraded to 1.27.0 and use Theia APIs to implement it*/
.hc-black.hc-theia.theia-hc button.theia-button:hover, .hc-black.hc-theia.theia-hc button.theia-button:hover,

View File

@ -44,102 +44,152 @@
height: 100%; /* This has top be 100% down to the `scrollContainer`. */ height: 100%; /* This has top be 100% down to the `scrollContainer`. */
} }
.filterable-list-container .items-container > div > div:nth-child(odd) {
background-color: var(--theia-sideBar-background);
filter: contrast(105%);
}
.filterable-list-container .items-container > div > div:nth-child(even) {
background-color: var(--theia-sideBar-background);
filter: contrast(95%);
}
.filterable-list-container .items-container > div > div:hover {
background-color: var(--theia-sideBar-background);
filter: contrast(90%);
}
.component-list-item { .component-list-item {
padding: 10px 10px 10px 15px; padding: 20px 15px 25px;
font-size: var(--theia-ui-font-size1);
}
.component-list-item:hover {
cursor: pointer;
} }
.component-list-item .header { .component-list-item .header {
padding-bottom: 2px; padding-bottom: 2px;
display: flex; min-height: var(--theia-statusBar-height);
flex-direction: column;
} }
.component-list-item .header .version-info { .component-list-item .header > div {
display: flex;
}
.component-list-item .header > div .p-TabBar-toolbar {
align-self: start;
padding: unset;
margin-right: unset;
}
.component-list-item:hover .header > div .p-TabBar-toolbar > div {
visibility: visible;
}
.component-list-item .header > div .p-TabBar-toolbar > div {
visibility: hidden;
}
.component-list-item .header .title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
}
.component-list-item .header .title .name {
font-family: 'Open Sans Bold';
font-style: normal;
font-weight: 700;
font-size: 14px;
}
.component-list-item .header .version {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.component-list-item .header .name {
font-weight: bold;
}
.component-list-item .header .author { .component-list-item .header .author {
font-weight: bold;
color: var(--theia-panelTitle-inactiveForeground); color: var(--theia-panelTitle-inactiveForeground);
} }
.component-list-item:hover .header .author {
color: var(--theia-foreground);
}
.component-list-item .header .version { .component-list-item .header .version {
color: var(--theia-panelTitle-inactiveForeground); color: var(--theia-panelTitle-inactiveForeground);
padding-top: 4px;
} }
.component-list-item .footer .theia-button.install { .component-list-item .footer .theia-button.install {
height: auto; /* resets the default Theia button height in the filterable list widget */ height: auto; /* resets the default Theia button height in the filterable list widget */
} }
.component-list-item .header .installed:before { .component-list-item .header .installed-version:before {
margin-left: 4px; min-width: 79px;
display: inline-block; display: inline-block;
justify-self: end; justify-self: end;
background-color: var(--theia-button-background); text-align: center;
background-color: var(--theia-arduino-toolbar-dropdown-option-backgroundHover);
padding: 2px 4px 2px 4px; padding: 2px 4px 2px 4px;
font-size: 10px; font-size: 12px;
font-weight: bold;
max-height: calc(1em + 4px); max-height: calc(1em + 4px);
color: var(--theia-button-foreground);
content: attr(install);
}
.component-list-item .header .installed:hover:before {
background-color: var(--theia-button-foreground);
color: var(--theia-button-background); color: var(--theia-button-background);
content: attr(uninstall); content: attr(version);
cursor: pointer;
border-radius: 4px;
} }
.component-list-item[min-width~="170px"] .footer { .component-list-item .header .installed-version:hover:before {
padding: 5px 5px 0px 0px; content: attr(remove);
min-height: 35px; text-transform: uppercase;
}
.component-list-item .content {
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: column;
padding-top: 4px;
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-size: 12px;
}
.component-list-item .content > p {
margin-block-start: unset;
margin-block-end: unset;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
}
.component-list-item .content > .info {
white-space: nowrap;
} }
.component-list-item .footer { .component-list-item .footer {
flex-direction: column-reverse; flex-direction: column-reverse;
padding-top: 8px;
} }
.component-list-item .footer > * { .component-list-item .footer > * {
display: inline-block; display: inline-block;
margin: 5px 0px 0px 10px; }
.filterable-list-container .separator {
display: flex;
flex-direction: row;
}
.filterable-list-container .separator :last-child,
.filterable-list-container .separator :first-child {
min-height: 8px;
max-height: 8px;
min-width: 8px;
max-width: 8px;
}
div.filterable-list-container > div > div > div > div:nth-child(1) > div.separator :first-child,
div.filterable-list-container > div > div > div > div:nth-child(1) > div.separator :last-child {
display: none;
}
.filterable-list-container .separator .line {
max-height: 1px;
height: 1px;
background-color: var(--theia-activityBar-inactiveForeground);
flex: 1 1 auto;
} }
.component-list-item:hover .footer > label { .component-list-item:hover .footer > label {
display: inline-block; display: inline-block;
align-self: center; align-self: center;
margin: 5px 0px 0px 10px;
} }
.component-list-item .info a { .component-list-item .info a {
@ -151,13 +201,33 @@
text-decoration: underline; text-decoration: underline;
} }
/* High Contrast Theme rules */ .component-list-item .theia-button.secondary.no-border {
/* TODO: Remove it when the Theia version is upgraded to 1.27.0 and use Theia APIs to implement it*/ border: 2px solid var(--theia-button-foreground)
.hc-black.hc-theia.theia-hc .component-list-item .header .installed:hover:before {
background-color: transparent;
outline: 1px dashed var(--theia-focusBorder);
} }
.hc-black.hc-theia.theia-hc .component-list-item .header .installed:before { .component-list-item .theia-button.secondary.no-border:hover {
border: 2px solid var(--theia-secondaryButton-foreground)
}
.component-list-item .theia-button {
margin-left: 12px;
}
.component-list-item .theia-select {
height: var(--arduino-button-height);
min-height: var(--arduino-button-height);
width: 65px;
min-width: 65px;
}
/* High Contrast Theme rules */
/* TODO: Remove it when the Theia version is upgraded to 1.27.0 and use Theia APIs to implement it*/
.hc-black.hc-theia.theia-hc .component-list-item .header .installed-version:hover:before {
background-color: transparent;
outline: 1px dashed var(--theia-focusBorder);
}
.hc-black.hc-theia.theia-hc .component-list-item .header .installed-version:before {
color: var(--theia-button-background);
border: 1px solid var(--theia-button-border); border: 1px solid var(--theia-button-border);
} }

View File

@ -28,8 +28,8 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 28px; height: var(--arduino-button-height);
width: 28px; width: var(--arduino-button-height);
} }
.p-TabBar-toolbar .item.arduino-tool-item .arduino-upload-sketch--toolbar, .p-TabBar-toolbar .item.arduino-tool-item .arduino-upload-sketch--toolbar,
@ -66,8 +66,8 @@
} }
.arduino-tool-icon { .arduino-tool-icon {
height: 28px; height: var(--arduino-button-height);
width: 28px; width: var(--arduino-button-height);
} }
.arduino-verify-sketch--toolbar-icon { .arduino-verify-sketch--toolbar-icon {

View File

@ -1,60 +1,76 @@
import * as React from '@theia/core/shared/react'; import * as React from '@theia/core/shared/react';
import type { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { Installable } from '../../../common/protocol/installable'; import { Installable } from '../../../common/protocol/installable';
import { ArduinoComponent } from '../../../common/protocol/arduino-component'; import type { ListItemRenderer } from './list-item-renderer';
import { ListItemRenderer } from './list-item-renderer'; import { UserAbortError } from './list-widget';
export class ComponentListItem< export class ComponentListItem<
T extends ArduinoComponent T extends ArduinoComponent
> extends React.Component<ComponentListItem.Props<T>, ComponentListItem.State> { > extends React.Component<ComponentListItem.Props<T>, ComponentListItem.State> {
constructor(props: ComponentListItem.Props<T>) { constructor(props: ComponentListItem.Props<T>) {
super(props); super(props);
if (props.item.installable) { this.state = {};
const version = props.item.availableVersions.filter(
(version) => version !== props.item.installedVersion
)[0];
this.state = {
selectedVersion: version,
};
}
} }
override render(): React.ReactNode { override render(): React.ReactNode {
const { item, itemRenderer } = this.props; const { item, itemRenderer } = this.props;
const selectedVersion =
this.props.edited?.item.name === item.name
? this.props.edited.selectedVersion
: this.latestVersion;
return ( return (
<> <>
{itemRenderer.renderItem( {itemRenderer.renderItem({
Object.assign(this.state, { item }), item,
this.install.bind(this), selectedVersion,
this.uninstall.bind(this), inProgress: this.state.inProgress,
this.onVersionChange.bind(this) install: (item) => this.install(item),
)} uninstall: (item) => this.uninstall(item),
onVersionChange: (version) => this.onVersionChange(version),
})}
</> </>
); );
} }
private async install(item: T): Promise<void> { private async install(item: T): Promise<void> {
const toInstall = this.state.selectedVersion; await this.withState('installing', () =>
const version = this.props.item.availableVersions.filter( this.props.install(
(version) => version !== this.state.selectedVersion item,
)[0]; this.props.edited?.item.name === item.name
this.setState({ ? this.props.edited.selectedVersion
selectedVersion: version, : Installable.latest(this.props.item.availableVersions)
}); )
try { );
await this.props.install(item, toInstall);
} catch {
this.setState({
selectedVersion: toInstall,
});
}
} }
private async uninstall(item: T): Promise<void> { private async uninstall(item: T): Promise<void> {
await this.props.uninstall(item); await this.withState('uninstalling', () => this.props.uninstall(item));
}
private async withState(
inProgress: 'installing' | 'uninstalling',
task: () => Promise<unknown>
): Promise<void> {
this.setState({ inProgress });
try {
await task();
} catch (err) {
if (err instanceof UserAbortError) {
// No state update when user cancels the task
return;
}
throw err;
} finally {
this.setState({ inProgress: undefined });
}
} }
private onVersionChange(version: Installable.Version): void { private onVersionChange(version: Installable.Version): void {
this.setState({ selectedVersion: version }); this.props.onItemEdit(this.props.item, version);
}
private get latestVersion(): Installable.Version | undefined {
return Installable.latest(this.props.item.availableVersions);
} }
} }
@ -63,10 +79,18 @@ export namespace ComponentListItem {
readonly item: T; readonly item: T;
readonly install: (item: T, version?: Installable.Version) => Promise<void>; readonly install: (item: T, version?: Installable.Version) => Promise<void>;
readonly uninstall: (item: T) => Promise<void>; readonly uninstall: (item: T) => Promise<void>;
readonly edited?: {
item: T;
selectedVersion: Installable.Version;
};
readonly onItemEdit: (
item: T,
selectedVersion: Installable.Version
) => void;
readonly itemRenderer: ListItemRenderer<T>; readonly itemRenderer: ListItemRenderer<T>;
} }
export interface State { export interface State {
selectedVersion?: Installable.Version; inProgress?: 'installing' | 'uninstalling' | undefined;
} }
} }

View File

@ -1,148 +1,32 @@
import 'react-virtualized/styles.css';
import * as React from '@theia/core/shared/react'; import * as React from '@theia/core/shared/react';
import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; import { Virtuoso } from '@theia/core/shared/react-virtuoso';
import {
CellMeasurer,
CellMeasurerCache,
} from 'react-virtualized/dist/commonjs/CellMeasurer';
import type {
ListRowProps,
ListRowRenderer,
} from 'react-virtualized/dist/commonjs/List';
import List from 'react-virtualized/dist/commonjs/List';
import { ArduinoComponent } from '../../../common/protocol/arduino-component'; import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { Installable } from '../../../common/protocol/installable'; import { Installable } from '../../../common/protocol/installable';
import { ComponentListItem } from './component-list-item'; import { ComponentListItem } from './component-list-item';
import { ListItemRenderer } from './list-item-renderer'; import { ListItemRenderer } from './list-item-renderer';
function sameAs<T>(
left: T[],
right: T[],
...compareProps: (keyof T)[]
): boolean {
if (left === right) {
return true;
}
const leftLength = left.length;
if (leftLength !== right.length) {
return false;
}
for (let i = 0; i < leftLength; i++) {
for (const prop of compareProps) {
const leftValue = left[i][prop];
const rightValue = right[i][prop];
if (leftValue !== rightValue) {
return false;
}
}
}
return true;
}
export class ComponentList<T extends ArduinoComponent> extends React.Component< export class ComponentList<T extends ArduinoComponent> extends React.Component<
ComponentList.Props<T> ComponentList.Props<T>
> { > {
private readonly cache: CellMeasurerCache;
private resizeAllFlag: boolean;
private list: List | undefined;
private mostRecentWidth: number | undefined;
constructor(props: ComponentList.Props<T>) {
super(props);
this.cache = new CellMeasurerCache({
defaultHeight: 140,
fixedWidth: true,
});
}
override render(): React.ReactNode { override render(): React.ReactNode {
return ( return (
<AutoSizer> <Virtuoso
{({ width, height }) => { data={this.props.items}
if (this.mostRecentWidth && this.mostRecentWidth !== width) { itemContent={(_: number, item: T) => (
this.resizeAllFlag = true; <ComponentListItem<T>
setTimeout(() => this.clearAll(), 0); key={this.props.itemLabel(item)}
} item={item}
this.mostRecentWidth = width; itemRenderer={this.props.itemRenderer}
return ( install={this.props.install}
<List uninstall={this.props.uninstall}
className={'items-container'} edited={this.props.edited}
rowRenderer={this.createItem} onItemEdit={this.props.onItemEdit}
height={height} />
width={width}
rowCount={this.props.items.length}
rowHeight={this.cache.rowHeight}
deferredMeasurementCache={this.cache}
ref={this.setListRef}
estimatedRowSize={140}
// If default value, then `react-virtualized` will optimize and list item will not receive a `:hover` event.
// Hence, install and version `<select>` won't be visible even if the mouse cursor is over the `<div>`.
// See https://github.com/bvaughn/react-virtualized/blob/005be24a608add0344284053dae7633be86053b2/source/Grid/Grid.js#L38-L42
scrollingResetTimeInterval={0}
/>
);
}}
</AutoSizer>
);
}
override componentDidUpdate(prevProps: ComponentList.Props<T>): void {
if (
this.resizeAllFlag ||
!sameAs(this.props.items, prevProps.items, 'name', 'installedVersion')
) {
this.clearAll(true);
}
}
private readonly setListRef = (ref: List | null): void => {
this.list = ref || undefined;
};
private clearAll(scrollToTop = false): void {
this.resizeAllFlag = false;
this.cache.clearAll();
if (this.list) {
this.list.recomputeRowHeights();
if (scrollToTop) {
this.list.scrollToPosition(0);
}
}
}
private readonly createItem: ListRowRenderer = ({
index,
parent,
key,
style,
}: ListRowProps): React.ReactNode => {
const item = this.props.items[index];
return (
<CellMeasurer
cache={this.cache}
columnIndex={0}
key={key}
rowIndex={index}
parent={parent}
>
{({ registerChild }) => (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<div ref={registerChild} style={style}>
<ComponentListItem<T>
key={this.props.itemLabel(item)}
item={item}
itemRenderer={this.props.itemRenderer}
install={this.props.install}
uninstall={this.props.uninstall}
/>
</div>
)} )}
</CellMeasurer> />
); );
}; }
} }
export namespace ComponentList { export namespace ComponentList {
export interface Props<T extends ArduinoComponent> { export interface Props<T extends ArduinoComponent> {
readonly items: T[]; readonly items: T[];
@ -150,5 +34,13 @@ export namespace ComponentList {
readonly itemRenderer: ListItemRenderer<T>; readonly itemRenderer: ListItemRenderer<T>;
readonly install: (item: T, version?: Installable.Version) => Promise<void>; readonly install: (item: T, version?: Installable.Version) => Promise<void>;
readonly uninstall: (item: T) => Promise<void>; readonly uninstall: (item: T) => Promise<void>;
readonly edited?: {
item: T;
selectedVersion: Installable.Version;
};
readonly onItemEdit: (
item: T,
selectedVersion: Installable.Version
) => void;
} }
} }

View File

@ -15,6 +15,7 @@ import { ListItemRenderer } from './list-item-renderer';
import { ResponseServiceClient } from '../../../common/protocol'; import { ResponseServiceClient } from '../../../common/protocol';
import { nls } from '@theia/core/lib/common'; import { nls } from '@theia/core/lib/common';
import { FilterRenderer } from './filter-renderer'; import { FilterRenderer } from './filter-renderer';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
export class FilterableListContainer< export class FilterableListContainer<
T extends ArduinoComponent, T extends ArduinoComponent,
@ -23,21 +24,30 @@ export class FilterableListContainer<
FilterableListContainer.Props<T, S>, FilterableListContainer.Props<T, S>,
FilterableListContainer.State<T, S> FilterableListContainer.State<T, S>
> { > {
private readonly toDispose: DisposableCollection;
constructor(props: Readonly<FilterableListContainer.Props<T, S>>) { constructor(props: Readonly<FilterableListContainer.Props<T, S>>) {
super(props); super(props);
this.state = { this.state = {
searchOptions: props.defaultSearchOptions, searchOptions: props.defaultSearchOptions,
items: [], items: [],
}; };
this.toDispose = new DisposableCollection();
} }
override componentDidMount(): void { override componentDidMount(): void {
this.search = debounce(this.search, 500, { trailing: true }); this.search = debounce(this.search, 500, { trailing: true });
this.search(this.state.searchOptions); this.search(this.state.searchOptions);
this.props.searchOptionsDidChange((newSearchOptions) => { this.toDispose.pushAll([
const { searchOptions } = this.state; this.props.searchOptionsDidChange((newSearchOptions) => {
this.setSearchOptionsAndUpdate({ ...searchOptions, ...newSearchOptions }); const { searchOptions } = this.state;
}); this.setSearchOptionsAndUpdate({
...searchOptions,
...newSearchOptions,
});
}),
this.props.onDidShow(() => this.setState({ edited: undefined })),
]);
} }
override componentDidUpdate(): void { override componentDidUpdate(): void {
@ -46,6 +56,10 @@ export class FilterableListContainer<
this.props.container.updateScrollBar(); this.props.container.updateScrollBar();
} }
override componentWillUnmount(): void {
this.toDispose.dispose();
}
override render(): React.ReactNode { override render(): React.ReactNode {
return ( return (
<div className={'filterable-list-container'}> <div className={'filterable-list-container'}>
@ -90,11 +104,13 @@ export class FilterableListContainer<
itemRenderer={itemRenderer} itemRenderer={itemRenderer}
install={this.install.bind(this)} install={this.install.bind(this)}
uninstall={this.uninstall.bind(this)} uninstall={this.uninstall.bind(this)}
edited={this.state.edited}
onItemEdit={this.onItemEdit.bind(this)}
/> />
); );
} }
protected handlePropChange = (prop: keyof S, value: S[keyof S]): void => { private handlePropChange = (prop: keyof S, value: S[keyof S]): void => {
const searchOptions = { const searchOptions = {
...this.state.searchOptions, ...this.state.searchOptions,
[prop]: value, [prop]: value,
@ -106,15 +122,14 @@ export class FilterableListContainer<
this.setState({ searchOptions }, () => this.search(searchOptions)); this.setState({ searchOptions }, () => this.search(searchOptions));
} }
protected search(searchOptions: S): void { private search(searchOptions: S): void {
const { searchable } = this.props; const { searchable } = this.props;
searchable.search(searchOptions).then((items) => this.setState({ items })); searchable
.search(searchOptions)
.then((items) => this.setState({ items, edited: undefined }));
} }
protected async install( private async install(item: T, version: Installable.Version): Promise<void> {
item: T,
version: Installable.Version
): Promise<void> {
const { install, searchable } = this.props; const { install, searchable } = this.props;
await ExecuteWithProgress.doWithProgress({ await ExecuteWithProgress.doWithProgress({
...this.props, ...this.props,
@ -124,10 +139,10 @@ export class FilterableListContainer<
run: ({ progressId }) => install({ item, progressId, version }), run: ({ progressId }) => install({ item, progressId, version }),
}); });
const items = await searchable.search(this.state.searchOptions); const items = await searchable.search(this.state.searchOptions);
this.setState({ items }); this.setState({ items, edited: undefined });
} }
protected async uninstall(item: T): Promise<void> { private async uninstall(item: T): Promise<void> {
const ok = await new ConfirmDialog({ const ok = await new ConfirmDialog({
title: nls.localize('arduino/component/uninstall', 'Uninstall'), title: nls.localize('arduino/component/uninstall', 'Uninstall'),
msg: nls.localize( msg: nls.localize(
@ -152,7 +167,11 @@ export class FilterableListContainer<
run: ({ progressId }) => uninstall({ item, progressId }), run: ({ progressId }) => uninstall({ item, progressId }),
}); });
const items = await searchable.search(this.state.searchOptions); const items = await searchable.search(this.state.searchOptions);
this.setState({ items }); this.setState({ items, edited: undefined });
}
private onItemEdit(item: T, selectedVersion: Installable.Version): void {
this.setState({ edited: { item, selectedVersion } });
} }
} }
@ -171,6 +190,7 @@ export namespace FilterableListContainer {
readonly searchOptionsDidChange: Event<Partial<S> | undefined>; readonly searchOptionsDidChange: Event<Partial<S> | undefined>;
readonly messageService: MessageService; readonly messageService: MessageService;
readonly responseService: ResponseServiceClient; readonly responseService: ResponseServiceClient;
readonly onDidShow: Event<void>;
readonly install: ({ readonly install: ({
item, item,
progressId, progressId,
@ -193,5 +213,9 @@ export namespace FilterableListContainer {
export interface State<T, S extends Searchable.Options> { export interface State<T, S extends Searchable.Options> {
searchOptions: S; searchOptions: S;
items: T[]; items: T[];
edited?: {
item: T;
selectedVersion: Installable.Version;
};
} }
} }

View File

@ -1,137 +1,783 @@
import * as React from '@theia/core/shared/react'; import { ApplicationError } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify'; import {
Anchor,
ContextMenuRenderer,
} from '@theia/core/lib/browser/context-menu-renderer';
import { TabBarToolbar } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { codicon } from '@theia/core/lib/browser/widgets/widget';
import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { Installable } from '../../../common/protocol/installable'; import {
import { ArduinoComponent } from '../../../common/protocol/arduino-component'; CommandHandler,
import { ComponentListItem } from './component-list-item'; CommandRegistry,
import { nls } from '@theia/core/lib/common'; CommandService,
} from '@theia/core/lib/common/command';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import {
MenuModelRegistry,
MenuPath,
SubMenuOptions,
} from '@theia/core/lib/common/menu';
import { MessageService } from '@theia/core/lib/common/message-service';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { Unknown } from '../../../common/nls'; import { Unknown } from '../../../common/nls';
import {
CoreService,
ExamplesService,
LibraryPackage,
Sketch,
SketchContainer,
SketchesService,
SketchRef,
} from '../../../common/protocol';
import type { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { Installable } from '../../../common/protocol/installable';
import { openClonedExample } from '../../contributions/examples';
import {
ArduinoMenus,
examplesLabel,
unregisterSubmenu,
} from '../../menu/arduino-menus';
const moreInfoLabel = nls.localize('arduino/component/moreInfo', 'More info');
const otherVersionsLabel = nls.localize(
'arduino/component/otherVersions',
'Other Versions'
);
const installLabel = nls.localize('arduino/component/install', 'Install');
const installLatestLabel = nls.localize(
'arduino/component/installLatest',
'Install Latest'
);
function installVersionLabel(selectedVersion: string) {
return nls.localize(
'arduino/component/installVersion',
'Install {0}',
selectedVersion
);
}
const updateLabel = nls.localize('arduino/component/update', 'Update');
const removeLabel = nls.localize('arduino/component/remove', 'Remove');
const byLabel = nls.localize('arduino/component/by', 'by');
function nameAuthorLabel(name: string, author: string) {
return nls.localize('arduino/component/title', '{0} by {1}', name, author);
}
function installedLabel(installedVersion: string) {
return nls.localize(
'arduino/component/installed',
'{0} installed',
installedVersion
);
}
function clickToOpenInBrowserLabel(href: string): string | undefined {
return nls.localize(
'arduino/component/clickToOpen',
'Click to open in browser: {0}',
href
);
}
interface MenuTemplate {
readonly menuLabel: string;
}
interface MenuActionTemplate extends MenuTemplate {
readonly menuPath: MenuPath;
readonly handler: CommandHandler;
/**
* If not defined the insertion oder will be the order string.
*/
readonly order?: string;
}
interface SubmenuTemplate extends MenuTemplate {
readonly menuLabel: string;
readonly submenuPath: MenuPath;
readonly options?: SubMenuOptions;
}
function isMenuTemplate(arg: unknown): arg is MenuTemplate {
return (
typeof arg === 'object' &&
(arg as MenuTemplate).menuLabel !== undefined &&
typeof (arg as MenuTemplate).menuLabel === 'string'
);
}
function isMenuActionTemplate(arg: MenuTemplate): arg is MenuActionTemplate {
return (
isMenuTemplate(arg) &&
(arg as MenuActionTemplate).handler !== undefined &&
typeof (arg as MenuActionTemplate).handler === 'object' &&
(arg as MenuActionTemplate).menuPath !== undefined &&
Array.isArray((arg as MenuActionTemplate).menuPath)
);
}
@injectable()
export class ArduinoComponentContextMenuRenderer {
@inject(CommandRegistry)
private readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry)
private readonly menuRegistry: MenuModelRegistry;
@inject(ContextMenuRenderer)
private readonly contextMenuRenderer: ContextMenuRenderer;
private readonly toDisposeBeforeRender = new DisposableCollection();
private menuIndexCounter = 0;
async render(
anchor: Anchor,
...templates: (MenuActionTemplate | SubmenuTemplate)[]
): Promise<void> {
this.toDisposeBeforeRender.dispose();
this.toDisposeBeforeRender.pushAll([
Disposable.create(() => (this.menuIndexCounter = 0)),
...templates.map((template) => this.registerMenu(template)),
]);
const options = {
menuPath: ArduinoMenus.ARDUINO_COMPONENT__CONTEXT,
anchor,
showDisabled: true,
};
this.contextMenuRenderer.render(options);
}
private registerMenu(
template: MenuActionTemplate | SubmenuTemplate
): Disposable {
if (isMenuActionTemplate(template)) {
const { menuLabel, menuPath, handler, order } = template;
const id = this.generateCommandId(menuLabel, menuPath);
const index = this.menuIndexCounter++;
return new DisposableCollection(
this.commandRegistry.registerCommand({ id }, handler),
this.menuRegistry.registerMenuAction(menuPath, {
commandId: id,
label: menuLabel,
order: typeof order === 'string' ? order : String(index).padStart(4),
})
);
} else {
const { menuLabel, submenuPath, options } = template;
return new DisposableCollection(
this.menuRegistry.registerSubmenu(submenuPath, menuLabel, options),
Disposable.create(() =>
unregisterSubmenu(submenuPath, this.menuRegistry)
)
);
}
}
private generateCommandId(menuLabel: string, menuPath: MenuPath): string {
return `arduino--component-context-${menuPath.join('-')}-${menuLabel}`;
}
}
interface ListItemRendererParams<T extends ArduinoComponent> {
readonly item: T;
readonly selectedVersion: Installable.Version | undefined;
readonly inProgress?: 'installing' | 'uninstalling' | undefined;
readonly install: (item: T) => Promise<void>;
readonly uninstall: (item: T) => Promise<void>;
readonly onVersionChange: (version: Installable.Version) => void;
}
interface ListItemRendererServices {
readonly windowService: WindowService;
readonly messagesService: MessageService;
readonly commandService: CommandService;
readonly coreService: CoreService;
readonly examplesService: ExamplesService;
readonly sketchesService: SketchesService;
readonly contextMenuRenderer: ArduinoComponentContextMenuRenderer;
}
@injectable() @injectable()
export class ListItemRenderer<T extends ArduinoComponent> { export class ListItemRenderer<T extends ArduinoComponent> {
@inject(WindowService) @inject(WindowService)
protected windowService: WindowService; private readonly windowService: WindowService;
@inject(MessageService)
private readonly messageService: MessageService;
@inject(CommandService)
private readonly commandService: CommandService;
@inject(CoreService)
private readonly coreService: CoreService;
@inject(ExamplesService)
private readonly examplesService: ExamplesService;
@inject(SketchesService)
private readonly sketchesService: SketchesService;
@inject(ArduinoComponentContextMenuRenderer)
private readonly contextMenuRenderer: ArduinoComponentContextMenuRenderer;
protected onMoreInfoClick = ( private readonly onMoreInfo = (href: string | undefined): void => {
event: React.SyntheticEvent<HTMLAnchorElement, Event> if (href) {
): void => { this.windowService.openNewWindow(href, { external: true });
const { target } = event.nativeEvent;
if (target instanceof HTMLAnchorElement) {
this.windowService.openNewWindow(target.href, { external: true });
event.nativeEvent.preventDefault();
} }
}; };
renderItem( renderItem(params: ListItemRendererParams<T>): React.ReactNode {
input: ComponentListItem.State & { item: T }, const action = this.action(params);
install: (item: T) => Promise<void>,
uninstall: (item: T) => Promise<void>,
onVersionChange: (version: Installable.Version) => void
): React.ReactNode {
const { item } = input;
let nameAndAuthor: JSX.Element;
if (item.name && item.author) {
const name = <span className="name">{item.name}</span>;
const author = <span className="author">{item.author}</span>;
nameAndAuthor = (
<span>
{name} {nls.localize('arduino/component/by', 'by')} {author}
</span>
);
} else if (item.name) {
nameAndAuthor = <span className="name">{item.name}</span>;
} else if ((item as any).id) {
nameAndAuthor = <span className="name">{(item as any).id}</span>;
} else {
nameAndAuthor = <span className="name">{Unknown}</span>;
}
const onClickUninstall = () => uninstall(item);
const installedVersion = !!item.installedVersion && (
<div className="version-info">
<span className="version">
{nls.localize(
'arduino/component/version',
'Version {0}',
item.installedVersion
)}
</span>
<span
className="installed uppercase"
onClick={onClickUninstall}
{...{
install: nls.localize('arduino/component/installed', 'Installed'),
uninstall: nls.localize('arduino/component/uninstall', 'Uninstall'),
}}
/>
</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.onMoreInfoClick}>
{nls.localize('arduino/component/moreInfo', 'More info')}
</a>
);
const onClickInstall = () => install(item);
const installButton = item.installable && (
<button
className="theia-button secondary install uppercase"
onClick={onClickInstall}
>
{nls.localize('arduino/component/install', 'Install')}
</button>
);
const onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const version = event.target.value;
if (version) {
onVersionChange(version);
}
};
const versions = (() => {
const { availableVersions } = item;
if (availableVersions.length === 0) {
return undefined;
} else if (availableVersions.length === 1) {
return <label>{availableVersions[0]}</label>;
} else {
return (
<select
className="theia-select"
value={input.selectedVersion}
onChange={onSelectChange}
>
{item.availableVersions
.filter((version) => version !== item.installedVersion) // Filter the version that is currently installed.
.map((version) => (
<option value={version} key={version}>
{version}
</option>
))}
</select>
);
}
})();
return ( return (
<div className="component-list-item noselect"> <>
<div className="header"> <Separator />
{nameAndAuthor} <div className="component-list-item noselect">
{installedVersion} <Header
</div> params={params}
<div className="content"> action={action}
{summary} services={this.services}
{description} onMoreInfo={this.onMoreInfo}
</div> />
<div className="info">{moreInfo}</div> <Content params={params} onMoreInfo={this.onMoreInfo} />
<div className="footer"> <Footer params={params} action={action} />
{versions}
{installButton}
</div> </div>
</>
);
}
private action(params: ListItemRendererParams<T>): Installable.Action {
const {
item: { installedVersion, availableVersions },
selectedVersion,
} = params;
return Installable.action({
installed: installedVersion,
available: availableVersions,
selected: selectedVersion,
});
}
private get services(): ListItemRendererServices {
return {
windowService: this.windowService,
messagesService: this.messageService,
commandService: this.commandService,
coreService: this.coreService,
sketchesService: this.sketchesService,
examplesService: this.examplesService,
contextMenuRenderer: this.contextMenuRenderer,
};
}
}
class Separator extends React.Component {
override render(): React.ReactNode {
return (
<div className="separator">
<div />
<div className="line" />
<div />
</div> </div>
); );
} }
} }
class Header<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
action: Installable.Action;
services: ListItemRendererServices;
onMoreInfo: (href: string | undefined) => void;
}>
> {
override render(): React.ReactNode {
return (
<div className="header">
<div>
<Title {...this.props} />
<Toolbar {...this.props} />
</div>
<InstalledVersion {...this.props} />
</div>
);
}
}
class Toolbar<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
action: Installable.Action;
services: ListItemRendererServices;
onMoreInfo: (href: string | undefined) => void;
}>
> {
private readonly onClick = (event: React.MouseEvent): void => {
event.stopPropagation();
event.preventDefault();
const anchor = this.toAnchor(event);
this.showContextMenu(anchor);
};
override render(): React.ReactNode {
return (
<div className={TabBarToolbar.Styles.TAB_BAR_TOOLBAR}>
<div className={`${TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM} enabled`}>
<div
id="__more__"
className={codicon('ellipsis', true)}
title={nls.localizeByDefault('More Actions...')}
onClick={this.onClick}
/>
</div>
</div>
);
}
private toAnchor(event: React.MouseEvent): Anchor {
const itemBox = event.currentTarget
.closest('.' + TabBarToolbar.Styles.TAB_BAR_TOOLBAR_ITEM)
?.getBoundingClientRect();
return itemBox
? {
y: itemBox.bottom + itemBox.height / 2,
x: itemBox.left,
}
: event.nativeEvent;
}
private async showContextMenu(anchor: Anchor): Promise<void> {
this.props.services.contextMenuRenderer.render(
anchor,
this.moreInfo,
...(await this.examples),
...this.otherVersions,
...this.actions
);
}
private get moreInfo(): MenuActionTemplate {
const {
params: {
item: { moreInfoLink },
},
} = this.props;
return {
menuLabel: moreInfoLabel,
menuPath: ArduinoMenus.ARDUINO_COMPONENT__CONTEXT,
handler: {
execute: () => this.props.onMoreInfo(moreInfoLink),
isEnabled: () => Boolean(moreInfoLink),
},
};
}
private get examples(): Promise<(MenuActionTemplate | SubmenuTemplate)[]> {
const {
params: {
item,
item: { installedVersion, name },
},
services: { examplesService },
} = this.props;
// TODO: `LibraryPackage.is` should not be here but it saves one extra `lib list`
// gRPC equivalent call with the name of a platform which will result an empty array.
if (!LibraryPackage.is(item) || !installedVersion) {
return Promise.resolve([]);
}
const submenuPath = [
...ArduinoMenus.ARDUINO_COMPONENT__CONTEXT,
'examples',
];
return examplesService.find({ libraryName: name }).then((containers) => [
{
submenuPath,
menuLabel: examplesLabel,
options: { order: String(0) },
},
...containers
.map((container) => this.flattenContainers(container, submenuPath))
.reduce((acc, curr) => acc.concat(curr), []),
]);
}
private flattenContainers(
container: SketchContainer,
menuPath: MenuPath,
depth = 0
): (MenuActionTemplate | SubmenuTemplate)[] {
const templates: (MenuActionTemplate | SubmenuTemplate)[] = [];
const { label } = container;
if (depth > 0) {
menuPath = [...menuPath, label];
templates.push({
submenuPath: menuPath,
menuLabel: label,
options: { order: label.toLocaleLowerCase() },
});
}
return templates
.concat(
...container.sketches.map((sketch) =>
this.sketchToMenuTemplate(sketch, menuPath)
)
)
.concat(
container.children
.map((childContainer) =>
this.flattenContainers(childContainer, menuPath, ++depth)
)
.reduce((acc, curr) => acc.concat(curr), [])
);
}
private sketchToMenuTemplate(
sketch: SketchRef,
menuPath: MenuPath
): MenuActionTemplate {
const { name, uri } = sketch;
const { sketchesService, commandService } = this.props.services;
return {
menuLabel: name,
menuPath,
handler: {
execute: () =>
openClonedExample(
uri,
{ sketchesService, commandService },
this.onExampleOpenError
),
},
order: name.toLocaleLowerCase(),
};
}
private get onExampleOpenError(): {
onDidFailClone: (
err: ApplicationError<number, unknown>,
uri: string
) => unknown;
onDidFailOpen: (
err: ApplicationError<number, unknown>,
sketch: Sketch
) => unknown;
} {
const {
services: { messagesService, coreService },
} = this.props;
const handle = async (err: ApplicationError<number, unknown>) => {
messagesService.error(err.message);
return coreService.refresh();
};
return {
onDidFailClone: handle,
onDidFailOpen: handle,
};
}
private get otherVersions(): (MenuActionTemplate | SubmenuTemplate)[] {
const {
params: {
item: { availableVersions },
selectedVersion,
onVersionChange,
},
} = this.props;
const submenuPath = [
...ArduinoMenus.ARDUINO_COMPONENT__CONTEXT,
'other-versions',
];
return [
{
submenuPath,
menuLabel: otherVersionsLabel,
options: { order: String(1) },
},
...availableVersions
.filter((version) => version !== selectedVersion)
.map((version) => ({
menuPath: submenuPath,
menuLabel: version,
handler: {
execute: () => onVersionChange(version),
},
})),
];
}
private get actions(): MenuActionTemplate[] {
const {
action,
params: {
item,
item: { availableVersions, installedVersion },
install,
uninstall,
selectedVersion,
},
} = this.props;
const removeAction = {
menuLabel: removeLabel,
menuPath: ArduinoMenus.ARDUINO_COMPONENT__CONTEXT__ACTION_GROUP,
handler: {
execute: () => uninstall(item),
},
};
const installAction = {
menuLabel: installVersionLabel(
selectedVersion ?? Installable.latest(availableVersions) ?? ''
),
menuPath: ArduinoMenus.ARDUINO_COMPONENT__CONTEXT__ACTION_GROUP,
handler: {
execute: () => install(item),
},
};
const installLatestAction = {
menuLabel: installLatestLabel,
menuPath: ArduinoMenus.ARDUINO_COMPONENT__CONTEXT__ACTION_GROUP,
handler: {
execute: () => install(item),
},
};
const updateAction = {
menuLabel: updateLabel,
menuPath: ArduinoMenus.ARDUINO_COMPONENT__CONTEXT__ACTION_GROUP,
handler: {
execute: () => install(item),
},
};
switch (action) {
case 'unknown':
return [];
case 'remove': {
return [removeAction];
}
case 'update': {
return [removeAction, updateAction];
}
case 'installLatest':
return [
...(Boolean(installedVersion) ? [removeAction] : []),
installLatestAction,
];
case 'installSelected': {
return [
...(Boolean(installedVersion) ? [removeAction] : []),
installAction,
];
}
}
}
}
class Title<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
}>
> {
override render(): React.ReactNode {
const { name, author } = this.props.params.item;
const title =
name && author ? nameAuthorLabel(name, author) : name ? name : Unknown;
return (
<div className="title" title={title}>
{name && author ? (
<>
{<span className="name">{name}</span>}{' '}
{<span className="author">{`${byLabel} ${author}`}</span>}
</>
) : name ? (
<span className="name">{name}</span>
) : (
<span className="name">{Unknown}</span>
)}
</div>
);
}
}
class InstalledVersion<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
}>
> {
private readonly onClick = (): void => {
this.props.params.uninstall(this.props.params.item);
};
override render(): React.ReactNode {
const { installedVersion } = this.props.params.item;
return (
installedVersion && (
<div className="version">
<span
className="installed-version"
onClick={this.onClick}
{...{
version: installedLabel(installedVersion),
remove: removeLabel,
}}
/>
</div>
)
);
}
}
class Content<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
onMoreInfo: (href: string | undefined) => void;
}>
> {
override render(): React.ReactNode {
const {
params: {
item: { summary, description },
},
} = this.props;
const content = [summary, description].filter(Boolean).join(' ');
return (
<div className="content" title={content}>
<p>{content}</p>
<MoreInfo {...this.props} />
</div>
);
}
}
class MoreInfo<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
onMoreInfo: (href: string | undefined) => void;
}>
> {
private readonly onClick = (
event: React.SyntheticEvent<HTMLAnchorElement, Event>
): void => {
const { target } = event.nativeEvent;
if (target instanceof HTMLAnchorElement) {
this.props.onMoreInfo(target.href);
event.nativeEvent.preventDefault();
}
};
override render(): React.ReactNode {
const {
params: {
item: { moreInfoLink: href },
},
} = this.props;
return (
href && (
<div className="info" title={clickToOpenInBrowserLabel(href)}>
<a href={href} onClick={this.onClick}>
{moreInfoLabel}
</a>
</div>
)
);
}
}
class Footer<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
action: Installable.Action;
}>
> {
override render(): React.ReactNode {
return (
<div className="footer">
<SelectVersion {...this.props} />
<Button {...this.props} />
</div>
);
}
}
class SelectVersion<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
action: Installable.Action;
}>
> {
private readonly onChange = (
event: React.ChangeEvent<HTMLSelectElement>
): void => {
const version = event.target.value;
if (version) {
this.props.params.onVersionChange(version);
}
};
override render(): React.ReactNode {
const {
selectedVersion,
item: { availableVersions },
} = this.props.params;
switch (this.props.action) {
case 'installLatest': // fall-through
case 'installSelected': // fall-through
case 'update': // fall-through
case 'remove':
return (
<select
className="theia-select"
value={selectedVersion}
onChange={this.onChange}
>
{availableVersions.map((version) => (
<option value={version} key={version}>
{version}
</option>
))}
</select>
);
case 'unknown':
return undefined;
}
}
}
class Button<T extends ArduinoComponent> extends React.Component<
Readonly<{
params: ListItemRendererParams<T>;
action: Installable.Action;
}>
> {
override render(): React.ReactNode {
const {
params: { item, install, uninstall, inProgress: state },
} = this.props;
const classNames = ['theia-button install uppercase'];
let onClick;
let label;
switch (this.props.action) {
case 'unknown':
return undefined;
case 'installLatest': {
classNames.push('primary');
label = installLabel;
onClick = () => install(item);
break;
}
case 'installSelected': {
classNames.push('secondary');
label = installLabel;
onClick = () => install(item);
break;
}
case 'update': {
classNames.push('secondary');
label = updateLabel;
onClick = () => install(item);
break;
}
case 'remove': {
classNames.push('secondary', 'no-border');
label = removeLabel;
onClick = () => uninstall(item);
break;
}
}
return (
<button
className={classNames.join(' ')}
onClick={onClick}
disabled={Boolean(state)}
>
{label}
</button>
);
}
}

View File

@ -29,29 +29,27 @@ export abstract class ListWidget<
> extends ReactWidget { > extends ReactWidget {
@inject(MessageService) @inject(MessageService)
protected readonly messageService: MessageService; protected readonly messageService: MessageService;
@inject(CommandService)
protected readonly commandService: CommandService;
@inject(ResponseServiceClient)
protected readonly responseService: ResponseServiceClient;
@inject(NotificationCenter) @inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter; protected readonly notificationCenter: NotificationCenter;
@inject(CommandService)
private readonly commandService: CommandService;
@inject(ResponseServiceClient)
private readonly responseService: ResponseServiceClient;
/** /**
* 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.
*/ */
protected focusNode: HTMLElement | undefined; private focusNode: HTMLElement | undefined;
private readonly didReceiveFirstFocus = new Deferred(); private readonly didReceiveFirstFocus = new Deferred();
protected readonly searchOptionsChangeEmitter = new Emitter< private readonly searchOptionsChangeEmitter = new Emitter<
Partial<S> | undefined Partial<S> | undefined
>(); >();
private readonly onDidShowEmitter = new Emitter<void>();
/** /**
* Instead of running an `update` from the `postConstruct` `init` method, * Instead of running an `update` from the `postConstruct` `init` method,
* we use this variable to track first activate, then run. * we use this variable to track first activate, then run.
*/ */
protected firstActivate = true; private firstUpdate = true;
constructor(protected options: ListWidget.Options<T, S>) { constructor(protected options: ListWidget.Options<T, S>) {
super(); super();
@ -64,7 +62,10 @@ export abstract class ListWidget<
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.
this.scrollOptions = undefined; this.scrollOptions = undefined;
this.toDispose.push(this.searchOptionsChangeEmitter); this.toDispose.pushAll([
this.searchOptionsChangeEmitter,
this.onDidShowEmitter,
]);
} }
@postConstruct() @postConstruct()
@ -81,12 +82,14 @@ export abstract class ListWidget<
protected override onAfterShow(message: Message): void { protected override onAfterShow(message: Message): void {
this.maybeUpdateOnFirstRender(); this.maybeUpdateOnFirstRender();
super.onAfterShow(message); super.onAfterShow(message);
this.onDidShowEmitter.fire();
} }
private maybeUpdateOnFirstRender() { private maybeUpdateOnFirstRender() {
if (this.firstActivate) { if (this.firstUpdate) {
this.firstActivate = false; this.firstUpdate = false;
this.update(); this.update();
this.didReceiveFirstFocus.promise.then(() => this.focusNode?.focus());
} }
} }
@ -106,7 +109,9 @@ export abstract class ListWidget<
this.updateScrollBar(); this.updateScrollBar();
} }
protected onFocusResolved = (element: HTMLElement | undefined): void => { private readonly onFocusResolved = (
element: HTMLElement | undefined
): void => {
this.focusNode = element; this.focusNode = element;
this.didReceiveFirstFocus.resolve(); this.didReceiveFirstFocus.resolve();
}; };
@ -133,7 +138,7 @@ export abstract class ListWidget<
return this.options.installable.uninstall({ item, progressId }); return this.options.installable.uninstall({ item, progressId });
} }
render(): React.ReactNode { override render(): React.ReactNode {
return ( return (
<FilterableListContainer<T, S> <FilterableListContainer<T, S>
defaultSearchOptions={this.options.defaultSearchOptions} defaultSearchOptions={this.options.defaultSearchOptions}
@ -149,6 +154,7 @@ export abstract class ListWidget<
messageService={this.messageService} messageService={this.messageService}
commandService={this.commandService} commandService={this.commandService}
responseService={this.responseService} responseService={this.responseService}
onDidShow={this.onDidShowEmitter.event}
/> />
); );
} }
@ -186,3 +192,10 @@ export namespace ListWidget {
readonly defaultSearchOptions: S; readonly defaultSearchOptions: S;
} }
} }
export class UserAbortError extends Error {
constructor(message = 'User abort') {
super(message);
Object.setPrototypeOf(this, UserAbortError.prototype);
}
}

View File

@ -1,34 +1,35 @@
import { Installable } from './installable'; import type { Installable } from './installable';
export interface ArduinoComponent { export interface ArduinoComponent {
readonly name: string; readonly name: string;
readonly deprecated?: boolean;
readonly author: string; readonly author: string;
readonly summary: string; readonly summary: string;
readonly description: string; readonly description: string;
readonly moreInfoLink?: string;
readonly availableVersions: Installable.Version[]; readonly availableVersions: Installable.Version[];
readonly installable: boolean;
readonly installedVersion?: Installable.Version; readonly installedVersion?: Installable.Version;
/** /**
* This is the `Type` in IDE (1.x) UI. * This is the `Type` in IDE (1.x) UI.
*/ */
readonly types: string[]; readonly types: string[];
readonly deprecated?: boolean;
readonly moreInfoLink?: string;
} }
export namespace ArduinoComponent { export namespace ArduinoComponent {
export function is(arg: any): arg is ArduinoComponent { export function is(arg: unknown): arg is ArduinoComponent {
return ( return (
!!arg && typeof arg === 'object' &&
'name' in arg && (<ArduinoComponent>arg).name !== undefined &&
typeof arg['name'] === 'string' && typeof (<ArduinoComponent>arg).name === 'string' &&
'author' in arg && (<ArduinoComponent>arg).author !== undefined &&
typeof arg['author'] === 'string' && typeof (<ArduinoComponent>arg).author === 'string' &&
'summary' in arg && (<ArduinoComponent>arg).summary !== undefined &&
typeof arg['summary'] === 'string' && typeof (<ArduinoComponent>arg).summary === 'string' &&
'description' in arg && (<ArduinoComponent>arg).description !== undefined &&
typeof arg['description'] === 'string' && typeof (<ArduinoComponent>arg).description === 'string' &&
'installable' in arg && (<ArduinoComponent>arg).availableVersions !== undefined &&
typeof arg['installable'] === 'boolean' Array.isArray((<ArduinoComponent>arg).availableVersions) &&
(<ArduinoComponent>arg).types !== undefined &&
Array.isArray((<ArduinoComponent>arg).types)
); );
} }
} }

View File

@ -9,4 +9,8 @@ export interface ExamplesService {
current: SketchContainer[]; current: SketchContainer[];
any: SketchContainer[]; any: SketchContainer[];
}>; }>;
/**
* Finds example sketch containers for the installed library.
*/
find(options: { libraryName: string }): Promise<SketchContainer[]>;
} }

View File

@ -51,6 +51,46 @@ export namespace Installable {
}; };
} }
export const ActionLiterals = [
'installLatest',
'installSelected',
'update',
'remove',
'unknown',
] as const;
export type Action = typeof ActionLiterals[number];
export function action(params: {
installed?: Version | undefined;
available: Version[];
selected?: Version;
}): Action {
const { installed, available } = params;
const latest = Installable.latest(available);
if (!latest || (installed && !available.includes(installed))) {
return 'unknown';
}
const selected = params.selected ?? latest;
if (installed === selected) {
return 'remove';
}
if (installed) {
return selected === latest && installed !== latest
? 'update'
: 'installSelected';
} else {
return selected === latest ? 'installLatest' : 'installSelected';
}
}
export function latest(versions: Version[]): Version | undefined {
if (!versions.length) {
return undefined;
}
const ordered = versions.slice().sort(Installable.Version.COMPARATOR);
return ordered[ordered.length - 1];
}
export const Installed = <T extends ArduinoComponent>({ export const Installed = <T extends ArduinoComponent>({
installedVersion, installedVersion,
}: T): boolean => { }: T): boolean => {

View File

@ -198,6 +198,10 @@ export namespace LibraryService {
export namespace List { export namespace List {
export interface Options { export interface Options {
readonly fqbn?: string | undefined; readonly fqbn?: string | undefined;
/**
* The name of the library to filter to.
*/
readonly libraryName?: string | undefined;
} }
} }
} }
@ -241,11 +245,15 @@ export interface LibraryPackage extends ArduinoComponent {
readonly category: string; readonly category: string;
} }
export namespace LibraryPackage { export namespace LibraryPackage {
export function is(arg: any): arg is LibraryPackage { export function is(arg: unknown): arg is LibraryPackage {
return ( return (
ArduinoComponent.is(arg) && ArduinoComponent.is(arg) &&
'includes' in arg && (<LibraryPackage>arg).includes !== undefined &&
Array.isArray(arg['includes']) Array.isArray((<LibraryPackage>arg).includes) &&
(<LibraryPackage>arg).exampleUris !== undefined &&
Array.isArray((<LibraryPackage>arg).exampleUris) &&
(<LibraryPackage>arg).location !== undefined &&
typeof (<LibraryPackage>arg).location === 'number'
); );
} }

View File

@ -118,6 +118,16 @@ export class ExamplesServiceImpl implements ExamplesService {
return { user, current, any }; return { user, current, any };
} }
async find(options: { libraryName: string }): Promise<SketchContainer[]> {
const { libraryName } = options;
const packages = await this.libraryService.list({ libraryName });
return Promise.all(
packages
.filter(({ location }) => location === LibraryLocation.USER)
.map((pkg) => this.tryGroupExamples(pkg))
);
}
/** /**
* The CLI provides direct FS paths to the examples so that menus and menu groups cannot be built for the UI by traversing the * The CLI provides direct FS paths to the examples so that menus and menu groups cannot be built for the UI by traversing the
* folder hierarchy. This method tries to workaround it by falling back to the `installDirUri` and manually creating the * folder hierarchy. This method tries to workaround it by falling back to the `installDirUri` and manually creating the

View File

@ -103,7 +103,6 @@ export class LibraryServiceImpl
return toLibrary( return toLibrary(
{ {
name: item.getName(), name: item.getName(),
installable: true,
installedVersion, installedVersion,
}, },
item.getLatest()!, item.getLatest()!,
@ -154,8 +153,10 @@ export class LibraryServiceImpl
async list({ async list({
fqbn, fqbn,
libraryName,
}: { }: {
fqbn?: string | undefined; fqbn?: string | undefined;
libraryName?: string | undefined;
}): Promise<LibraryPackage[]> { }): Promise<LibraryPackage[]> {
const coreClient = await this.coreClient; const coreClient = await this.coreClient;
const { client, instance } = coreClient; const { client, instance } = coreClient;
@ -166,6 +167,9 @@ export class LibraryServiceImpl
req.setAll(true); // https://github.com/arduino/arduino-ide/pull/303#issuecomment-815556447 req.setAll(true); // https://github.com/arduino/arduino-ide/pull/303#issuecomment-815556447
req.setFqbn(fqbn); req.setFqbn(fqbn);
} }
if (libraryName) {
req.setName(libraryName);
}
const resp = await new Promise<LibraryListResponse | undefined>( const resp = await new Promise<LibraryListResponse | undefined>(
(resolve, reject) => { (resolve, reject) => {
@ -219,7 +223,6 @@ export class LibraryServiceImpl
{ {
name: library.getName(), name: library.getName(),
installedVersion, installedVersion,
installable: true,
description: library.getSentence(), description: library.getSentence(),
summary: library.getParagraph(), summary: library.getParagraph(),
moreInfoLink: library.getWebsite(), moreInfoLink: library.getWebsite(),
@ -455,8 +458,7 @@ function toLibrary(
return { return {
name: '', name: '',
exampleUris: [], exampleUris: [],
installable: false, location: LibraryLocation.BUILTIN,
location: 0,
...pkg, ...pkg,
author: lib.getAuthor(), author: lib.getAuthor(),

View File

@ -49,7 +49,6 @@ export const aPackage: BoardsPackage = {
deprecated: false, deprecated: false,
description: 'Some Arduino Board, Some Other Arduino Board', description: 'Some Arduino Board, Some Other Arduino Board',
id: 'some:arduinoCoreId', id: 'some:arduinoCoreId',
installable: true,
moreInfoLink: 'http://www.some-url.lol/', moreInfoLink: 'http://www.some-url.lol/',
name: 'Some Arduino Package', name: 'Some Arduino Package',
summary: 'Boards included in this package:', summary: 'Boards included in this package:',

View File

@ -2,6 +2,16 @@ import { expect } from 'chai';
import { Installable } from '../../common/protocol/installable'; import { Installable } from '../../common/protocol/installable';
describe('installable', () => { describe('installable', () => {
const latest = '2.0.0';
// shuffled versions
const available: Installable.Version[] = [
'1.4.1',
'1.0.0',
latest,
'2.0.0-beta.1',
'1.5',
];
describe('compare', () => { describe('compare', () => {
const testMe = Installable.Version.COMPARATOR; const testMe = Installable.Version.COMPARATOR;
@ -39,4 +49,93 @@ describe('installable', () => {
}); });
}); });
}); });
describe('latest', () => {
it('should get the latest version from a shuffled array', () => {
const copy = available.slice();
expect(Installable.latest(copy)).to.be.equal(latest);
expect(available).to.be.deep.equal(copy);
});
});
describe('action', () => {
const installLatest: Installable.Action = 'installLatest';
const installSelected: Installable.Action = 'installSelected';
const update: Installable.Action = 'update';
const remove: Installable.Action = 'remove';
const unknown: Installable.Action = 'unknown';
const notAvailable = '0.0.0';
it("should result 'unknown' if available is empty", () => {
expect(Installable.action({ available: [] })).to.be.equal(unknown);
});
it("should result 'unknown' if installed is not in available", () => {
expect(
Installable.action({ available, installed: notAvailable })
).to.be.equal(unknown);
});
it("should result 'installLatest' if not installed and not selected", () => {
expect(Installable.action({ available })).to.be.equal(installLatest);
});
it("should result 'installLatest' if not installed and latest is selected", () => {
expect(Installable.action({ available, selected: latest })).to.be.equal(
installLatest
);
});
it("should result 'installSelected' if not installed and not latest is selected", () => {
available
.filter((version) => version !== latest)
.forEach((selected) =>
expect(
Installable.action({
available,
selected,
})
).to.be.equal(installSelected)
);
});
it("should result 'installSelected' if installed and the selected is neither the latest nor the installed", () => {
available.forEach((installed) =>
available
.filter((selected) => selected !== latest && selected !== installed)
.forEach((selected) =>
expect(
Installable.action({
installed,
available,
selected,
})
).to.be.equal(installSelected)
)
);
});
it("should result 'update' if the installed version is not the latest and the latest is selected", () => {
available
.filter((installed) => installed !== latest)
.forEach((installed) =>
expect(
Installable.action({
installed,
available,
selected: latest,
})
).to.be.equal(update)
);
});
it("should result 'remove' if the selected version equals the installed version", () => {
available.forEach((version) =>
expect(
Installable.action({
installed: version,
available,
selected: version,
})
).to.be.equal(remove)
);
});
});
}); });

View File

@ -160,13 +160,19 @@
"component": { "component": {
"boardsIncluded": "Boards included in this package:", "boardsIncluded": "Boards included in this package:",
"by": "by", "by": "by",
"clickToOpen": "Click to open in browser: {0}",
"filterSearch": "Filter your search...", "filterSearch": "Filter your search...",
"install": "Install", "install": "Install",
"installed": "Installed", "installLatest": "Install Latest",
"installVersion": "Install {0}",
"installed": "{0} installed",
"moreInfo": "More info", "moreInfo": "More info",
"otherVersions": "Other Versions",
"remove": "Remove",
"title": "{0} by {1}",
"uninstall": "Uninstall", "uninstall": "Uninstall",
"uninstallMsg": "Do you want to uninstall {0}?", "uninstallMsg": "Do you want to uninstall {0}?",
"version": "Version {0}" "update": "Update"
}, },
"configuration": { "configuration": {
"cli": { "cli": {

View File

@ -36,10 +36,6 @@
"typescript": "~4.5.5", "typescript": "~4.5.5",
"xhr2": "^0.2.1" "xhr2": "^0.2.1"
}, },
"resolutions": {
"@types/react": "18.0.0",
"@types/react-dom": "18.0.0"
},
"scripts": { "scripts": {
"prepare": "lerna run prepare && yarn download:plugins", "prepare": "lerna run prepare && yarn download:plugins",
"cleanup": "npx rimraf ./**/node_modules && rm -rf ./node_modules ./.browser_modules ./arduino-ide-extension/build ./arduino-ide-extension/downloads ./arduino-ide-extension/Examples ./arduino-ide-extension/lib ./electron-app/lib ./electron-app/src-gen ./electron-app/gen-webpack.config.js", "cleanup": "npx rimraf ./**/node_modules && rm -rf ./node_modules ./.browser_modules ./arduino-ide-extension/build ./arduino-ide-extension/downloads ./arduino-ide-extension/Examples ./arduino-ide-extension/lib ./electron-app/lib ./electron-app/src-gen ./electron-app/gen-webpack.config.js",

View File

@ -898,13 +898,6 @@
dependencies: dependencies:
regenerator-runtime "^0.13.10" regenerator-runtime "^0.13.10"
"@babel/runtime@^7.7.2":
version "7.20.1"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9"
integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==
dependencies:
regenerator-runtime "^0.13.10"
"@babel/template@^7.18.6": "@babel/template@^7.18.6":
version "7.18.6" version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31"
@ -3535,10 +3528,10 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-dom@18.0.0", "@types/react-dom@^18.0.6": "@types/react-dom@^18.0.6":
version "18.0.0" version "18.0.11"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.0.tgz#b13f8d098e4b0c45df4f1ed123833143b0c71141" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.11.tgz#321351c1459bc9ca3d216aefc8a167beec334e33"
integrity sha512-49897Y0UiCGmxZqpC8Blrf6meL8QUla6eb+BBhn69dTXlmuOlzkfr7HHY/O8J25e1lTUMs+YYxSlVDAaGHCOLg== integrity sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
@ -3556,14 +3549,6 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-virtualized@^9.21.21":
version "9.21.21"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.21.tgz#65c96f25314f0fb3d40536929dc78112753b49e1"
integrity sha512-Exx6I7p4Qn+BBA1SRyj/UwQlZ0I0Pq7g7uhAp0QQ4JWzZunqEqNBGTmCmMmS/3N9wFgAGWuBD16ap7k8Y14VPA==
dependencies:
"@types/prop-types" "*"
"@types/react" "^17"
"@types/react-window@^1.8.5": "@types/react-window@^1.8.5":
version "1.8.5" version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1" resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
@ -3571,7 +3556,7 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react@*", "@types/react@18.0.0", "@types/react@^17", "@types/react@^18.0.15": "@types/react@*":
version "18.0.0" version "18.0.0"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.0.tgz#4be8aa3a2d04afc3ac2cc1ca43d39b0bd412890c" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.0.tgz#4be8aa3a2d04afc3ac2cc1ca43d39b0bd412890c"
integrity sha512-7+K7zEQYu7NzOwQGLR91KwWXXDzmTFODRVizJyIALf6RfLv2GDpqpknX64pvRVILXCpXi7O/pua8NGk44dLvJw== integrity sha512-7+K7zEQYu7NzOwQGLR91KwWXXDzmTFODRVizJyIALf6RfLv2GDpqpknX64pvRVILXCpXi7O/pua8NGk44dLvJw==
@ -3580,6 +3565,15 @@
"@types/scheduler" "*" "@types/scheduler" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/react@^18.0.15":
version "18.0.28"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.28.tgz#accaeb8b86f4908057ad629a26635fe641480065"
integrity sha512-RD0ivG1kEztNBdoAK7lekI9M+azSnitIn85h4iOiaLjaTrMjzslhaqCGaI4IyCJ1RljWiLCEu4jyrLLgqxBTew==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/request@^2.0.3": "@types/request@^2.0.3":
version "2.48.8" version "2.48.8"
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.8.tgz#0b90fde3b655ab50976cb8c5ac00faca22f5a82c" resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.8.tgz#0b90fde3b655ab50976cb8c5ac00faca22f5a82c"
@ -5542,7 +5536,7 @@ cloneable-readable@^1.0.0:
process-nextick-args "^2.0.0" process-nextick-args "^2.0.0"
readable-stream "^2.3.5" readable-stream "^2.3.5"
clsx@^1.0.4, clsx@^1.1.0: clsx@^1.1.0:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
@ -6436,7 +6430,7 @@ doctrine@^3.0.0:
dependencies: dependencies:
esutils "^2.0.2" esutils "^2.0.2"
dom-helpers@^5.0.1, dom-helpers@^5.1.3: dom-helpers@^5.0.1:
version "5.2.1" version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
@ -12322,11 +12316,6 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-markdown@^8.0.0: react-markdown@^8.0.0:
version "8.0.3" version "8.0.3"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.3.tgz#e8aba0d2f5a1b2124d476ee1fff9448a2f57e4b3" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.3.tgz#e8aba0d2f5a1b2124d476ee1fff9448a2f57e4b3"
@ -12397,18 +12386,6 @@ react-transition-group@^4.3.0:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react-virtualized@^9.22.3:
version "9.22.3"
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.3.tgz#f430f16beb0a42db420dbd4d340403c0de334421"
integrity sha512-MKovKMxWTcwPSxE1kK1HcheQTWfuCxAuBoSTf2gwyMM21NdX/PXUhnoP8Uc5dRKd+nKm8v41R36OellhdCpkrw==
dependencies:
"@babel/runtime" "^7.7.2"
clsx "^1.0.4"
dom-helpers "^5.1.3"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-lifecycles-compat "^3.0.4"
react-virtuoso@^2.17.0: react-virtuoso@^2.17.0:
version "2.19.1" version "2.19.1"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-2.19.1.tgz#a660a5c3cafcc7a84b59dfc356e1916e632c1e3a" resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-2.19.1.tgz#a660a5c3cafcc7a84b59dfc356e1916e632c1e3a"