From 6626701bc999b3e651ecbb8835d2b5f04dd3ac71 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Mon, 18 Jan 2021 16:35:18 +0100 Subject: [PATCH] ATL-815: Implemented `Open Recent`. Signed-off-by: Akos Kitta --- .../browser/arduino-ide-frontend-module.ts | 2 + .../contributions/open-recent-sketch.ts | 62 +++++++++++ .../browser/contributions/save-as-sketch.ts | 4 +- .../src/browser/menu/arduino-menus.ts | 7 +- .../src/browser/notification-center.ts | 6 + .../theia/core/frontend-application.ts | 5 + .../common/protocol/notification-service.ts | 1 + .../src/common/protocol/sketches-service.ts | 11 +- .../src/node/notification-service-server.ts | 4 + .../src/node/sketches-service-impl.ts | 103 ++++++++++++++++-- 10 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 arduino-ide-extension/src/browser/contributions/open-recent-sketch.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 85fc716a..dd036ea1 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -134,6 +134,7 @@ import { Sketchbook } from './contributions/sketchbook'; import { DebugFrontendApplicationContribution } from './theia/debug/debug-frontend-application-contribution'; import { DebugFrontendApplicationContribution as TheiaDebugFrontendApplicationContribution } from '@theia/debug/lib/browser/debug-frontend-application-contribution'; import { BoardSelection } from './contributions/board-selection'; +import { OpenRecentSketch } from './contributions/open-recent-sketch'; const ElementQueries = require('css-element-queries/src/ElementQueries'); @@ -337,6 +338,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, Debug); Contribution.configure(bind, Sketchbook); Contribution.configure(bind, BoardSelection); + Contribution.configure(bind, OpenRecentSketch); bind(OutputServiceImpl).toSelf().inSingletonScope().onActivation(({ container }, outputService) => { WebSocketConnectionProvider.createProxy(container, OutputServicePath, outputService); diff --git a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts new file mode 100644 index 00000000..1f482222 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts @@ -0,0 +1,62 @@ +import { inject, injectable } from 'inversify'; +import { WorkspaceServer } from '@theia/workspace/lib/common/workspace-protocol'; +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 { OpenSketch } from './open-sketch'; +import { NotificationCenter } from '../notification-center'; + +@injectable() +export class OpenRecentSketch extends SketchContribution { + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(MenuModelRegistry) + protected readonly menuRegistry: MenuModelRegistry; + + @inject(MainMenuManager) + protected readonly mainMenuManager: MainMenuManager; + + @inject(WorkspaceServer) + protected readonly workspaceServer: WorkspaceServer; + + @inject(NotificationCenter) + protected readonly notificationCenter: NotificationCenter; + + protected toDisposeBeforeRegister = new Map(); + + onStart(): void { + const refreshMenu = (sketches: Sketch[]) => { + this.register(sketches); + this.mainMenuManager.update(); + }; + this.notificationCenter.onRecentSketchesChanged(({ sketches }) => refreshMenu(sketches)); + this.sketchService.recentlyOpenedSketches().then(refreshMenu); + } + + registerMenus(registry: MenuModelRegistry): void { + registry.registerSubmenu(ArduinoMenus.FILE__OPEN_RECENT_SUBMENU, 'Open Recent', { order: '2' }); + } + + protected register(sketches: Sketch[]): void { + let order = 0; + for (const sketch of sketches) { + const { uri } = sketch; + const toDispose = this.toDisposeBeforeRegister.get(uri); + if (toDispose) { + toDispose.dispose(); + } + const command = { id: `arduino-open-recent--${uri}` }; + const handler = { execute: () => this.commandRegistry.executeCommand(OpenSketch.Commands.OPEN_SKETCH.id, sketch) }; + this.commandRegistry.registerCommand(command, handler); + this.menuRegistry.registerMenuAction(ArduinoMenus.FILE__OPEN_RECENT_SUBMENU, { commandId: command.id, label: sketch.name, order: String(order) }); + this.toDisposeBeforeRegister.set(sketch.uri, new DisposableCollection( + Disposable.create(() => this.commandRegistry.unregisterCommand(command)), + Disposable.create(() => this.menuRegistry.unregisterMenuAction(command)) + )); + } + } + +} diff --git a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts index 07bd5b98..78acbbb5 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -60,8 +60,8 @@ export class SaveAsSketch extends SketchContribution { } const workspaceUri = await this.sketchService.copy(sketch, { destinationUri }); if (workspaceUri && openAfterMove) { - if (wipeOriginal) { - await this.fileService.delete(new URI(sketch.uri)); + if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) { + await this.fileService.delete(new URI(sketch.uri), { recursive: true }); } this.workspaceService.open(new URI(workspaceUri), { preserveWindow: true }); } diff --git a/arduino-ide-extension/src/browser/menu/arduino-menus.ts b/arduino-ide-extension/src/browser/menu/arduino-menus.ts index 501d9c62..70a6fee8 100644 --- a/arduino-ide-extension/src/browser/menu/arduino-menus.ts +++ b/arduino-ide-extension/src/browser/menu/arduino-menus.ts @@ -12,11 +12,14 @@ 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 / Open Recent + export const FILE__OPEN_RECENT_SUBMENU = [...FILE__SKETCH_GROUP, '0_open_recent']; + // -- File / Sketchbook - export const FILE__SKETCHBOOK_SUBMENU = [...FILE__SKETCH_GROUP, '0_sketchbook']; + export const FILE__SKETCHBOOK_SUBMENU = [...FILE__SKETCH_GROUP, '1_sketchbook']; // -- File / Examples - export const FILE__EXAMPLES_SUBMENU = [...FILE__SKETCH_GROUP, '1_examples']; + export const FILE__EXAMPLES_SUBMENU = [...FILE__SKETCH_GROUP, '2_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 e69355f0..87ecc23a 100644 --- a/arduino-ide-extension/src/browser/notification-center.ts +++ b/arduino-ide-extension/src/browser/notification-center.ts @@ -22,6 +22,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp protected readonly libraryUninstalledEmitter = new Emitter<{ item: LibraryPackage }>(); protected readonly attachedBoardsChangedEmitter = new Emitter(); protected readonly sketchbookChangedEmitter = new Emitter<{ created: Sketch[], removed: Sketch[] }>(); + protected readonly recentSketchesChangedEmitter = new Emitter<{ sketches: Sketch[] }>(); protected readonly toDispose = new DisposableCollection( this.indexUpdatedEmitter, @@ -46,6 +47,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp readonly onLibraryUninstalled = this.libraryUninstalledEmitter.event; readonly onAttachedBoardsChanged = this.attachedBoardsChangedEmitter.event; readonly onSketchbookChanged = this.sketchbookChangedEmitter.event; + readonly onRecentSketchesChanged = this.recentSketchesChangedEmitter.event; @postConstruct() protected init(): void { @@ -96,4 +98,8 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp this.sketchbookChangedEmitter.fire(event); } + notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void { + this.recentSketchesChangedEmitter.fire(event); + } + } diff --git a/arduino-ide-extension/src/browser/theia/core/frontend-application.ts b/arduino-ide-extension/src/browser/theia/core/frontend-application.ts index b49cbd08..20f69c6b 100644 --- a/arduino-ide-extension/src/browser/theia/core/frontend-application.ts +++ b/arduino-ide-extension/src/browser/theia/core/frontend-application.ts @@ -3,6 +3,7 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { CommandService } from '@theia/core/lib/common/command'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { FrontendApplication as TheiaFrontendApplication } from '@theia/core/lib/browser/frontend-application'; +import { SketchesService } from '../../../common/protocol'; import { ArduinoCommands } from '../../arduino-commands'; @injectable() @@ -17,12 +18,16 @@ export class FrontendApplication extends TheiaFrontendApplication { @inject(CommandService) protected readonly commandService: CommandService; + @inject(SketchesService) + protected readonly sketchesService: SketchesService; + protected async initializeLayout(): Promise { await super.initializeLayout(); const roots = await this.workspaceService.roots; for (const root of roots) { const exists = await this.fileService.exists(root.resource); if (exists) { + this.sketchesService.markAsRecentlyOpened(root.resource.toString()); // no await, will get the notification later and rebuild the menu await this.commandService.executeCommand(ArduinoCommands.OPEN_SKETCH_FILES.id, root.resource); } } diff --git a/arduino-ide-extension/src/common/protocol/notification-service.ts b/arduino-ide-extension/src/common/protocol/notification-service.ts index 81431025..5b2596d8 100644 --- a/arduino-ide-extension/src/common/protocol/notification-service.ts +++ b/arduino-ide-extension/src/common/protocol/notification-service.ts @@ -13,6 +13,7 @@ export interface NotificationServiceClient { notifyLibraryUninstalled(event: { item: LibraryPackage }): void; notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void; notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void; + notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void; } export const NotificationServicePath = '/services/notification-service'; diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index a045f646..94bc8eef 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -48,6 +48,16 @@ export interface SketchesService { */ getSketchFolder(uri: string): Promise; + /** + * Marks the sketch with the given URI as recently opened. It does nothing if the sketch is temp or not valid. + */ + markAsRecentlyOpened(uri: string): Promise; + + /** + * Resolves to an array of sketches in inverse chronological order. The newest is the first. + */ + recentlyOpenedSketches(): Promise; + } export interface Sketch { @@ -72,4 +82,3 @@ export namespace Sketch { return [mainFileUri, ...otherSketchFileUris, ...additionalFileUris].indexOf(uri.toString()) !== -1; } } - diff --git a/arduino-ide-extension/src/node/notification-service-server.ts b/arduino-ide-extension/src/node/notification-service-server.ts index 57108f7d..7d0be513 100644 --- a/arduino-ide-extension/src/node/notification-service-server.ts +++ b/arduino-ide-extension/src/node/notification-service-server.ts @@ -46,6 +46,10 @@ export class NotificationServiceServerImpl implements NotificationServiceServer this.clients.forEach(client => client.notifySketchbookChanged(event)); } + notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void { + this.clients.forEach(client => client.notifyRecentSketchesChanged(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 2fd3e360..d4bc11a5 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -14,6 +14,8 @@ 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'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { notEmpty } from '@theia/core'; // As currently implemented on Linux, // the maximum number of symbolic links that will be followed while resolving a pathname is 40 @@ -33,7 +35,10 @@ export class SketchesServiceImpl implements SketchesService { @inject(NotificationServiceServerImpl) protected readonly notificationService: NotificationServiceServerImpl; - async getSketches(uri?: string): Promise { + @inject(EnvVariablesServer) + protected readonly envVariableServer: EnvVariablesServer; + + async getSketches(uri?: string): Promise { let fsPath: undefined | string; if (!uri) { const { sketchDirUri } = await this.configService.getConfiguration(); @@ -57,7 +62,7 @@ export class SketchesServiceImpl implements SketchesService { /** * Dev note: The keys are filesystem paths, not URI strings. */ - private sketchbooks = new Map>(); + private sketchbooks = new Map>(); private fireSoonHandle?: NodeJS.Timer; private bufferedSketchbookEvents: { type: 'created' | 'removed', sketch: Sketch }[] = []; @@ -88,7 +93,7 @@ export class SketchesServiceImpl implements SketchesService { /** * Assumes the `fsPath` points to an existing directory. */ - private async doGetSketches(sketchbookPath: string): Promise { + private async doGetSketches(sketchbookPath: string): Promise { const resolvedSketches = this.sketchbooks.get(sketchbookPath); if (resolvedSketches) { if (Array.isArray(resolvedSketches)) { @@ -97,9 +102,9 @@ export class SketchesServiceImpl implements SketchesService { return resolvedSketches.promise; } - const deferred = new Deferred(); + const deferred = new Deferred(); this.sketchbooks.set(sketchbookPath, deferred); - const sketches: Array = []; + const sketches: Array = []; const filenames = await fs.readdir(sketchbookPath); for (const fileName of filenames) { const filePath = path.join(sketchbookPath, fileName); @@ -201,7 +206,7 @@ export class SketchesServiceImpl implements SketchesService { * See: https://github.com/arduino/arduino-cli/issues/837 * Based on: https://github.com/arduino/arduino-cli/blob/eef3705c4afcba4317ec38b803d9ffce5dd59a28/arduino/builder/sketch.go#L100-L215 */ - async loadSketch(uri: string): Promise { + async loadSketch(uri: string): Promise { const sketchPath = FileUri.fsPath(uri); const exists = await fs.exists(sketchPath); if (!exists) { @@ -294,7 +299,80 @@ export class SketchesServiceImpl implements SketchesService { } - private newSketch(sketchFolderPath: string, mainFilePath: string, allFilesPaths: string[]): Sketch { + private get recentSketchesFsPath(): Promise { + return this.envVariableServer.getConfigDirUri().then(uri => path.join(FileUri.fsPath(uri), 'recent-sketches.json')); + } + + private async loadRecentSketches(fsPath: string): Promise> { + let data: Record = {}; + try { + const raw = await fs.readFile(fsPath, { encoding: 'utf8' }); + data = JSON.parse(raw); + } catch { } + return data; + } + + async markAsRecentlyOpened(uri: string): Promise { + let sketch: Sketch | undefined = undefined; + try { + sketch = await this.loadSketch(uri); + } catch { + return; + } + if (await this.isTemp(sketch)) { + return; + } + + const fsPath = await this.recentSketchesFsPath; + const data = await this.loadRecentSketches(fsPath); + const now = Date.now(); + data[sketch.uri] = now; + + let toDeleteUri: string | undefined = undefined; + if (Object.keys(data).length > 10) { + let min = Number.MAX_SAFE_INTEGER; + for (const uri of Object.keys(data)) { + if (min > data[uri]) { + min = data[uri]; + toDeleteUri = uri; + } + } + } + + if (toDeleteUri) { + delete data[toDeleteUri]; + } + + await fs.writeFile(fsPath, JSON.stringify(data, null, 2)); + this.recentlyOpenedSketches().then(sketches => this.notificationService.notifyRecentSketchesChanged({ sketches })); + } + + async recentlyOpenedSketches(): Promise { + const configDirUri = await this.envVariableServer.getConfigDirUri(); + const fsPath = path.join(FileUri.fsPath(configDirUri), 'recent-sketches.json'); + let data: Record = {}; + try { + const raw = await fs.readFile(fsPath, { encoding: 'utf8' }); + data = JSON.parse(raw); + } catch { } + + const loadSketchSafe = (uri: string) => { + try { + return this.loadSketch(uri); + } catch { + return undefined; + } + } + + const sketches = await Promise.all(Object.keys(data) + .sort((left, right) => data[right] - data[left]) + .map(loadSketchSafe) + .filter(notEmpty)); + + return sketches; + } + + private async newSketch(sketchFolderPath: string, mainFilePath: string, allFilesPaths: string[]): Promise { let mainFile: string | undefined; const paths = new Set(); for (const p of allFilesPaths) { @@ -326,13 +404,15 @@ export class SketchesServiceImpl implements SketchesService { additionalFiles.sort(); otherSketchFiles.sort(); + const { mtimeMs } = await fs.lstat(sketchFolderPath); return { uri: FileUri.create(sketchFolderPath).toString(), mainFileUri: FileUri.create(mainFile).toString(), name: path.basename(sketchFolderPath), additionalFileUris: additionalFiles.map(p => FileUri.create(p).toString()), - otherSketchFileUris: otherSketchFiles.map(p => FileUri.create(p).toString()) - } + otherSketchFileUris: otherSketchFiles.map(p => FileUri.create(p).toString()), + mtimeMs + }; } async cloneExample(uri: string): Promise { @@ -538,3 +618,8 @@ class SkipDir extends Error { Object.setPrototypeOf(this, SkipDir.prototype); } } + +interface SketchWithDetails extends Sketch { + readonly mtimeMs: number; +} +