Avoid deleting the workspace when it's still in use.

- From now on, NSFW service disposes after last reference
is removed. No more 10sec delay.
 - Moved the temp workspace deletion to a startup task.
 - Can set initial task for the window from electron-main.
 - Removed the `browser-app`.

Closes #39

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta
2022-09-05 11:22:16 +02:00
committed by Akos Kitta
parent 0151e4c224
commit fdf6f0f9c8
38 changed files with 560 additions and 582 deletions

View File

@@ -105,7 +105,8 @@ import {
} from '@theia/core/lib/browser/connection-status-service';
import { BoardsDataMenuUpdater } from './boards/boards-data-menu-updater';
import { BoardsDataStore } from './boards/boards-data-store';
import { ILogger } from '@theia/core';
import { ILogger } from '@theia/core/lib/common/logger';
import { bindContributionProvider } from '@theia/core/lib/common/contribution-provider';
import {
FileSystemExt,
FileSystemExtPath,
@@ -308,7 +309,7 @@ import { CoreErrorHandler } from './contributions/core-error-handler';
import { CompilerErrors } from './contributions/compiler-errors';
import { WidgetManager } from './theia/core/widget-manager';
import { WidgetManager as TheiaWidgetManager } from '@theia/core/lib/browser/widget-manager';
import { StartupTasks } from './widgets/sketchbook/startup-task';
import { StartupTasks } from './contributions/startup-task';
import { IndexesUpdateProgress } from './contributions/indexes-update-progress';
import { Daemon } from './contributions/daemon';
import { FirstStartupInstaller } from './contributions/first-startup-installer';
@@ -334,6 +335,8 @@ import {
} from './widgets/component-list/filter-renderer';
import { CheckForUpdates } from './contributions/check-for-updates';
import { OutputEditorFactory } from './theia/output/output-editor-factory';
import { StartupTaskProvider } from '../electron-common/startup-task';
import { DeleteSketch } from './contributions/delete-sketch';
const registerArduinoThemes = () => {
const themes: MonacoThemeJson[] = [
@@ -433,6 +436,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Boards service client to receive and delegate notifications from the backend.
bind(BoardsServiceProvider).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(BoardsServiceProvider);
bind(CommandContribution).toService(BoardsServiceProvider);
// To be able to track, and update the menu based on the core settings (aka. board details) of the currently selected board.
bind(FrontendApplicationContribution)
@@ -757,6 +761,10 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
Contribution.configure(bind, OpenBoardsConfig);
Contribution.configure(bind, SketchFilesTracker);
Contribution.configure(bind, CheckForUpdates);
Contribution.configure(bind, DeleteSketch);
bindContributionProvider(bind, StartupTaskProvider);
bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window
// Disabled the quick-pick customization from Theia when multiple formatters are available.
// Use the default VS Code behavior, and pick the first one. In the IDE2, clang-format has `exclusive` selectors.

View File

@@ -413,53 +413,5 @@ export namespace BoardsConfig {
const { name } = selectedBoard;
return `${name}${port ? ` at ${port.address}` : ''}`;
}
export function setConfig(
config: Config | undefined,
urlToAttachTo: URL
): URL {
const copy = new URL(urlToAttachTo.toString());
if (!config) {
copy.searchParams.delete('boards-config');
return copy;
}
const selectedBoard = config.selectedBoard
? {
name: config.selectedBoard.name,
fqbn: config.selectedBoard.fqbn,
}
: undefined;
const selectedPort = config.selectedPort
? {
protocol: config.selectedPort.protocol,
address: config.selectedPort.address,
}
: undefined;
const jsonConfig = JSON.stringify({ selectedBoard, selectedPort });
copy.searchParams.set('boards-config', encodeURIComponent(jsonConfig));
return copy;
}
export function getConfig(url: URL): Config | undefined {
const encoded = url.searchParams.get('boards-config');
if (!encoded) {
return undefined;
}
try {
const raw = decodeURIComponent(encoded);
const candidate = JSON.parse(raw);
if (typeof candidate === 'object') {
return candidate;
}
console.warn(
`Expected candidate to be an object. It was ${typeof candidate}. URL was: ${url}`
);
return undefined;
} catch (e) {
console.log(`Could not get board config from URL: ${url}.`, e);
return undefined;
}
}
}
}

View File

@@ -1,7 +1,12 @@
import { injectable, inject } from '@theia/core/shared/inversify';
import { Emitter } from '@theia/core/lib/common/event';
import { ILogger } from '@theia/core/lib/common/logger';
import { CommandService } from '@theia/core/lib/common/command';
import {
Command,
CommandContribution,
CommandRegistry,
CommandService,
} from '@theia/core/lib/common/command';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { RecursiveRequired } from '../../common/types';
@@ -23,9 +28,18 @@ import { nls } from '@theia/core/lib/common';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { Unknown } from '../../common/nls';
import {
StartupTask,
StartupTaskProvider,
} from '../../electron-common/startup-task';
@injectable()
export class BoardsServiceProvider implements FrontendApplicationContribution {
export class BoardsServiceProvider
implements
FrontendApplicationContribution,
StartupTaskProvider,
CommandContribution
{
@inject(ILogger)
protected logger: ILogger;
@@ -50,6 +64,7 @@ export class BoardsServiceProvider implements FrontendApplicationContribution {
AvailableBoard[]
>();
protected readonly onAvailablePortsChangedEmitter = new Emitter<Port[]>();
private readonly inheritedConfig = new Deferred<BoardsConfig.Config>();
/**
* Used for the auto-reconnecting. Sometimes, the attached board gets disconnected after uploading something to it.
@@ -115,6 +130,13 @@ export class BoardsServiceProvider implements FrontendApplicationContribution {
});
}
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(USE_INHERITED_CONFIG, {
execute: (inheritedConfig: BoardsConfig.Config) =>
this.inheritedConfig.resolve(inheritedConfig),
});
}
get reconciled(): Promise<void> {
return this._reconciled.promise;
}
@@ -655,11 +677,14 @@ export class BoardsServiceProvider implements FrontendApplicationContribution {
let storedLatestBoardsConfig = await this.getData<
BoardsConfig.Config | undefined
>('latest-boards-config');
// Try to get from the URL if it was not persisted.
// Try to get from the startup task. Wait for it, then timeout. Maybe it never arrives.
if (!storedLatestBoardsConfig) {
storedLatestBoardsConfig = BoardsConfig.Config.getConfig(
new URL(window.location.href)
);
storedLatestBoardsConfig = await Promise.race([
this.inheritedConfig.promise,
new Promise<undefined>((resolve) =>
setTimeout(() => resolve(undefined), 2_000)
),
]);
}
if (storedLatestBoardsConfig) {
this.latestBoardsConfig = storedLatestBoardsConfig;
@@ -682,8 +707,31 @@ export class BoardsServiceProvider implements FrontendApplicationContribution {
key
);
}
tasks(): StartupTask[] {
return [
{
command: USE_INHERITED_CONFIG.id,
args: [this.boardsConfig],
},
];
}
}
/**
* It should be neither visible nor called from outside.
*
* This service creates a startup task with the current board config and
* passes the task to the electron-main process so that the new window
* can inherit the boards config state of this service.
*
* Note that the state is always set, but new windows might ignore it.
* For example, the new window already has a valid boards config persisted to the local storage.
*/
const USE_INHERITED_CONFIG: Command = {
id: 'arduino-use-inherited-boards-config',
};
/**
* Representation of a ready-to-use board, either the user has configured it or was automatically recognized by the CLI.
* An available board was not necessarily recognized by the CLI (e.g.: it is a 3rd party board) or correctly configured but ready for `verify`.

View File

@@ -0,0 +1,45 @@
import { injectable } from '@theia/core/shared/inversify';
import { SketchesError } from '../../common/protocol';
import {
Command,
CommandRegistry,
SketchContribution,
Sketch,
} from './contribution';
@injectable()
export class DeleteSketch extends SketchContribution {
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(DeleteSketch.Commands.DELETE_SKETCH, {
execute: (uri: string) => this.deleteSketch(uri),
});
}
private async deleteSketch(uri: string): Promise<void> {
const sketch = await this.loadSketch(uri);
if (!sketch) {
console.info(`Sketch not found at ${uri}. Skipping deletion.`);
return;
}
return this.sketchService.deleteSketch(sketch);
}
private async loadSketch(uri: string): Promise<Sketch | undefined> {
try {
const sketch = await this.sketchService.loadSketch(uri);
return sketch;
} catch (err) {
if (SketchesError.NotFound.is(err)) {
return undefined;
}
throw err;
}
}
}
export namespace DeleteSketch {
export namespace Commands {
export const DELETE_SKETCH: Command = {
id: 'arduino-delete-sketch',
};
}
}

View File

@@ -12,21 +12,19 @@ import {
} from './contribution';
import { nls } from '@theia/core/lib/common';
import { ApplicationShell, NavigatableWidget, Saveable } from '@theia/core/lib/browser';
import { EditorManager } from '@theia/editor/lib/browser';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { WorkspaceInput } from '@theia/workspace/lib/browser';
import { StartupTask } from '../../electron-common/startup-task';
import { DeleteSketch } from './delete-sketch';
@injectable()
export class SaveAsSketch extends SketchContribution {
@inject(ApplicationShell)
protected readonly applicationShell: ApplicationShell;
@inject(EditorManager)
protected override readonly editorManager: EditorManager;
private readonly applicationShell: ApplicationShell;
@inject(WindowService)
protected readonly windowService: WindowService;
private readonly windowService: WindowService;
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(SaveAsSketch.Commands.SAVE_AS_SKETCH, {
@@ -107,21 +105,19 @@ export class SaveAsSketch extends SketchContribution {
this.sketchService.markAsRecentlyOpened(workspaceUri);
}
}
const options: WorkspaceInput & StartupTask.Owner = {
preserveWindow: true,
tasks: [],
};
if (workspaceUri && openAfterMove) {
this.windowService.setSafeToShutDown();
if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) {
// This window will navigate away.
// Explicitly stop the contribution to dispose the file watcher before deleting the temp sketch.
// Otherwise, users might see irrelevant _Unable to watch for file changes in this large workspace._ notification.
// https://github.com/arduino/arduino-ide/issues/39.
this.sketchServiceClient.onStop();
// TODO: consider implementing the temp sketch deletion the following way:
// Open the other sketch with a `delete the temp sketch` startup-task.
this.sketchService.notifyDeleteSketch(sketch); // This is a notification and will execute on the backend.
options.tasks.push({
command: DeleteSketch.Commands.DELETE_SKETCH.id,
args: [sketch.uri],
});
}
this.workspaceService.open(new URI(workspaceUri), {
preserveWindow: true,
});
this.workspaceService.open(new URI(workspaceUri), options);
}
return !!workspaceUri;
}

View File

@@ -4,7 +4,7 @@ import { inject, injectable } from '@theia/core/shared/inversify';
import { FileSystemFrontendContribution } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution';
import { FileChangeType } from '@theia/filesystem/lib/common/files';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { Sketch, SketchContribution, URI } from './contribution';
import { Sketch, SketchContribution } from './contribution';
import { OpenSketchFiles } from './open-sketch-files';
@injectable()
@@ -31,7 +31,6 @@ export class SketchFilesTracker extends SketchContribution {
override onReady(): void {
this.sketchServiceClient.currentSketch().then(async (sketch) => {
if (CurrentSketch.isValid(sketch)) {
this.toDisposeOnStop.push(this.fileService.watch(new URI(sketch.uri)));
this.toDisposeOnStop.push(
this.fileService.onDidFilesChange(async (event) => {
for (const { type, resource } of event.changes) {

View File

@@ -0,0 +1,52 @@
import * as remote from '@theia/core/electron-shared/@electron/remote';
import type { IpcRendererEvent } from '@theia/core/electron-shared/electron';
import { ipcRenderer } from '@theia/core/electron-shared/electron';
import { injectable } from '@theia/core/shared/inversify';
import { StartupTask } from '../../electron-common/startup-task';
import { Contribution } from './contribution';
@injectable()
export class StartupTasks extends Contribution {
override onReady(): void {
ipcRenderer.once(
StartupTask.Messaging.STARTUP_TASKS_SIGNAL,
(_: IpcRendererEvent, args: unknown) => {
console.debug(
`Received the startup tasks from the electron main process. Args: ${JSON.stringify(
args
)}`
);
if (!StartupTask.has(args)) {
console.warn(`Could not detect 'tasks' from the signal. Skipping.`);
return;
}
const tasks = args.tasks;
if (tasks.length) {
console.log(`Executing startup tasks:`);
tasks.forEach(({ command, args = [] }) => {
console.log(
` - '${command}' ${
args.length ? `, args: ${JSON.stringify(args)}` : ''
}`
);
this.commandService
.executeCommand(command, ...args)
.catch((err) =>
console.error(
`Error occurred when executing the startup task '${command}'${
args?.length ? ` with args: '${JSON.stringify(args)}` : ''
}.`,
err
)
);
});
}
}
);
const { id } = remote.getCurrentWindow();
console.debug(
`Signalling app ready event to the electron main process. Sender ID: ${id}.`
);
ipcRenderer.send(StartupTask.Messaging.APP_READY_SIGNAL(id));
}
}

View File

@@ -1,26 +0,0 @@
import { injectable } from '@theia/core/shared/inversify';
import {
BrowserMainMenuFactory as TheiaBrowserMainMenuFactory,
MenuBarWidget,
} from '@theia/core/lib/browser/menu/browser-menu-plugin';
import { MainMenuManager } from '../../../common/main-menu-manager';
@injectable()
export class BrowserMainMenuFactory
extends TheiaBrowserMainMenuFactory
implements MainMenuManager
{
protected menuBar: MenuBarWidget | undefined;
override createMenuBar(): MenuBarWidget {
this.menuBar = super.createMenuBar();
return this.menuBar;
}
update(): void {
if (this.menuBar) {
this.menuBar.clearMenus();
this.fillMenuBar(this.menuBar);
}
}
}

View File

@@ -1,18 +0,0 @@
import '../../../../src/browser/style/browser-menu.css';
import { ContainerModule } from '@theia/core/shared/inversify';
import {
BrowserMenuBarContribution,
BrowserMainMenuFactory as TheiaBrowserMainMenuFactory,
} from '@theia/core/lib/browser/menu/browser-menu-plugin';
import { MainMenuManager } from '../../../common/main-menu-manager';
import { ArduinoMenuContribution } from './browser-menu-plugin';
import { BrowserMainMenuFactory } from './browser-main-menu-factory';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(BrowserMainMenuFactory).toSelf().inSingletonScope();
bind(MainMenuManager).toService(BrowserMainMenuFactory);
rebind(TheiaBrowserMainMenuFactory).toService(BrowserMainMenuFactory);
rebind(BrowserMenuBarContribution)
.to(ArduinoMenuContribution)
.inSingletonScope();
});

View File

@@ -1,10 +0,0 @@
import { DefaultWindowService as TheiaDefaultWindowService } from '@theia/core/lib/browser/window/default-window-service';
import { ContainerModule } from '@theia/core/shared/inversify';
import { DefaultWindowService } from './default-window-service';
import { WindowServiceExt } from './window-service-ext';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(DefaultWindowService).toSelf().inSingletonScope();
rebind(TheiaDefaultWindowService).toService(DefaultWindowService);
bind(WindowServiceExt).toService(DefaultWindowService);
});

View File

@@ -1,17 +0,0 @@
import { DefaultWindowService as TheiaDefaultWindowService } from '@theia/core/lib/browser/window/default-window-service';
import { injectable } from '@theia/core/shared/inversify';
import { WindowServiceExt } from './window-service-ext';
@injectable()
export class DefaultWindowService
extends TheiaDefaultWindowService
implements WindowServiceExt
{
/**
* The default implementation always resolves to `true`.
* IDE2 does not use it. It's currently an electron-only app.
*/
async isFirstWindow(): Promise<boolean> {
return true;
}
}

View File

@@ -1,7 +1,10 @@
import type { StartupTask } from '../../../electron-common/startup-task';
export const WindowServiceExt = Symbol('WindowServiceExt');
export interface WindowServiceExt {
/**
* Returns with a promise that resolves to `true` if the current window is the first window.
*/
isFirstWindow(): Promise<boolean>;
reload(options?: StartupTask.Owner): void;
}

View File

@@ -1,54 +1,41 @@
import * as remote from '@theia/core/electron-shared/@electron/remote';
import { injectable, inject } from '@theia/core/shared/inversify';
import { injectable, inject, named } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { EditorWidget } from '@theia/editor/lib/browser';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { MessageService } from '@theia/core/lib/common/message-service';
import { ApplicationServer } from '@theia/core/lib/common/application-protocol';
import { FrontendApplication } from '@theia/core/lib/browser/frontend-application';
import { FocusTracker, Widget } from '@theia/core/lib/browser';
import { DEFAULT_WINDOW_HASH } from '@theia/core/lib/common/window';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import {
DEFAULT_WINDOW_HASH,
NewWindowOptions,
} from '@theia/core/lib/common/window';
import {
WorkspaceInput,
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 { BoardsServiceProvider } from '../../boards/boards-service-provider';
import { BoardsConfig } from '../../boards/boards-config';
import { FileStat } from '@theia/filesystem/lib/common/files';
import {
StartupTask,
StartupTasks,
} from '../../widgets/sketchbook/startup-task';
import { setURL } from '../../utils/window';
StartupTaskProvider,
} from '../../../electron-common/startup-task';
import { WindowServiceExt } from '../core/window-service-ext';
import { ContributionProvider } from '@theia/core/lib/common/contribution-provider';
@injectable()
export class WorkspaceService extends TheiaWorkspaceService {
@inject(SketchesService)
protected readonly sketchService: SketchesService;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(LabelProvider)
protected override readonly labelProvider: LabelProvider;
@inject(MessageService)
protected override readonly messageService: MessageService;
private readonly sketchService: SketchesService;
@inject(ApplicationServer)
protected readonly applicationServer: ApplicationServer;
@inject(FrontendApplicationStateService)
protected readonly appStateService: FrontendApplicationStateService;
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider;
private readonly applicationServer: ApplicationServer;
@inject(WindowServiceExt)
private readonly windowServiceExt: WindowServiceExt;
@inject(ContributionProvider)
@named(StartupTaskProvider)
private readonly providers: ContributionProvider<StartupTaskProvider>;
private version?: string;
@@ -156,27 +143,33 @@ export class WorkspaceService extends TheiaWorkspaceService {
}
protected override reloadWindow(options?: WorkspaceInput): void {
if (StartupTasks.WorkspaceInput.is(options)) {
setURL(StartupTask.append(options.tasks, new URL(window.location.href)));
}
super.reloadWindow();
const tasks = this.tasks(options);
this.setURLFragment(this._workspace?.resource.path.toString() || '');
this.windowServiceExt.reload({ tasks });
}
protected override openNewWindow(
workspacePath: string,
options?: WorkspaceInput
): void {
const { boardsConfig } = this.boardsServiceProvider;
let url = BoardsConfig.Config.setConfig(
boardsConfig,
new URL(window.location.href)
); // Set the current boards config for the new browser window.
url.hash = workspacePath;
if (StartupTasks.WorkspaceInput.is(options)) {
url = StartupTask.append(options.tasks, url);
}
const tasks = this.tasks(options);
const url = new URL(window.location.href);
url.hash = encodeURI(workspacePath);
this.windowService.openNewWindow(
url.toString(),
Object.assign({} as NewWindowOptions, { tasks })
);
}
this.windowService.openNewWindow(url.toString());
private tasks(options?: WorkspaceInput): StartupTask[] {
const tasks = this.providers
.getContributions()
.map((contribution) => contribution.tasks())
.reduce((prev, curr) => prev.concat(curr), []);
if (StartupTask.has(options)) {
tasks.push(...options.tasks);
}
return tasks;
}
protected onCurrentWidgetChange({

View File

@@ -1,92 +0,0 @@
import { injectable } from '@theia/core/shared/inversify';
import { WorkspaceInput as TheiaWorkspaceInput } from '@theia/workspace/lib/browser';
import { Contribution } from '../../contributions/contribution';
import { setURL } from '../../utils/window';
@injectable()
export class StartupTasks extends Contribution {
override onReady(): void {
const tasks = StartupTask.get(new URL(window.location.href));
console.log(`Executing startup tasks: ${JSON.stringify(tasks)}`);
tasks.forEach(({ command, args = [] }) =>
this.commandService
.executeCommand(command, ...args)
.catch((err) =>
console.error(
`Error occurred when executing the startup task '${command}'${
args?.length ? ` with args: '${JSON.stringify(args)}` : ''
}.`,
err
)
)
);
if (tasks.length) {
// Remove the startup tasks after the execution.
// Otherwise, IDE2 executes them again on a window reload event.
setURL(StartupTask.set([], new URL(window.location.href)));
console.info(`Removed startup tasks from URL.`);
}
}
}
export interface StartupTask {
command: string;
/**
* Must be JSON serializable.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
args?: any[];
}
export namespace StartupTask {
const QUERY = 'startupTasks';
export function is(arg: unknown): arg is StartupTasks {
if (typeof arg === 'object') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const object = arg as any;
return 'command' in object && typeof object['command'] === 'string';
}
return false;
}
export function get(url: URL): StartupTask[] {
const { searchParams } = url;
const encodedTasks = searchParams.get(QUERY);
if (encodedTasks) {
const rawTasks = decodeURIComponent(encodedTasks);
const tasks = JSON.parse(rawTasks);
if (Array.isArray(tasks)) {
return tasks.filter((task) => {
if (StartupTask.is(task)) {
return true;
}
console.warn(`Was not a task: ${JSON.stringify(task)}. Ignoring.`);
return false;
});
} else {
debugger;
console.warn(`Startup tasks was not an array: ${rawTasks}. Ignoring.`);
}
}
return [];
}
export function set(tasks: StartupTask[], url: URL): URL {
const copy = new URL(url);
copy.searchParams.set(QUERY, encodeURIComponent(JSON.stringify(tasks)));
return copy;
}
export function append(tasks: StartupTask[], url: URL): URL {
return set([...get(url), ...tasks], url);
}
}
export namespace StartupTasks {
export interface WorkspaceInput extends TheiaWorkspaceInput {
tasks: StartupTask[];
}
export namespace WorkspaceInput {
export function is(
input: (TheiaWorkspaceInput & Partial<WorkspaceInput>) | undefined
): input is WorkspaceInput {
return !!input && !!input.tasks;
}
}
}