Link resolved for lib/boards manager.

Closes #1442

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta 2022-09-21 18:41:46 +02:00 committed by Akos Kitta
parent 019b2d5588
commit 0c49709f26
8 changed files with 360 additions and 28 deletions

View File

@ -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();

View File

@ -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<void> {
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);
}
}

View File

@ -1,16 +1,17 @@
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 { ArduinoMenus } from '../menu/arduino-menus';
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 { URI } from '../contributions/contribution';
import { ArduinoMenus } from '../menu/arduino-menus';
import { ListWidgetFrontendContribution } from '../widgets/component-list/list-widget-frontend-contribution';
import { LibraryListWidget } from './library-list-widget';
@injectable()
export class LibraryListWidgetFrontendContribution
extends AbstractViewContribution<LibraryListWidget>
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<void> {
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);
}
}

View File

@ -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<ListWidget<T, S>>
implements FrontendApplicationContribution
implements FrontendApplicationContribution, OpenHandler
{
readonly id: string = `http-opener-${this.viewId}`;
async initializeLayout(): Promise<void> {
// 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<void> {
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;
}

View File

@ -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<string, [Port, Array<Board>]>;
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<Type, string> = {
All: All,
Updatable: Updatable,
@ -177,8 +190,41 @@ export namespace BoardSearch {
keyof Omit<BoardSearch, 'query'>,
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 {

View File

@ -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<Type, string> = {
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<Topic, string> = {
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 {

View File

@ -1,3 +1,5 @@
import URI from '@theia/core/lib/common/uri';
export interface Searchable<T, O extends Searchable.Options> {
search(options: O): Promise<T[]>;
}
@ -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);
}
}
}

View File

@ -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<S extends Searchable.Options> {
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<BoardSearch>[]
).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<LibrarySearch>[]
).map((expectation) => toIt(expectation, LibrarySearch.UriParser.parse));
});
});
});
function toIt<S extends Searchable.Options>(
{ uri, expected }: Expectation<S>,
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)}`
);
}
}
});
}