ATL-1064: Support for nested sketchbook structure

Signed-off-by: Akos Kitta <kittaakos@typefox.io>
This commit is contained in:
Akos Kitta 2021-03-10 17:30:58 +01:00 committed by Akos Kitta
parent ac502053d7
commit c64ac48fe3
9 changed files with 188 additions and 116 deletions

View File

@ -1,15 +1,15 @@
import * as PQueue from 'p-queue';
import { inject, injectable, postConstruct } from 'inversify';
import { MenuPath, CompositeMenuNode } from '@theia/core/lib/common/menu';
import { MenuPath, CompositeMenuNode, SubMenuOptions } from '@theia/core/lib/common/menu';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { OpenSketch } from './open-sketch';
import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus';
import { MainMenuManager } from '../../common/main-menu-manager';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import { ExamplesService, ExampleContainer } from '../../common/protocol/examples-service';
import { ExamplesService } from '../../common/protocol/examples-service';
import { SketchContribution, CommandRegistry, MenuModelRegistry } from './contribution';
import { NotificationCenter } from '../notification-center';
import { Board } from '../../common/protocol';
import { Board, Sketch, SketchContainer } from '../../common/protocol';
@injectable()
export abstract class Examples extends SketchContribution {
@ -59,18 +59,35 @@ export abstract class Examples extends SketchContribution {
}
registerRecursively(
exampleContainerOrPlaceholder: ExampleContainer | string,
sketchContainerOrPlaceholder: SketchContainer | (Sketch | SketchContainer)[] | string,
menuPath: MenuPath,
pushToDispose: DisposableCollection = new DisposableCollection()): void {
pushToDispose: DisposableCollection = new DisposableCollection(),
subMenuOptions?: SubMenuOptions | undefined): void {
if (typeof exampleContainerOrPlaceholder === 'string') {
const placeholder = new PlaceholderMenuNode(menuPath, exampleContainerOrPlaceholder);
if (typeof sketchContainerOrPlaceholder === 'string') {
const placeholder = new PlaceholderMenuNode(menuPath, sketchContainerOrPlaceholder);
this.menuRegistry.registerMenuNode(menuPath, placeholder);
pushToDispose.push(Disposable.create(() => this.menuRegistry.unregisterMenuNode(placeholder.id)));
} else {
const { label, sketches, children } = exampleContainerOrPlaceholder;
const submenuPath = [...menuPath, label];
this.menuRegistry.registerSubmenu(submenuPath, label);
const sketches: Sketch[] = [];
const children: SketchContainer[] = [];
let submenuPath = menuPath;
if (SketchContainer.is(sketchContainerOrPlaceholder)) {
const { label } = sketchContainerOrPlaceholder;
submenuPath = [...menuPath, label];
this.menuRegistry.registerSubmenu(submenuPath, label, subMenuOptions);
sketches.push(...sketchContainerOrPlaceholder.sketches);
children.push(...sketchContainerOrPlaceholder.children);
} else {
for (const sketchOrContainer of sketchContainerOrPlaceholder) {
if (SketchContainer.is(sketchOrContainer)) {
children.push(sketchOrContainer);
} else {
sketches.push(sketchOrContainer);
}
}
}
children.forEach(child => this.registerRecursively(child, submenuPath, pushToDispose));
for (const sketch of sketches) {
const { uri } = sketch;
@ -98,22 +115,20 @@ export class BuiltInExamples extends Examples {
this.register(); // no `await`
}
protected async register() {
let exampleContainers: ExampleContainer[] | undefined;
protected async register(): Promise<void> {
let sketchContainers: SketchContainer[] | undefined;
try {
exampleContainers = await this.examplesService.builtIns();
sketchContainers = await this.examplesService.builtIns();
} catch (e) {
console.error('Could not initialize built-in examples.', e);
this.messageService.error('Could not initialize built-in examples.');
return;
}
this.toDispose.dispose();
for (const container of ['Built-in examples', ...exampleContainers]) {
for (const container of ['Built-in examples', ...sketchContainers]) {
this.registerRecursively(container, ArduinoMenus.EXAMPLES__BUILT_IN_GROUP, this.toDispose);
}
this.menuManager.update();
// TODO: remove
console.log(typeof this.menuRegistry);
}
}
@ -136,7 +151,7 @@ export class LibraryExamples extends Examples {
this.register(board);
}
protected async register(board: Board | undefined = this.boardsServiceClient.boardsConfig.selectedBoard) {
protected async register(board: Board | undefined = this.boardsServiceClient.boardsConfig.selectedBoard): Promise<void> {
return this.queue.add(async () => {
this.toDispose.dispose();
if (!board || !board.fqbn) {

View File

@ -8,6 +8,8 @@ import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
import { SketchContribution, Sketch, URI, Command, CommandRegistry, MenuModelRegistry, KeybindingRegistry, TabBarToolbarRegistry } from './contribution';
import { ExamplesService } from '../../common/protocol/examples-service';
import { BuiltInExamples } from './examples';
import { Sketchbook } from './sketchbook';
import { SketchContainer } from '../../common/protocol';
@injectable()
export class OpenSketch extends SketchContribution {
@ -24,7 +26,10 @@ export class OpenSketch extends SketchContribution {
@inject(ExamplesService)
protected readonly examplesService: ExamplesService;
protected readonly toDisposeBeforeCreateNewContextMenu = new DisposableCollection();
@inject(Sketchbook)
protected readonly sketchbook: Sketchbook;
protected readonly toDispose = new DisposableCollection();
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(OpenSketch.Commands.OPEN_SKETCH, {
@ -33,11 +38,11 @@ export class OpenSketch extends SketchContribution {
registry.registerCommand(OpenSketch.Commands.OPEN_SKETCH__TOOLBAR, {
isVisible: widget => ArduinoToolbar.is(widget) && widget.side === 'left',
execute: async (_: Widget, target: EventTarget) => {
const sketches = await this.sketchService.getSketches();
if (!sketches.length) {
const container = await this.sketchService.getSketches({ exclude: ['**/hardware/**'] });
if (SketchContainer.isEmpty(container)) {
this.openSketch();
} else {
this.toDisposeBeforeCreateNewContextMenu.dispose();
this.toDispose.dispose();
if (!(target instanceof HTMLElement)) {
return;
}
@ -50,21 +55,12 @@ export class OpenSketch extends SketchContribution {
commandId: OpenSketch.Commands.OPEN_SKETCH.id,
label: 'Open...'
});
this.toDisposeBeforeCreateNewContextMenu.push(Disposable.create(() => this.menuRegistry.unregisterMenuAction(OpenSketch.Commands.OPEN_SKETCH)));
for (const sketch of sketches) {
const command = { id: `arduino-open-sketch--${sketch.uri}` };
const handler = { execute: () => this.openSketch(sketch) };
this.toDisposeBeforeCreateNewContextMenu.push(registry.registerCommand(command, handler));
this.menuRegistry.registerMenuAction(ArduinoMenus.OPEN_SKETCH__CONTEXT__RECENT_GROUP, {
commandId: command.id,
label: sketch.name
});
this.toDisposeBeforeCreateNewContextMenu.push(Disposable.create(() => this.menuRegistry.unregisterMenuAction(command)));
}
this.toDispose.push(Disposable.create(() => this.menuRegistry.unregisterMenuAction(OpenSketch.Commands.OPEN_SKETCH)));
this.sketchbook.registerRecursively([...container.children, ...container.sketches], ArduinoMenus.OPEN_SKETCH__CONTEXT__RECENT_GROUP, this.toDispose);
try {
const containers = await this.examplesService.builtIns();
for (const container of containers) {
this.builtInExamples.registerRecursively(container, ArduinoMenus.OPEN_SKETCH__CONTEXT__EXAMPLES_GROUP, this.toDisposeBeforeCreateNewContextMenu);
this.builtInExamples.registerRecursively(container, ArduinoMenus.OPEN_SKETCH__CONTEXT__EXAMPLES_GROUP, this.toDispose);
}
} catch (e) {
console.error('Error when collecting built-in examples.', e);

View File

@ -1,13 +1,13 @@
import { inject, injectable } from 'inversify';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { SketchContribution, CommandRegistry, MenuModelRegistry, Sketch } from './contribution';
import { CommandRegistry, MenuModelRegistry } from './contribution';
import { ArduinoMenus } from '../menu/arduino-menus';
import { MainMenuManager } from '../../common/main-menu-manager';
import { NotificationCenter } from '../notification-center';
import { OpenSketch } from './open-sketch';
import { Examples } from './examples';
import { SketchContainer } from '../../common/protocol';
@injectable()
export class Sketchbook extends SketchContribution {
export class Sketchbook extends Examples {
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@ -21,17 +21,16 @@ export class Sketchbook extends SketchContribution {
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
protected toDisposePerSketch = new Map<string, DisposableCollection>();
onStart(): void {
this.sketchService.getSketches().then(sketches => {
this.register(sketches);
this.sketchService.getSketches({}).then(container => {
this.register(container);
this.mainMenuManager.update();
});
this.sketchServiceClient.onSketchbookDidChange(({ created, removed }) => {
this.unregister(removed);
this.register(created);
this.mainMenuManager.update();
this.sketchServiceClient.onSketchbookDidChange(() => {
this.sketchService.getSketches({}).then(container => {
this.register(container);
this.mainMenuManager.update();
});
});
}
@ -39,31 +38,9 @@ export class Sketchbook extends SketchContribution {
registry.registerSubmenu(ArduinoMenus.FILE__SKETCHBOOK_SUBMENU, 'Sketchbook', { order: '3' });
}
protected register(sketches: Sketch[]): void {
for (const sketch of sketches) {
const { uri } = sketch;
const toDispose = this.toDisposePerSketch.get(uri);
if (toDispose) {
toDispose.dispose();
}
const command = { id: `arduino-sketchbook-open--${uri}` };
const handler = { execute: () => this.commandRegistry.executeCommand(OpenSketch.Commands.OPEN_SKETCH.id, sketch) };
this.commandRegistry.registerCommand(command, handler);
this.menuRegistry.registerMenuAction(ArduinoMenus.FILE__SKETCHBOOK_SUBMENU, { commandId: command.id, label: sketch.name });
this.toDisposePerSketch.set(sketch.uri, new DisposableCollection(
Disposable.create(() => this.commandRegistry.unregisterCommand(command)),
Disposable.create(() => this.menuRegistry.unregisterMenuAction(command))
));
}
}
protected unregister(sketches: Sketch[]): void {
for (const { uri } of sketches) {
const toDispose = this.toDisposePerSketch.get(uri);
if (toDispose) {
toDispose.dispose();
}
}
protected register(container: SketchContainer): void {
this.toDispose.dispose();
this.registerRecursively([...container.children, ...container.sketches], ArduinoMenus.FILE__SKETCHBOOK_SUBMENU, this.toDispose);
}
}

View File

@ -8,7 +8,7 @@ import { FrontendApplication } from '@theia/core/lib/browser/frontend-applicatio
import { FocusTracker, Widget } from '@theia/core/lib/browser';
import { WorkspaceService as TheiaWorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { ConfigService } from '../../../common/protocol/config-service';
import { SketchesService, Sketch } from '../../../common/protocol/sketches-service';
import { SketchesService, Sketch, SketchContainer } from '../../../common/protocol/sketches-service';
import { ArduinoWorkspaceRootResolver } from '../../arduino-workspace-resolver';
@injectable()
@ -50,7 +50,7 @@ export class WorkspaceService extends TheiaWorkspaceService {
const hash = window.location.hash;
const [recentWorkspaces, recentSketches] = await Promise.all([
this.server.getRecentWorkspaces(),
this.sketchService.getSketches().then(sketches => sketches.map(s => s.uri))
this.sketchService.getSketches({}).then(container => SketchContainer.toArray(container).map(s => s.uri))
]);
const toOpen = await new ArduinoWorkspaceRootResolver({
isValid: this.isValid.bind(this)

View File

@ -1,14 +1,10 @@
import { Sketch } from './sketches-service';
import { SketchContainer } from './sketches-service';
export const ExamplesServicePath = '/services/example-service';
export const ExamplesService = Symbol('ExamplesService');
export interface ExamplesService {
builtIns(): Promise<ExampleContainer[]>;
installed(options: { fqbn: string }): Promise<{ user: ExampleContainer[], current: ExampleContainer[], any: ExampleContainer[] }>;
builtIns(): Promise<SketchContainer[]>;
installed(options: { fqbn: string }): Promise<{ user: SketchContainer[], current: SketchContainer[], any: SketchContainer[] }>;
}
export interface ExampleContainer {
readonly label: string;
readonly children: ExampleContainer[];
readonly sketches: Sketch[];
}

View File

@ -9,6 +9,7 @@ import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { ConfigService } from './config-service';
import { DisposableCollection, Emitter } from '@theia/core';
import { FileChangeType } from '@theia/filesystem/lib/browser';
import { SketchContainer } from './sketches-service';
@injectable()
export class SketchesServiceClientImpl implements FrontendApplicationContribution {
@ -35,9 +36,9 @@ export class SketchesServiceClientImpl implements FrontendApplicationContributio
onStart(): void {
this.configService.getConfiguration().then(({ sketchDirUri }) => {
this.sketchService.getSketches(sketchDirUri).then(sketches => {
this.sketchService.getSketches({ uri: sketchDirUri }).then(container => {
const sketchbookUri = new URI(sketchDirUri);
for (const sketch of sketches) {
for (const sketch of SketchContainer.toArray(container)) {
this.sketches.set(sketch.uri, sketch);
}
this.toDispose.push(this.fileService.watch(new URI(sketchDirUri), { recursive: true, excludes: [] }));

View File

@ -5,10 +5,11 @@ export const SketchesService = Symbol('SketchesService');
export interface SketchesService {
/**
* Returns with the direct sketch folders from the location of the `fileStat`.
* The sketches returns with inverse-chronological order, the first item is the most recent one.
* Resolves to a sketch container representing the hierarchical structure of the sketches.
* If `uri` is not given, `directories.user` will be user instead. Specify `exclude` global patterns to filter folders from the sketch container.
* If `exclude` is not set `['**\/libraries\/**', '**\/hardware\/**']` will be used instead.
*/
getSketches(uri?: string): Promise<Sketch[]>;
getSketches({ uri, exclude }: { uri?: string, exclude?: string[] }): Promise<SketchContainer>;
/**
* This is the TS implementation of `SketchLoad` from the CLI and should be replaced with a gRPC call eventually.
@ -100,3 +101,51 @@ export namespace Sketch {
return Extensions.MAIN.some(ext => arg.endsWith(ext));
}
}
export interface SketchContainer {
readonly label: string;
readonly children: SketchContainer[];
readonly sketches: Sketch[];
}
export namespace SketchContainer {
export function is(arg: any): arg is SketchContainer {
return !!arg
&& 'label' in arg && typeof arg.label === 'string'
&& 'children' in arg && Array.isArray(arg.children)
&& 'sketches' in arg && Array.isArray(arg.sketches);
}
/**
* `false` if the `container` recursively contains at least one sketch. Otherwise, `true`.
*/
export function isEmpty(container: SketchContainer): boolean {
const hasSketch = (parent: SketchContainer) => {
if (parent.sketches.length || parent.children.some(child => hasSketch(child))) {
return true;
}
return false;
}
return !hasSketch(container);
}
export function prune<T extends SketchContainer>(container: T): T {
for (let i = container.children.length - 1; i >= 0; i--) {
if (isEmpty(container.children[i])) {
container.children.splice(i, 1);
}
}
return container;
}
export function toArray(container: SketchContainer): Sketch[] {
const visit = (parent: SketchContainer, toPushSketch: Sketch[]) => {
toPushSketch.push(...parent.sketches);
parent.children.map(child => visit(child, toPushSketch));
}
const sketches: Sketch[] = [];
visit(container, sketches);
return sketches;
}
}

View File

@ -4,9 +4,9 @@ import * as fs from 'fs';
import { promisify } from 'util';
import { FileUri } from '@theia/core/lib/node/file-uri';
import { notEmpty } from '@theia/core/lib/common/objects';
import { Sketch } from '../common/protocol/sketches-service';
import { Sketch, SketchContainer } from '../common/protocol/sketches-service';
import { SketchesServiceImpl } from './sketches-service-impl';
import { ExamplesService, ExampleContainer } from '../common/protocol/examples-service';
import { ExamplesService } from '../common/protocol/examples-service';
import { LibraryLocation, LibraryPackage, LibraryService } from '../common/protocol';
import { ConfigServiceImpl } from './config-service-impl';
@ -22,14 +22,14 @@ export class ExamplesServiceImpl implements ExamplesService {
@inject(ConfigServiceImpl)
protected readonly configService: ConfigServiceImpl;
protected _all: ExampleContainer[] | undefined;
protected _all: SketchContainer[] | undefined;
@postConstruct()
protected init(): void {
this.builtIns();
}
async builtIns(): Promise<ExampleContainer[]> {
async builtIns(): Promise<SketchContainer[]> {
if (this._all) {
return this._all;
}
@ -40,10 +40,10 @@ export class ExamplesServiceImpl implements ExamplesService {
}
// TODO: decide whether it makes sense to cache them. Keys should be: `fqbn` + version of containing core/library.
async installed({ fqbn }: { fqbn: string }): Promise<{ user: ExampleContainer[], current: ExampleContainer[], any: ExampleContainer[] }> {
const user: ExampleContainer[] = [];
const current: ExampleContainer[] = [];
const any: ExampleContainer[] = [];
async installed({ fqbn }: { fqbn: string }): Promise<{ user: SketchContainer[], current: SketchContainer[], any: SketchContainer[] }> {
const user: SketchContainer[] = [];
const current: SketchContainer[] = [];
const any: SketchContainer[] = [];
if (fqbn) {
const packages: LibraryPackage[] = await this.libraryService.list({ fqbn });
for (const pkg of packages) {
@ -66,7 +66,7 @@ export class ExamplesServiceImpl implements ExamplesService {
* folder hierarchy. This method tries to workaround it by falling back to the `installDirUri` and manually creating the
* location of the examples. Otherwise it creates the example container from the direct examples FS paths.
*/
protected async tryGroupExamples({ label, exampleUris, installDirUri }: LibraryPackage): Promise<ExampleContainer> {
protected async tryGroupExamples({ label, exampleUris, installDirUri }: LibraryPackage): Promise<SketchContainer> {
const paths = exampleUris.map(uri => FileUri.fsPath(uri));
if (installDirUri) {
for (const example of ['example', 'Example', 'EXAMPLE', 'examples', 'Examples', 'EXAMPLES']) {
@ -75,7 +75,7 @@ export class ExamplesServiceImpl implements ExamplesService {
const isDir = exists && (await promisify(fs.lstat)(examplesPath)).isDirectory();
if (isDir) {
const fileNames = await promisify(fs.readdir)(examplesPath);
const children: ExampleContainer[] = [];
const children: SketchContainer[] = [];
const sketches: Sketch[] = [];
for (const fileName of fileNames) {
const subPath = join(examplesPath, fileName);
@ -109,7 +109,7 @@ export class ExamplesServiceImpl implements ExamplesService {
}
// Built-ins are included inside the IDE.
protected async load(path: string): Promise<ExampleContainer> {
protected async load(path: string): Promise<SketchContainer> {
if (!await promisify(fs.exists)(path)) {
throw new Error('Examples are not available');
}
@ -119,7 +119,7 @@ export class ExamplesServiceImpl implements ExamplesService {
}
const names = await promisify(fs.readdir)(path);
const sketches: Sketch[] = [];
const children: ExampleContainer[] = [];
const children: SketchContainer[] = [];
for (const p of names.map(name => join(path, name))) {
const stat = await promisify(fs.stat)(p);
if (stat.isDirectory()) {

View File

@ -1,4 +1,5 @@
import { injectable, inject } from 'inversify';
import * as minimatch from 'minimatch';
import * as fs from 'fs';
import * as os from 'os';
import * as temp from 'temp';
@ -10,7 +11,7 @@ import URI from '@theia/core/lib/common/uri';
import { FileUri } from '@theia/core/lib/node';
import { isWindows } from '@theia/core/lib/common/os';
import { ConfigService } from '../common/protocol/config-service';
import { SketchesService, Sketch } from '../common/protocol/sketches-service';
import { SketchesService, Sketch, SketchContainer } from '../common/protocol/sketches-service';
import { firstToLowerCase } from '../common/utils';
import { NotificationServiceServerImpl } from './notification-service-server';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
@ -32,8 +33,8 @@ export class SketchesServiceImpl extends CoreClientAware implements SketchesServ
@inject(EnvVariablesServer)
protected readonly envVariableServer: EnvVariablesServer;
async getSketches(uri?: string): Promise<SketchWithDetails[]> {
async getSketches({ uri, exclude }: { uri?: string, exclude?: string[] }): Promise<SketchContainerWithDetails> {
const start = Date.now();
let sketchbookPath: undefined | string;
if (!uri) {
const { sketchDirUri } = await this.configService.getConfiguration();
@ -44,33 +45,65 @@ export class SketchesServiceImpl extends CoreClientAware implements SketchesServ
} else {
sketchbookPath = FileUri.fsPath(uri);
}
const container: SketchContainerWithDetails = {
label: uri ? path.basename(sketchbookPath) : 'Sketchbook',
sketches: [],
children: []
};
if (!await promisify(fs.exists)(sketchbookPath)) {
return [];
return container;
}
const stat = await promisify(fs.stat)(sketchbookPath);
if (!stat.isDirectory()) {
return [];
return container;
}
const sketches: Array<SketchWithDetails> = [];
const filenames = await promisify(fs.readdir)(sketchbookPath);
for (const fileName of filenames) {
const filePath = path.join(sketchbookPath, fileName);
const sketch = await this._isSketchFolder(FileUri.create(filePath).toString());
if (sketch) {
const recursivelyLoad = async (fsPath: string, containerToLoad: SketchContainerWithDetails) => {
const filenames = await promisify(fs.readdir)(fsPath);
for (const name of filenames) {
const childFsPath = path.join(fsPath, name);
let skip = false;
for (const pattern of exclude || ['**/libraries/**', '**/hardware/**']) {
if (!skip && minimatch(childFsPath, pattern)) {
skip = true;
}
}
if (skip) {
continue;
}
try {
const stat = await promisify(fs.stat)(filePath);
sketches.push({
...sketch,
mtimeMs: stat.mtimeMs
});
const stat = await promisify(fs.stat)(childFsPath);
if (stat.isDirectory()) {
const sketch = await this._isSketchFolder(FileUri.create(childFsPath).toString());
if (sketch) {
containerToLoad.sketches.push({
...sketch,
mtimeMs: stat.mtimeMs
});
} else {
const childContainer: SketchContainerWithDetails = {
label: name,
children: [],
sketches: []
};
await recursivelyLoad(childFsPath, childContainer);
if (!SketchContainer.isEmpty(childContainer)) {
containerToLoad.children.push(childContainer);
}
}
}
} catch {
console.warn(`Could not load sketch from ${filePath}.`);
console.warn(`Could not load sketch from ${childFsPath}.`);
}
}
containerToLoad.sketches.sort((left, right) => right.mtimeMs - left.mtimeMs);
return containerToLoad;
}
sketches.sort((left, right) => right.mtimeMs - left.mtimeMs);
return sketches;
await recursivelyLoad(sketchbookPath, container);
SketchContainer.prune(container);
console.debug(`Loading the sketches from ${sketchbookPath} took ${Date.now() - start} ms.`);
return container;
}
async loadSketch(uri: string): Promise<SketchWithDetails> {
@ -363,3 +396,8 @@ void loop() {
interface SketchWithDetails extends Sketch {
readonly mtimeMs: number;
}
interface SketchContainerWithDetails extends SketchContainer {
readonly label: string;
readonly children: SketchContainerWithDetails[];
readonly sketches: SketchWithDetails[];
}