From 380676e814faca476543dce2d00d107a92311e64 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Wed, 21 Sep 2022 18:41:46 +0200 Subject: [PATCH] Link resolved for lib/boards manager. Closes #1442 Signed-off-by: Akos Kitta --- .../browser/arduino-ide-frontend-module.ts | 3 + .../boards-widget-frontend-contribution.ts | 18 ++- .../library-widget-frontend-contribution.ts | 36 +++-- .../list-widget-frontend-contribution.ts | 53 ++++++- .../src/common/protocol/boards-service.ts | 50 ++++++- .../src/common/protocol/library-service.ts | 68 ++++++++- .../src/common/protocol/searchable.ts | 22 +++ .../src/test/common/searchable.test.ts | 136 ++++++++++++++++++ 8 files changed, 359 insertions(+), 27 deletions(-) create mode 100644 arduino-ide-extension/src/test/common/searchable.test.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 380c2c9ab..5e87af4fc 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -334,6 +334,7 @@ import { DeleteSketch } from './contributions/delete-sketch'; import { UserFields } from './contributions/user-fields'; import { UpdateIndexes } from './contributions/update-indexes'; import { InterfaceScale } from './contributions/interface-scale'; +import { OpenHandler } from '@theia/core/lib/browser/opener-service'; const registerArduinoThemes = () => { const themes: MonacoThemeJson[] = [ @@ -398,6 +399,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(FrontendApplicationContribution).toService( LibraryListWidgetFrontendContribution ); + bind(OpenHandler).toService(LibraryListWidgetFrontendContribution); // Sketch list service bind(SketchesService) @@ -464,6 +466,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(FrontendApplicationContribution).toService( BoardsListWidgetFrontendContribution ); + bind(OpenHandler).toService(BoardsListWidgetFrontendContribution); // Board select dialog bind(BoardsConfigDialogWidget).toSelf().inSingletonScope(); 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 a6e535d6f..c64d08690 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,10 +1,11 @@ import { injectable } from '@theia/core/shared/inversify'; -import { BoardsListWidget } from './boards-list-widget'; -import type { +import { BoardSearch, BoardsPackage, } from '../../common/protocol/boards-service'; +import { URI } from '../contributions/contribution'; import { ListWidgetFrontendContribution } from '../widgets/component-list/list-widget-frontend-contribution'; +import { BoardsListWidget } from './boards-list-widget'; @injectable() export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendContribution< @@ -24,7 +25,16 @@ export class BoardsListWidgetFrontendContribution extends ListWidgetFrontendCont }); } - override async initializeLayout(): Promise { - this.openView(); + protected canParse(uri: URI): boolean { + try { + BoardSearch.UriParser.parse(uri); + return true; + } catch { + return false; + } + } + + protected parse(uri: URI): BoardSearch | undefined { + return BoardSearch.UriParser.parse(uri); } } 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 37a3b0679..74d5de4a4 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,16 +1,17 @@ +import { nls } from '@theia/core/lib/common'; +import { MenuModelRegistry } from '@theia/core/lib/common/menu'; import { injectable } from '@theia/core/shared/inversify'; -import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; -import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; -import { MenuModelRegistry } from '@theia/core'; -import { LibraryListWidget } from './library-list-widget'; +import { LibraryPackage, LibrarySearch } from '../../common/protocol'; +import { URI } from '../contributions/contribution'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { nls } from '@theia/core/lib/common'; +import { ListWidgetFrontendContribution } from '../widgets/component-list/list-widget-frontend-contribution'; +import { LibraryListWidget } from './library-list-widget'; @injectable() -export class LibraryListWidgetFrontendContribution - extends AbstractViewContribution - implements FrontendApplicationContribution -{ +export class LibraryListWidgetFrontendContribution extends ListWidgetFrontendContribution< + LibraryPackage, + LibrarySearch +> { constructor() { super({ widgetId: LibraryListWidget.WIDGET_ID, @@ -24,10 +25,6 @@ export class LibraryListWidgetFrontendContribution }); } - async initializeLayout(): Promise { - this.openView(); - } - override registerMenus(menus: MenuModelRegistry): void { if (this.toggleCommand) { menus.registerMenuAction(ArduinoMenus.TOOLS__MAIN_GROUP, { @@ -40,4 +37,17 @@ export class LibraryListWidgetFrontendContribution }); } } + + protected canParse(uri: URI): boolean { + try { + LibrarySearch.UriParser.parse(uri); + return true; + } catch { + return false; + } + } + + protected parse(uri: URI): LibrarySearch | undefined { + return LibrarySearch.UriParser.parse(uri); + } } 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 6ec22ddfd..56dec744d 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,9 +1,15 @@ -import { injectable } from '@theia/core/shared/inversify'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { + OpenerOptions, + OpenHandler, +} from '@theia/core/lib/browser/opener-service'; import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; +import { MenuModelRegistry } from '@theia/core/lib/common/menu'; +import { URI } from '@theia/core/lib/common/uri'; +import { injectable } from '@theia/core/shared/inversify'; +import { Searchable } from '../../../common/protocol'; import { ArduinoComponent } from '../../../common/protocol/arduino-component'; import { ListWidget } from './list-widget'; -import { Searchable } from '../../../common/protocol'; @injectable() export abstract class ListWidgetFrontendContribution< @@ -11,14 +17,49 @@ export abstract class ListWidgetFrontendContribution< S extends Searchable.Options > extends AbstractViewContribution> - implements FrontendApplicationContribution + implements FrontendApplicationContribution, OpenHandler { + readonly id: string = `http-opener-${this.viewId}`; + async initializeLayout(): Promise { - // TS requires at least one method from `FrontendApplicationContribution`. - // Expected to be empty. + this.openView(); } - override registerMenus(): void { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override registerMenus(_: MenuModelRegistry): void { // NOOP } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + canHandle(uri: URI, _?: OpenerOptions): number { + // `500` is the default HTTP opener in Theia. IDE2 has higher priority. + // https://github.com/eclipse-theia/theia/blob/b75b6144b0ffea06a549294903c374fa642135e4/packages/core/src/browser/http-open-handler.ts#L39 + return this.canParse(uri) ? 501 : 0; + } + + async open( + uri: URI, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _?: OpenerOptions | undefined + ): Promise { + const searchOptions = this.parse(uri); + if (!searchOptions) { + console.warn( + `Failed to parse URI into a search options. URI: ${uri.toString()}` + ); + return; + } + const widget = await this.openView({ + activate: true, + reveal: true, + }); + if (!widget) { + console.warn(`Failed to open view for URI: ${uri.toString()}`); + return; + } + widget.refresh(searchOptions); + } + + protected abstract canParse(uri: URI): boolean; + protected abstract parse(uri: URI): S | undefined; } diff --git a/arduino-ide-extension/src/common/protocol/boards-service.ts b/arduino-ide-extension/src/common/protocol/boards-service.ts index 763fc9bd6..f8c1b085f 100644 --- a/arduino-ide-extension/src/common/protocol/boards-service.ts +++ b/arduino-ide-extension/src/common/protocol/boards-service.ts @@ -3,7 +3,14 @@ import { Searchable } from './searchable'; import { Installable } from './installable'; import { ArduinoComponent } from './arduino-component'; import { nls } from '@theia/core/lib/common/nls'; -import { All, Contributed, Partner, Type, Updatable } from '../nls'; +import { + All, + Contributed, + Partner, + Type as TypeLabel, + Updatable, +} from '../nls'; +import URI from '@theia/core/lib/common/uri'; export type AvailablePorts = Record]>; export namespace AvailablePorts { @@ -151,6 +158,7 @@ export interface BoardSearch extends Searchable.Options { readonly type?: BoardSearch.Type; } export namespace BoardSearch { + export const Default: BoardSearch = { type: 'All' }; export const TypeLiterals = [ 'All', 'Updatable', @@ -161,6 +169,11 @@ export namespace BoardSearch { 'Arduino@Heart', ] as const; export type Type = typeof TypeLiterals[number]; + export namespace Type { + export function is(arg: unknown): arg is Type { + return typeof arg === 'string' && TypeLiterals.includes(arg as Type); + } + } export const TypeLabels: Record = { All: All, Updatable: Updatable, @@ -177,8 +190,41 @@ export namespace BoardSearch { keyof Omit, string > = { - type: Type, + type: TypeLabel, }; + export namespace UriParser { + export const authority = 'boardsmanager'; + export function parse(uri: URI): BoardSearch | undefined { + if (uri.scheme !== 'http') { + throw new Error( + `Invalid 'scheme'. Expected 'http'. URI was: ${uri.toString()}.` + ); + } + if (uri.authority !== authority) { + throw new Error( + `Invalid 'authority'. Expected: '${authority}'. URI was: ${uri.toString()}.` + ); + } + const segments = Searchable.UriParser.normalizedSegmentsOf(uri); + if (segments.length !== 1) { + return undefined; + } + let searchOptions: BoardSearch | undefined = undefined; + const [type] = segments; + if (!type) { + searchOptions = BoardSearch.Default; + } else if (BoardSearch.Type.is(type)) { + searchOptions = { type }; + } + if (searchOptions) { + return { + ...searchOptions, + ...Searchable.UriParser.parseQuery(uri), + }; + } + return undefined; + } + } } export interface Port { diff --git a/arduino-ide-extension/src/common/protocol/library-service.ts b/arduino-ide-extension/src/common/protocol/library-service.ts index fb01356d2..4a20aae21 100644 --- a/arduino-ide-extension/src/common/protocol/library-service.ts +++ b/arduino-ide-extension/src/common/protocol/library-service.ts @@ -8,9 +8,10 @@ import { Partner, Recommended, Retired, - Type, + Type as TypeLabel, Updatable, } from '../nls'; +import URI from '@theia/core/lib/common/uri'; export const LibraryServicePath = '/services/library-service'; export const LibraryService = Symbol('LibraryService'); @@ -55,6 +56,7 @@ export interface LibrarySearch extends Searchable.Options { readonly topic?: LibrarySearch.Topic; } export namespace LibrarySearch { + export const Default: LibrarySearch = { type: 'All', topic: 'All' }; export const TypeLiterals = [ 'All', 'Updatable', @@ -66,6 +68,11 @@ export namespace LibrarySearch { 'Retired', ] as const; export type Type = typeof TypeLiterals[number]; + export namespace Type { + export function is(arg: unknown): arg is Type { + return typeof arg === 'string' && TypeLiterals.includes(arg as Type); + } + } export const TypeLabels: Record = { All: All, Updatable: Updatable, @@ -90,6 +97,11 @@ export namespace LibrarySearch { 'Uncategorized', ] as const; export type Topic = typeof TopicLiterals[number]; + export namespace Topic { + export function is(arg: unknown): arg is Topic { + return typeof arg === 'string' && TopicLiterals.includes(arg as Topic); + } + } export const TopicLabels: Record = { All: All, Communication: nls.localize( @@ -126,8 +138,60 @@ export namespace LibrarySearch { string > = { topic: nls.localize('arduino/librarySearchProperty/topic', 'Topic'), - type: Type, + type: TypeLabel, }; + export namespace UriParser { + export const authority = 'librarymanager'; + export function parse(uri: URI): LibrarySearch | undefined { + if (uri.scheme !== 'http') { + throw new Error( + `Invalid 'scheme'. Expected 'http'. URI was: ${uri.toString()}.` + ); + } + if (uri.authority !== authority) { + throw new Error( + `Invalid 'authority'. Expected: '${authority}'. URI was: ${uri.toString()}.` + ); + } + const segments = Searchable.UriParser.normalizedSegmentsOf(uri); + // Special magic handling for `Signal Input/Output`. + // TODO: IDE2 deserves a better lib/boards URL spec. + // https://github.com/arduino/arduino-ide/issues/1442#issuecomment-1252136377 + if (segments.length === 3) { + const [type, topicHead, topicTail] = segments; + const maybeTopic = `${topicHead}/${topicTail}`; + if ( + LibrarySearch.Topic.is(maybeTopic) && + maybeTopic === 'Signal Input/Output' && + LibrarySearch.Type.is(type) + ) { + return { + type, + topic: maybeTopic, + ...Searchable.UriParser.parseQuery(uri), + }; + } + } + let searchOptions: LibrarySearch | undefined = undefined; + const [type, topic] = segments; + if (!type && !topic) { + searchOptions = LibrarySearch.Default; + } else if (LibrarySearch.Type.is(type)) { + if (!topic) { + searchOptions = { ...LibrarySearch.Default, type }; + } else if (LibrarySearch.Topic.is(topic)) { + searchOptions = { type, topic }; + } + } + if (searchOptions) { + return { + ...searchOptions, + ...Searchable.UriParser.parseQuery(uri), + }; + } + return undefined; + } + } } export namespace LibraryService { diff --git a/arduino-ide-extension/src/common/protocol/searchable.ts b/arduino-ide-extension/src/common/protocol/searchable.ts index af6a2c02e..30d3cd2dd 100644 --- a/arduino-ide-extension/src/common/protocol/searchable.ts +++ b/arduino-ide-extension/src/common/protocol/searchable.ts @@ -1,3 +1,5 @@ +import URI from '@theia/core/lib/common/uri'; + export interface Searchable { search(options: O): Promise; } @@ -8,4 +10,24 @@ export namespace Searchable { */ readonly query?: string; } + export namespace UriParser { + /** + * Parses the `URI#fragment` into a query term. + */ + export function parseQuery(uri: URI): { query: string } { + return { query: uri.fragment }; + } + /** + * Splits the `URI#path#toString` on the `/` POSIX separator into decoded segments. The first, empty segment representing the root is omitted. + * Examples: + * - `/` -> `['']` + * - `/All` -> `['All']` + * - `/All/Device%20Control` -> `['All', 'Device Control']` + * - `/All/Display` -> `['All', 'Display']` + * - `/Updatable/Signal%20Input%2FOutput` -> `['Updatable', 'Signal Input', 'Output']` (**caveat**!) + */ + export function normalizedSegmentsOf(uri: URI): string[] { + return uri.path.toString().split('/').slice(1).map(decodeURIComponent); + } + } } diff --git a/arduino-ide-extension/src/test/common/searchable.test.ts b/arduino-ide-extension/src/test/common/searchable.test.ts new file mode 100644 index 000000000..e302d574d --- /dev/null +++ b/arduino-ide-extension/src/test/common/searchable.test.ts @@ -0,0 +1,136 @@ +import URI from '@theia/core/lib/common/uri'; +import { expect } from 'chai'; +import { BoardSearch, LibrarySearch, Searchable } from '../../common/protocol'; + +interface Expectation { + readonly uri: string; + readonly expected: S | undefined | string; +} + +describe('searchable', () => { + describe('parse', () => { + describe(BoardSearch.UriParser.authority, () => { + ( + [ + { + uri: 'http://boardsmanager#SAMD', + expected: { query: 'SAMD', type: 'All' }, + }, + { + uri: 'http://boardsmanager/Arduino%40Heart#littleBits', + expected: { query: 'littleBits', type: 'Arduino@Heart' }, + }, + { + uri: 'http://boardsmanager/too/many/segments#invalidPath', + expected: undefined, + }, + { + uri: 'http://boardsmanager/random#invalidPath', + expected: undefined, + }, + { + uri: 'https://boardsmanager/#invalidScheme', + expected: `Invalid 'scheme'. Expected 'http'. URI was: https://boardsmanager/#invalidScheme.`, + }, + { + uri: 'http://librarymanager/#invalidAuthority', + expected: `Invalid 'authority'. Expected: 'boardsmanager'. URI was: http://librarymanager/#invalidAuthority.`, + }, + ] as Expectation[] + ).map((expectation) => toIt(expectation, BoardSearch.UriParser.parse)); + }); + describe(LibrarySearch.UriParser.authority, () => { + ( + [ + { + uri: 'http://librarymanager#WiFiNINA', + expected: { query: 'WiFiNINA', type: 'All', topic: 'All' }, + }, + { + uri: 'http://librarymanager/All/Device%20Control#Servo', + expected: { + query: 'Servo', + type: 'All', + topic: 'Device Control', + }, + }, + { + uri: 'http://librarymanager/All/Display#SparkFun', + expected: { + query: 'SparkFun', + type: 'All', + topic: 'Display', + }, + }, + { + uri: 'http://librarymanager/Updatable/Display#SparkFun', + expected: { + query: 'SparkFun', + type: 'Updatable', + topic: 'Display', + }, + }, + { + uri: 'http://librarymanager/All/Signal%20Input%2FOutput#debouncer', + expected: { + query: 'debouncer', + type: 'All', + topic: 'Signal Input/Output', + }, + }, + { + uri: 'http://librarymanager/too/many/segments#invalidPath', + expected: undefined, + }, + { + uri: 'http://librarymanager/absent/invalid#invalidPath', + expected: undefined, + }, + { + uri: 'https://librarymanager/#invalidScheme', + expected: `Invalid 'scheme'. Expected 'http'. URI was: https://librarymanager/#invalidScheme.`, + }, + { + uri: 'http://boardsmanager/#invalidAuthority', + expected: `Invalid 'authority'. Expected: 'librarymanager'. URI was: http://boardsmanager/#invalidAuthority.`, + }, + ] as Expectation[] + ).map((expectation) => toIt(expectation, LibrarySearch.UriParser.parse)); + }); + }); +}); + +function toIt( + { uri, expected }: Expectation, + run: (uri: URI) => Searchable.Options | undefined +): Mocha.Test { + return it(`should ${ + typeof expected === 'string' + ? `fail to parse '${uri}'` + : !expected + ? `not parse '${uri}'` + : `parse '${uri}' to ${JSON.stringify(expected)}` + }`, () => { + if (typeof expected === 'string') { + try { + run(new URI(uri)); + expect.fail( + `Expected an error with message '${expected}' when parsing URI: ${uri}.` + ); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect(err.message).to.be.equal(expected); + } + } else { + const actual = run(new URI(uri)); + if (!expected) { + expect(actual).to.be.undefined; + } else { + expect(actual).to.be.deep.equal( + expected, + `Was: ${JSON.stringify(actual)}` + ); + } + } + }); +}