feat: support updates in lib/boards widget

- can show badge with updates count,
 - better hover for libraries and platforms,
 - save/restore widget state (Closes #1398),
 - fixed `sentence` and `paragraph` order (Ref #1611)

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta 2023-03-13 17:48:23 +01:00
parent 9b49712669
commit fa4626bf14
27 changed files with 1398 additions and 336 deletions

View File

@ -9,7 +9,10 @@ import {
FrontendApplicationContribution,
FrontendApplication as TheiaFrontendApplication,
} from '@theia/core/lib/browser/frontend-application';
import { LibraryListWidget } from './library/library-list-widget';
import {
LibraryListWidget,
LibraryListWidgetSearchOptions,
} from './library/library-list-widget';
import { ArduinoFrontendContribution } from './arduino-frontend-contribution';
import {
LibraryService,
@ -25,7 +28,10 @@ import {
} from '../common/protocol/sketches-service';
import { SketchesServiceClientImpl } from './sketches-service-client-impl';
import { CoreService, CoreServicePath } from '../common/protocol/core-service';
import { BoardsListWidget } from './boards/boards-list-widget';
import {
BoardsListWidget,
BoardsListWidgetSearchOptions,
} from './boards/boards-list-widget';
import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution';
import { BoardsServiceProvider } from './boards/boards-service-provider';
import { WorkspaceService as TheiaWorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
@ -73,7 +79,10 @@ import {
} from '../common/protocol/config-service';
import { MonitorWidget } from './serial/monitor/monitor-widget';
import { MonitorViewContribution } from './serial/monitor/monitor-view-contribution';
import { TabBarDecoratorService as TheiaTabBarDecoratorService } from '@theia/core/lib/browser/shell/tab-bar-decorator';
import {
TabBarDecorator,
TabBarDecoratorService as TheiaTabBarDecoratorService,
} from '@theia/core/lib/browser/shell/tab-bar-decorator';
import { TabBarDecoratorService } from './theia/core/tab-bar-decorator';
import { ProblemManager as TheiaProblemManager } from '@theia/markers/lib/browser';
import { ProblemManager } from './theia/markers/problem-manager';
@ -313,10 +322,10 @@ import { PreferencesEditorWidget } from './theia/preferences/preference-editor-w
import { PreferencesWidget } from '@theia/preferences/lib/browser/views/preference-widget';
import { createPreferencesWidgetContainer } from '@theia/preferences/lib/browser/views/preference-widget-bindings';
import {
BoardsFilterRenderer,
LibraryFilterRenderer,
} from './widgets/component-list/filter-renderer';
import { CheckForUpdates } from './contributions/check-for-updates';
CheckForUpdates,
BoardsUpdates,
LibraryUpdates,
} from './contributions/check-for-updates';
import { OutputEditorFactory } from './theia/output/output-editor-factory';
import { StartupTaskProvider } from '../electron-common/startup-task';
import { DeleteSketch } from './contributions/delete-sketch';
@ -356,6 +365,11 @@ import { Account } from './contributions/account';
import { SidebarBottomMenuWidget } from './theia/core/sidebar-bottom-menu-widget';
import { SidebarBottomMenuWidget as TheiaSidebarBottomMenuWidget } from '@theia/core/lib/browser/shell/sidebar-bottom-menu-widget';
import { CreateCloudCopy } from './contributions/create-cloud-copy';
import {
BoardsListWidgetTabBarDecorator,
LibraryListWidgetTabBarDecorator,
} from './widgets/component-list/list-widget-tabbar-decorator';
import { HoverService } from './theia/core/hover-service';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Commands and toolbar items
@ -371,8 +385,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Renderer for both the library and the core widgets.
bind(ListItemRenderer).toSelf().inSingletonScope();
bind(LibraryFilterRenderer).toSelf().inSingletonScope();
bind(BoardsFilterRenderer).toSelf().inSingletonScope();
// Library service
bind(LibraryService)
@ -395,6 +407,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
LibraryListWidgetFrontendContribution
);
bind(OpenHandler).toService(LibraryListWidgetFrontendContribution);
bind(TabBarToolbarContribution).toService(
LibraryListWidgetFrontendContribution
);
bind(CommandContribution).toService(LibraryListWidgetFrontendContribution);
bind(LibraryListWidgetSearchOptions).toSelf().inSingletonScope();
// Sketch list service
bind(SketchesService)
@ -464,6 +481,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
BoardsListWidgetFrontendContribution
);
bind(OpenHandler).toService(BoardsListWidgetFrontendContribution);
bind(TabBarToolbarContribution).toService(
BoardsListWidgetFrontendContribution
);
bind(CommandContribution).toService(BoardsListWidgetFrontendContribution);
bind(BoardsListWidgetSearchOptions).toSelf().inSingletonScope();
// Board select dialog
bind(BoardsConfigDialogWidget).toSelf().inSingletonScope();
@ -1034,4 +1056,20 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(FrontendApplicationContribution).toService(DaemonPort);
bind(IsOnline).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(IsOnline);
bind(HoverService).toSelf().inSingletonScope();
bind(LibraryUpdates).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(LibraryUpdates);
bind(LibraryListWidgetTabBarDecorator).toSelf().inSingletonScope();
bind(TabBarDecorator).toService(LibraryListWidgetTabBarDecorator);
bind(FrontendApplicationContribution).toService(
LibraryListWidgetTabBarDecorator
);
bind(BoardsUpdates).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(BoardsUpdates);
bind(BoardsListWidgetTabBarDecorator).toSelf().inSingletonScope();
bind(TabBarDecorator).toService(BoardsListWidgetTabBarDecorator);
bind(FrontendApplicationContribution).toService(
BoardsListWidgetTabBarDecorator
);
});

View File

