From db2967084f1656b9265d109b23076a204f65d720 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Wed, 16 Dec 2020 18:14:00 +0100 Subject: [PATCH] Added the `Sketchbook` menu with FS event tracking Signed-off-by: Akos Kitta --- .../browser/arduino-ide-frontend-module.ts | 2 + .../src/browser/contributions/sketchbook.ts | 69 +++++++++ .../src/browser/menu/arduino-menus.ts | 5 +- .../src/browser/notification-center.ts | 11 +- .../core/common-frontend-contribution.ts | 3 +- .../common/protocol/notification-service.ts | 4 +- .../src/node/arduino-ide-backend-module.ts | 2 +- .../src/node/notification-service-server.ts | 6 +- .../src/node/sketches-service-impl.ts | 146 +++++++++++++++++- arduino-ide-extension/tsconfig.json | 1 + 10 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 arduino-ide-extension/src/browser/contributions/sketchbook.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 c4702638..38ace419 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -130,6 +130,7 @@ import { NavigatorTabBarDecorator } from './theia/navigator/navigator-tab-bar-de import { Debug } from './contributions/debug'; import { DebugSessionManager } from './theia/debug/debug-session-manager'; import { DebugSessionManager as TheiaDebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager'; +import { Sketchbook } from './contributions/sketchbook'; const ElementQueries = require('css-element-queries/src/ElementQueries'); @@ -331,6 +332,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, IncludeLibrary); Contribution.configure(bind, About); Contribution.configure(bind, Debug); + Contribution.configure(bind, Sketchbook); bind(OutputServiceImpl).toSelf().inSingletonScope().onActivation(({ container }, outputService) => { WebSocketConnectionProvider.createProxy(container, OutputServicePath, outputService); diff --git a/arduino-ide-extension/src/browser/contributions/sketchbook.ts b/arduino-ide-extension/src/browser/contributions/sketchbook.ts new file mode 100644 index 00000000..1792ffd1 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/sketchbook.ts @@ -0,0 +1,69 @@ +import { inject, injectable } from 'inversify'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; +import { SketchContribution, CommandRegistry, MenuModelRegistry, Sketch } 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'; + +@injectable() +export class Sketchbook extends SketchContribution { + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(MenuModelRegistry) + protected readonly menuRegistry: MenuModelRegistry; + + @inject(MainMenuManager) + protected readonly mainMenuManager: MainMenuManager; + + @inject(NotificationCenter) + protected readonly notificationCenter: NotificationCenter; + + protected toDisposePerSketch = new Map(); + + onStart(): void { + this.sketchService.getSketches().then(sketches => { + this.register(sketches); + this.mainMenuManager.update(); + }); + this.notificationCenter.onSketchbookChanged(({ created, removed }) => { + this.unregister(removed); + this.register(created); + this.mainMenuManager.update(); + }); + } + + registerMenus(registry: MenuModelRegistry): void { + 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(); + } + } + } + +} diff --git a/arduino-ide-extension/src/browser/menu/arduino-menus.ts b/arduino-ide-extension/src/browser/menu/arduino-menus.ts index 1ed14cf8..d2ba0540 100644 --- a/arduino-ide-extension/src/browser/menu/arduino-menus.ts +++ b/arduino-ide-extension/src/browser/menu/arduino-menus.ts @@ -12,8 +12,11 @@ export namespace ArduinoMenus { export const FILE__SETTINGS_GROUP = [...(isOSX ? MAIN_MENU_BAR : CommonMenus.FILE), '2_settings']; export const FILE__QUIT_GROUP = [...CommonMenus.FILE, '3_quit']; + // -- File / Sketchbook + export const FILE__SKETCHBOOK_SUBMENU = [...FILE__SKETCH_GROUP, '0_sketchbook']; + // -- File / Examples - export const FILE__EXAMPLES_SUBMENU = [...FILE__SKETCH_GROUP, '0_examples']; + export const FILE__EXAMPLES_SUBMENU = [...FILE__SKETCH_GROUP, '1_examples']; export const EXAMPLES__BUILT_IN_GROUP = [...FILE__EXAMPLES_SUBMENU, '0_built_ins']; export const EXAMPLES__ANY_BOARD_GROUP = [...FILE__EXAMPLES_SUBMENU, '1_any_board']; export const EXAMPLES__CURRENT_BOARD_GROUP = [...FILE__EXAMPLES_SUBMENU, '2_current_board']; diff --git a/arduino-ide-extension/src/browser/notification-center.ts b/arduino-ide-extension/src/browser/notification-center.ts index 97689a69..e69355f0 100644 --- a/arduino-ide-extension/src/browser/notification-center.ts +++ b/arduino-ide-extension/src/browser/notification-center.ts @@ -4,7 +4,7 @@ import { JsonRpcProxy } from '@theia/core/lib/common/messaging/proxy-factory'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; import { NotificationServiceClient, NotificationServiceServer } from '../common/protocol/notification-service'; -import { AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, Config } from '../common/protocol'; +import { AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, Config, Sketch } from '../common/protocol'; @injectable() export class NotificationCenter implements NotificationServiceClient, FrontendApplicationContribution { @@ -21,6 +21,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp protected readonly libraryInstalledEmitter = new Emitter<{ item: LibraryPackage }>(); protected readonly libraryUninstalledEmitter = new Emitter<{ item: LibraryPackage }>(); protected readonly attachedBoardsChangedEmitter = new Emitter(); + protected readonly sketchbookChangedEmitter = new Emitter<{ created: Sketch[], removed: Sketch[] }>(); protected readonly toDispose = new DisposableCollection( this.indexUpdatedEmitter, @@ -31,7 +32,8 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp this.platformUninstalledEmitter, this.libraryInstalledEmitter, this.libraryUninstalledEmitter, - this.attachedBoardsChangedEmitter + this.attachedBoardsChangedEmitter, + this.sketchbookChangedEmitter ); readonly onIndexUpdated = this.indexUpdatedEmitter.event; @@ -43,6 +45,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp readonly onLibraryInstalled = this.libraryInstalledEmitter.event; readonly onLibraryUninstalled = this.libraryUninstalledEmitter.event; readonly onAttachedBoardsChanged = this.attachedBoardsChangedEmitter.event; + readonly onSketchbookChanged = this.sketchbookChangedEmitter.event; @postConstruct() protected init(): void { @@ -89,4 +92,8 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp this.attachedBoardsChangedEmitter.fire(event); } + notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void { + this.sketchbookChangedEmitter.fire(event); + } + } diff --git a/arduino-ide-extension/src/browser/theia/core/common-frontend-contribution.ts b/arduino-ide-extension/src/browser/theia/core/common-frontend-contribution.ts index a7560613..36f56235 100644 --- a/arduino-ide-extension/src/browser/theia/core/common-frontend-contribution.ts +++ b/arduino-ide-extension/src/browser/theia/core/common-frontend-contribution.ts @@ -20,7 +20,8 @@ export class CommonFrontendContribution extends TheiaCommonFrontendContribution CommonCommands.OPEN_PREFERENCES, CommonCommands.SELECT_ICON_THEME, CommonCommands.SELECT_COLOR_THEME, - CommonCommands.ABOUT_COMMAND + CommonCommands.ABOUT_COMMAND, + CommonCommands.SAVE_WITHOUT_FORMATTING // Patched for https://github.com/eclipse-theia/theia/pull/8877 ]) { registry.unregisterMenuAction(command); } diff --git a/arduino-ide-extension/src/common/protocol/notification-service.ts b/arduino-ide-extension/src/common/protocol/notification-service.ts index 0129a1ca..81431025 100644 --- a/arduino-ide-extension/src/common/protocol/notification-service.ts +++ b/arduino-ide-extension/src/common/protocol/notification-service.ts @@ -1,7 +1,6 @@ import { LibraryPackage } from './library-service'; import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; -import { BoardsPackage, AttachedBoardsChangeEvent } from './boards-service'; -import { Config } from './config-service'; +import { Sketch, Config, BoardsPackage, AttachedBoardsChangeEvent } from '../protocol'; export interface NotificationServiceClient { notifyIndexUpdated(): void; @@ -13,6 +12,7 @@ export interface NotificationServiceClient { notifyLibraryInstalled(event: { item: LibraryPackage }): void; notifyLibraryUninstalled(event: { item: LibraryPackage }): void; notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void; + notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void; } export const NotificationServicePath = '/services/notification-service'; diff --git a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts index 1f7657e8..84a1b177 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -75,7 +75,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bindBackendService(LibraryServicePath, LibraryService); })); - // Shred sketches service + // Shared sketches service bind(SketchesServiceImpl).toSelf().inSingletonScope(); bind(SketchesService).toService(SketchesServiceImpl); bind(ConnectionHandler).toDynamicValue(context => new JsonRpcConnectionHandler(SketchesServicePath, () => context.container.get(SketchesService))).inSingletonScope(); diff --git a/arduino-ide-extension/src/node/notification-service-server.ts b/arduino-ide-extension/src/node/notification-service-server.ts index 65f7a652..57108f7d 100644 --- a/arduino-ide-extension/src/node/notification-service-server.ts +++ b/arduino-ide-extension/src/node/notification-service-server.ts @@ -1,5 +1,5 @@ import { injectable } from 'inversify'; -import { NotificationServiceServer, NotificationServiceClient, AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, Config } from '../common/protocol'; +import { NotificationServiceServer, NotificationServiceClient, AttachedBoardsChangeEvent, BoardsPackage, LibraryPackage, Config, Sketch } from '../common/protocol'; @injectable() export class NotificationServiceServerImpl implements NotificationServiceServer { @@ -42,6 +42,10 @@ export class NotificationServiceServerImpl implements NotificationServiceServer this.clients.forEach(client => client.notifyConfigChanged(event)); } + notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void { + this.clients.forEach(client => client.notifySketchbookChanged(event)); + } + setClient(client: NotificationServiceClient): void { this.clients.push(client); } diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 2c1c3c0b..2fd3e360 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -2,16 +2,18 @@ import { injectable, inject } from 'inversify'; import * as os from 'os'; import * as temp from 'temp'; import * as path from 'path'; +import * as nsfw from 'nsfw'; import { ncp } from 'ncp'; import { Stats } from 'fs'; import * as fs from './fs-extra'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node'; +import { Deferred } from '@theia/core/lib/common/promise-util'; import { isWindows } from '@theia/core/lib/common/os'; import { ConfigService } from '../common/protocol/config-service'; import { SketchesService, Sketch } from '../common/protocol/sketches-service'; import { firstToLowerCase } from '../common/utils'; - +import { NotificationServiceServerImpl } from './notification-service-server'; // As currently implemented on Linux, // the maximum number of symbolic links that will be followed while resolving a pathname is 40 @@ -28,8 +30,10 @@ export class SketchesServiceImpl implements SketchesService { @inject(ConfigService) protected readonly configService: ConfigService; + @inject(NotificationServiceServerImpl) + protected readonly notificationService: NotificationServiceServerImpl; + async getSketches(uri?: string): Promise { - const sketches: Array = []; let fsPath: undefined | string; if (!uri) { const { sketchDirUri } = await this.configService.getConfiguration(); @@ -43,9 +47,62 @@ export class SketchesServiceImpl implements SketchesService { if (!fs.existsSync(fsPath)) { return []; } - const fileNames = await fs.readdir(fsPath); - for (const fileName of fileNames) { - const filePath = path.join(fsPath, fileName); + const stat = await fs.stat(fsPath); + if (!stat.isDirectory()) { + return []; + } + return this.doGetSketches(fsPath); + } + + /** + * Dev note: The keys are filesystem paths, not URI strings. + */ + private sketchbooks = new Map>(); + private fireSoonHandle?: NodeJS.Timer; + private bufferedSketchbookEvents: { type: 'created' | 'removed', sketch: Sketch }[] = []; + + private fireSoon(type: 'created' | 'removed', sketch: Sketch): void { + this.bufferedSketchbookEvents.push({ type, sketch }); + + if (this.fireSoonHandle) { + clearTimeout(this.fireSoonHandle); + } + + this.fireSoonHandle = setTimeout(() => { + const event: { created: Sketch[], removed: Sketch[] } = { + created: [], + removed: [] + }; + for (const { type, sketch } of this.bufferedSketchbookEvents) { + if (type === 'created') { + event.created.push(sketch); + } else { + event.removed.push(sketch); + } + } + this.notificationService.notifySketchbookChanged(event); + this.bufferedSketchbookEvents.length = 0; + }, 100); + } + + /** + * Assumes the `fsPath` points to an existing directory. + */ + private async doGetSketches(sketchbookPath: string): Promise { + const resolvedSketches = this.sketchbooks.get(sketchbookPath); + if (resolvedSketches) { + if (Array.isArray(resolvedSketches)) { + return resolvedSketches; + } + return resolvedSketches.promise; + } + + const deferred = new Deferred(); + this.sketchbooks.set(sketchbookPath, deferred); + const sketches: Array = []; + const filenames = await fs.readdir(sketchbookPath); + for (const fileName of filenames) { + const filePath = path.join(sketchbookPath, fileName); if (await this.isSketchFolder(FileUri.create(filePath).toString())) { try { const stat = await fs.stat(filePath); @@ -59,7 +116,84 @@ export class SketchesServiceImpl implements SketchesService { } } } - return sketches.sort((left, right) => right.mtimeMs - left.mtimeMs); + sketches.sort((left, right) => right.mtimeMs - left.mtimeMs); + const deleteSketch = (toDelete: Sketch & { mtimeMs: number }) => { + const index = sketches.indexOf(toDelete); + if (index !== -1) { + console.log(`Sketch '${toDelete.name}' was removed from sketchbook '${sketchbookPath}'.`); + sketches.splice(index, 1); + sketches.sort((left, right) => right.mtimeMs - left.mtimeMs); + this.fireSoon('removed', toDelete); + } + }; + const createSketch = async (path: string) => { + try { + const [stat, sketch] = await Promise.all([ + fs.stat(path), + this.loadSketch(path) + ]); + console.log(`New sketch '${sketch.name}' was crated in sketchbook '${sketchbookPath}'.`); + sketches.push({ ...sketch, mtimeMs: stat.mtimeMs }); + sketches.sort((left, right) => right.mtimeMs - left.mtimeMs); + this.fireSoon('created', sketch); + } catch { } + }; + const watcher = await nsfw(sketchbookPath, async (events: any) => { + // We track `.ino` files changes only. + for (const event of events) { + switch (event.action) { + case nsfw.ActionType.CREATED: + if (event.file.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.file === `${path.basename(event.directory)}.ino`) { + createSketch(event.directory); + } + break; + case nsfw.ActionType.DELETED: + let sketch: Sketch & { mtimeMs: number } | undefined = undefined + // Deleting the `ino` file. + if (event.file.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.file === `${path.basename(event.directory)}.ino`) { + sketch = sketches.find(sketch => FileUri.fsPath(sketch.uri) === event.directory); + } else if (event.directory === sketchbookPath) { // Deleting the sketch (or any folder folder in the sketchbook). + sketch = sketches.find(sketch => FileUri.fsPath(sketch.uri) === path.join(event.directory, event.file)); + } + if (sketch) { + deleteSketch(sketch); + } + break; + case nsfw.ActionType.RENAMED: + let sketchToDelete: Sketch & { mtimeMs: number } | undefined = undefined + // When renaming with the Java IDE we got an event where `directory` is the sketchbook and `oldFile` is the sketch. + if (event.directory === sketchbookPath) { + sketchToDelete = sketches.find(sketch => FileUri.fsPath(sketch.uri) === path.join(event.directory, event.oldFile)); + } + + if (sketchToDelete) { + deleteSketch(sketchToDelete); + } else { + // If it's not a deletion, check for creation. The `directory` is the new sketch and the `newFile` is the new `ino` file. + // tslint:disable-next-line:max-line-length + if (event.newFile.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.newFile === `${path.basename(event.directory)}.ino`) { + createSketch(event.directory); + } else { + // When renaming the `ino` file directly on the filesystem. The `directory` is the sketch and `newFile` and `oldFile` is the `ino` file. + // tslint:disable-next-line:max-line-length + if (event.oldFile.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.oldFile === `${path.basename(event.directory)}.ino`) { + sketchToDelete = sketches.find(sketch => FileUri.fsPath(sketch.uri) === event.directory, event.oldFile); + } + if (sketchToDelete) { + deleteSketch(sketchToDelete); + } else if (event.directory === sketchbookPath) { + createSketch(path.join(event.directory, event.newFile)); + } + } + } + break; + } + } + }); + await watcher.start(); + deferred.resolve(sketches); + this.sketchbooks.set(sketchbookPath, sketches); + return sketches; } /** diff --git a/arduino-ide-extension/tsconfig.json b/arduino-ide-extension/tsconfig.json index e4965d72..45dcd2eb 100644 --- a/arduino-ide-extension/tsconfig.json +++ b/arduino-ide-extension/tsconfig.json @@ -26,6 +26,7 @@ "src" ], "files": [ + "../node_modules/@theia/core/src/typings/nsfw/index.d.ts", "../node_modules/@theia/monaco/src/typings/monaco/index.d.ts" ] }