From fa4626bf1494745482837923f30ffa26efe64c75 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Mon, 13 Mar 2023 17:48:23 +0100 Subject: [PATCH 1/3] 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 --- .../browser/arduino-ide-frontend-module.ts | 56 ++++- .../src/browser/boards/boards-list-widget.ts | 21 +- .../boards-widget-frontend-contribution.ts | 62 ++++- .../contributions/check-for-updates.ts | 233 ++++++++++++++++-- .../browser/contributions/sketch-control.ts | 10 +- .../src/browser/data/dark.color-theme.json | 3 +- .../src/browser/data/default.color-theme.json | 1 + .../browser/library/library-list-widget.ts | 31 ++- .../library-widget-frontend-contribution.ts | 92 ++++++- .../src/browser/menu/arduino-menus.ts | 11 + .../src/browser/menu/register-menu.ts | 151 ++++++++++++ .../src/browser/style/hover-service.css | 82 ++++++ .../src/browser/style/index.css | 5 +- .../src/browser/theia/core/hover-service.ts | 225 +++++++++++++++++ .../component-list/filter-renderer.tsx | 121 --------- .../filterable-list-container.tsx | 25 +- .../component-list/list-item-renderer.tsx | 166 ++++++------- .../list-widget-frontend-contribution.ts | 177 ++++++++++++- .../list-widget-tabbar-decorator.ts | 109 ++++++++ .../widgets/component-list/list-widget.tsx | 106 ++++++-- arduino-ide-extension/src/common/nls.ts | 4 + .../src/common/protocol/boards-service.ts | 3 + .../src/common/protocol/library-service.ts | 10 +- .../src/common/protocol/searchable.ts | 2 + .../src/node/boards-service-impl.ts | 6 +- .../src/node/library-service-impl.ts | 8 +- i18n/en.json | 14 +- 27 files changed, 1398 insertions(+), 336 deletions(-) create mode 100644 arduino-ide-extension/src/browser/menu/register-menu.ts create mode 100644 arduino-ide-extension/src/browser/style/hover-service.css create mode 100644 arduino-ide-extension/src/browser/theia/core/hover-service.ts delete mode 100644 arduino-ide-extension/src/browser/widgets/component-list/filter-renderer.tsx create mode 100644 arduino-ide-extension/src/browser/widgets/component-list/list-widget-tabbar-decorator.ts diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 6c6b44a63..6d376a838 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -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 + ); }); diff --git a/arduino-ide-extension/src/browser/boards/boards-list-widget.ts b/arduino-ide-extension/src/browser/boards/boards-list-widget.ts index 7067225dc..b71f352ee 100644 --- a/arduino-ide-extension/src/browser/boards/boards-list-widget.ts +++ b/arduino-ide-extension/src/browser/boards/boards-list-widget.ts @@ -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 { + get defaultOptions(): Required { + return { query: '', type: 'All' }; + } +} @injectable() export class BoardsListWidget extends ListWidget { @@ -21,7 +30,8 @@ export class BoardsListWidget extends ListWidget { constructor( @inject(BoardsService) service: BoardsService, @inject(ListItemRenderer) itemRenderer: ListItemRenderer, - @inject(BoardsFilterRenderer) filterRenderer: BoardsFilterRenderer + @inject(BoardsListWidgetSearchOptions) + searchOptions: BoardsListWidgetSearchOptions ) { super({ id: BoardsListWidget.WIDGET_ID, @@ -31,8 +41,7 @@ export class BoardsListWidget extends ListWidget { installable: service, itemLabel: (item: BoardsPackage) => item.name, itemRenderer, - filterRenderer, - defaultSearchOptions: { query: '', type: 'All' }, + searchOptions, }); } diff --git a/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts b/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts index c64d08690..e483a968e 100644 --- a/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts +++ b/arduino-ide-extension/src/browser/boards/boards-widget-frontend-contribution.ts @@ -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 { + const typeSubmenuPath = [...menuPath, TypeLabel]; + return [ + { + submenuPath: typeSubmenuPath, + menuLabel: `${TypeLabel}: "${ + BoardSearch.TypeLabels[this.searchOptions.options.type] + }"`, + options: { order: String(0) }, + }, + ...this.buildMenuActions( + 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...'), + }; + } } diff --git a/arduino-ide-extension/src/browser/contributions/check-for-updates.ts b/arduino-ide-extension/src/browser/contributions/check-for-updates.ts index d305f9db2..7b2decdb6 100644 --- a/arduino-ide-extension/src/browser/contributions/check-for-updates.ts +++ b/arduino-ide-extension/src/browser/contributions/check-for-updates.ts @@ -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 { 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 { @@ -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>, + viewSearchOptions: S + ): Promise { + const widget = await viewContribution.openView({ reveal: true }); + widget.refresh(viewSearchOptions); + } + private async executeTasks( message: string, tasks: Task[] @@ -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 + 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(); + 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; + + private async refreshDebounced(): Promise { + const checkForUpdates = this.preferences['arduino.checkForUpdates']; + this._updates = checkForUpdates ? await this.searchUpdates() : []; + this.onDidChangeEmitter.fire(this._updates.slice()); + } +} + +@injectable() +export class LibraryUpdates extends ComponentUpdates { + @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 { + return this.libraryService.search(updatableLibrariesSearchOption); + } +} + +@injectable() +export class BoardsUpdates extends ComponentUpdates { + @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 { + return this.boardsService.search(updatableBoardsSearchOption); } } diff --git a/arduino-ide-extension/src/browser/contributions/sketch-control.ts b/arduino-ide-extension/src/browser/contributions/sketch-control.ts index 64bbb1ce9..9e6f5ef5c 100644 --- a/arduino-ide-extension/src/browser/contributions/sketch-control.ts +++ b/arduino-ide-extension/src/browser/contributions/sketch-control.ts @@ -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); }, } diff --git a/arduino-ide-extension/src/browser/data/dark.color-theme.json b/arduino-ide-extension/src/browser/data/dark.color-theme.json index 9e9d15718..17bb95b20 100644 --- a/arduino-ide-extension/src/browser/data/dark.color-theme.json +++ b/arduino-ide-extension/src/browser/data/dark.color-theme.json @@ -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", diff --git a/arduino-ide-extension/src/browser/data/default.color-theme.json b/arduino-ide-extension/src/browser/data/default.color-theme.json index e81e4baa0..3b45dc4c7 100644 --- a/arduino-ide-extension/src/browser/data/default.color-theme.json +++ b/arduino-ide-extension/src/browser/data/default.color-theme.json @@ -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", diff --git a/arduino-ide-extension/src/browser/library/library-list-widget.ts b/arduino-ide-extension/src/browser/library/library-list-widget.ts index 050783816..1c0078026 100644 --- a/arduino-ide-extension/src/browser/library/library-list-widget.ts +++ b/arduino-ide-extension/src/browser/library/library-list-widget.ts @@ -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 { + get defaultOptions(): Required { + 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, - @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, }); } diff --git a/arduino-ide-extension/src/browser/library/library-widget-frontend-contribution.ts b/arduino-ide-extension/src/browser/library/library-widget-frontend-contribution.ts index 74d5de4a4..01fc03839 100644 --- a/arduino-ide-extension/src/browser/library/library-widget-frontend-contribution.ts +++ b/arduino-ide-extension/src/browser/library/library-widget-frontend-contribution.ts @@ -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 { + 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( + 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( + 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...' + ), + }; + } } diff --git a/arduino-ide-extension/src/browser/menu/arduino-menus.ts b/arduino-ide-extension/src/browser/menu/arduino-menus.ts index 9ecfec550..dd9208eb1 100644 --- a/arduino-ide-extension/src/browser/menu/arduino-menus.ts +++ b/arduino-ide-extension/src/browser/menu/arduino-menus.ts @@ -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 }); +} diff --git a/arduino-ide-extension/src/browser/menu/register-menu.ts b/arduino-ide-extension/src/browser/menu/register-menu.ts new file mode 100644 index 000000000..0f1bfebf8 --- /dev/null +++ b/arduino-ide-extension/src/browser/menu/register-menu.ts @@ -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 & { + 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; + } & 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}`; + } +} diff --git a/arduino-ide-extension/src/browser/style/hover-service.css b/arduino-ide-extension/src/browser/style/hover-service.css new file mode 100644 index 000000000..0468d4241 --- /dev/null +++ b/arduino-ide-extension/src/browser/style/hover-service.css @@ -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; +} \ No newline at end of file diff --git a/arduino-ide-extension/src/browser/style/index.css b/arduino-ide-extension/src/browser/style/index.css index d0ac1e45e..07513615c 100644 --- a/arduino-ide-extension/src/browser/style/index.css +++ b/arduino-ide-extension/src/browser/style/index.css @@ -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. */ diff --git a/arduino-ide-extension/src/browser/theia/core/hover-service.ts b/arduino-ide-extension/src/browser/theia/core/hover-service.ts new file mode 100644 index 000000000..4dd2bee91 --- /dev/null +++ b/arduino-ide-extension/src/browser/theia/core/hover-service.ts @@ -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 { + 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(); + } +} diff --git a/arduino-ide-extension/src/browser/widgets/component-list/filter-renderer.tsx b/arduino-ide-extension/src/browser/widgets/component-list/filter-renderer.tsx deleted file mode 100644 index 9f4a9cffb..000000000 --- a/arduino-ide-extension/src/browser/widgets/component-list/filter-renderer.tsx +++ /dev/null @@ -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 { - render( - options: S, - handlePropChange: (prop: keyof S, value: S[keyof S]) => void - ): React.ReactNode { - const props = this.props(); - return ( -
- {Object.entries(options) - .filter(([prop]) => props.includes(prop as keyof S)) - .map(([prop, value]) => ( -
-
- {`${this.propertyLabel(prop as keyof S)}:`} -
- -
- ))} -
- ); - } - 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 { - 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 { - 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}`); - } - } -} diff --git a/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx b/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx index 05e0e95be..d9f4f1531 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx @@ -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>) { 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 (
{this.renderSearchBar()} - {this.renderSearchFilter()}
{this.renderComponentList()}
@@ -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 ( { - readonly defaultSearchOptions: S; + readonly searchOptions: ListWidgetSearchOptions; readonly container: ListWidget; readonly searchable: Searchable; readonly itemLabel: (item: T) => string; readonly itemRenderer: ListItemRenderer; - readonly filterRenderer: FilterRenderer; readonly resolveFocus: (element: HTMLElement | undefined) => void; - readonly searchOptionsDidChange: Event | undefined>; readonly messageService: MessageService; readonly responseService: ResponseServiceClient; readonly onDidShow: Event; diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx b/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx index 945b563dc..238a4a6fa 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx @@ -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 ): Promise { 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 { @@ -201,6 +137,8 @@ export class ListItemRenderer { 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 { } }; + private readonly showHover = ( + event: React.MouseEvent, + markdown: string + ) => { + this.hoverService.requestHover({ + content: new MarkdownStringImpl(markdown), + target: event.currentTarget, + position: 'right', + }); + }; + renderItem(params: ListItemRendererParams): React.ReactNode { const action = this.action(params); return ( <> -
+
this.showHover(event, this.markdown(params))} + >
{ }); } + private markdown(params: ListItemRendererParams): 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 extends React.Component< }; } - private get examples(): Promise<(MenuActionTemplate | SubmenuTemplate)[]> { + private get examples(): Promise> { const { params: { item, @@ -394,8 +370,8 @@ class Toolbar extends React.Component< container: SketchContainer, menuPath: MenuPath, depth = 0 - ): (MenuActionTemplate | SubmenuTemplate)[] { - const templates: (MenuActionTemplate | SubmenuTemplate)[] = []; + ): Array { + const templates: Array = []; const { label } = container; if (depth > 0) { menuPath = [...menuPath, label]; @@ -464,7 +440,7 @@ class Toolbar extends React.Component< }; } - private get otherVersions(): (MenuActionTemplate | SubmenuTemplate)[] { + private get otherVersions(): Array { const { params: { item: { availableVersions }, @@ -566,10 +542,8 @@ class Title 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 ( -
+
{name && author ? ( <> {{name}}{' '} @@ -627,7 +601,7 @@ class Content extends React.Component< } = this.props; const content = [summary, description].filter(Boolean).join(' '); return ( -
+

{content}

diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-widget-frontend-contribution.ts b/arduino-ide-extension/src/browser/widgets/component-list/list-widget-frontend-contribution.ts index 56dec744d..bc67ee7cd 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/list-widget-frontend-contribution.ts +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-widget-frontend-contribution.ts @@ -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> - 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; + + private readonly toDisposeBeforeShowContextMenu = new DisposableCollection(); + readonly id: string = `http-opener-${this.viewId}`; async initializeLayout(): Promise { 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 as Event, + }); + } + + 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; + + private buildQuickFiltersMenuGroup( + menuPath: MenuPath + ): Array { + return [ + menuActionWithCommandDelegate( + { + menuPath, + command: this.showInstalledCommandId, + }, + this.commandRegistry + ), + menuActionWithCommandDelegate( + { menuPath, command: this.showUpdatesCommandId }, + this.commandRegistry + ), + ]; + } + + private buildActionsMenuGroup( + menuPath: MenuPath + ): Array { + if (!this.searchOptions.hasFilters()) { + return []; + } + return [ + { + menuPath, + menuLabel: nls.localize('arduino/filter/clearAll', 'Clear All Filters'), + handler: { + execute: () => this.searchOptions.clearFilters(), + }, + }, + ]; + } + + protected buildMenuActions( + 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 + ): Disposable { + return registerMenus({ + commandRegistry: this.commandRegistry, + menuRegistry: this.menuRegistry, + contextId: this.viewId, + templates, + }); + } } diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-widget-tabbar-decorator.ts b/arduino-ide-extension/src/browser/widgets/component-list/list-widget-tabbar-decorator.ts new file mode 100644 index 000000000..0958c2a26 --- /dev/null +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-widget-tabbar-decorator.ts @@ -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(); + protected readonly toDispose = new DisposableCollection( + this.onDidChangeDecorationsEmitter + ); + + abstract readonly id: string; + readonly onDidChangeDecorations: Event = + this.onDidChangeDecorationsEmitter.event; + + onStop(): void { + this.toDispose.dispose(); + } + + decorate(title: Title): WidgetDecoration.Data[] { + const { owner } = title; + if (this.isListWidget(owner)) { + if (this.count > 0) { + return [{ badge: this.count }]; + } + } + return []; + } + + protected async update(count: number): Promise { + 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; + } +} diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx b/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx index ca340a01f..e6dfd41b1 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx @@ -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 | undefined - >(); + private readonly searchOptions: ListWidgetSearchOptions; private readonly onDidShowEmitter = new Emitter(); /** * Instead of running an `update` from the `postConstruct` `init` method, @@ -53,7 +54,7 @@ export abstract class ListWidget< constructor(protected options: ListWidget.Options) { 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 ( - 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 | 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; readonly itemLabel: (item: T) => string; readonly itemRenderer: ListItemRenderer; - readonly filterRenderer: FilterRenderer; - readonly defaultSearchOptions: S; + readonly searchOptions: ListWidgetSearchOptions; } } @@ -199,3 +209,57 @@ export class UserAbortError extends Error { Object.setPrototypeOf(this, UserAbortError.prototype); } } + +@injectable() +export abstract class ListWidgetSearchOptions { + private readonly onDidChangeEmitter = new Emitter>(); + protected _options: Required; + + @postConstruct() + protected init(): void { + this.options = this.defaultOptions; + } + + get onDidChange(): Event> { + return this.onDidChangeEmitter.event; + } + + get options(): Required { + return this._options; + } + + set options(options: Required) { + this._options = options; + this.onDidChangeEmitter.fire({ ...this._options }); + } + + update(options: Partial): 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)[key]; + const currentValue = (currentOptions as Record)[key]; + if (defaultValue !== currentValue) { + return true; + } + } + return false; + } + + abstract get defaultOptions(): Required; +} diff --git a/arduino-ide-extension/src/common/nls.ts b/arduino-ide-extension/src/common/nls.ts index a2e58b86a..6b0707df1 100644 --- a/arduino-ide-extension/src/common/nls.ts +++ b/arduino-ide-extension/src/common/nls.ts @@ -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'); diff --git a/arduino-ide-extension/src/common/protocol/boards-service.ts b/arduino-ide-extension/src/common/protocol/boards-service.ts index c955b9462..d7df7bff7 100644 --- a/arduino-ide-extension/src/common/protocol/boards-service.ts +++ b/arduino-ide-extension/src/common/protocol/boards-service.ts @@ -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 = { All: All, Updatable: Updatable, + Installed: Installed, Arduino: 'Arduino', Contributed: Contributed, 'Arduino Certified': nls.localize( diff --git a/arduino-ide-extension/src/common/protocol/library-service.ts b/arduino-ide-extension/src/common/protocol/library-service.ts index e8a32d901..f5e996f13 100644 --- a/arduino-ide-extension/src/common/protocol/library-service.ts +++ b/arduino-ide-extension/src/common/protocol/library-service.ts @@ -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 = { 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, string > = { - topic: nls.localize('arduino/librarySearchProperty/topic', 'Topic'), + topic: TopicLabel, type: TypeLabel, }; export namespace UriParser { diff --git a/arduino-ide-extension/src/common/protocol/searchable.ts b/arduino-ide-extension/src/common/protocol/searchable.ts index 2caf53730..0854336f2 100644 --- a/arduino-ide-extension/src/common/protocol/searchable.ts +++ b/arduino-ide-extension/src/common/protocol/searchable.ts @@ -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 { search(options: O): Promise; } diff --git a/arduino-ide-extension/src/node/boards-service-impl.ts b/arduino-ide-extension/src/node/boards-service-impl.ts index b336d04c4..20b1f1793 100644 --- a/arduino-ide-extension/src/node/boards-service-impl.ts +++ b/arduino-ide-extension/src/node/boards-service-impl.ts @@ -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': diff --git a/arduino-ide-extension/src/node/library-service-impl.ts b/arduino-ide-extension/src/node/library-service-impl.ts index 1bb836dcc..e31020995 100644 --- a/arduino-ide-extension/src/node/library-service-impl.ts +++ b/arduino-ide-extension/src/node/library-service-impl.ts @@ -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(), }; diff --git a/i18n/en.json b/i18n/en.json index 22419c5bf..7cf6f927f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", From 03355903b97d235253eea2c0eb643a6fb679f2ac Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 16 Mar 2023 10:12:02 +0100 Subject: [PATCH 2/3] fix: no group for the description if it's missing Signed-off-by: Akos Kitta --- .../src/browser/widgets/component-list/list-item-renderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx b/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx index 238a4a6fa..6acf0f842 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx @@ -213,7 +213,7 @@ export class ListItemRenderer { 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) { + if (description && description !== summary) { content += `\n_____\n\n${description}`; } return `${title}\n\n____${content}\n\n____\n${TopicLabel}: \`${item.category}\``; From 5c31c93636d339315b38f5159d77f9c20b492259 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 16 Mar 2023 16:11:37 +0100 Subject: [PATCH 3/3] fix: relaxed hover service request when scrolling - do not render footer when scrolling - fix anchor word wrapping for long long links in the markdown - underline the link and change the cursor to pointer on hover - consider status-bar height when calculating hover top Signed-off-by: Akos Kitta --- .../src/browser/style/hover-service.css | 7 +++-- .../src/browser/style/list-widget.css | 4 +++ .../src/browser/theia/core/hover-service.ts | 2 +- .../component-list/component-list-item.tsx | 2 ++ .../widgets/component-list/component-list.tsx | 24 ++++++++++++++++- .../filterable-list-container.tsx | 3 +++ .../component-list/list-item-renderer.tsx | 26 ++++++++++++------- .../widgets/component-list/list-widget.tsx | 4 +++ 8 files changed, 59 insertions(+), 13 deletions(-) diff --git a/arduino-ide-extension/src/browser/style/hover-service.css b/arduino-ide-extension/src/browser/style/hover-service.css index 0468d4241..cf4218cf0 100644 --- a/arduino-ide-extension/src/browser/style/hover-service.css +++ b/arduino-ide-extension/src/browser/style/hover-service.css @@ -4,7 +4,7 @@ /* 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-max-width: 200px; /* customized */ } .theia-hover { @@ -29,10 +29,13 @@ .theia-hover a { color: var(--theia-textLink-foreground); + word-wrap: break-word; /* customized */ + cursor: pointer; /* customized */ } .theia-hover a:hover { - color: var(--theia-textLink-active-foreground); + /* color: var(--theia-textLink-active-foreground); */ + text-decoration: underline; /* customized */ } .theia-hover .hover-row .actions { diff --git a/arduino-ide-extension/src/browser/style/list-widget.css b/arduino-ide-extension/src/browser/style/list-widget.css index 0ce86919c..07d61a8d4 100644 --- a/arduino-ide-extension/src/browser/style/list-widget.css +++ b/arduino-ide-extension/src/browser/style/list-widget.css @@ -158,6 +158,10 @@ padding-top: 8px; } +.component-list-item .footer.scrolling { + visibility: hidden; +} + .component-list-item .footer > * { display: inline-block; } diff --git a/arduino-ide-extension/src/browser/theia/core/hover-service.ts b/arduino-ide-extension/src/browser/theia/core/hover-service.ts index 4dd2bee91..26bcd359f 100644 --- a/arduino-ide-extension/src/browser/theia/core/hover-service.ts +++ b/arduino-ide-extension/src/browser/theia/core/hover-service.ts @@ -144,7 +144,7 @@ export class HoverService { 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; + const documentHeight = document.documentElement.scrollHeight - 22; // --theia-statusBar-height: 22px; position = HoverPosition.invertIfNecessary( position, targetDimensions, diff --git a/arduino-ide-extension/src/browser/widgets/component-list/component-list-item.tsx b/arduino-ide-extension/src/browser/widgets/component-list/component-list-item.tsx index 83b0dea71..fcd93e128 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/component-list-item.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/component-list-item.tsx @@ -24,6 +24,7 @@ export class ComponentListItem< item, selectedVersion, inProgress: this.state.inProgress, + isScrolling: this.props.isScrolling, install: (item) => this.install(item), uninstall: (item) => this.uninstall(item), onVersionChange: (version) => this.onVersionChange(version), @@ -88,6 +89,7 @@ export namespace ComponentListItem { selectedVersion: Installable.Version ) => void; readonly itemRenderer: ListItemRenderer; + readonly isScrolling: boolean; } export interface State { diff --git a/arduino-ide-extension/src/browser/widgets/component-list/component-list.tsx b/arduino-ide-extension/src/browser/widgets/component-list/component-list.tsx index 86f4d3df7..20bc16212 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/component-list.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/component-list.tsx @@ -2,16 +2,33 @@ import * as React from '@theia/core/shared/react'; import { Virtuoso } from '@theia/core/shared/react-virtuoso'; import { ArduinoComponent } from '../../../common/protocol/arduino-component'; import { Installable } from '../../../common/protocol/installable'; +import { HoverService } from '../../theia/core/hover-service'; import { ComponentListItem } from './component-list-item'; import { ListItemRenderer } from './list-item-renderer'; export class ComponentList extends React.Component< - ComponentList.Props + ComponentList.Props, + ComponentList.State > { + constructor(props: Readonly>) { + super(props); + this.state = { + isScrolling: false, + }; + } + override render(): React.ReactNode { return ( { + if (this.state.isScrolling !== isScrolling) { + this.setState({ isScrolling }); + if (isScrolling) { + this.props.hoverService.cancelHover(); + } + } + }} itemContent={(_: number, item: T) => ( key={this.props.itemLabel(item)} @@ -21,6 +38,7 @@ export class ComponentList extends React.Component< uninstall={this.props.uninstall} edited={this.props.edited} onItemEdit={this.props.onItemEdit} + isScrolling={this.state.isScrolling} /> )} /> @@ -42,5 +60,9 @@ export namespace ComponentList { item: T, selectedVersion: Installable.Version ) => void; + readonly hoverService: HoverService; + } + export interface State { + isScrolling: boolean; } } diff --git a/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx b/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx index d9f4f1531..0a73e5ec1 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/filterable-list-container.tsx @@ -15,6 +15,7 @@ import { ListItemRenderer } from './list-item-renderer'; import { ResponseServiceClient } from '../../../common/protocol'; import { nls } from '@theia/core/lib/common'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { HoverService } from '../../theia/core/hover-service'; export class FilterableListContainer< T extends ArduinoComponent, @@ -93,6 +94,7 @@ export class FilterableListContainer< uninstall={this.uninstall.bind(this)} edited={this.state.edited} onItemEdit={this.onItemEdit.bind(this)} + hoverService={this.props.hoverService} /> ); } @@ -193,6 +195,7 @@ export namespace FilterableListContainer { progressId: string; }) => Promise; readonly commandService: CommandService; + readonly hoverService: HoverService; } export interface State { diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx b/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx index 6acf0f842..6a2f00ec3 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-item-renderer.tsx @@ -114,6 +114,7 @@ interface ListItemRendererParams { readonly item: T; readonly selectedVersion: Installable.Version | undefined; readonly inProgress?: 'installing' | 'uninstalling' | undefined; + readonly isScrolling: boolean; readonly install: (item: T) => Promise; readonly uninstall: (item: T) => Promise; readonly onVersionChange: (version: Installable.Version) => void; @@ -156,15 +157,17 @@ export class ListItemRenderer { private readonly showHover = ( event: React.MouseEvent, - markdown: string + params: ListItemRendererParams ) => { - this.hoverService.requestHover({ - content: new MarkdownStringImpl(markdown), - target: event.currentTarget, - position: 'right', - }); + if (!params.isScrolling) { + const markdown = this.markdown(params); + this.hoverService.requestHover({ + content: new MarkdownStringImpl(markdown), + target: event.currentTarget, + position: 'right', + }); + } }; - renderItem(params: ListItemRendererParams): React.ReactNode { const action = this.action(params); return ( @@ -172,7 +175,7 @@ export class ListItemRenderer {
this.showHover(event, this.markdown(params))} + onMouseOver={(event) => this.showHover(event, params)} >
extends React.Component< }> > { override render(): React.ReactNode { + const { isScrolling } = this.props.params; + const className = ['footer']; + if (isScrolling) { + className.push('scrolling'); + } return ( -
+
diff --git a/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx b/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx index e6dfd41b1..173ffcc48 100644 --- a/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/component-list/list-widget.tsx @@ -21,6 +21,7 @@ import { FilterableListContainer } from './filterable-list-container'; import { ListItemRenderer } from './list-item-renderer'; import { NotificationCenter } from '../../notification-center'; import { StatefulWidget } from '@theia/core/lib/browser'; +import { HoverService } from '../../theia/core/hover-service'; @injectable() export abstract class ListWidget< @@ -38,6 +39,8 @@ export abstract class ListWidget< private readonly commandService: CommandService; @inject(ResponseServiceClient) private readonly responseService: ResponseServiceClient; + @inject(HoverService) + private readonly hoverService: HoverService; /** * Do not touch or use it. It is for setting the focus on the `input` after the widget activation. @@ -162,6 +165,7 @@ export abstract class ListWidget< commandService={this.commandService} responseService={this.responseService} onDidShow={this.onDidShowEmitter.event} + hoverService={this.hoverService} /> ); }