@ -1,3 +1,4 @@
import { nls } from '@theia/core/lib/common';
import {
inject,
injectable,
@ -8,10 +9,18 @@ import {
BoardsPackage,
BoardsService,
} from '../../common/protocol/boards-service';
import { ListWidget } from '../widgets/component-list/list-widget';
import { ListItemRenderer } from '../widgets/component-list/list-item-renderer';
import { nls } from '@theia/core/lib/common';
import { BoardsFilterRenderer } from '../widgets/component-list/filter-renderer';
import {
ListWidget,
ListWidgetSearchOptions,
} from '../widgets/component-list/list-widget';
@injectable()
export class BoardsListWidgetSearchOptions extends ListWidgetSearchOptions<BoardSearch> {
get defaultOptions(): Required<BoardSearch> {
return { query: '', type: 'All' };
}
}
@injectable()
export class BoardsListWidget extends ListWidget<BoardsPackage, BoardSearch> {
@ -21,7 +30,8 @@ export class BoardsListWidget extends ListWidget<BoardsPackage, BoardSearch> {
constructor(
@inject(BoardsService) service: BoardsService,
@inject(ListItemRenderer) itemRenderer: ListItemRenderer<BoardsPackage>,
@inject(BoardsFilterRenderer) filterRenderer: BoardsFilterRenderer
@inject(BoardsListWidgetSearchOptions)
searchOptions: BoardsListWidgetSearchOptions
) {
super({
id: BoardsListWidget.WIDGET_ID,
@ -31,8 +41,7 @@ export class BoardsListWidget extends ListWidget<BoardsPackage, BoardSearch> {
installable: service,
itemLabel: (item: BoardsPackage) => item.name,
itemRenderer,
filterRenderer,
defaultSearchOptions: { query: '', type: 'All' },
searchOptions,
});
}

View File

@ -1,17 +1,28 @@
import { injectable } from '@theia/core/shared/inversify';
import { MenuPath } from '@theia/core';
import { Command } from '@theia/core/lib/common/command';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Type as TypeLabel } from '../../common/nls';
import {
BoardSearch,
BoardsPackage,
} from '../../common/protocol/boards-service';
import { URI } from '../contributions/contribution';
import { MenuActionTemplate, SubmenuTemplate } from '../menu/register-menu';
import { ListWidgetFrontendContribution } from '../widgets/component-list/list-widget-frontend-contribution';
import { BoardsListWidget } from './boards-list-widget';
import {
BoardsListWidget,
BoardsListWidgetSearchOptions,
} from './boards-list-widget';
@injectable()
export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution<
BoardsPackage,
BoardSearch
> {
@inject(BoardsListWidgetSearchOptions)
protected readonly searchOptions: BoardsListWidgetSearchOptions;
constructor() {
super({
widgetId: BoardsListWidget.WIDGET_ID,
@ -37,4 +48,51 @@ export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendCont
protected parse(uri: URI): BoardSearch | undefined {
return BoardSearch.UriParser.parse(uri);
}
protected buildFilterMenuGroup(
menuPath: MenuPath
): Array<MenuActionTemplate | SubmenuTemplate> {
const typeSubmenuPath = [...menuPath, TypeLabel];
return [
{
submenuPath: typeSubmenuPath,
menuLabel: `${TypeLabel}: "${
BoardSearch.TypeLabels[this.searchOptions.options.type]
}"`,
options: { order: String(0) },
},
...this.buildMenuActions<BoardSearch.Type>(
typeSubmenuPath,
BoardSearch.TypeLiterals.slice(),
(type) => this.searchOptions.options.type === type,
(type) => this.searchOptions.update({ type }),
(type) => BoardSearch.TypeLabels[type]
),
];
}
protected get showViewFilterContextMenuCommand(): Command & {
label: string;
} {
return BoardsListWidgetFrontendContribution.Commands
.SHOW_BOARDS_LIST_WIDGET_FILTER_CONTEXT_MENU;
}
protected get showInstalledCommandId(): string {
return 'arduino-show-installed-boards';
}
protected get showUpdatesCommandId(): string {
return 'arduino-show-boards-updates';
}
}
export namespace BoardsListWidgetFrontendContribution {
export namespace Commands {
export const SHOW_BOARDS_LIST_WIDGET_FILTER_CONTEXT_MENU: Command & {
label: string;
} = {
id: 'arduino-boards-list-widget-show-filter-context-menu',
label: nls.localize('arduino/boards/filterBoards', 'Filter Boards...'),
};
}
}

View File

@ -1,45 +1,55 @@
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import type { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { InstallManually, Later } from '../../common/nls';
import {
ArduinoComponent,
BoardSearch,
BoardsPackage,
BoardsService,
LibraryPackage,
LibrarySearch,
LibraryService,
ResponseServiceClient,
Searchable,
Updatable,
} from '../../common/protocol';
import { Installable } from '../../common/protocol/installable';
import { ExecuteWithProgress } from '../../common/protocol/progressible';
import { BoardsListWidgetFrontendContribution } from '../boards/boards-widget-frontend-contribution';
import { LibraryListWidgetFrontendContribution } from '../library/library-widget-frontend-contribution';
import { NotificationCenter } from '../notification-center';
import { WindowServiceExt } from '../theia/core/window-service-ext';
import type { ListWidget } from '../widgets/component-list/list-widget';
import { Command, CommandRegistry, Contribution } from './contribution';
import { Emitter } from '@theia/core';
import debounce = require('lodash.debounce');
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { ArduinoPreferences } from '../arduino-preferences';
const NoUpdates = nls.localize(
const noUpdates = nls.localize(
'arduino/checkForUpdates/noUpdates',
'There are no recent updates available.'
);
const PromptUpdateBoards = nls.localize(
const promptUpdateBoards = nls.localize(
'arduino/checkForUpdates/promptUpdateBoards',
'Updates are available for some of your boards.'
);
const PromptUpdateLibraries = nls.localize(
const promptUpdateLibraries = nls.localize(
'arduino/checkForUpdates/promptUpdateLibraries',
'Updates are available for some of your libraries.'
);
const UpdatingBoards = nls.localize(
const updatingBoards = nls.localize(
'arduino/checkForUpdates/updatingBoards',
'Updating boards...'
);
const UpdatingLibraries = nls.localize(
const updatingLibraries = nls.localize(
'arduino/checkForUpdates/updatingLibraries',
'Updating libraries...'
);
const InstallAll = nls.localize(
const installAll = nls.localize(
'arduino/checkForUpdates/installAll',
'Install All'
);
@ -49,7 +59,24 @@ interface Task<T extends ArduinoComponent> {
readonly item: T;
}
const Updatable = { type: 'Updatable' } as const;
const updatableLibrariesSearchOption: LibrarySearch = {
query: '',
topic: 'All',
...Updatable,
};
const updatableBoardsSearchOption: BoardSearch = {
query: '',
...Updatable,
};
const installedLibrariesSearchOptions: LibrarySearch = {
query: '',
topic: 'All',
type: 'Installed',
};
const installedBoardsSearchOptions: BoardSearch = {
query: '',
type: 'Installed',
};
@injectable()
export class CheckForUpdates extends Contribution {
@ -70,6 +97,37 @@ export class CheckForUpdates extends Contribution {
register.registerCommand(CheckForUpdates.Commands.CHECK_FOR_UPDATES, {
execute: () => this.checkForUpdates(false),
});
register.registerCommand(CheckForUpdates.Commands.SHOW_BOARDS_UPDATES, {
execute: () =>
this.showUpdatableItems(
this.boardsContribution,
updatableBoardsSearchOption
),
});
register.registerCommand(CheckForUpdates.Commands.SHOW_LIBRARY_UPDATES, {
execute: () =>
this.showUpdatableItems(
this.librariesContribution,
updatableLibrariesSearchOption
),
});
register.registerCommand(CheckForUpdates.Commands.SHOW_INSTALLED_BOARDS, {
execute: () =>
this.showUpdatableItems(
this.boardsContribution,
installedBoardsSearchOptions
),
});
register.registerCommand(
CheckForUpdates.Commands.SHOW_INSTALLED_LIBRARIES,
{
execute: () =>
this.showUpdatableItems(
this.librariesContribution,
installedLibrariesSearchOptions
),
}
);
}
override async onReady(): Promise<void> {
@ -85,13 +143,13 @@ export class CheckForUpdates extends Contribution {
private async checkForUpdates(silent = true) {
const [boardsPackages, libraryPackages] = await Promise.all([
this.boardsService.search(Updatable),
this.libraryService.search(Updatable),
this.boardsService.search(updatableBoardsSearchOption),
this.libraryService.search(updatableLibrariesSearchOption),
]);
this.promptUpdateBoards(boardsPackages);
this.promptUpdateLibraries(libraryPackages);
if (!libraryPackages.length && !boardsPackages.length && !silent) {
this.messageService.info(NoUpdates);
this.messageService.info(noUpdates);
}
}
@ -100,9 +158,9 @@ export class CheckForUpdates extends Contribution {
items,
installable: this.boardsService,
viewContribution: this.boardsContribution,
viewSearchOptions: { query: '', ...Updatable },
promptMessage: PromptUpdateBoards,
updatingMessage: UpdatingBoards,
viewSearchOptions: updatableBoardsSearchOption,
promptMessage: promptUpdateBoards,
updatingMessage: updatingBoards,
});
}
@ -111,9 +169,9 @@ export class CheckForUpdates extends Contribution {
items,
installable: this.libraryService,
viewContribution: this.librariesContribution,
viewSearchOptions: { query: '', topic: 'All', ...Updatable },
promptMessage: PromptUpdateLibraries,
updatingMessage: UpdatingLibraries,
viewSearchOptions: updatableLibrariesSearchOption,
promptMessage: promptUpdateLibraries,
updatingMessage: updatingLibraries,
});
}
@ -141,21 +199,30 @@ export class CheckForUpdates extends Contribution {
return;
}
this.messageService
.info(message, Later, InstallManually, InstallAll)
.info(message, Later, InstallManually, installAll)
.then((answer) => {
if (answer === InstallAll) {
if (answer === installAll) {
const tasks = items.map((item) =>
this.createInstallTask(item, installable)
);
this.executeTasks(updatingMessage, tasks);
return this.executeTasks(updatingMessage, tasks);
} else if (answer === InstallManually) {
viewContribution
.openView({ reveal: true })
.then((widget) => widget.refresh(viewSearchOptions));
return this.showUpdatableItems(viewContribution, viewSearchOptions);
}
});
}
private async showUpdatableItems<
T extends ArduinoComponent,
S extends Searchable.Options
>(
viewContribution: AbstractViewContribution<ListWidget<T, S>>,
viewSearchOptions: S
): Promise<void> {
const widget = await viewContribution.openView({ reveal: true });
widget.refresh(viewSearchOptions);
}
private async executeTasks(
message: string,
tasks: Task<ArduinoComponent>[]
@ -217,5 +284,127 @@ export namespace CheckForUpdates {
},
'arduino/checkForUpdates/checkForUpdates'
);
export const SHOW_BOARDS_UPDATES: Command & { label: string } = {
id: 'arduino-show-boards-updates',
label: nls.localize(
'arduino/checkForUpdates/showBoardsUpdates',
'Boards Updates'
),
category: 'Arduino',
};
export const SHOW_LIBRARY_UPDATES: Command & { label: string } = {
id: 'arduino-show-library-updates',
label: nls.localize(
'arduino/checkForUpdates/showLibraryUpdates',
'Library Updates'
),
category: 'Arduino',
};
export const SHOW_INSTALLED_BOARDS: Command & { label: string } = {
id: 'arduino-show-installed-boards',
label: nls.localize(
'arduino/checkForUpdates/showInstalledBoards',
'Installed Boards'
),
category: 'Arduino',
};
export const SHOW_INSTALLED_LIBRARIES: Command & { label: string } = {
id: 'arduino-show-installed-libraries',
label: nls.localize(
'arduino/checkForUpdates/showInstalledLibraries',
'Installed Libraries'
),
category: 'Arduino',
};
}
}
@injectable()
abstract class ComponentUpdates<T extends ArduinoComponent>
implements FrontendApplicationContribution
{
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
@inject(ArduinoPreferences)
private readonly preferences: ArduinoPreferences;
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
private _updates: T[] | undefined;
private readonly onDidChangeEmitter = new Emitter<T[]>();
protected readonly toDispose = new DisposableCollection(
this.onDidChangeEmitter
);
readonly onDidChange = this.onDidChangeEmitter.event;
readonly refresh = debounce(() => this.refreshDebounced(), 200);
onStart(): void {
this.appStateService.reachedState('ready').then(() => this.refresh());
this.toDispose.push(
this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => {
if (
preferenceName === 'arduino.checkForUpdates' &&
typeof newValue === 'boolean'
) {
this.refresh();
}
})
);
}
onStop(): void {
this.toDispose.dispose();
}
get updates(): T[] | undefined {
return this._updates;
}
/**
* Search updatable components (libraries and platforms) via the CLI.
*/
abstract searchUpdates(): Promise<T[]>;
private async refreshDebounced(): Promise<void> {
const checkForUpdates = this.preferences['arduino.checkForUpdates'];
this._updates = checkForUpdates ? await this.searchUpdates() : [];
this.onDidChangeEmitter.fire(this._updates.slice());
}
}
@injectable()
export class LibraryUpdates extends ComponentUpdates<LibraryPackage> {
@inject(LibraryService)
private readonly libraryService: LibraryService;
override onStart(): void {
super.onStart();
this.toDispose.pushAll([
this.notificationCenter.onLibraryDidInstall(() => this.refresh()),
this.notificationCenter.onLibraryDidUninstall(() => this.refresh()),
]);
}
override searchUpdates(): Promise<LibraryPackage[]> {
return this.libraryService.search(updatableLibrariesSearchOption);
}
}
@injectable()
export class BoardsUpdates extends ComponentUpdates<BoardsPackage> {
@inject(BoardsService)
private readonly boardsService: BoardsService;
override onStart(): void {
super.onStart();
this.toDispose.pushAll([
this.notificationCenter.onPlatformDidInstall(() => this.refresh()),
this.notificationCenter.onPlatformDidUninstall(() => this.refresh()),
this.notificationCenter.onIndexUpdateDidComplete(() => this.refresh()),
]);
}
override searchUpdates(): Promise<BoardsPackage[]> {
return this.boardsService.search(updatableBoardsSearchOption);
}
}

View File

@ -8,7 +8,10 @@ import {
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands';
import { ArduinoMenus } from '../menu/arduino-menus';
import {
ArduinoMenus,
showDisabledContextMenuOptions,
} from '../menu/arduino-menus';
import { CurrentSketch } from '../sketches-service-client-impl';
import {
Command,
@ -119,7 +122,7 @@ export class SketchControl extends SketchContribution {
)
);
}
const options = {
const options = showDisabledContextMenuOptions({
menuPath: ArduinoMenus.SKETCH_CONTROL__CONTEXT,
anchor: {
x: parentElement.getBoundingClientRect().left,
@ -127,8 +130,7 @@ export class SketchControl extends SketchContribution {
parentElement.getBoundingClientRect().top +
parentElement.offsetHeight,
},
showDisabled: true,
};
});
this.contextMenuRenderer.render(options);
},
}

View File

@ -38,7 +38,8 @@
"activityBar.foreground": "#dae3e3",
"activityBar.inactiveForeground": "#4e5b61",
"activityBar.activeBorder": "#0ca1a6",
"statusBar.background": "#171e21",
"activityBarBadge.background": "#008184",
"statusBar.background": "#0ca1a6",
"secondaryButton.background": "#ff000000",
"secondaryButton.foreground": "#dae3e3",
"secondaryButton.hoverBackground": "#ffffff1a",

View File

@ -38,6 +38,7 @@
"activityBar.foreground": "#4e5b61",
"activityBar.inactiveForeground": "#bdc7c7",
"activityBar.activeBorder": "#008184",
"activityBarBadge.background": "#008184",
"statusBar.background": "#006d70",
"secondaryButton.background": "#ff000000",
"secondaryButton.foreground": "#008184",

View File

@ -1,25 +1,32 @@
import { DialogProps } from '@theia/core/lib/browser/dialogs';
import { addEventListener } from '@theia/core/lib/browser/widgets/widget';
import { nls } from '@theia/core/lib/common/nls';
import { Message } from '@theia/core/shared/@phosphor/messaging';
import {
inject,
injectable,
postConstruct,
inject,
} from '@theia/core/shared/inversify';
import { Message } from '@theia/core/shared/@phosphor/messaging';
import { addEventListener } from '@theia/core/lib/browser/widgets/widget';
import { DialogProps } from '@theia/core/lib/browser/dialogs';
import { AbstractDialog } from '../theia/dialogs/dialogs';
import { Installable } from '../../common/protocol';
import {
LibraryPackage,
LibrarySearch,
LibraryService,
} from '../../common/protocol/library-service';
import { AbstractDialog } from '../theia/dialogs/dialogs';
import { ListItemRenderer } from '../widgets/component-list/list-item-renderer';
import {
ListWidget,
ListWidgetSearchOptions,
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';
import { LibraryFilterRenderer } from '../widgets/component-list/filter-renderer';
@injectable()
export class LibraryListWidgetSearchOptions extends ListWidgetSearchOptions<LibrarySearch> {
get defaultOptions(): Required<LibrarySearch> {
return { query: '', type: 'All', topic: 'All' };
}
}
@injectable()
export class LibraryListWidget extends ListWidget<
@ -35,7 +42,8 @@ export class LibraryListWidget extends ListWidget<
constructor(
@inject(LibraryService) private service: LibraryService,
@inject(ListItemRenderer) itemRenderer: ListItemRenderer<LibraryPackage>,
@inject(LibraryFilterRenderer) filterRenderer: LibraryFilterRenderer
@inject(LibraryListWidgetSearchOptions)
searchOptions: LibraryListWidgetSearchOptions
) {
super({
id: LibraryListWidget.WIDGET_ID,
@ -45,8 +53,7 @@ export class LibraryListWidget extends ListWidget<
installable: service,
itemLabel: (item: LibraryPackage) => item.name,
itemRenderer,
filterRenderer,
defaultSearchOptions: { query: '', type: 'All', topic: 'All' },
searchOptions,
});
}

View File

@ -1,17 +1,30 @@
import { nls } from '@theia/core/lib/common';
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
import { injectable } from '@theia/core/shared/inversify';
import { LibraryPackage, LibrarySearch } from '../../common/protocol';
import { Command } from '@theia/core/lib/common/command';
import { MenuModelRegistry, MenuPath } from '@theia/core/lib/common/menu';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Type as TypeLabel } from '../../common/nls';
import {
LibraryPackage,
LibrarySearch,
TopicLabel,
} from '../../common/protocol';
import { URI } from '../contributions/contribution';
import { ArduinoMenus } from '../menu/arduino-menus';
import { MenuActionTemplate, SubmenuTemplate } from '../menu/register-menu';
import { ListWidgetFrontendContribution } from '../widgets/component-list/list-widget-frontend-contribution';
import { LibraryListWidget } from './library-list-widget';
import {
LibraryListWidget,
LibraryListWidgetSearchOptions,
} from './library-list-widget';
@injectable()
export class LibraryListWidgetFrontendContribution extends ListWidgetFrontendContribution<
LibraryPackage,
LibrarySearch
> {
@inject(LibraryListWidgetSearchOptions)
protected readonly searchOptions: LibraryListWidgetSearchOptions;
constructor() {
super({
widgetId: LibraryListWidget.WIDGET_ID,
@ -38,7 +51,7 @@ export class LibraryListWidgetFrontendContribution extends ListWidgetFrontendCon
}
}
protected canParse(uri: URI): boolean {
protected override canParse(uri: URI): boolean {
try {
LibrarySearch.UriParser.parse(uri);
return true;
@ -47,7 +60,72 @@ export class LibraryListWidgetFrontendContribution extends ListWidgetFrontendCon
}
}
protected parse(uri: URI): LibrarySearch | undefined {
protected override parse(uri: URI): LibrarySearch | undefined {
return LibrarySearch.UriParser.parse(uri);
}
protected override buildFilterMenuGroup(
menuPath: MenuPath
): Array<MenuActionTemplate | SubmenuTemplate> {
const typeSubmenuPath = [...menuPath, TypeLabel];
const topicSubmenuPath = [...menuPath, TopicLabel];
return [
{
submenuPath: typeSubmenuPath,
menuLabel: `${TypeLabel}: "${
LibrarySearch.TypeLabels[this.searchOptions.options.type]
}"`,
options: { order: String(0) },
},
...this.buildMenuActions<LibrarySearch.Type>(
typeSubmenuPath,
LibrarySearch.TypeLiterals.slice(),
(type) => this.searchOptions.options.type === type,
(type) => this.searchOptions.update({ type }),
(type) => LibrarySearch.TypeLabels[type]
),
{
submenuPath: topicSubmenuPath,
menuLabel: `${TopicLabel}: "${
LibrarySearch.TopicLabels[this.searchOptions.options.topic]
}"`,
options: { order: String(1) },
},
...this.buildMenuActions<LibrarySearch.Topic>(
topicSubmenuPath,
LibrarySearch.TopicLiterals.slice(),
(topic) => this.searchOptions.options.topic === topic,
(topic) => this.searchOptions.update({ topic }),
(topic) => LibrarySearch.TopicLabels[topic]
),
];
}
protected override get showViewFilterContextMenuCommand(): Command & {
label: string;
} {
return LibraryListWidgetFrontendContribution.Commands
.SHOW_LIBRARY_LIST_WIDGET_FILTER_CONTEXT_MENU;
}
protected get showInstalledCommandId(): string {
return 'arduino-show-installed-libraries';
}
protected get showUpdatesCommandId(): string {
return 'arduino-show-library-updates';
}
}
export namespace LibraryListWidgetFrontendContribution {
export namespace Commands {
export const SHOW_LIBRARY_LIST_WIDGET_FILTER_CONTEXT_MENU: Command & {
label: string;
} = {
id: 'arduino-library-list-widget-show-filter-context-menu',
label: nls.localize(
'arduino/libraries/filterLibraries',
'Filter Libraries...'
),
};
}
}

View File

@ -1,3 +1,4 @@
import { RenderContextMenuOptions } from '@theia/core/lib/browser';
import { CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution';
import {
MAIN_MENU_BAR,
@ -244,3 +245,13 @@ export class PlaceholderMenuNode implements MenuNode {
}
export const examplesLabel = nls.localize('arduino/examples/menu', 'Examples');
/**
* Helper function to optionally show disabled context menu items in IDE2. They're invisible in Theia.
* See `ElectronContextMenuRenderer#showDisabled` for more details.
*/
export function showDisabledContextMenuOptions(
options: RenderContextMenuOptions
): RenderContextMenuOptions {
return Object.assign(options, { showDisabled: true });
}

View File

@ -0,0 +1,151 @@
import {
CommandHandler,
CommandRegistry,
} 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 { unregisterSubmenu } from './arduino-menus';
export interface MenuTemplate {
readonly menuLabel: string;
}
export function isMenuTemplate(arg: unknown): arg is MenuTemplate {
return (
typeof arg === 'object' &&
(arg as MenuTemplate).menuLabel !== undefined &&
typeof (arg as MenuTemplate).menuLabel === 'string'
);
}
export interface MenuActionTemplate extends MenuTemplate {
readonly menuPath: MenuPath;
readonly handler: CommandHandler;
/**
* If not defined the insertion oder will be the order string.
*/
readonly order?: string;
}
export 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)
);
}
export function menuActionWithCommandDelegate(
template: Omit<MenuActionTemplate, 'handler' | 'menuLabel'> & {
command: string;
},
commandRegistry: CommandRegistry
): MenuActionTemplate {
const id = template.command;
const command = commandRegistry.getCommand(id);
if (!command) {
throw new Error(`Could not find the registered command with ID: ${id}`);
}
return {
...template,
menuLabel: command.label ?? id,
handler: {
execute: (args) => commandRegistry.executeCommand(id, args),
isEnabled: (args) => commandRegistry.isEnabled(id, args),
isVisible: (args) => commandRegistry.isVisible(id, args),
isToggled: (args) => commandRegistry.isToggled(id, args),
},
};
}
export interface SubmenuTemplate extends MenuTemplate {
readonly menuLabel: string;
readonly submenuPath: MenuPath;
readonly options?: SubMenuOptions;
}
interface Services {
readonly commandRegistry: CommandRegistry;
readonly menuRegistry: MenuModelRegistry;
}
class MenuIndexCounter {
private _counter: number;
constructor(counter = 0) {
this._counter = counter;
}
getAndIncrement(): number {
const counter = this._counter;
this._counter++;
return counter;
}
}
export function registerMenus(
options: {
contextId: string;
templates: Array<MenuActionTemplate | SubmenuTemplate>;
} & Services
): Disposable {
const { templates } = options;
const menuIndexCounter = new MenuIndexCounter();
return new DisposableCollection(
...templates.map((template) =>
registerMenu({ template, menuIndexCounter, ...options })
)
);
}
function registerMenu(
options: {
contextId: string;
menuIndexCounter: MenuIndexCounter;
template: MenuActionTemplate | SubmenuTemplate;
} & Services
): Disposable {
const {
template,
commandRegistry,
menuRegistry,
contextId,
menuIndexCounter,
} = options;
if (isMenuActionTemplate(template)) {
const { menuLabel, menuPath, handler, order } = template;
const id = generateCommandId(contextId, menuLabel, menuPath);
const index = menuIndexCounter.getAndIncrement();
return new DisposableCollection(
commandRegistry.registerCommand({ id }, handler),
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(
menuRegistry.registerSubmenu(submenuPath, menuLabel, options),
Disposable.create(() => unregisterSubmenu(submenuPath, menuRegistry))
);
}
function generateCommandId(
contextId: string,
menuLabel: string,
menuPath: MenuPath
): string {
return `arduino-${contextId}-context-${menuPath.join('-')}-${menuLabel}`;
}
}

View File

@ -0,0 +1,82 @@
/* Copied from https://github.com/eclipse-theia/theia/commit/909f4106e8c15c5c2c320401da4f48f8c6080734 */
/* Remove when IDE2 uses 1.32.0 */
/* Adapted from https://github.com/microsoft/vscode/blob/7d9b1c37f8e5ae3772782ba3b09d827eb3fdd833/src/vs/workbench/services/hover/browser/hoverService.ts */
:root {
--theia-hover-max-width: 200px;
}
.theia-hover {
font-family: var(--theia-ui-font-family);
font-size: var(--theia-ui-font-size1);
color: var(--theia-editorHoverWidget-foreground);
background-color: var(--theia-editorHoverWidget-background);
border: 1px solid var(--theia-editorHoverWidget-border);
padding: var(--theia-ui-padding);
max-width: var(--theia-hover-max-width);
}
.theia-hover .hover-row:not(:first-child):not(:empty) {
border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder);
}
.theia-hover hr {
border-top: 1px solid var(--theia-editorHoverWidgetInternalBorder);
border-bottom: 0px solid var(--theia-editorHoverWidgetInternalBorder);
margin: var(--theia-ui-padding) calc(var(--theia-ui-padding) * -1);
}
.theia-hover a {
color: var(--theia-textLink-foreground);
}
.theia-hover a:hover {
color: var(--theia-textLink-active-foreground);
}
.theia-hover .hover-row .actions {
background-color: var(--theia-editorHoverWidget-statusBarBackground);
}
.theia-hover code {
background-color: var(--theia-textCodeBlock-background);
font-family: var(--theia-code-font-family);
}
.theia-hover::before {
content: '';
position: absolute;
}
.theia-hover.top::before {
left: var(--theia-hover-before-position);
bottom: -5px;
border-top: 5px solid var(--theia-editorHoverWidget-border);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
}
.theia-hover.bottom::before {
left: var(--theia-hover-before-position);
top: -5px;
border-bottom: 5px solid var(--theia-editorHoverWidget-border);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
}
.theia-hover.left::before {
top: var(--theia-hover-before-position);
right: -5px;
border-left: 5px solid var(--theia-editorHoverWidget-border);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
.theia-hover.right::before {
top: var(--theia-hover-before-position);
left: -5px;
border-right: 5px solid var(--theia-editorHoverWidget-border);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}

View File

@ -22,6 +22,7 @@
:root {
--arduino-button-height: 28px;
--arduino-side-panel-min-width: 220px;
}
/* Revive of the `--theia-icon-loading`. The variable has been removed from Theia while IDE2 still uses is. */
@ -68,9 +69,9 @@ body.theia-dark {
/* Makes the sidepanel a bit wider when opening the widget */
.p-DockPanel-widget {
min-width: 220px;
min-width: var(--arduino-side-panel-min-width);
min-height: 20px;
height: 220px;
height: var(--arduino-side-panel-min-width);
}
/* Overrule the default Theia CSS button styles. */

View File

@ -0,0 +1,225 @@
// Copied from https://github.com/eclipse-theia/theia/commit/909f4106e8c15c5c2c320401da4f48f8c6080734
// Remove when IDE2 uses 1.32.0
import { animationFrame } from '@theia/core/lib/browser/browser';
import {
MarkdownRenderer,
MarkdownRendererFactory,
} from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
import { PreferenceService } from '@theia/core/lib/browser/preferences/preference-service';
import {
Disposable,
DisposableCollection,
disposableTimeout,
} from '@theia/core/lib/common/disposable';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering/markdown-string';
import { isOSX } from '@theia/core/lib/common/os';
import { inject, injectable } from '@theia/core/shared/inversify';
import '../../../../src/browser/style/hover-service.css';
export type HoverPosition = 'left' | 'right' | 'top' | 'bottom';
export namespace HoverPosition {
export function invertIfNecessary(
position: HoverPosition,
target: DOMRect,
host: DOMRect,
totalWidth: number,
totalHeight: number
): HoverPosition {
if (position === 'left') {
if (target.left - host.width - 5 < 0) {
return 'right';
}
} else if (position === 'right') {
if (target.right + host.width + 5 > totalWidth) {
return 'left';
}
} else if (position === 'top') {
if (target.top - host.height - 5 < 0) {
return 'bottom';
}
} else if (position === 'bottom') {
if (target.bottom + host.height + 5 > totalHeight) {
return 'top';
}
}
return position;
}
}
export interface HoverRequest {
content: string | MarkdownString | HTMLElement;
target: HTMLElement;
/**
* The position where the hover should appear.
* Note that the hover service will try to invert the position (i.e. right -> left)
* if the specified content does not fit in the window next to the target element
*/
position: HoverPosition;
}
@injectable()
export class HoverService {
protected static hostClassName = 'theia-hover';
protected static styleSheetId = 'theia-hover-style';
@inject(PreferenceService) protected readonly preferences: PreferenceService;
@inject(MarkdownRendererFactory)
protected readonly markdownRendererFactory: MarkdownRendererFactory;
protected _markdownRenderer: MarkdownRenderer | undefined;
protected get markdownRenderer(): MarkdownRenderer {
this._markdownRenderer ||= this.markdownRendererFactory();
return this._markdownRenderer;
}
protected _hoverHost: HTMLElement | undefined;
protected get hoverHost(): HTMLElement {
if (!this._hoverHost) {
this._hoverHost = document.createElement('div');
this._hoverHost.classList.add(HoverService.hostClassName);
this._hoverHost.style.position = 'absolute';
}
return this._hoverHost;
}
protected pendingTimeout: Disposable | undefined;
protected hoverTarget: HTMLElement | undefined;
protected lastHidHover = Date.now();
protected readonly disposeOnHide = new DisposableCollection();
requestHover(request: HoverRequest): void {
if (request.target !== this.hoverTarget) {
this.cancelHover();
this.pendingTimeout = disposableTimeout(
() => this.renderHover(request),
this.getHoverDelay()
);
}
}
protected getHoverDelay(): number {
return Date.now() - this.lastHidHover < 200
? 0
: this.preferences.get('workbench.hover.delay', isOSX ? 1500 : 500);
}
protected async renderHover(request: HoverRequest): Promise<void> {
const host = this.hoverHost;
const { target, content, position } = request;
this.hoverTarget = target;
if (content instanceof HTMLElement) {
host.appendChild(content);
} else if (typeof content === 'string') {
host.textContent = content;
} else {
const renderedContent = this.markdownRenderer.render(content);
this.disposeOnHide.push(renderedContent);
host.appendChild(renderedContent.element);
}
// browsers might insert linebreaks when the hover appears at the edge of the window
// resetting the position prevents that
host.style.left = '0px';
host.style.top = '0px';
document.body.append(host);
await animationFrame(); // Allow the browser to size the host
const updatedPosition = this.setHostPosition(target, host, position);
this.disposeOnHide.push({
dispose: () => {
this.lastHidHover = Date.now();
host.classList.remove(updatedPosition);
},
});
this.listenForMouseOut();
}
protected setHostPosition(
target: HTMLElement,
host: HTMLElement,
position: HoverPosition
): HoverPosition {
const targetDimensions = target.getBoundingClientRect();
const hostDimensions = host.getBoundingClientRect();
const documentWidth = document.body.getBoundingClientRect().width;
// document.body.getBoundingClientRect().height doesn't work as expected
// scrollHeight will always be accurate here: https://stackoverflow.com/a/44077777
const documentHeight = document.documentElement.scrollHeight;
position = HoverPosition.invertIfNecessary(
position,
targetDimensions,
hostDimensions,
documentWidth,
documentHeight
);
if (position === 'top' || position === 'bottom') {
const targetMiddleWidth =
targetDimensions.left + targetDimensions.width / 2;
const middleAlignment = targetMiddleWidth - hostDimensions.width / 2;
const furthestRight = Math.min(
documentWidth - hostDimensions.width,
middleAlignment
);
const left = Math.max(0, furthestRight);
const top =
position === 'top'
? targetDimensions.top - hostDimensions.height - 5
: targetDimensions.bottom + 5;
host.style.setProperty(
'--theia-hover-before-position',
`${targetMiddleWidth - left - 5}px`
);
host.style.top = `${top}px`;
host.style.left = `${left}px`;
} else {
const targetMiddleHeight =
targetDimensions.top + targetDimensions.height / 2;
const middleAlignment = targetMiddleHeight - hostDimensions.height / 2;
const furthestTop = Math.min(
documentHeight - hostDimensions.height,
middleAlignment
);
const top = Math.max(0, furthestTop);
const left =
position === 'left'
? targetDimensions.left - hostDimensions.width - 5
: targetDimensions.right + 5;
host.style.setProperty(
'--theia-hover-before-position',
`${targetMiddleHeight - top - 5}px`
);
host.style.left = `${left}px`;
host.style.top = `${top}px`;
}
host.classList.add(position);
return position;
}
protected listenForMouseOut(): void {
const handleMouseMove = (e: MouseEvent) => {
if (
e.target instanceof Node &&
!this.hoverHost.contains(e.target) &&
!this.hoverTarget?.contains(e.target)
) {
this.cancelHover();
}
};
document.addEventListener('mousemove', handleMouseMove);
this.disposeOnHide.push({
dispose: () => document.removeEventListener('mousemove', handleMouseMove),
});
}
cancelHover(): void {
this.pendingTimeout?.dispose();
this.unRenderHover();
this.disposeOnHide.dispose();
this.hoverTarget = undefined;
}
protected unRenderHover(): void {
this.hoverHost.remove();
this.hoverHost.replaceChildren();
}
}

View File

@ -1,121 +0,0 @@
import { injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import {
BoardSearch,
LibrarySearch,
Searchable,
} from '../../../common/protocol';
@injectable()
export abstract class FilterRenderer<S extends Searchable.Options> {
render(
options: S,
handlePropChange: (prop: keyof S, value: S[keyof S]) => void
): React.ReactNode {
const props = this.props();
return (
<div className="filter-bar">
{Object.entries(options)
.filter(([prop]) => props.includes(prop as keyof S))
.map(([prop, value]) => (
<div key={prop} className="filter">
<div className="filter-label">
{`${this.propertyLabel(prop as keyof S)}:`}
</div>
<select
className="theia-select"
value={value}
onChange={(event) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handlePropChange(prop as keyof S, event.target.value as any)
}
>
{this.options(prop as keyof S).map((key) => (
<option key={key} value={key}>
{this.valueLabel(prop as keyof S, key)}
</option>
))}
</select>
</div>
))}
</div>
);
}
protected abstract props(): (keyof S)[];
protected abstract options(prop: keyof S): string[];
protected abstract valueLabel(prop: keyof S, key: string): string;
protected abstract propertyLabel(prop: keyof S): string;
}
@injectable()
export class BoardsFilterRenderer extends FilterRenderer<BoardSearch> {
protected props(): (keyof BoardSearch)[] {
return ['type'];
}
protected options(prop: keyof BoardSearch): string[] {
switch (prop) {
case 'type':
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return BoardSearch.TypeLiterals as any;
default:
throw new Error(`Unexpected prop: ${prop}`);
}
}
protected valueLabel(prop: keyof BoardSearch, key: string): string {
switch (prop) {
case 'type':
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (BoardSearch.TypeLabels as any)[key];
default:
throw new Error(`Unexpected key: ${prop}`);
}
}
protected propertyLabel(prop: keyof BoardSearch): string {
switch (prop) {
case 'type':
return BoardSearch.PropertyLabels[prop];
default:
throw new Error(`Unexpected key: ${prop}`);
}
}
}
@injectable()
export class LibraryFilterRenderer extends FilterRenderer<LibrarySearch> {
protected props(): (keyof LibrarySearch)[] {
return ['type', 'topic'];
}
protected options(prop: keyof LibrarySearch): string[] {
switch (prop) {
case 'type':
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return LibrarySearch.TypeLiterals as any;
case 'topic':
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return LibrarySearch.TopicLiterals as any;
default:
throw new Error(`Unexpected prop: ${prop}`);
}
}
protected propertyLabel(prop: keyof LibrarySearch): string {
switch (prop) {
case 'type':
case 'topic':
return LibrarySearch.PropertyLabels[prop];
default:
throw new Error(`Unexpected key: ${prop}`);
}
}
protected valueLabel(prop: keyof LibrarySearch, key: string): string {
switch (prop) {
case 'type':
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (LibrarySearch.TypeLabels as any)[key] as any;
case 'topic':
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (LibrarySearch.TopicLabels as any)[key] as any;
default:
throw new Error(`Unexpected prop: ${prop}`);
}
}
}

View File

@ -9,12 +9,11 @@ import { ExecuteWithProgress } from '../../../common/protocol/progressible';
import { Installable } from '../../../common/protocol/installable';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { SearchBar } from './search-bar';
import { ListWidget } from './list-widget';
import { ListWidget, ListWidgetSearchOptions } from './list-widget';
import { ComponentList } from './component-list';
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<
@ -29,7 +28,7 @@ export class FilterableListContainer<
constructor(props: Readonly<FilterableListContainer.Props<T, S>>) {
super(props);
this.state = {
searchOptions: props.defaultSearchOptions,
searchOptions: props.searchOptions.options,
items: [],
};
this.toDispose = new DisposableCollection();
@ -39,7 +38,7 @@ export class FilterableListContainer<
this.search = debounce(this.search, 500, { trailing: true });
this.search(this.state.searchOptions);
this.toDispose.pushAll([
this.props.searchOptionsDidChange((newSearchOptions) => {
this.props.searchOptions.onDidChange((newSearchOptions) => {
const { searchOptions } = this.state;
this.setSearchOptionsAndUpdate({
...searchOptions,
@ -64,7 +63,6 @@ export class FilterableListContainer<
return (
<div className={'filterable-list-container'}>
{this.renderSearchBar()}
{this.renderSearchFilter()}
<div className="filterable-list-container">
{this.renderComponentList()}
</div>
@ -72,17 +70,6 @@ export class FilterableListContainer<
);
}
protected renderSearchFilter(): React.ReactNode {
return (
<>
{this.props.filterRenderer.render(
this.state.searchOptions,
this.handlePropChange.bind(this)
)}
</>
);
}
protected renderSearchBar(): React.ReactNode {
return (
<SearchBar
@ -115,7 +102,7 @@ export class FilterableListContainer<
...this.state.searchOptions,
[prop]: value,
};
this.setSearchOptionsAndUpdate(searchOptions);
this.props.searchOptions.update(searchOptions);
};
private setSearchOptionsAndUpdate(searchOptions: S) {
@ -180,14 +167,12 @@ export namespace FilterableListContainer {
T extends ArduinoComponent,
S extends Searchable.Options
> {
readonly defaultSearchOptions: S;
readonly searchOptions: ListWidgetSearchOptions<S>;
readonly container: ListWidget<T, S>;
readonly searchable: Searchable<T, S>;
readonly itemLabel: (item: T) => string;
readonly itemRenderer: ListItemRenderer<T>;
readonly filterRenderer: FilterRenderer<S>;
readonly resolveFocus: (element: HTMLElement | undefined) => void;
readonly searchOptionsDidChange: Event<Partial<S> | undefined>;
readonly messageService: MessageService;
readonly responseService: ResponseServiceClient;
readonly onDidShow: Event<void>;

View File

@ -1,4 +1,3 @@
import { ApplicationError } from '@theia/core';
import {
Anchor,
ContextMenuRenderer,
@ -6,20 +5,14 @@ import {
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 { ApplicationError } from '@theia/core/lib/common/application-error';
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 { DisposableCollection } from '@theia/core/lib/common/disposable';
import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering';
import { MenuModelRegistry, MenuPath } 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';
@ -33,6 +26,7 @@ import {
SketchContainer,
SketchesService,
SketchRef,
TopicLabel,
} from '../../../common/protocol';
import type { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { Installable } from '../../../common/protocol/installable';
@ -40,8 +34,14 @@ import { openClonedExample } from '../../contributions/examples';
import {
ArduinoMenus,
examplesLabel,
unregisterSubmenu,
showDisabledContextMenuOptions,
} from '../../menu/arduino-menus';
import {
MenuActionTemplate,
registerMenus,
SubmenuTemplate,
} from '../../menu/register-menu';
import { HoverService } from '../../theia/core/hover-service';
const moreInfoLabel = nls.localize('arduino/component/moreInfo', 'More info');
const otherVersionsLabel = nls.localize(
@ -63,9 +63,6 @@ function installVersionLabel(selectedVersion: string) {
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',
@ -81,39 +78,6 @@ function clickToOpenInBrowserLabel(href: string): string | undefined {
);
}
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)
@ -124,54 +88,26 @@ export class ArduinoComponentContextMenuRenderer {
private readonly contextMenuRenderer: ContextMenuRenderer;
private readonly toDisposeBeforeRender = new DisposableCollection();
private menuIndexCounter = 0;
async render(
anchor: Anchor,
...templates: (MenuActionTemplate | SubmenuTemplate)[]
...templates: Array<MenuActionTemplate | SubmenuTemplate>
): Promise<void> {
this.toDisposeBeforeRender.dispose();
this.toDisposeBeforeRender.pushAll([
Disposable.create(() => (this.menuIndexCounter = 0)),
...templates.map((template) => this.registerMenu(template)),
]);
const options = {
this.toDisposeBeforeRender.push(
registerMenus({
contextId: 'component',
commandRegistry: this.commandRegistry,
menuRegistry: this.menuRegistry,
templates,
})
);
const options = showDisabledContextMenuOptions({
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> {
@ -201,6 +137,8 @@ export class ListItemRenderer<T extends ArduinoComponent> {
private readonly messageService: MessageService;
@inject(CommandService)
private readonly commandService: CommandService;
@inject(HoverService)
private readonly hoverService: HoverService;
@inject(CoreService)
private readonly coreService: CoreService;
@inject(ExamplesService)
@ -216,12 +154,26 @@ export class ListItemRenderer<T extends ArduinoComponent> {
}
};
private readonly showHover = (
event: React.MouseEvent<HTMLElement>,
markdown: string
) => {
this.hoverService.requestHover({
content: new MarkdownStringImpl(markdown),
target: event.currentTarget,
position: 'right',
});
};
renderItem(params: ListItemRendererParams<T>): React.ReactNode {
const action = this.action(params);
return (
<>
<Separator />
<div className="component-list-item noselect">
<div
className="component-list-item noselect"
onMouseEnter={(event) => this.showHover(event, this.markdown(params))}
>
<Header
params={params}
action={action}
@ -247,6 +199,30 @@ export class ListItemRenderer<T extends ArduinoComponent> {
});
}
private markdown(params: ListItemRendererParams<T>): string {
// TODO: dedicated library and boards services for the markdown content generation
const {
item,
item: { name, author, description, summary, installedVersion },
} = params;
let title = `__${name}__ ${byLabel} ${author}`;
if (installedVersion) {
title += `\n\n(${installedLabel(`\`${installedVersion}\``)})`;
}
if (LibraryPackage.is(item)) {
let content = `\n\n${summary}`;
// do not repeat the same info if paragraph and sentence are the same
// example: https://github.com/arduino-libraries/ArduinoCloudThing/blob/8cbcee804e99fed614366c1b87143b1f1634c45f/library.properties#L5-L6
if (description !== summary) {
content += `\n_____\n\n${description}`;
}
return `${title}\n\n____${content}\n\n____\n${TopicLabel}: \`${item.category}\``;
}
return `${title}\n\n____\n\n${summary}\n\n - ${description
.split(',')
.join('\n - ')}`;
}
private get services(): ListItemRendererServices {
return {
windowService: this.windowService,
@ -361,7 +337,7 @@ class Toolbar<T extends ArduinoComponent> extends React.Component<
};
}
private get examples(): Promise<(MenuActionTemplate | SubmenuTemplate)[]> {
private get examples(): Promise<Array<MenuActionTemplate | SubmenuTemplate>> {
const {
params: {
item,
@ -394,8 +370,8 @@ class Toolbar<T extends ArduinoComponent> extends React.Component<
container: SketchContainer,
menuPath: MenuPath,
depth = 0
): (MenuActionTemplate | SubmenuTemplate)[] {
const templates: (MenuActionTemplate | SubmenuTemplate)[] = [];
): Array<MenuActionTemplate | SubmenuTemplate> {
const templates: Array<MenuActionTemplate | SubmenuTemplate> = [];
const { label } = container;
if (depth > 0) {
menuPath = [...menuPath, label];
@ -464,7 +440,7 @@ class Toolbar<T extends ArduinoComponent> extends React.Component<
};
}
private get otherVersions(): (MenuActionTemplate | SubmenuTemplate)[] {
private get otherVersions(): Array<MenuActionTemplate | SubmenuTemplate> {
const {
params: {
item: { availableVersions },
@ -566,10 +542,8 @@ class Title<T extends ArduinoComponent> extends React.Component<
> {
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}>
<div className="title">
{name && author ? (
<>
{<span className="name">{name}</span>}{' '}
@ -627,7 +601,7 @@ class Content<T extends ArduinoComponent> extends React.Component<
} = this.props;
const content = [summary, description].filter(Boolean).join(' ');
return (
<div className="content" title={content}>
<div className="content">
<p>{content}</p>
<MoreInfo {...this.props} />
</div>

View File

@ -1,15 +1,39 @@
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import {
OpenerOptions,
OpenHandler,
} from '@theia/core/lib/browser/opener-service';
import {
TabBarToolbarContribution,
TabBarToolbarRegistry,
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
import { codicon } from '@theia/core/lib/browser/widgets/widget';
import {
Command,
CommandContribution,
CommandRegistry,
} from '@theia/core/lib/common/command';
import { MenuModelRegistry, MenuPath } from '@theia/core/lib/common/menu';
import { URI } from '@theia/core/lib/common/uri';
import { injectable } from '@theia/core/shared/inversify';
import { Widget } from '@theia/core/shared/@phosphor/widgets';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Searchable } from '../../../common/protocol';
import { ArduinoComponent } from '../../../common/protocol/arduino-component';
import { ListWidget } from './list-widget';
import { showDisabledContextMenuOptions } from '../../menu/arduino-menus';
import {
MenuActionTemplate,
menuActionWithCommandDelegate,
registerMenus,
SubmenuTemplate,
} from '../../menu/register-menu';
import { ListWidget, ListWidgetSearchOptions } from './list-widget';
import { Event, nls } from '@theia/core';
@injectable()
export abstract class ListWidgetFrontendContribution<
@ -17,14 +41,32 @@ export abstract class ListWidgetFrontendContribution<
S extends Searchable.Options
>
extends AbstractViewContribution<ListWidget<T, S>>
implements FrontendApplicationContribution, OpenHandler
implements
FrontendApplicationContribution,
OpenHandler,
TabBarToolbarContribution,
CommandContribution
{
@inject(ContextMenuRenderer)
private readonly contextMenuRenderer: ContextMenuRenderer;
@inject(CommandRegistry)
private readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry)
private readonly menuRegistry: MenuModelRegistry;
protected abstract readonly searchOptions: ListWidgetSearchOptions<S>;
private readonly toDisposeBeforeShowContextMenu = new DisposableCollection();
readonly id: string = `http-opener-${this.viewId}`;
async initializeLayout(): Promise<void> {
this.openView();
}
onStop(): void {
this.toDisposeBeforeShowContextMenu.dispose();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override registerMenus(_: MenuModelRegistry): void {
// NOOP
@ -62,4 +104,131 @@ export abstract class ListWidgetFrontendContribution<
protected abstract canParse(uri: URI): boolean;
protected abstract parse(uri: URI): S | undefined;
registerToolbarItems(registry: TabBarToolbarRegistry): void {
const filterCommand = this.showViewFilterContextMenuCommand;
registry.registerItem({
id: filterCommand.id,
command: filterCommand.id,
icon: () =>
codicon(
this.searchOptions.hasFilters() ? 'filter-filled' : 'filter',
true
),
onDidChange: this.searchOptions
.onDidChange as Event<unknown> as Event<void>,
});
}
override registerCommands(registry: CommandRegistry): void {
const filterCommand = this.showViewFilterContextMenuCommand;
registry.registerCommand(filterCommand, {
execute: () => this.showFilterContextMenu(filterCommand.id),
isVisible: (arg: unknown) =>
arg instanceof Widget && arg.id === this.viewId,
});
}
protected abstract get showViewFilterContextMenuCommand(): Command & {
label: string;
};
protected abstract get showInstalledCommandId(): string;
protected abstract get showUpdatesCommandId(): string;
protected abstract buildFilterMenuGroup(
menuPath: MenuPath
): Array<MenuActionTemplate | SubmenuTemplate>;
private buildQuickFiltersMenuGroup(
menuPath: MenuPath
): Array<MenuActionTemplate | SubmenuTemplate> {
return [
menuActionWithCommandDelegate(
{
menuPath,
command: this.showInstalledCommandId,
},
this.commandRegistry
),
menuActionWithCommandDelegate(
{ menuPath, command: this.showUpdatesCommandId },
this.commandRegistry
),
];
}
private buildActionsMenuGroup(
menuPath: MenuPath
): Array<MenuActionTemplate | SubmenuTemplate> {
if (!this.searchOptions.hasFilters()) {
return [];
}
return [
{
menuPath,
menuLabel: nls.localize('arduino/filter/clearAll', 'Clear All Filters'),
handler: {
execute: () => this.searchOptions.clearFilters(),
},
},
];
}
protected buildMenuActions<T>(
menuPath: MenuPath,
literals: T[],
isSelected: (literal: T) => boolean,
select: (literal: T) => void,
menuLabelProvider: (literal: T) => string
): MenuActionTemplate[] {
return literals
.map((literal) => ({ literal, label: menuLabelProvider(literal) }))
.map(({ literal, label }) => ({
menuPath,
menuLabel: label,
handler: {
execute: () => select(literal),
isToggled: () => isSelected(literal),
},
}));
}
private showFilterContextMenu(commandId: string): void {
this.toDisposeBeforeShowContextMenu.dispose();
const element = document.getElementById(commandId);
if (!element) {
return;
}
const client = element.getBoundingClientRect();
const menuPath = [`${this.viewId}-filter-context-menu`];
this.toDisposeBeforeShowContextMenu.pushAll([
this.registerMenuGroup(
this.buildFilterMenuGroup([...menuPath, '0_filter'])
),
this.registerMenuGroup(
this.buildQuickFiltersMenuGroup([...menuPath, '1_quick_filters'])
),
this.registerMenuGroup(
this.buildActionsMenuGroup([...menuPath, '2_actions'])
),
]);
const options = showDisabledContextMenuOptions({
menuPath,
anchor: { x: client.left, y: client.bottom + client.height / 2 },
});
this.contextMenuRenderer.render(options);
}
private registerMenuGroup(
templates: Array<MenuActionTemplate | SubmenuTemplate>
): Disposable {
return registerMenus({
commandRegistry: this.commandRegistry,
menuRegistry: this.menuRegistry,
contextId: this.viewId,
templates,
});
}
}

View File

@ -0,0 +1,109 @@
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { TabBarDecorator } from '@theia/core/lib/browser/shell/tab-bar-decorator';
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { Title, Widget } from '@theia/core/shared/@phosphor/widgets';
import { inject, injectable } from '@theia/core/shared/inversify';
import { BoardsListWidget } from '../../boards/boards-list-widget';
import {
BoardsUpdates,
LibraryUpdates,
} from '../../contributions/check-for-updates';
import { LibraryListWidget } from '../../library/library-list-widget';
import { NotificationCenter } from '../../notification-center';
@injectable()
abstract class ListWidgetTabBarDecorator
implements TabBarDecorator, FrontendApplicationContribution
{
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
private count = 0;
private readonly onDidChangeDecorationsEmitter = new Emitter<void>();
protected readonly toDispose = new DisposableCollection(
this.onDidChangeDecorationsEmitter
);
abstract readonly id: string;
readonly onDidChangeDecorations: Event<void> =
this.onDidChangeDecorationsEmitter.event;
onStop(): void {
this.toDispose.dispose();
}
decorate(title: Title<Widget>): WidgetDecoration.Data[] {
const { owner } = title;
if (this.isListWidget(owner)) {
if (this.count > 0) {
return [{ badge: this.count }];
}
}
return [];
}
protected async update(count: number): Promise<void> {
this.count = count;
this.onDidChangeDecorationsEmitter.fire();
}
protected abstract isListWidget(widget: Widget): boolean;
protected abstract get updatableCount(): number | undefined;
}
@injectable()
export class LibraryListWidgetTabBarDecorator extends ListWidgetTabBarDecorator {
@inject(LibraryUpdates)
private readonly libraryUpdates: LibraryUpdates;
readonly id = `${LibraryListWidget.WIDGET_ID}-badge-decorator`;
onStart(): void {
this.toDispose.push(
this.libraryUpdates.onDidChange((libraries) =>
this.update(libraries.length)
)
);
const count = this.updatableCount;
if (count) {
this.update(count);
}
}
protected isListWidget(widget: Widget): boolean {
return widget instanceof LibraryListWidget;
}
protected get updatableCount(): number | undefined {
return this.libraryUpdates.updates?.length;
}
}
@injectable()
export class BoardsListWidgetTabBarDecorator extends ListWidgetTabBarDecorator {
@inject(BoardsUpdates)
private readonly boardsUpdates: BoardsUpdates;
readonly id = `${BoardsListWidget.WIDGET_ID}-badge-decorator`;
onStart(): void {
this.toDispose.push(
this.boardsUpdates.onDidChange((boards) => this.update(boards.length))
);
const count = this.updatableCount;
if (count) {
this.update(count);
}
}
protected isListWidget(widget: Widget): boolean {
return widget instanceof BoardsListWidget;
}
protected get updatableCount(): number | undefined {
return this.boardsUpdates.updates?.length;
}
}

View File

@ -6,7 +6,7 @@ import {
} from '@theia/core/shared/inversify';
import { Widget } from '@theia/core/shared/@phosphor/widgets';
import { Message } from '@theia/core/shared/@phosphor/messaging';
import { Emitter } from '@theia/core/lib/common/event';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget';
import { CommandService } from '@theia/core/lib/common/command';
@ -20,13 +20,16 @@ import {
import { FilterableListContainer } from './filterable-list-container';
import { ListItemRenderer } from './list-item-renderer';
import { NotificationCenter } from '../../notification-center';
import { FilterRenderer } from './filter-renderer';
import { StatefulWidget } from '@theia/core/lib/browser';
@injectable()
export abstract class ListWidget<
T extends ArduinoComponent,
S extends Searchable.Options
> extends ReactWidget {
T extends ArduinoComponent,
S extends Searchable.Options
>
extends ReactWidget
implements StatefulWidget
{
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(NotificationCenter)
@ -41,9 +44,7 @@ export abstract class ListWidget<
*/
private focusNode: HTMLElement | undefined;
private readonly didReceiveFirstFocus = new Deferred();
private readonly searchOptionsChangeEmitter = new Emitter<
Partial<S> | undefined
>();
private readonly searchOptions: ListWidgetSearchOptions<S>;
private readonly onDidShowEmitter = new Emitter<void>();
/**
* Instead of running an `update` from the `postConstruct` `init` method,
@ -53,7 +54,7 @@ export abstract class ListWidget<
constructor(protected options: ListWidget.Options<T, S>) {
super();
const { id, label, iconClass } = options;
const { id, label, iconClass, searchOptions } = options;
this.id = id;
this.title.label = label;
this.title.caption = label;
@ -62,10 +63,8 @@ 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.pushAll([
this.searchOptionsChangeEmitter,
this.onDidShowEmitter,
]);
this.searchOptions = searchOptions;
this.toDispose.push(this.onDidShowEmitter);
}
@postConstruct()
@ -79,6 +78,16 @@ export abstract class ListWidget<
]);
}
storeState(): S | undefined {
return this.searchOptions.options;
}
restoreState(oldState: unknown): void {
if (oldState) {
this.searchOptions.update(oldState as S);
}
}
protected override onAfterShow(message: Message): void {
this.maybeUpdateOnFirstRender();
super.onAfterShow(message);
@ -141,7 +150,7 @@ export abstract class ListWidget<
override render(): React.ReactNode {
return (
<FilterableListContainer<T, S>
defaultSearchOptions={this.options.defaultSearchOptions}
searchOptions={this.searchOptions}
container={this}
resolveFocus={this.onFocusResolved}
searchable={this.options.searchable}
@ -149,8 +158,6 @@ export abstract class ListWidget<
uninstall={this.uninstall.bind(this)}
itemLabel={this.options.itemLabel}
itemRenderer={this.options.itemRenderer}
filterRenderer={this.options.filterRenderer}
searchOptionsDidChange={this.searchOptionsChangeEmitter.event}
messageService={this.messageService}
commandService={this.commandService}
responseService={this.responseService}
@ -164,9 +171,13 @@ export abstract class ListWidget<
* If it is `undefined`, updates the view state by re-running the search with the current `filterText` term.
*/
refresh(searchOptions: Partial<S> | undefined): void {
this.didReceiveFirstFocus.promise.then(() =>
this.searchOptionsChangeEmitter.fire(searchOptions)
);
this.didReceiveFirstFocus.promise.then(() => {
if (searchOptions) {
this.searchOptions.update(searchOptions);
} else {
this.searchOptions.options = this.searchOptions.options; // triggers a refresh. TODO fix this!
}
});
}
updateScrollBar(): void {
@ -188,8 +199,7 @@ export namespace ListWidget {
readonly searchable: Searchable<T, S>;
readonly itemLabel: (item: T) => string;
readonly itemRenderer: ListItemRenderer<T>;
readonly filterRenderer: FilterRenderer<S>;
readonly defaultSearchOptions: S;
readonly searchOptions: ListWidgetSearchOptions<S>;
}
}
@ -199,3 +209,57 @@ export class UserAbortError extends Error {
Object.setPrototypeOf(this, UserAbortError.prototype);
}
}
@injectable()
export abstract class ListWidgetSearchOptions<S extends Searchable.Options> {
private readonly onDidChangeEmitter = new Emitter<Required<S>>();
protected _options: Required<S>;
@postConstruct()
protected init(): void {
this.options = this.defaultOptions;
}
get onDidChange(): Event<Required<S>> {
return this.onDidChangeEmitter.event;
}
get options(): Required<S> {
return this._options;
}
set options(options: Required<S>) {
this._options = options;
this.onDidChangeEmitter.fire({ ...this._options });
}
update(options: Partial<S>): void {
this.options = { ...this.options, ...options };
}
clearFilters(): void {
const { query } = this.options;
this.options = { ...this.defaultOptions, query };
}
/**
* `true` if all property values of the `options` object equals with the `defaultOptions` property values. The `query` property is ignored in the comparison.
*/
hasFilters(): boolean {
const defaultOptions = this.defaultOptions;
const currentOptions = this.options;
for (const key of Object.keys(currentOptions)) {
if (key === 'query') {
continue;
}
const defaultValue = (defaultOptions as Record<string, unknown>)[key];
const currentValue = (currentOptions as Record<string, unknown>)[key];
if (defaultValue !== currentValue) {
return true;
}
}
return false;
}
abstract get defaultOptions(): Required<S>;
}

View File

@ -3,6 +3,10 @@ import { nls } from '@theia/core/lib/common/nls';
export const Unknown = nls.localize('arduino/common/unknown', 'Unknown');
export const Later = nls.localize('arduino/common/later', 'Later');
export const Updatable = nls.localize('arduino/common/updateable', 'Updatable');
export const Installed = nls.localize(
'arduino/libraryType/installed', // TODO: rename `libraryType` to `common`?
'Installed'
);
export const All = nls.localize('arduino/common/all', 'All');
export const Type = nls.localize('arduino/common/type', 'Type');
export const Partner = nls.localize('arduino/common/partner', 'Partner');

View File

@ -6,6 +6,7 @@ import { nls } from '@theia/core/lib/common/nls';
import {
All,
Contributed,
Installed,
Partner,
Type as TypeLabel,
Updatable,
@ -174,6 +175,7 @@ export namespace BoardSearch {
export const TypeLiterals = [
'All',
'Updatable',
'Installed',
'Arduino',
'Contributed',
'Arduino Certified',
@ -189,6 +191,7 @@ export namespace BoardSearch {
export const TypeLabels: Record<Type, string> = {
All: All,
Updatable: Updatable,
Installed: Installed,
Arduino: 'Arduino',
Contributed: Contributed,
'Arduino Certified': nls.localize(

View File

@ -5,6 +5,7 @@ import { nls } from '@theia/core/lib/common/nls';
import {
All,
Contributed,
Installed,
Partner,
Recommended,
Retired,
@ -13,6 +14,11 @@ import {
} from '../nls';
import URI from '@theia/core/lib/common/uri';
export const TopicLabel = nls.localize(
'arduino/librarySearchProperty/topic',
'Topic'
);
export const LibraryServicePath = '/services/library-service';
export const LibraryService = Symbol('LibraryService');
export interface LibraryService
@ -76,7 +82,7 @@ export namespace LibrarySearch {
export const TypeLabels: Record<Type, string> = {
All: All,
Updatable: Updatable,
Installed: nls.localize('arduino/libraryType/installed', 'Installed'),
Installed: Installed,
Arduino: 'Arduino',
Partner: Partner,
Recommended: Recommended,
@ -137,7 +143,7 @@ export namespace LibrarySearch {
keyof Omit<LibrarySearch, 'query'>,
string
> = {
topic: nls.localize('arduino/librarySearchProperty/topic', 'Topic'),
topic: TopicLabel,
type: TypeLabel,
};
export namespace UriParser {

View File

@ -1,6 +1,8 @@
import URI from '@theia/core/lib/common/uri';
import type { ArduinoComponent } from './arduino-component';
export const Updatable = { type: 'Updatable' } as const;
export interface Searchable<T, O extends Searchable.Options> {
search(options: O): Promise<T[]>;
}

View File

@ -402,8 +402,8 @@ export class BoardsServiceImpl
}
}
const filter = this.typePredicate(options);
const boardsPackages = [...packages.values()].filter(filter);
const typeFilter = this.typePredicate(options);
const boardsPackages = [...packages.values()].filter(typeFilter);
return sortComponents(boardsPackages, boardsPackageSortGroup);
}
@ -415,6 +415,8 @@ export class BoardsServiceImpl
return () => true;
}
switch (options.type) {
case 'Installed':
return Installable.Installed;
case 'Updatable':
return Installable.Updateable;
case 'Arduino':

View File

@ -221,8 +221,8 @@ export class LibraryServiceImpl
{
name: library.getName(),
installedVersion,
description: library.getSentence(),
summary: library.getParagraph(),
description: library.getParagraph(),
summary: library.getSentence(),
moreInfoLink: library.getWebsite(),
includes: library.getProvidesIncludesList(),
location: this.mapLocation(library.getLocation()),
@ -462,9 +462,9 @@ function toLibrary(
author: lib.getAuthor(),
availableVersions,
includes: lib.getProvidesIncludesList(),
description: lib.getSentence(),
description: lib.getParagraph(),
moreInfoLink: lib.getWebsite(),
summary: lib.getParagraph(),
summary: lib.getSentence(),
category: lib.getCategory(),
types: lib.getTypesList(),
};

View File

@ -46,6 +46,9 @@
"typeOfPorts": "{0} ports",
"unknownBoard": "Unknown board"
},
"boards": {
"filterBoards": "Filter Boards..."
},
"boardsManager": "Boards Manager",
"boardsType": {
"arduinoCertified": "Arduino Certified"
@ -81,6 +84,10 @@
"noUpdates": "There are no recent updates available.",
"promptUpdateBoards": "Updates are available for some of your boards.",
"promptUpdateLibraries": "Updates are available for some of your libraries.",
"showBoardsUpdates": "Boards Updates",
"showInstalledBoards": "Installed Boards",
"showInstalledLibraries": "Installed Libraries",
"showLibraryUpdates": "Library Updates",
"updatingBoards": "Updating boards...",
"updatingLibraries": "Updating libraries..."
},
@ -169,7 +176,6 @@
"moreInfo": "More info",
"otherVersions": "Other Versions",
"remove": "Remove",
"title": "{0} by {1}",
"uninstall": "Uninstall",
"uninstallMsg": "Do you want to uninstall {0}?",
"update": "Update"
@ -242,6 +248,9 @@
"forAny": "Examples for any board",
"menu": "Examples"
},
"filter": {
"clearAll": "Clear All Filters"
},
"firmware": {
"checkUpdates": "Check Updates",
"failedInstall": "Installation failed. Please try again.",
@ -282,6 +291,9 @@
"updateAvailable": "Update Available",
"versionDownloaded": "Arduino IDE {0} has been downloaded."
},
"libraries": {
"filterLibraries": "Filter Libraries..."
},
"library": {
"addZip": "Add .ZIP Library...",
"arduinoLibraries": "Arduino libraries",