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/ps-tree": "^1.1.0",
"@types/react-tabs": "^2.3.2",
"@types/react-virtualized": "^9.21.21",
"@types/temp": "^0.8.34",
"@types/which": "^1.3.1",
"@vscode/debugprotocol": "^1.51.0",
@ -96,7 +95,6 @@
"react-perfect-scrollbar": "^1.5.8",
"react-select": "^5.6.0",
"react-tabs": "^3.1.2",
"react-virtualized": "^9.22.3",
"react-window": "^1.8.6",
"semver": "^7.3.2",
"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 { BoardsAutoInstaller } from './boards/boards-auto-installer';
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 {
@ -1021,4 +1024,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(SidebarBottomMenuWidget).toSelf();
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
// in order to ensure the new ones are preferred
const candidates = packagesForBoard.filter(
({ installable, installedVersion }) => installable && !installedVersion
({ installedVersion }) => !installedVersion
);
return candidates[0];

View File

@ -1,6 +1,6 @@
import * as PQueue from 'p-queue';
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 {
MenuPath,
CompositeMenuNode,
@ -11,7 +11,11 @@ import {
DisposableCollection,
} from '@theia/core/lib/common/disposable';
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 { ExamplesService } from '../../common/protocol/examples-service';
import {
@ -25,11 +29,73 @@ import {
SketchRef,
SketchContainer,
SketchesError,
Sketch,
CoreService,
SketchesService,
Sketch,
} 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 { 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()
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
registry.registerSubmenu(
ArduinoMenus.FILE__EXAMPLES_SUBMENU,
nls.localize('arduino/examples/menu', 'Examples'),
examplesLabel,
{
order: '4',
}
@ -174,47 +240,33 @@ export abstract class Examples extends SketchContribution {
}
protected createHandler(uri: string): CommandHandler {
const forceUpdate = () =>
this.update({
board: this.boardsServiceClient.boardsConfig.selectedBoard,
forceRefresh: true,
});
return {
execute: async () => {
const sketch = await this.clone(uri);
if (sketch) {
try {
return this.commandService.executeCommand(
OpenSketch.Commands.OPEN_SKETCH.id,
sketch
);
} catch (err) {
if (SketchesError.NotFound.is(err)) {
await openClonedExample(
uri,
{
sketchesService: this.sketchesService,
commandService: this.commandRegistry,
},
{
onDidFailClone: () => {
// Do not toast the error message. It's handled by the `Open Sketch` command.
this.update({
board: this.boardsServiceClient.boardsConfig.selectedBoard,
forceRefresh: true,
});
} else {
throw err;
}
forceUpdate();
},
onDidFailOpen: (err) => {
this.messageService.error(err.message);
forceUpdate();
},
}
}
);
},
};
}
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()

View File

@ -12,7 +12,10 @@ import {
LibrarySearch,
LibraryService,
} 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 { ListItemRenderer } from '../widgets/component-list/list-item-renderer';
import { nls } from '@theia/core/lib/common';
@ -141,6 +144,8 @@ export class LibraryListWidget extends ListWidget<
// All
installDependencies = true;
}
} else {
throw new UserAbortError();
}
} else {
// The lib does not have any dependencies.
@ -235,6 +240,21 @@ class MessageBoxDialog extends AbstractDialog<MessageBoxDialog.Result> {
this.response = 0;
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 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 {
MAIN_MENU_BAR,
@ -7,6 +6,8 @@ import {
MenuPath,
SubMenuOptions,
} 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 {
// Main menu
@ -173,6 +174,17 @@ export namespace ArduinoMenus {
'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
export const ROOT_CERTIFICATES__CONTEXT = [
'arduino-root-certificates--context',
@ -230,3 +242,5 @@ export class PlaceholderMenuNode implements MenuNode {
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);
display: flex;
gap: 10px;
height: 28px;
height: var(--arduino-button-height);
margin: 0 4px;
overflow: hidden;
padding: 0 10px;

View File

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

View File

@ -1,5 +1,10 @@
@font-face {
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');
}

View File

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

View File

@ -20,6 +20,10 @@
@import './progress-bar.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. */
/* The SVG icons are still part of Theia (1.31.1) */
/* 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 */
.p-DockPanel-widget {
min-width: 200px;
min-width: 220px;
min-height: 20px;
height: 200px;
height: 220px;
}
/* Overrule the default Theia CSS button styles. */
@ -74,9 +78,9 @@ button.theia-button,
.theia-button {
align-items: center;
display: flex;
font-family: 'Open Sans',sans-serif;
font-family: 'Open Sans Bold',sans-serif;
font-style: normal;
font-weight: 700;
font-weight: 700;
font-size: 14px;
justify-content: center;
cursor: pointer;
@ -95,7 +99,7 @@ button.theia-button,
}
button.theia-button {
height: 28px;
height: var(--arduino-button-height);
max-width: none;
}
@ -154,10 +158,6 @@ button.theia-button.message-box-dialog-button {
font-size: 14px;
}
.uppercase {
text-transform: uppercase;
}
/* 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 button.theia-button:hover,

View File

@ -44,102 +44,152 @@
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 {
padding: 10px 10px 10px 15px;
font-size: var(--theia-ui-font-size1);
}
.component-list-item:hover {
cursor: pointer;
padding: 20px 15px 25px;
}
.component-list-item .header {
padding-bottom: 2px;
display: flex;
flex-direction: column;
min-height: var(--theia-statusBar-height);
}
.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;
justify-content: space-between;
align-items: center;
}
.component-list-item .header .name {
font-weight: bold;
}
.component-list-item .header .author {
font-weight: bold;
color: var(--theia-panelTitle-inactiveForeground);
}
.component-list-item:hover .header .author {
color: var(--theia-foreground);
}
.component-list-item .header .version {
color: var(--theia-panelTitle-inactiveForeground);
padding-top: 4px;
}
.component-list-item .footer .theia-button.install {
height: auto; /* resets the default Theia button height in the filterable list widget */
}
.component-list-item .header .installed:before {
margin-left: 4px;
.component-list-item .header .installed-version:before {
min-width: 79px;
display: inline-block;
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;
font-size: 10px;
font-weight: bold;
font-size: 12px;
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);
content: attr(uninstall);
content: attr(version);
cursor: pointer;
border-radius: 4px;
}
.component-list-item[min-width~="170px"] .footer {
padding: 5px 5px 0px 0px;
min-height: 35px;
.component-list-item .header .installed-version:hover:before {
content: attr(remove);
text-transform: uppercase;
}
.component-list-item .content {
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 {
flex-direction: column-reverse;
padding-top: 8px;
}
.component-list-item .footer > * {
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 {
display: inline-block;
align-self: center;
margin: 5px 0px 0px 10px;
}
.component-list-item .info a {
@ -151,13 +201,33 @@
text-decoration: underline;
}
/* 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:hover:before {
background-color: transparent;
outline: 1px dashed var(--theia-focusBorder);
.component-list-item .theia-button.secondary.no-border {
border: 2px solid var(--theia-button-foreground)
}
.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);
}

View File

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

View File

@ -1,60 +1,76 @@
import * as React from '@theia/core/shared/react';
import type { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { Installable } from '../../../common/protocol/installable';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { ListItemRenderer } from './list-item-renderer';
import type { ListItemRenderer } from './list-item-renderer';
import { UserAbortError } from './list-widget';
export class ComponentListItem<
T extends ArduinoComponent
> extends React.Component<ComponentListItem.Props<T>, ComponentListItem.State> {
constructor(props: ComponentListItem.Props<T>) {
super(props);
if (props.item.installable) {
const version = props.item.availableVersions.filter(
(version) => version !== props.item.installedVersion
)[0];
this.state = {
selectedVersion: version,
};
}
this.state = {};
}
override render(): React.ReactNode {
const { item, itemRenderer } = this.props;
const selectedVersion =
this.props.edited?.item.name === item.name
? this.props.edited.selectedVersion
: this.latestVersion;
return (
<>
{itemRenderer.renderItem(
Object.assign(this.state, { item }),
this.install.bind(this),
this.uninstall.bind(this),
this.onVersionChange.bind(this)
)}
{itemRenderer.renderItem({
item,
selectedVersion,
inProgress: this.state.inProgress,
install: (item) => this.install(item),
uninstall: (item) => this.uninstall(item),
onVersionChange: (version) => this.onVersionChange(version),
})}
</>
);
}
private async install(item: T): Promise<void> {
const toInstall = this.state.selectedVersion;
const version = this.props.item.availableVersions.filter(
(version) => version !== this.state.selectedVersion
)[0];
this.setState({
selectedVersion: version,
});
try {
await this.props.install(item, toInstall);
} catch {
this.setState({
selectedVersion: toInstall,
});
}
await this.withState('installing', () =>
this.props.install(
item,
this.props.edited?.item.name === item.name
? this.props.edited.selectedVersion
: Installable.latest(this.props.item.availableVersions)
)
);
}
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 {
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 install: (item: T, version?: Installable.Version) => 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>;
}
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 AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer';
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 { Virtuoso } from '@theia/core/shared/react-virtuoso';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { Installable } from '../../../common/protocol/installable';
import { ComponentListItem } from './component-list-item';
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<
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 {
return (
<AutoSizer>
{({ width, height }) => {
if (this.mostRecentWidth && this.mostRecentWidth !== width) {
this.resizeAllFlag = true;
setTimeout(() => this.clearAll(), 0);
}
this.mostRecentWidth = width;
return (
<List
className={'items-container'}
rowRenderer={this.createItem}
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>
<Virtuoso
data={this.props.items}
itemContent={(_: number, item: T) => (
<ComponentListItem<T>
key={this.props.itemLabel(item)}
item={item}
itemRenderer={this.props.itemRenderer}
install={this.props.install}
uninstall={this.props.uninstall}
edited={this.props.edited}
onItemEdit={this.props.onItemEdit}
/>
)}
</CellMeasurer>
/>
);
};
}
}
export namespace ComponentList {
export interface Props<T extends ArduinoComponent> {
readonly items: T[];
@ -150,5 +34,13 @@ export namespace ComponentList {
readonly itemRenderer: ListItemRenderer<T>;
readonly install: (item: T, version?: Installable.Version) => 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 { nls } from '@theia/core/lib/common';
import { FilterRenderer } from './filter-renderer';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
export class FilterableListContainer<
T extends ArduinoComponent,
@ -23,21 +24,30 @@ export class FilterableListContainer<
FilterableListContainer.Props<T, S>,
FilterableListContainer.State<T, S>
> {
private readonly toDispose: DisposableCollection;
constructor(props: Readonly<FilterableListContainer.Props<T, S>>) {
super(props);
this.state = {
searchOptions: props.defaultSearchOptions,
items: [],
};
this.toDispose = new DisposableCollection();
}
override componentDidMount(): void {
this.search = debounce(this.search, 500, { trailing: true });
this.search(this.state.searchOptions);
this.props.searchOptionsDidChange((newSearchOptions) => {
const { searchOptions } = this.state;
this.setSearchOptionsAndUpdate({ ...searchOptions, ...newSearchOptions });
});
this.toDispose.pushAll([
this.props.searchOptionsDidChange((newSearchOptions) => {
const { searchOptions } = this.state;
this.setSearchOptionsAndUpdate({
...searchOptions,
...newSearchOptions,
});
}),
this.props.onDidShow(() => this.setState({ edited: undefined })),
]);
}
override componentDidUpdate(): void {
@ -46,6 +56,10 @@ export class FilterableListContainer<
this.props.container.updateScrollBar();
}
override componentWillUnmount(): void {
this.toDispose.dispose();
}
override render(): React.ReactNode {
return (
<div className={'filterable-list-container'}>
@ -90,11 +104,13 @@ export class FilterableListContainer<
itemRenderer={itemRenderer}
install={this.install.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 = {
...this.state.searchOptions,
[prop]: value,
@ -106,15 +122,14 @@ export class FilterableListContainer<
this.setState({ searchOptions }, () => this.search(searchOptions));
}
protected search(searchOptions: S): void {
private search(searchOptions: S): void {
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(
item: T,
version: Installable.Version
): Promise<void> {
private async install(item: T, version: Installable.Version): Promise<void> {
const { install, searchable } = this.props;
await ExecuteWithProgress.doWithProgress({
...this.props,
@ -124,10 +139,10 @@ export class FilterableListContainer<
run: ({ progressId }) => install({ item, progressId, version }),
});
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({
title: nls.localize('arduino/component/uninstall', 'Uninstall'),
msg: nls.localize(
@ -152,7 +167,11 @@ export class FilterableListContainer<
run: ({ progressId }) => uninstall({ item, progressId }),
});
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 messageService: MessageService;
readonly responseService: ResponseServiceClient;
readonly onDidShow: Event<void>;
readonly install: ({
item,
progressId,
@ -193,5 +213,9 @@ export namespace FilterableListContainer {
export interface State<T, S extends Searchable.Options> {
searchOptions: S;
items: T[];
edited?: {
item: T;
selectedVersion: Installable.Version;
};
}
}

View File

@ -1,137 +1,783 @@
import * as React from '@theia/core/shared/react';
import { inject, injectable } from '@theia/core/shared/inversify';
import { ApplicationError } from '@theia/core';
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 { Installable } from '../../../common/protocol/installable';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { ComponentListItem } from './component-list-item';
import { nls } from '@theia/core/lib/common';
import {
CommandHandler,
CommandRegistry,
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 {
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()
export class ListItemRenderer<T extends ArduinoComponent> {
@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 = (
event: React.SyntheticEvent<HTMLAnchorElement, Event>
): void => {
const { target } = event.nativeEvent;
if (target instanceof HTMLAnchorElement) {
this.windowService.openNewWindow(target.href, { external: true });
event.nativeEvent.preventDefault();
private readonly onMoreInfo = (href: string | undefined): void => {
if (href) {
this.windowService.openNewWindow(href, { external: true });
}
};
renderItem(
input: ComponentListItem.State & { item: T },
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>
);
}
})();
renderItem(params: ListItemRendererParams<T>): React.ReactNode {
const action = this.action(params);
return (
<div className="component-list-item noselect">
<div className="header">
{nameAndAuthor}
{installedVersion}
</div>
<div className="content">
{summary}
{description}
</div>
<div className="info">{moreInfo}</div>
<div className="footer">
{versions}
{installButton}
<>
<Separator />
<div className="component-list-item noselect">
<Header
params={params}
action={action}
services={this.services}
onMoreInfo={this.onMoreInfo}
/>
<Content params={params} onMoreInfo={this.onMoreInfo} />
<Footer params={params} action={action} />
</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>
);
}
}
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 {
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(CommandService)
protected readonly commandService: CommandService;
@inject(ResponseServiceClient)
protected readonly responseService: ResponseServiceClient;
@inject(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.
*/
protected focusNode: HTMLElement | undefined;
private focusNode: HTMLElement | undefined;
private readonly didReceiveFirstFocus = new Deferred();
protected readonly searchOptionsChangeEmitter = new Emitter<
private readonly searchOptionsChangeEmitter = new Emitter<
Partial<S> | undefined
>();
private readonly onDidShowEmitter = new Emitter<void>();
/**
* Instead of running an `update` from the `postConstruct` `init` method,
* we use this variable to track first activate, then run.
*/
protected firstActivate = true;
private firstUpdate = true;
constructor(protected options: ListWidget.Options<T, S>) {
super();
@ -64,7 +62,10 @@ export abstract class ListWidget<
this.addClass('arduino-list-widget');
this.node.tabIndex = 0; // To be able to set the focus on the widget.
this.scrollOptions = undefined;
this.toDispose.push(this.searchOptionsChangeEmitter);
this.toDispose.pushAll([
this.searchOptionsChangeEmitter,
this.onDidShowEmitter,
]);
}
@postConstruct()
@ -81,12 +82,14 @@ export abstract class ListWidget<
protected override onAfterShow(message: Message): void {
this.maybeUpdateOnFirstRender();
super.onAfterShow(message);
this.onDidShowEmitter.fire();
}
private maybeUpdateOnFirstRender() {
if (this.firstActivate) {
this.firstActivate = false;
if (this.firstUpdate) {
this.firstUpdate = false;
this.update();
this.didReceiveFirstFocus.promise.then(() => this.focusNode?.focus());
}
}
@ -106,7 +109,9 @@ export abstract class ListWidget<
this.updateScrollBar();
}
protected onFocusResolved = (element: HTMLElement | undefined): void => {
private readonly onFocusResolved = (
element: HTMLElement | undefined
): void => {
this.focusNode = element;
this.didReceiveFirstFocus.resolve();
};
@ -133,7 +138,7 @@ export abstract class ListWidget<
return this.options.installable.uninstall({ item, progressId });
}
render(): React.ReactNode {
override render(): React.ReactNode {
return (
<FilterableListContainer<T, S>
defaultSearchOptions={this.options.defaultSearchOptions}
@ -149,6 +154,7 @@ export abstract class ListWidget<
messageService={this.messageService}
commandService={this.commandService}
responseService={this.responseService}
onDidShow={this.onDidShowEmitter.event}
/>
);
}
@ -186,3 +192,10 @@ export namespace ListWidget {
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 {
readonly name: string;
readonly deprecated?: boolean;
readonly author: string;
readonly summary: string;
readonly description: string;
readonly moreInfoLink?: string;
readonly availableVersions: Installable.Version[];
readonly installable: boolean;
readonly installedVersion?: Installable.Version;
/**
* This is the `Type` in IDE (1.x) UI.
*/
readonly types: string[];
readonly deprecated?: boolean;
readonly moreInfoLink?: string;
}
export namespace ArduinoComponent {
export function is(arg: any): arg is ArduinoComponent {
export function is(arg: unknown): arg is ArduinoComponent {
return (
!!arg &&
'name' in arg &&
typeof arg['name'] === 'string' &&
'author' in arg &&
typeof arg['author'] === 'string' &&
'summary' in arg &&
typeof arg['summary'] === 'string' &&
'description' in arg &&
typeof arg['description'] === 'string' &&
'installable' in arg &&
typeof arg['installable'] === 'boolean'
typeof arg === 'object' &&
(<ArduinoComponent>arg).name !== undefined &&
typeof (<ArduinoComponent>arg).name === 'string' &&
(<ArduinoComponent>arg).author !== undefined &&
typeof (<ArduinoComponent>arg).author === 'string' &&
(<ArduinoComponent>arg).summary !== undefined &&
typeof (<ArduinoComponent>arg).summary === 'string' &&
(<ArduinoComponent>arg).description !== undefined &&
typeof (<ArduinoComponent>arg).description === 'string' &&
(<ArduinoComponent>arg).availableVersions !== undefined &&
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[];
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>({
installedVersion,
}: T): boolean => {

View File

@ -198,6 +198,10 @@ export namespace LibraryService {
export namespace List {
export interface Options {
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;
}
export namespace LibraryPackage {
export function is(arg: any): arg is LibraryPackage {
export function is(arg: unknown): arg is LibraryPackage {
return (
ArduinoComponent.is(arg) &&
'includes' in arg &&
Array.isArray(arg['includes'])
(<LibraryPackage>arg).includes !== undefined &&
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 };
}
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
* 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(
{
name: item.getName(),
installable: true,
installedVersion,
},
item.getLatest()!,
@ -154,8 +153,10 @@ export class LibraryServiceImpl
async list({
fqbn,
libraryName,
}: {
fqbn?: string | undefined;
libraryName?: string | undefined;
}): Promise<LibraryPackage[]> {
const coreClient = await this.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.setFqbn(fqbn);
}
if (libraryName) {
req.setName(libraryName);
}
const resp = await new Promise<LibraryListResponse | undefined>(
(resolve, reject) => {
@ -219,7 +223,6 @@ export class LibraryServiceImpl
{
name: library.getName(),
installedVersion,
installable: true,
description: library.getSentence(),
summary: library.getParagraph(),
moreInfoLink: library.getWebsite(),
@ -455,8 +458,7 @@ function toLibrary(
return {
name: '',
exampleUris: [],
installable: false,
location: 0,
location: LibraryLocation.BUILTIN,
...pkg,
author: lib.getAuthor(),

View File

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

View File

@ -2,6 +2,16 @@ import { expect } from 'chai';
import { Installable } from '../../common/protocol/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', () => {
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": {
"boardsIncluded": "Boards included in this package:",
"by": "by",
"clickToOpen": "Click to open in browser: {0}",
"filterSearch": "Filter your search...",
"install": "Install",
"installed": "Installed",
"installLatest": "Install Latest",
"installVersion": "Install {0}",
"installed": "{0} installed",
"moreInfo": "More info",
"otherVersions": "Other Versions",
"remove": "Remove",
"title": "{0} by {1}",
"uninstall": "Uninstall",
"uninstallMsg": "Do you want to uninstall {0}?",
"version": "Version {0}"
"update": "Update"
},
"configuration": {
"cli": {

View File

@ -36,10 +36,6 @@
"typescript": "~4.5.5",
"xhr2": "^0.2.1"
},
"resolutions": {
"@types/react": "18.0.0",
"@types/react-dom": "18.0.0"
},
"scripts": {
"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",

View File

@ -898,13 +898,6 @@
dependencies:
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":
version "7.18.6"
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"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/react-dom@18.0.0", "@types/react-dom@^18.0.6":
version "18.0.0"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.0.tgz#b13f8d098e4b0c45df4f1ed123833143b0c71141"
integrity sha512-49897Y0UiCGmxZqpC8Blrf6meL8QUla6eb+BBhn69dTXlmuOlzkfr7HHY/O8J25e1lTUMs+YYxSlVDAaGHCOLg==
"@types/react-dom@^18.0.6":
version "18.0.11"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.11.tgz#321351c1459bc9ca3d216aefc8a167beec334e33"
integrity sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==
dependencies:
"@types/react" "*"
@ -3556,14 +3549,6 @@
dependencies:
"@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":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1"
@ -3571,7 +3556,7 @@
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@18.0.0", "@types/react@^17", "@types/react@^18.0.15":
"@types/react@*":
version "18.0.0"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.0.tgz#4be8aa3a2d04afc3ac2cc1ca43d39b0bd412890c"
integrity sha512-7+K7zEQYu7NzOwQGLR91KwWXXDzmTFODRVizJyIALf6RfLv2GDpqpknX64pvRVILXCpXi7O/pua8NGk44dLvJw==
@ -3580,6 +3565,15 @@
"@types/scheduler" "*"
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":
version "2.48.8"
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"
readable-stream "^2.3.5"
clsx@^1.0.4, clsx@^1.1.0:
clsx@^1.1.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
@ -6436,7 +6430,7 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-helpers@^5.0.1, dom-helpers@^5.1.3:
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
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"
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:
version "8.0.3"
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"
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:
version "2.19.1"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-2.19.1.tgz#a660a5c3cafcc7a84b59dfc356e1916e632c1e3a"