Added the Sketchbook menu with FS event tracking

Signed-off-by: Akos Kitta <kittaakos@typefox.io>
This commit is contained in:
Akos Kitta 2020-12-16 18:14:00 +01:00 committed by Akos Kitta
parent 1b6d9eccdc
commit db2967084f
10 changed files with 235 additions and 14 deletions

View File

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

View File

@ -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<string, DisposableCollection>();
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();
}
}
}
}

View File

@ -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'];

View File

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

View File

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

View File

@ -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';

View File

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

View File

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

View File

@ -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<Sketch[]> {
const sketches: Array<Sketch & { mtimeMs: number }> = [];
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<string, Sketch[] | Deferred<Sketch[]>>();
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<Sketch[]> {
const resolvedSketches = this.sketchbooks.get(sketchbookPath);
if (resolvedSketches) {
if (Array.isArray(resolvedSketches)) {
return resolvedSketches;
}
return resolvedSketches.promise;
}
const deferred = new Deferred<Sketch[]>();
this.sketchbooks.set(sketchbookPath, deferred);
const sketches: Array<Sketch & { mtimeMs: number }> = [];
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;
}
/**

View File

@ -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"
]
}