ATL-815: Implemented Open Recent.

Signed-off-by: Akos Kitta <kittaakos@typefox.io>
This commit is contained in:
Akos Kitta 2021-01-18 16:35:18 +01:00 committed by Akos Kitta
parent 66b711f43c
commit 6626701bc9
10 changed files with 191 additions and 14 deletions

View File

@ -134,6 +134,7 @@ import { Sketchbook } from './contributions/sketchbook';
import { DebugFrontendApplicationContribution } from './theia/debug/debug-frontend-application-contribution'; import { DebugFrontendApplicationContribution } from './theia/debug/debug-frontend-application-contribution';
import { DebugFrontendApplicationContribution as TheiaDebugFrontendApplicationContribution } from '@theia/debug/lib/browser/debug-frontend-application-contribution'; import { DebugFrontendApplicationContribution as TheiaDebugFrontendApplicationContribution } from '@theia/debug/lib/browser/debug-frontend-application-contribution';
import { BoardSelection } from './contributions/board-selection'; import { BoardSelection } from './contributions/board-selection';
import { OpenRecentSketch } from './contributions/open-recent-sketch';
const ElementQueries = require('css-element-queries/src/ElementQueries'); 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, Debug);
Contribution.configure(bind, Sketchbook); Contribution.configure(bind, Sketchbook);
Contribution.configure(bind, BoardSelection); Contribution.configure(bind, BoardSelection);
Contribution.configure(bind, OpenRecentSketch);
bind(OutputServiceImpl).toSelf().inSingletonScope().onActivation(({ container }, outputService) => { bind(OutputServiceImpl).toSelf().inSingletonScope().onActivation(({ container }, outputService) => {
WebSocketConnectionProvider.createProxy(container, OutputServicePath, outputService); WebSocketConnectionProvider.createProxy(container, OutputServicePath, outputService);

View File

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

View File

@ -60,8 +60,8 @@ export class SaveAsSketch extends SketchContribution {
} }
const workspaceUri = await this.sketchService.copy(sketch, { destinationUri }); const workspaceUri = await this.sketchService.copy(sketch, { destinationUri });
if (workspaceUri && openAfterMove) { if (workspaceUri && openAfterMove) {
if (wipeOriginal) { if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) {
await this.fileService.delete(new URI(sketch.uri)); await this.fileService.delete(new URI(sketch.uri), { recursive: true });
} }
this.workspaceService.open(new URI(workspaceUri), { preserveWindow: true }); this.workspaceService.open(new URI(workspaceUri), { preserveWindow: true });
} }

View File

@ -12,11 +12,14 @@ export namespace ArduinoMenus {
export const FILE__SETTINGS_GROUP = [...(isOSX ? MAIN_MENU_BAR : CommonMenus.FILE), '2_settings']; export const FILE__SETTINGS_GROUP = [...(isOSX ? MAIN_MENU_BAR : CommonMenus.FILE), '2_settings'];
export const FILE__QUIT_GROUP = [...CommonMenus.FILE, '3_quit']; 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 // -- File / Sketchbook
export const FILE__SKETCHBOOK_SUBMENU = [...FILE__SKETCH_GROUP, '0_sketchbook']; export const FILE__SKETCHBOOK_SUBMENU = [...FILE__SKETCH_GROUP, '1_sketchbook'];
// -- File / Examples // -- 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__BUILT_IN_GROUP = [...FILE__EXAMPLES_SUBMENU, '0_built_ins'];
export const EXAMPLES__ANY_BOARD_GROUP = [...FILE__EXAMPLES_SUBMENU, '1_any_board']; export const EXAMPLES__ANY_BOARD_GROUP = [...FILE__EXAMPLES_SUBMENU, '1_any_board'];
export const EXAMPLES__CURRENT_BOARD_GROUP = [...FILE__EXAMPLES_SUBMENU, '2_current_board']; export const EXAMPLES__CURRENT_BOARD_GROUP = [...FILE__EXAMPLES_SUBMENU, '2_current_board'];

View File

@ -22,6 +22,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
protected readonly libraryUninstalledEmitter = new Emitter<{ item: LibraryPackage }>(); protected readonly libraryUninstalledEmitter = new Emitter<{ item: LibraryPackage }>();
protected readonly attachedBoardsChangedEmitter = new Emitter<AttachedBoardsChangeEvent>(); protected readonly attachedBoardsChangedEmitter = new Emitter<AttachedBoardsChangeEvent>();
protected readonly sketchbookChangedEmitter = new Emitter<{ created: Sketch[], removed: Sketch[] }>(); protected readonly sketchbookChangedEmitter = new Emitter<{ created: Sketch[], removed: Sketch[] }>();
protected readonly recentSketchesChangedEmitter = new Emitter<{ sketches: Sketch[] }>();
protected readonly toDispose = new DisposableCollection( protected readonly toDispose = new DisposableCollection(
this.indexUpdatedEmitter, this.indexUpdatedEmitter,
@ -46,6 +47,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
readonly onLibraryUninstalled = this.libraryUninstalledEmitter.event; readonly onLibraryUninstalled = this.libraryUninstalledEmitter.event;
readonly onAttachedBoardsChanged = this.attachedBoardsChangedEmitter.event; readonly onAttachedBoardsChanged = this.attachedBoardsChangedEmitter.event;
readonly onSketchbookChanged = this.sketchbookChangedEmitter.event; readonly onSketchbookChanged = this.sketchbookChangedEmitter.event;
readonly onRecentSketchesChanged = this.recentSketchesChangedEmitter.event;
@postConstruct() @postConstruct()
protected init(): void { protected init(): void {
@ -96,4 +98,8 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
this.sketchbookChangedEmitter.fire(event); this.sketchbookChangedEmitter.fire(event);
} }
notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void {
this.recentSketchesChangedEmitter.fire(event);
}
} }

View File

@ -3,6 +3,7 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { CommandService } from '@theia/core/lib/common/command'; import { CommandService } from '@theia/core/lib/common/command';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { FrontendApplication as TheiaFrontendApplication } from '@theia/core/lib/browser/frontend-application'; import { FrontendApplication as TheiaFrontendApplication } from '@theia/core/lib/browser/frontend-application';
import { SketchesService } from '../../../common/protocol';
import { ArduinoCommands } from '../../arduino-commands'; import { ArduinoCommands } from '../../arduino-commands';
@injectable() @injectable()
@ -17,12 +18,16 @@ export class FrontendApplication extends TheiaFrontendApplication {
@inject(CommandService) @inject(CommandService)
protected readonly commandService: CommandService; protected readonly commandService: CommandService;
@inject(SketchesService)
protected readonly sketchesService: SketchesService;
protected async initializeLayout(): Promise<void> { protected async initializeLayout(): Promise<void> {
await super.initializeLayout(); await super.initializeLayout();
const roots = await this.workspaceService.roots; const roots = await this.workspaceService.roots;
for (const root of roots) { for (const root of roots) {
const exists = await this.fileService.exists(root.resource); const exists = await this.fileService.exists(root.resource);
if (exists) { 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); await this.commandService.executeCommand(ArduinoCommands.OPEN_SKETCH_FILES.id, root.resource);
} }
} }

View File

@ -13,6 +13,7 @@ export interface NotificationServiceClient {
notifyLibraryUninstalled(event: { item: LibraryPackage }): void; notifyLibraryUninstalled(event: { item: LibraryPackage }): void;
notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void; notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void;
notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void; notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void;
notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void;
} }
export const NotificationServicePath = '/services/notification-service'; export const NotificationServicePath = '/services/notification-service';

View File

@ -48,6 +48,16 @@ export interface SketchesService {
*/ */
getSketchFolder(uri: string): Promise<Sketch | undefined>; getSketchFolder(uri: string): Promise<Sketch | undefined>;
/**
* 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<void>;
/**
* Resolves to an array of sketches in inverse chronological order. The newest is the first.
*/
recentlyOpenedSketches(): Promise<Sketch[]>;
} }
export interface Sketch { export interface Sketch {
@ -72,4 +82,3 @@ export namespace Sketch {
return [mainFileUri, ...otherSketchFileUris, ...additionalFileUris].indexOf(uri.toString()) !== -1; return [mainFileUri, ...otherSketchFileUris, ...additionalFileUris].indexOf(uri.toString()) !== -1;
} }
} }

View File

@ -46,6 +46,10 @@ export class NotificationServiceServerImpl implements NotificationServiceServer
this.clients.forEach(client => client.notifySketchbookChanged(event)); this.clients.forEach(client => client.notifySketchbookChanged(event));
} }
notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void {
this.clients.forEach(client => client.notifyRecentSketchesChanged(event));
}
setClient(client: NotificationServiceClient): void { setClient(client: NotificationServiceClient): void {
this.clients.push(client); this.clients.push(client);
} }

View File

@ -14,6 +14,8 @@ import { ConfigService } from '../common/protocol/config-service';
import { SketchesService, Sketch } from '../common/protocol/sketches-service'; import { SketchesService, Sketch } from '../common/protocol/sketches-service';
import { firstToLowerCase } from '../common/utils'; import { firstToLowerCase } from '../common/utils';
import { NotificationServiceServerImpl } from './notification-service-server'; 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, // As currently implemented on Linux,
// the maximum number of symbolic links that will be followed while resolving a pathname is 40 // 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) @inject(NotificationServiceServerImpl)
protected readonly notificationService: NotificationServiceServerImpl; protected readonly notificationService: NotificationServiceServerImpl;
async getSketches(uri?: string): Promise<Sketch[]> { @inject(EnvVariablesServer)
protected readonly envVariableServer: EnvVariablesServer;
async getSketches(uri?: string): Promise<SketchWithDetails[]> {
let fsPath: undefined | string; let fsPath: undefined | string;
if (!uri) { if (!uri) {
const { sketchDirUri } = await this.configService.getConfiguration(); 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. * Dev note: The keys are filesystem paths, not URI strings.
*/ */
private sketchbooks = new Map<string, Sketch[] | Deferred<Sketch[]>>(); private sketchbooks = new Map<string, SketchWithDetails[] | Deferred<SketchWithDetails[]>>();
private fireSoonHandle?: NodeJS.Timer; private fireSoonHandle?: NodeJS.Timer;
private bufferedSketchbookEvents: { type: 'created' | 'removed', sketch: Sketch }[] = []; private bufferedSketchbookEvents: { type: 'created' | 'removed', sketch: Sketch }[] = [];
@ -88,7 +93,7 @@ export class SketchesServiceImpl implements SketchesService {
/** /**
* Assumes the `fsPath` points to an existing directory. * Assumes the `fsPath` points to an existing directory.
*/ */
private async doGetSketches(sketchbookPath: string): Promise<Sketch[]> { private async doGetSketches(sketchbookPath: string): Promise<SketchWithDetails[]> {
const resolvedSketches = this.sketchbooks.get(sketchbookPath); const resolvedSketches = this.sketchbooks.get(sketchbookPath);
if (resolvedSketches) { if (resolvedSketches) {
if (Array.isArray(resolvedSketches)) { if (Array.isArray(resolvedSketches)) {
@ -97,9 +102,9 @@ export class SketchesServiceImpl implements SketchesService {
return resolvedSketches.promise; return resolvedSketches.promise;
} }
const deferred = new Deferred<Sketch[]>(); const deferred = new Deferred<SketchWithDetails[]>();
this.sketchbooks.set(sketchbookPath, deferred); this.sketchbooks.set(sketchbookPath, deferred);
const sketches: Array<Sketch & { mtimeMs: number }> = []; const sketches: Array<SketchWithDetails> = [];
const filenames = await fs.readdir(sketchbookPath); const filenames = await fs.readdir(sketchbookPath);
for (const fileName of filenames) { for (const fileName of filenames) {
const filePath = path.join(sketchbookPath, fileName); const filePath = path.join(sketchbookPath, fileName);
@ -201,7 +206,7 @@ export class SketchesServiceImpl implements SketchesService {
* See: https://github.com/arduino/arduino-cli/issues/837 * 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 * Based on: https://github.com/arduino/arduino-cli/blob/eef3705c4afcba4317ec38b803d9ffce5dd59a28/arduino/builder/sketch.go#L100-L215
*/ */
async loadSketch(uri: string): Promise<Sketch> { async loadSketch(uri: string): Promise<SketchWithDetails> {
const sketchPath = FileUri.fsPath(uri); const sketchPath = FileUri.fsPath(uri);
const exists = await fs.exists(sketchPath); const exists = await fs.exists(sketchPath);
if (!exists) { if (!exists) {
@ -294,7 +299,80 @@ export class SketchesServiceImpl implements SketchesService {
} }
private newSketch(sketchFolderPath: string, mainFilePath: string, allFilesPaths: string[]): Sketch { private get recentSketchesFsPath(): Promise<string> {
return this.envVariableServer.getConfigDirUri().then(uri => path.join(FileUri.fsPath(uri), 'recent-sketches.json'));
}
private async loadRecentSketches(fsPath: string): Promise<Record<string, number>> {
let data: Record<string, number> = {};
try {
const raw = await fs.readFile(fsPath, { encoding: 'utf8' });
data = JSON.parse(raw);
} catch { }
return data;
}
async markAsRecentlyOpened(uri: string): Promise<void> {
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<Sketch[]> {
const configDirUri = await this.envVariableServer.getConfigDirUri();
const fsPath = path.join(FileUri.fsPath(configDirUri), 'recent-sketches.json');
let data: Record<string, number> = {};
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<SketchWithDetails> {
let mainFile: string | undefined; let mainFile: string | undefined;
const paths = new Set<string>(); const paths = new Set<string>();
for (const p of allFilesPaths) { for (const p of allFilesPaths) {
@ -326,13 +404,15 @@ export class SketchesServiceImpl implements SketchesService {
additionalFiles.sort(); additionalFiles.sort();
otherSketchFiles.sort(); otherSketchFiles.sort();
const { mtimeMs } = await fs.lstat(sketchFolderPath);
return { return {
uri: FileUri.create(sketchFolderPath).toString(), uri: FileUri.create(sketchFolderPath).toString(),
mainFileUri: FileUri.create(mainFile).toString(), mainFileUri: FileUri.create(mainFile).toString(),
name: path.basename(sketchFolderPath), name: path.basename(sketchFolderPath),
additionalFileUris: additionalFiles.map(p => FileUri.create(p).toString()), 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<Sketch> { async cloneExample(uri: string): Promise<Sketch> {
@ -538,3 +618,8 @@ class SkipDir extends Error {
Object.setPrototypeOf(this, SkipDir.prototype); Object.setPrototypeOf(this, SkipDir.prototype);
} }
} }
interface SketchWithDetails extends Sketch {
readonly mtimeMs: number;
}