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} /> ); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy