From efdb5d53fee4e06e04b2181a94ae6542064218cd Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Tue, 23 Aug 2022 15:39:19 +0200 Subject: [PATCH 01/13] init Signed-off-by: Akos Kitta --- .../browser/arduino-frontend-contribution.tsx | 70 +--------------- .../src/browser/contributions/close.ts | 80 +++++++++++++++---- 2 files changed, 66 insertions(+), 84 deletions(-) diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index f2b4af002..bcbd4747f 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -5,17 +5,14 @@ import { postConstruct, } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; -import { SketchesService } from '../common/protocol'; import { MAIN_MENU_BAR, MenuContribution, MenuModelRegistry, } from '@theia/core'; import { - Dialog, FrontendApplication, FrontendApplicationContribution, - OnWillStopAction, } from '@theia/core/lib/browser'; import { ColorContribution } from '@theia/core/lib/browser/color-application-contribution'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; @@ -34,14 +31,9 @@ import { EditorCommands, EditorMainMenu } from '@theia/editor/lib/browser'; import { MonacoMenus } from '@theia/monaco/lib/browser/monaco-menu'; import { FileNavigatorCommands } from '@theia/navigator/lib/browser/navigator-contribution'; import { TerminalMenus } from '@theia/terminal/lib/browser/terminal-frontend-contribution'; -import { - CurrentSketch, - SketchesServiceClientImpl, -} from '../common/protocol/sketches-service-client-impl'; import { ArduinoPreferences } from './arduino-preferences'; import { BoardsServiceProvider } from './boards/boards-service-provider'; import { BoardsToolBarItem } from './boards/boards-toolbar-item'; -import { SaveAsSketch } from './contributions/save-as-sketch'; import { ArduinoMenus } from './menu/arduino-menus'; import { MonitorViewContribution } from './serial/monitor/monitor-view-contribution'; import { ArduinoToolbar } from './toolbar/arduino-toolbar'; @@ -63,18 +55,12 @@ export class ArduinoFrontendContribution @inject(BoardsServiceProvider) private readonly boardsServiceProvider: BoardsServiceProvider; - @inject(SketchesService) - private readonly sketchService: SketchesService; - @inject(CommandRegistry) private readonly commandRegistry: CommandRegistry; @inject(ArduinoPreferences) private readonly arduinoPreferences: ArduinoPreferences; - @inject(SketchesServiceClientImpl) - private readonly sketchServiceClient: SketchesServiceClientImpl; - @inject(FrontendApplicationStateService) private readonly appStateService: FrontendApplicationStateService; @@ -91,7 +77,7 @@ export class ArduinoFrontendContribution } } - async onStart(app: FrontendApplication): Promise { + onStart(app: FrontendApplication): void { this.arduinoPreferences.onPreferenceChanged((event) => { if (event.newValue !== event.oldValue) { switch (event.preferenceName) { @@ -303,58 +289,4 @@ export class ArduinoFrontendContribution } ); } - - // TODO: should be handled by `Close` contribution. https://github.com/arduino/arduino-ide/issues/1016 - onWillStop(): OnWillStopAction { - return { - reason: 'temp-sketch', - action: () => { - return this.showTempSketchDialog(); - }, - }; - } - - private async showTempSketchDialog(): Promise { - const sketch = await this.sketchServiceClient.currentSketch(); - if (!CurrentSketch.isValid(sketch)) { - return true; - } - const isTemp = await this.sketchService.isTemp(sketch); - if (!isTemp) { - return true; - } - const messageBoxResult = await remote.dialog.showMessageBox( - remote.getCurrentWindow(), - { - message: nls.localize( - 'arduino/sketch/saveTempSketch', - 'Save your sketch to open it again later.' - ), - title: nls.localize( - 'theia/core/quitTitle', - 'Are you sure you want to quit?' - ), - type: 'question', - buttons: [ - Dialog.CANCEL, - nls.localizeByDefault('Save As...'), - nls.localizeByDefault("Don't Save"), - ], - } - ); - const result = messageBoxResult.response; - if (result === 2) { - return true; - } else if (result === 1) { - return !!(await this.commandRegistry.executeCommand( - SaveAsSketch.Commands.SAVE_AS_SKETCH.id, - { - execOnlyIfTemp: false, - openAfterMove: false, - wipeOriginal: true, - } - )); - } - return false; - } } diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts index 45597613c..b13ddfda3 100644 --- a/arduino-ide-extension/src/browser/contributions/close.ts +++ b/arduino-ide-extension/src/browser/contributions/close.ts @@ -1,9 +1,6 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; +import { injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; -import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; -import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; -import { FrontendApplication } from '@theia/core/lib/browser/frontend-application'; import { ArduinoMenus } from '../menu/arduino-menus'; import { SketchContribution, @@ -14,24 +11,19 @@ import { URI, } from './contribution'; import { nls } from '@theia/core/lib/common'; +import { Dialog } from '@theia/core/lib/browser/dialogs'; +import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { SaveAsSketch } from './save-as-sketch'; +import type { OnWillStopAction } from '@theia/core/lib/browser/frontend-application'; /** * Closes the `current` closeable editor, or any closeable current widget from the main area, or the current sketch window. */ @injectable() export class Close extends SketchContribution { - @inject(EditorManager) - protected override readonly editorManager: EditorManager; - - protected shell: ApplicationShell; - - override onStart(app: FrontendApplication): void { - this.shell = app.shell; - } - override registerCommands(registry: CommandRegistry): void { registry.registerCommand(Close.Commands.CLOSE, { - execute: () => remote.getCurrentWindow().close() + execute: () => remote.getCurrentWindow().close(), }); } @@ -50,6 +42,60 @@ export class Close extends SketchContribution { }); } + // `FrontendApplicationContribution#onWillStop` + onWillStop(): OnWillStopAction { + return { + reason: 'temp-sketch', + action: () => { + return this.showTempSketchDialog(); + }, + }; + } + + private async showTempSketchDialog(): Promise { + const sketch = await this.sketchServiceClient.currentSketch(); + if (!CurrentSketch.isValid(sketch)) { + return true; + } + const isTemp = await this.sketchService.isTemp(sketch); + if (!isTemp) { + return true; + } + const messageBoxResult = await remote.dialog.showMessageBox( + remote.getCurrentWindow(), + { + message: nls.localize( + 'arduino/sketch/saveTempSketch', + 'Save your sketch to open it again later.' + ), + title: nls.localize( + 'theia/core/quitTitle', + 'Are you sure you want to quit?' + ), + type: 'question', + buttons: [ + Dialog.CANCEL, + nls.localizeByDefault('Save As...'), + nls.localizeByDefault("Don't Save"), + ], + } + ); + const result = messageBoxResult.response; + if (result === 2) { + return true; + } else if (result === 1) { + return !!(await this.commandService.executeCommand( + SaveAsSketch.Commands.SAVE_AS_SKETCH.id, + { + execOnlyIfTemp: false, + openAfterMove: false, + wipeOriginal: true, + } + )); + } + return false; + } + /** * If the file was ever touched/modified. We get this based on the `version` of the monaco model. */ @@ -59,13 +105,17 @@ export class Close extends SketchContribution { const { editor } = editorWidget; if (editor instanceof MonacoEditor) { const versionId = editor.getControl().getModel()?.getVersionId(); - if (Number.isInteger(versionId) && versionId! > 1) { + if (this.isInteger(versionId) && versionId > 1) { return true; } } } return false; } + + private isInteger(arg: unknown): arg is number { + return Number.isInteger(arg); + } } export namespace Close { From ad6d99cbdcbd17096760751cfe40f1ccb1899d08 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Wed, 24 Aug 2022 16:36:54 +0200 Subject: [PATCH 02/13] No save dialog prompt if closing untouched sketch. Signed-off-by: Akos Kitta --- .../src/browser/contributions/close.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts index b13ddfda3..92026a38c 100644 --- a/arduino-ide-extension/src/browser/contributions/close.ts +++ b/arduino-ide-extension/src/browser/contributions/close.ts @@ -8,6 +8,7 @@ import { CommandRegistry, MenuModelRegistry, KeybindingRegistry, + Sketch, URI, } from './contribution'; import { nls } from '@theia/core/lib/common'; @@ -47,12 +48,12 @@ export class Close extends SketchContribution { return { reason: 'temp-sketch', action: () => { - return this.showTempSketchDialog(); + return this.showSaveTempSketchDialog(); }, }; } - private async showTempSketchDialog(): Promise { + private async showSaveTempSketchDialog(): Promise { const sketch = await this.sketchServiceClient.currentSketch(); if (!CurrentSketch.isValid(sketch)) { return true; @@ -61,6 +62,15 @@ export class Close extends SketchContribution { if (!isTemp) { return true; } + + // If non of the sketch files were ever touched, do not prompt the save dialog. (#1274) + const wereTouched = await Promise.all( + Sketch.uris(sketch).map((uri) => this.wasTouched(uri)) + ); + if (wereTouched.every((wasTouched) => !Boolean(wasTouched))) { + return true; + } + const messageBoxResult = await remote.dialog.showMessageBox( remote.getCurrentWindow(), { From ea69029fb57f713dfcb81d409d92f843bcad3f9c Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Wed, 24 Aug 2022 18:51:57 +0200 Subject: [PATCH 03/13] do not try to restore temp sketches. Signed-off-by: Akos Kitta --- .../browser/contributions/save-as-sketch.ts | 17 ++--- .../src/common/protocol/sketches-service.ts | 5 ++ .../arduino-electron-main-module.ts | 3 + .../theia/electron-main-application.ts | 20 +++++- .../src/node/arduino-ide-backend-module.ts | 3 + .../src/node/is-temp-sketch.ts | 47 +++++++++++++ .../src/node/sketches-service-impl.ts | 67 +++++++------------ .../workspace/default-workspace-server.ts | 50 ++++++++++---- 8 files changed, 148 insertions(+), 64 deletions(-) create mode 100644 arduino-ide-extension/src/node/is-temp-sketch.ts 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 36071db0e..e31b81358 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -104,16 +104,17 @@ export class SaveAsSketch extends SketchContribution { await this.saveOntoCopiedSketch(sketch.mainFileUri, sketch.uri, workspaceUri); } if (workspaceUri && openAfterMove) { + this.windowService.setSafeToShutDown(); if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) { - try { - await this.fileService.delete(new URI(sketch.uri), { - recursive: true, - }); - } catch { - /* NOOP: from time to time, it's not possible to wipe the old resource from the temp dir on Windows */ - } + // 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. } - this.windowService.setSafeToShutDown(); this.workspaceService.open(new URI(workspaceUri), { preserveWindow: true, }); diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index 0394e6e94..e34aa275f 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -95,6 +95,11 @@ export interface SketchesService { * Based on https://github.com/arduino/arduino-cli/blob/550179eefd2d2bca299d50a4af9e9bfcfebec649/arduino/builder/builder.go#L30-L38 */ getIdeTempFolderUri(sketch: Sketch): Promise; + + /** + * Notifies the backend to recursively delete the sketch folder with all its content. + */ + notifyDeleteSketch(sketch: Sketch): void; } export interface SketchRef { diff --git a/arduino-ide-extension/src/electron-main/arduino-electron-main-module.ts b/arduino-ide-extension/src/electron-main/arduino-electron-main-module.ts index ea25a4e09..b6767e57d 100644 --- a/arduino-ide-extension/src/electron-main/arduino-electron-main-module.ts +++ b/arduino-ide-extension/src/electron-main/arduino-electron-main-module.ts @@ -16,6 +16,7 @@ import { ElectronMainWindowServiceExt, electronMainWindowServiceExtPath, } from '../electron-common/electron-main-window-service-ext'; +import { IsTempSketch } from '../node/is-temp-sketch'; import { ElectronMainWindowServiceExtImpl } from './electron-main-window-service-ext-impl'; import { IDEUpdaterImpl } from './ide-updater/ide-updater-impl'; import { ElectronMainApplication } from './theia/electron-main-application'; @@ -62,4 +63,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ) ) .inSingletonScope(); + + bind(IsTempSketch).toSelf().inSingletonScope(); }); diff --git a/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts b/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts index 9c990f0a3..981c5ad68 100644 --- a/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts +++ b/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts @@ -1,4 +1,4 @@ -import { injectable } from '@theia/core/shared/inversify'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { app, BrowserWindow, @@ -22,6 +22,7 @@ import { Deferred } from '@theia/core/lib/common/promise-util'; import * as os from '@theia/core/lib/common/os'; import { Restart } from '@theia/core/lib/electron-common/messaging/electron-messages'; import { TheiaBrowserWindowOptions } from '@theia/core/lib/electron-main/theia-electron-window'; +import { IsTempSketch } from '../../node/is-temp-sketch'; app.commandLine.appendSwitch('disable-http-cache'); @@ -54,6 +55,8 @@ const APP_STARTED_WITH_CONTENT_TRACE = @injectable() export class ElectronMainApplication extends TheiaElectronMainApplication { + @inject(IsTempSketch) + private readonly isTempSketch: IsTempSketch; private startup = false; private _firstWindowId: number | undefined; private openFilePromise = new Deferred(); @@ -176,6 +179,12 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { ); for (const workspace of workspaces) { if (await this.isValidSketchPath(workspace.file)) { + if (this.isTempSketch.is(workspace.file)) { + console.info( + `Skipped opening sketch. The sketch was detected as temporary. Workspace path: ${workspace.file}.` + ); + continue; + } useDefault = false; await this.openSketch(workspace); } @@ -405,6 +414,15 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { const workspaceUri = URI.file(workspace); const bounds = window.getNormalBounds(); const now = Date.now(); + // Do not try to reopen the sketch if it was temp. + // Unfortunately, IDE2 has two different logic of restoring recent sketches: the Theia default `recentworkspace.json` and there is the `recent-sketches.json`. + const file = workspaceUri.fsPath; + if (this.isTempSketch.is(file)) { + console.info( + `Ignored marking workspace as a closed sketch. The sketch was detected as temporary. Workspace URI: ${workspaceUri.toString()}.` + ); + return; + } console.log( `Marking workspace as a closed sketch. Workspace URI: ${workspaceUri.toString()}. Date: ${now}.` ); 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 b21f5d301..b007b1bc3 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -109,6 +109,7 @@ import { SurveyNotificationService, SurveyNotificationServicePath, } from '../common/protocol/survey-service'; +import { IsTempSketch } from './is-temp-sketch'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(BackendApplication).toSelf().inSingletonScope(); @@ -419,4 +420,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ) ) .inSingletonScope(); + + bind(IsTempSketch).toSelf().inSingletonScope(); }); diff --git a/arduino-ide-extension/src/node/is-temp-sketch.ts b/arduino-ide-extension/src/node/is-temp-sketch.ts new file mode 100644 index 000000000..5c62716e9 --- /dev/null +++ b/arduino-ide-extension/src/node/is-temp-sketch.ts @@ -0,0 +1,47 @@ +import * as fs from 'fs'; +import * as tempDir from 'temp-dir'; +import { isWindows, isOSX } from '@theia/core/lib/common/os'; +import { injectable } from '@theia/core/shared/inversify'; +import { firstToLowerCase } from '../common/utils'; + +const Win32DriveRegex = /^[a-zA-Z]:\\/; +export const TempSketchPrefix = '.arduinoIDE-unsaved'; + +@injectable() +export class IsTempSketch { + // If on macOS, the `temp-dir` lib will make sure there is resolved realpath. + // If on Windows, the `C:\Users\KITTAA~1\AppData\Local\Temp` path will be resolved and normalized to `C:\Users\kittaakos\AppData\Local\Temp`. + // Note: VS Code URI normalizes the drive letter. `C:` will be converted into `c:`. + // https://github.com/Microsoft/vscode/issues/68325#issuecomment-462239992 + private readonly tempDirRealpath = isOSX + ? tempDir + : maybeNormalizeDrive(fs.realpathSync.native(tempDir)); + + is(sketchPath: string): boolean { + // Consider the following paths: + // macOS: + // - Temp folder: /var/folders/k3/d2fkvv1j16v3_rz93k7f74180000gn/T + // - Sketch folder: /private/var/folders/k3/d2fkvv1j16v3_rz93k7f74180000gn/T/arduino-ide2-A0337D47F86B24A51DF3DBCF2CC17925 + // Windows: + // - Temp folder: C:\Users\KITTAA~1\AppData\Local\Temp + // - Sketch folder: c:\Users\kittaakos\AppData\Local\Temp\.arduinoIDE-unsaved2022431-21824-116kfaz.9ljl\sketch_may31a + // Both sketches are valid and temp, but this function will give a false-negative result if we use the default `os.tmpdir()` logic. + const normalizedSketchPath = maybeNormalizeDrive(sketchPath); + const result = + normalizedSketchPath.startsWith(this.tempDirRealpath) && + normalizedSketchPath.includes(TempSketchPrefix); + console.debug(`isTempSketch: ${result}. Input was ${normalizedSketchPath}`); + return result; + } +} + +/** + * If on Windows, will change the input `C:\\path\\to\\somewhere` to `c:\\path\\to\\somewhere`. + * Otherwise, returns with the argument. + */ +export function maybeNormalizeDrive(fsPath: string): string { + if (isWindows && Win32DriveRegex.test(fsPath)) { + return firstToLowerCase(fsPath); + } + return fsPath; +} diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 055171ce1..bba68f941 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -2,14 +2,13 @@ import { injectable, inject } from '@theia/core/shared/inversify'; import * as fs from 'fs'; import * as os from 'os'; import * as temp from 'temp'; -import * as tempDir from 'temp-dir'; + import * as path from 'path'; import * as crypto from 'crypto'; import { ncp } from 'ncp'; import { promisify } from 'util'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node'; -import { isWindows, isOSX } from '@theia/core/lib/common/os'; import { ConfigServiceImpl } from './config-service-impl'; import { SketchesService, @@ -18,7 +17,6 @@ import { SketchContainer, SketchesError, } 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 { CoreClientAware } from './core-client-provider'; @@ -30,10 +28,11 @@ import { duration } from '../common/decorators'; import * as glob from 'glob'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { ServiceError } from './service-error'; - -const WIN32_DRIVE_REGEXP = /^[a-zA-Z]:\\/; - -const prefix = '.arduinoIDE-unsaved'; +import { + IsTempSketch, + maybeNormalizeDrive, + TempSketchPrefix, +} from './is-temp-sketch'; @injectable() export class SketchesServiceImpl @@ -42,22 +41,18 @@ export class SketchesServiceImpl { private sketchSuffixIndex = 1; private lastSketchBaseName: string; - // If on macOS, the `temp-dir` lib will make sure there is resolved realpath. - // If on Windows, the `C:\Users\KITTAA~1\AppData\Local\Temp` path will be resolved and normalized to `C:\Users\kittaakos\AppData\Local\Temp`. - // Note: VS Code URI normalizes the drive letter. `C:` will be converted into `c:`. - // https://github.com/Microsoft/vscode/issues/68325#issuecomment-462239992 - private tempDirRealpath = isOSX - ? tempDir - : maybeNormalizeDrive(fs.realpathSync.native(tempDir)); @inject(ConfigServiceImpl) - protected readonly configService: ConfigServiceImpl; + private readonly configService: ConfigServiceImpl; @inject(NotificationServiceServerImpl) - protected readonly notificationService: NotificationServiceServerImpl; + private readonly notificationService: NotificationServiceServerImpl; @inject(EnvVariablesServer) - protected readonly envVariableServer: EnvVariablesServer; + private readonly envVariableServer: EnvVariablesServer; + + @inject(IsTempSketch) + private readonly isTempSketch: IsTempSketch; async getSketches({ uri, @@ -424,7 +419,7 @@ void loop() { */ private createTempFolder(): Promise { return new Promise((resolve, reject) => { - temp.mkdir({ prefix }, (createError, dirPath) => { + temp.mkdir({ prefix: TempSketchPrefix }, (createError, dirPath) => { if (createError) { reject(createError); return; @@ -475,20 +470,7 @@ void loop() { } async isTemp(sketch: SketchRef): Promise { - // Consider the following paths: - // macOS: - // - Temp folder: /var/folders/k3/d2fkvv1j16v3_rz93k7f74180000gn/T - // - Sketch folder: /private/var/folders/k3/d2fkvv1j16v3_rz93k7f74180000gn/T/arduino-ide2-A0337D47F86B24A51DF3DBCF2CC17925 - // Windows: - // - Temp folder: C:\Users\KITTAA~1\AppData\Local\Temp - // - Sketch folder: c:\Users\kittaakos\AppData\Local\Temp\.arduinoIDE-unsaved2022431-21824-116kfaz.9ljl\sketch_may31a - // Both sketches are valid and temp, but this function will give a false-negative result if we use the default `os.tmpdir()` logic. - const sketchPath = maybeNormalizeDrive(FileUri.fsPath(sketch.uri)); - const tempPath = this.tempDirRealpath; // https://github.com/sindresorhus/temp-dir - const result = - sketchPath.indexOf(prefix) !== -1 && sketchPath.startsWith(tempPath); - console.log('isTemp?', result, sketch.uri); - return result; + return this.isTempSketch.is(FileUri.fsPath(sketch.uri)); } async copy( @@ -578,6 +560,17 @@ void loop() { const suffix = crypto.createHash('md5').update(sketchPath).digest('hex'); return path.join(os.tmpdir(), `arduino-ide2-${suffix}`); } + + notifyDeleteSketch(sketch: Sketch): void { + const sketchPath = FileUri.fsPath(sketch.uri); + fs.rm(sketchPath, { recursive: true, maxRetries: 5 }, (error) => { + if (error) { + console.error(`Failed to delete sketch at ${sketchPath}.`, error); + } else { + console.error(`Successfully delete sketch at ${sketchPath}.`); + } + }); + } } interface SketchWithDetails extends Sketch { @@ -618,16 +611,6 @@ function isNotFoundError(err: unknown): err is ServiceError { return ServiceError.is(err) && err.code === 5; // `NOT_FOUND` https://grpc.github.io/grpc/core/md_doc_statuscodes.html } -/** - * If on Windows, will change the input `C:\\path\\to\\somewhere` to `c:\\path\\to\\somewhere`. - */ -function maybeNormalizeDrive(input: string): string { - if (isWindows && WIN32_DRIVE_REGEXP.test(input)) { - return firstToLowerCase(input); - } - return input; -} - /* * When a new sketch is created, add a suffix to distinguish it * from other new sketches I created today. diff --git a/arduino-ide-extension/src/node/theia/workspace/default-workspace-server.ts b/arduino-ide-extension/src/node/theia/workspace/default-workspace-server.ts index 3481f7de1..89a5e0698 100644 --- a/arduino-ide-extension/src/node/theia/workspace/default-workspace-server.ts +++ b/arduino-ide-extension/src/node/theia/workspace/default-workspace-server.ts @@ -1,26 +1,16 @@ import { promises as fs, constants } from 'fs'; import { injectable, inject } from '@theia/core/shared/inversify'; -import { ILogger } from '@theia/core/lib/common/logger'; import { DefaultWorkspaceServer as TheiaDefaultWorkspaceServer } from '@theia/workspace/lib/node/default-workspace-server'; -import { ConfigService } from '../../../common/protocol/config-service'; import { SketchesService } from '../../../common/protocol'; import { FileUri } from '@theia/core/lib/node'; +import { IsTempSketch } from '../../is-temp-sketch'; @injectable() export class DefaultWorkspaceServer extends TheiaDefaultWorkspaceServer { - @inject(ConfigService) - protected readonly configService: ConfigService; - - @inject(ILogger) - protected readonly logger: ILogger; - @inject(SketchesService) private readonly sketchesService: SketchesService; - - override async onStart(): Promise { - // NOOP - // No need to remove untitled workspaces. IDE2 does not use workspaces. - } + @inject(IsTempSketch) + private readonly isTempSketch: IsTempSketch; override async getMostRecentlyUsedWorkspace(): Promise { const uri = await super.getMostRecentlyUsedWorkspace(); @@ -51,6 +41,35 @@ export class DefaultWorkspaceServer extends TheiaDefaultWorkspaceServer { return listUri; } + protected override async writeToUserHome( + data: RecentWorkspacePathsData + ): Promise { + return super.writeToUserHome(this.filterTempSketches(data)); + } + + protected override async readRecentWorkspacePathsFromUserHome(): Promise< + RecentWorkspacePathsData | undefined + > { + const data = await super.readRecentWorkspacePathsFromUserHome(); + return data ? this.filterTempSketches(data) : undefined; + } + + protected override async removeOldUntitledWorkspaces(): Promise { + // NOOP + // No need to remove untitled workspaces. IDE2 does not use workspaces. + } + + private filterTempSketches( + data: RecentWorkspacePathsData + ): RecentWorkspacePathsData { + const recentRoots = data.recentRoots.filter( + (uri) => !this.isTempSketch.is(FileUri.fsPath(uri)) + ); + return { + recentRoots, + }; + } + private async exists(uri: string): Promise { try { await fs.access(FileUri.fsPath(uri), constants.R_OK | constants.W_OK); @@ -60,3 +79,8 @@ export class DefaultWorkspaceServer extends TheiaDefaultWorkspaceServer { } } } + +// Remove after https://github.com/eclipse-theia/theia/pull/11603 +interface RecentWorkspacePathsData { + recentRoots: string[]; +} From efb54519096b7b7dbb581d7ce8a495ecf85addff Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 13:32:47 +0200 Subject: [PATCH 04/13] Update `currentSketch` when files change. Signed-off-by: Akos Kitta --- .../contributions/sketch-files-tracker.ts | 5 +- .../src/browser/theia/core/widget-manager.ts | 32 ++++++++++- .../protocol/sketches-service-client-impl.ts | 56 +++++++++++++++++-- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts b/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts index ad7704adb..fcd07cb67 100644 --- a/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts +++ b/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts @@ -30,10 +30,7 @@ export class SketchFilesTracker extends SketchContribution { override onReady(): void { this.sketchServiceClient.currentSketch().then(async (sketch) => { - if ( - CurrentSketch.isValid(sketch) && - !(await this.sketchService.isTemp(sketch)) - ) { + if (CurrentSketch.isValid(sketch)) { this.toDisposeOnStop.push(this.fileService.watch(new URI(sketch.uri))); this.toDisposeOnStop.push( this.fileService.onDidFilesChange(async (event) => { diff --git a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts index adc32860f..4e8e10131 100644 --- a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts +++ b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts @@ -1,11 +1,41 @@ import type { MaybePromise } from '@theia/core'; import type { Widget } from '@theia/core/lib/browser'; import { WidgetManager as TheiaWidgetManager } from '@theia/core/lib/browser/widget-manager'; -import { injectable } from '@theia/core/shared/inversify'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import { EditorWidget } from '@theia/editor/lib/browser'; import deepEqual = require('deep-equal'); +import { + CurrentSketch, + SketchesServiceClientImpl, +} from '../../../common/protocol/sketches-service-client-impl'; +import { Sketch } from '../../contributions/contribution'; @injectable() export class WidgetManager extends TheiaWidgetManager { + @inject(SketchesServiceClientImpl) + private readonly sketchesServiceClient: SketchesServiceClientImpl; + + @postConstruct() + protected init(): void { + this.sketchesServiceClient.onCurrentSketchDidChange((currentSketch) => { + if (CurrentSketch.isValid(currentSketch)) { + const sketchFileUris = new Set(Sketch.uris(currentSketch)); + for (const widget of this.widgets.values()) { + if (widget instanceof EditorWidget) { + const uri = widget.editor.uri.toString(); + if (sketchFileUris.has(uri)) { + widget.title.closable = false; + } + } + } + } + }); + } + /** * Customized to find any existing widget based on `options` deepEquals instead of string equals. * See https://github.com/eclipse-theia/theia/issues/11309. diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts index 7aea3367d..63141ca9f 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts @@ -10,7 +10,7 @@ import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; import { Sketch, SketchesService } from '../../common/protocol'; import { ConfigService } from './config-service'; -import { SketchContainer, SketchRef } from './sketches-service'; +import { SketchContainer, SketchesError, SketchRef } from './sketches-service'; import { ARDUINO_CLOUD_FOLDER, REMOTE_SKETCHBOOK_FOLDER, @@ -79,6 +79,7 @@ export class SketchesServiceClientImpl this.sketches.set(sketch.uri, sketch); } this.toDispose.push( + // Watch changes in the sketchbook to update `File` > `Sketchbook` menu items. this.fileService.watch(new URI(sketchDirUri), { recursive: true, excludes: [], @@ -87,6 +88,34 @@ export class SketchesServiceClientImpl this.toDispose.push( this.fileService.onDidFilesChange(async (event) => { for (const { type, resource } of event.changes) { + // The file change events have higher precedence in the current sketch over the sketchbook. + if ( + CurrentSketch.isValid(this._currentSketch) && + new URI(this._currentSketch.uri).isEqualOrParent(resource) + ) { + if (type === FileChangeType.UPDATED) { + return; + } + + let reloadedSketch: Sketch | undefined = undefined; + try { + reloadedSketch = await this.sketchService.loadSketch( + this._currentSketch.uri + ); + } catch (err) { + if (!SketchesError.NotFound.is(err)) { + throw err; + } + } + + if (!reloadedSketch) { + return; + } + + // TODO: check if current is the same as reloaded? + this.useCurrentSketch(reloadedSketch, true); + return; + } // We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file. if (sketchbookUri.isEqualOrParent(resource)) { if (Sketch.isSketchFile(resource)) { @@ -125,12 +154,31 @@ export class SketchesServiceClientImpl .reachedState('started_contributions') .then(async () => { const currentSketch = await this.loadCurrentSketch(); - this._currentSketch = currentSketch; - this.currentSketchDidChangeEmitter.fire(this._currentSketch); - this.currentSketchLoaded.resolve(this._currentSketch); + if (CurrentSketch.isValid(currentSketch)) { + this.toDispose.pushAll([ + // Watch the file changes of the current sketch + this.fileService.watch(new URI(currentSketch.uri), { + recursive: true, + excludes: [], + }), + ]); + } + this.useCurrentSketch(currentSketch); }); } + private useCurrentSketch( + currentSketch: CurrentSketch, + reassignPromise = false + ) { + this._currentSketch = currentSketch; + if (reassignPromise) { + this.currentSketchLoaded = new Deferred(); + } + this.currentSketchLoaded.resolve(this._currentSketch); + this.currentSketchDidChangeEmitter.fire(this._currentSketch); + } + onStop(): void { this.toDispose.dispose(); } From 5e668ff574bdcb79266e901e59bbc7baa1ee1087 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 15:35:54 +0200 Subject: [PATCH 05/13] Unified the sketch close and the app quit logic. Signed-off-by: Akos Kitta --- .../src/browser/contributions/close.ts | 125 ++++++++++++++---- .../browser/contributions/save-as-sketch.ts | 6 + .../browser/theia/core/application-shell.ts | 15 +-- .../core/common-frontend-contribution.ts | 6 + .../theia/core/electron-menu-module.ts | 25 ---- 5 files changed, 113 insertions(+), 64 deletions(-) diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts index 92026a38c..47278fb92 100644 --- a/arduino-ide-extension/src/browser/contributions/close.ts +++ b/arduino-ide-extension/src/browser/contributions/close.ts @@ -1,6 +1,13 @@ import { injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import type { MaybePromise } from '@theia/core/lib/common/types'; +import type { + FrontendApplication, + OnWillStopAction, +} from '@theia/core/lib/browser/frontend-application'; +import { nls } from '@theia/core/lib/common/nls'; +import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { ArduinoMenus } from '../menu/arduino-menus'; import { SketchContribution, @@ -11,17 +18,21 @@ import { Sketch, URI, } from './contribution'; -import { nls } from '@theia/core/lib/common'; import { Dialog } from '@theia/core/lib/browser/dialogs'; import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; import { SaveAsSketch } from './save-as-sketch'; -import type { OnWillStopAction } from '@theia/core/lib/browser/frontend-application'; /** * Closes the `current` closeable editor, or any closeable current widget from the main area, or the current sketch window. */ @injectable() export class Close extends SketchContribution { + private shell: ApplicationShell | undefined; + + override onStart(app: FrontendApplication): MaybePromise { + this.shell = app.shell; + } + override registerCommands(registry: CommandRegistry): void { registry.registerCommand(Close.Commands.CLOSE, { execute: () => remote.getCurrentWindow().close(), @@ -46,20 +57,41 @@ export class Close extends SketchContribution { // `FrontendApplicationContribution#onWillStop` onWillStop(): OnWillStopAction { return { - reason: 'temp-sketch', + reason: 'save-sketch', action: () => { - return this.showSaveTempSketchDialog(); + return this.showSaveSketchDialog(); }, }; } - private async showSaveTempSketchDialog(): Promise { - const sketch = await this.sketchServiceClient.currentSketch(); - if (!CurrentSketch.isValid(sketch)) { - return true; - } - const isTemp = await this.sketchService.isTemp(sketch); - if (!isTemp) { + /** + * If returns with `true`, IDE2 will close. Otherwise, it won't. + */ + private async showSaveSketchDialog(): Promise { + const sketch = await this.isCurrentSketchTemp(); + if (!sketch) { + // Normal close workflow: if there are dirty editors prompt the user. + if (!this.shell) { + console.error( + `Could not get the application shell. Something went wrong.` + ); + return true; + } + if (this.shell.canSaveAll()) { + const prompt = await this.prompt(false); + switch (prompt) { + case Prompt.DoNotSave: + return true; + case Prompt.Cancel: + return false; + case Prompt.Save: { + await this.shell.saveAll(); + return true; + } + default: + throw new Error(`Unexpected prompt: ${prompt}`); + } + } return true; } @@ -71,11 +103,36 @@ export class Close extends SketchContribution { return true; } - const messageBoxResult = await remote.dialog.showMessageBox( + const prompt = await this.prompt(true); + switch (prompt) { + case Prompt.DoNotSave: + return true; + case Prompt.Cancel: + return false; + case Prompt.Save: { + // If `save as` was canceled by user, the result will be `undefined`, otherwise the new URI. + const result = await this.commandService.executeCommand( + SaveAsSketch.Commands.SAVE_AS_SKETCH.id, + { + execOnlyIfTemp: false, + openAfterMove: false, + wipeOriginal: true, + markAsRecentlyOpened: true, + } + ); + return !!result; + } + default: + throw new Error(`Unexpected prompt: ${prompt}`); + } + } + + private async prompt(isTemp: boolean): Promise { + const { response } = await remote.dialog.showMessageBox( remote.getCurrentWindow(), { message: nls.localize( - 'arduino/sketch/saveTempSketch', + 'arduino/sketch/saveSketch', 'Save your sketch to open it again later.' ), title: nls.localize( @@ -84,24 +141,32 @@ export class Close extends SketchContribution { ), type: 'question', buttons: [ - Dialog.CANCEL, - nls.localizeByDefault('Save As...'), nls.localizeByDefault("Don't Save"), + Dialog.CANCEL, + nls.localizeByDefault(isTemp ? 'Save As...' : 'Save'), ], + defaultId: 2, // `Save`/`Save As...` button index is the default. } ); - const result = messageBoxResult.response; - if (result === 2) { - return true; - } else if (result === 1) { - return !!(await this.commandService.executeCommand( - SaveAsSketch.Commands.SAVE_AS_SKETCH.id, - { - execOnlyIfTemp: false, - openAfterMove: false, - wipeOriginal: true, - } - )); + switch (response) { + case 0: + return Prompt.DoNotSave; + case 1: + return Prompt.Cancel; + case 2: + return Prompt.Save; + default: + throw new Error(`Unexpected response: ${response}`); + } + } + + private async isCurrentSketchTemp(): Promise { + const currentSketch = await this.sketchServiceClient.currentSketch(); + if (CurrentSketch.isValid(currentSketch)) { + const isTemp = await this.sketchService.isTemp(currentSketch); + if (isTemp) { + return currentSketch; + } } return false; } @@ -128,6 +193,12 @@ export class Close extends SketchContribution { } } +enum Prompt { + Save, + DoNotSave, + Cancel, +} + export namespace Close { export namespace Commands { export const CLOSE: 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 e31b81358..2954a6038 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -57,6 +57,7 @@ export class SaveAsSketch extends SketchContribution { execOnlyIfTemp, openAfterMove, wipeOriginal, + markAsRecentlyOpened, }: SaveAsSketch.Options = SaveAsSketch.Options.DEFAULT ): Promise { const sketch = await this.sketchServiceClient.currentSketch(); @@ -102,6 +103,9 @@ export class SaveAsSketch extends SketchContribution { }); if (workspaceUri) { await this.saveOntoCopiedSketch(sketch.mainFileUri, sketch.uri, workspaceUri); + if (markAsRecentlyOpened) { + this.sketchService.markAsRecentlyOpened(workspaceUri); + } } if (workspaceUri && openAfterMove) { this.windowService.setSafeToShutDown(); @@ -171,12 +175,14 @@ export namespace SaveAsSketch { * Ignored if `openAfterMove` is `false`. */ readonly wipeOriginal?: boolean; + readonly markAsRecentlyOpened?: boolean; } export namespace Options { export const DEFAULT: Options = { execOnlyIfTemp: false, openAfterMove: true, wipeOriginal: false, + markAsRecentlyOpened: false, }; } } diff --git a/arduino-ide-extension/src/browser/theia/core/application-shell.ts b/arduino-ide-extension/src/browser/theia/core/application-shell.ts index f0610a569..d3d7cc2f3 100644 --- a/arduino-ide-extension/src/browser/theia/core/application-shell.ts +++ b/arduino-ide-extension/src/browser/theia/core/application-shell.ts @@ -1,6 +1,5 @@ import { injectable, inject } from '@theia/core/shared/inversify'; import { EditorWidget } from '@theia/editor/lib/browser'; -import { CommandService } from '@theia/core/lib/common/command'; import { MessageService } from '@theia/core/lib/common/message-service'; import { OutputWidget } from '@theia/output/lib/browser/output-widget'; import { @@ -15,9 +14,9 @@ import { TabBar, Widget, SHELL_TABBAR_CONTEXT_MENU, + SaveOptions, } from '@theia/core/lib/browser'; import { Sketch } from '../../../common/protocol'; -import { SaveAsSketch } from '../../contributions/save-as-sketch'; import { CurrentSketch, SketchesServiceClientImpl, @@ -28,9 +27,6 @@ import { ToolbarAwareTabBar } from './tab-bars'; @injectable() export class ApplicationShell extends TheiaApplicationShell { - @inject(CommandService) - private readonly commandService: CommandService; - @inject(MessageService) private readonly messageService: MessageService; @@ -106,7 +102,7 @@ export class ApplicationShell extends TheiaApplicationShell { return topPanel; } - override async saveAll(): Promise { + override async saveAll(options?: SaveOptions): Promise { if ( this.connectionStatusService.currentStatus === ConnectionStatus.OFFLINE ) { @@ -118,12 +114,7 @@ export class ApplicationShell extends TheiaApplicationShell { ); return; // Theia does not reject on failed save: https://github.com/eclipse-theia/theia/pull/8803 } - await super.saveAll(); - const options = { execOnlyIfTemp: true, openAfterMove: true }; - await this.commandService.executeCommand( - SaveAsSketch.Commands.SAVE_AS_SKETCH.id, - options - ); + return super.saveAll(options); } } 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 c92d4972f..0785cad03 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 @@ -5,6 +5,7 @@ import { CommonCommands, } from '@theia/core/lib/browser/common-frontend-contribution'; import { CommandRegistry } from '@theia/core/lib/common/command'; +import type { OnWillStopAction } from '@theia/core/lib/browser/frontend-application'; @injectable() export class CommonFrontendContribution extends TheiaCommonFrontendContribution { @@ -48,4 +49,9 @@ export class CommonFrontendContribution extends TheiaCommonFrontendContribution registry.unregisterMenuAction(command); } } + + override onWillStop(): OnWillStopAction | undefined { + // This is NOOP here. All window close and app quit requests are handled in the `Close` contribution. + return undefined; + } } diff --git a/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts index 101e44b56..7e827ff4a 100644 --- a/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts @@ -4,31 +4,6 @@ import { ElectronMenuContribution as TheiaElectronMenuContribution } from '@thei import { MainMenuManager } from '../../../common/main-menu-manager'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ElectronMenuContribution } from './electron-menu-contribution'; -import { nls } from '@theia/core/lib/common/nls'; - -import * as remote from '@theia/core/electron-shared/@electron/remote'; -import * as dialogs from '@theia/core/lib/browser/dialogs'; - -Object.assign(dialogs, { - confirmExit: async () => { - const messageBoxResult = await remote.dialog.showMessageBox( - remote.getCurrentWindow(), - { - message: nls.localize( - 'theia/core/quitMessage', - 'Any unsaved changes will not be saved.' - ), - title: nls.localize( - 'theia/core/quitTitle', - 'Are you sure you want to quit?' - ), - type: 'question', - buttons: [dialogs.Dialog.CANCEL, dialogs.Dialog.YES], - } - ); - return messageBoxResult.response === 1; - }, -}); export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ElectronMenuContribution).toSelf().inSingletonScope(); From 0608fe46106581a89012c6ed967259933d3ce29a Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 15:40:19 +0200 Subject: [PATCH 06/13] Restored logic to close current closable widget and then the window. Signed-off-by: Akos Kitta --- .../src/browser/contributions/close.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts index 47278fb92..033d02edd 100644 --- a/arduino-ide-extension/src/browser/contributions/close.ts +++ b/arduino-ide-extension/src/browser/contributions/close.ts @@ -1,4 +1,5 @@ import { injectable } from '@theia/core/shared/inversify'; +import { toArray } from '@theia/core/shared/@phosphor/algorithm'; import * as remote from '@theia/core/electron-shared/@electron/remote'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import type { MaybePromise } from '@theia/core/lib/common/types'; @@ -35,7 +36,28 @@ export class Close extends SketchContribution { override registerCommands(registry: CommandRegistry): void { registry.registerCommand(Close.Commands.CLOSE, { - execute: () => remote.getCurrentWindow().close(), + execute: () => { + // Close current editor if closeable. + const { currentEditor } = this.editorManager; + if (currentEditor && currentEditor.title.closable) { + currentEditor.close(); + return; + } + + if (this.shell) { + // Close current widget from the main area if possible. + const { currentWidget } = this.shell; + if (currentWidget) { + const currentWidgetInMain = toArray( + this.shell.mainPanel.widgets() + ).find((widget) => widget === currentWidget); + if (currentWidgetInMain && currentWidgetInMain.title.closable) { + return currentWidgetInMain.close(); + } + } + } + return remote.getCurrentWindow().close(); + }, }); } From be7816247ff9fe524e7f6012cfc41972df5b24cf Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 19:38:58 +0200 Subject: [PATCH 07/13] Updated translations. Signed-off-by: Akos Kitta --- i18n/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 014067cc6..65b02b5c6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -324,8 +324,8 @@ "openRecent": "Open Recent", "openSketchInNewWindow": "Open Sketch in New Window", "saveFolderAs": "Save sketch folder as...", + "saveSketch": "Save your sketch to open it again later.", "saveSketchAs": "Save sketch folder as...", - "saveTempSketch": "Save your sketch to open it again later.", "showFolder": "Show Sketch Folder", "sketch": "Sketch", "sketchbook": "Sketchbook", @@ -362,7 +362,6 @@ "couldNotSave": "Could not save the sketch. Please copy your unsaved work into your favorite text editor, and restart the IDE.", "daemonOffline": "CLI Daemon Offline", "offline": "Offline", - "quitMessage": "Any unsaved changes will not be saved.", "quitTitle": "Are you sure you want to quit?" }, "debug": { From 20020dcfc398dccbabee35212d4ff0701c7d7b21 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 19:40:55 +0200 Subject: [PATCH 08/13] Moved uncloseable widget tracking to manager. Signed-off-by: Akos Kitta --- .../browser/theia/core/application-shell.ts | 55 +++---------------- .../src/browser/theia/core/widget-manager.ts | 54 ++++++++++++++---- 2 files changed, 51 insertions(+), 58 deletions(-) diff --git a/arduino-ide-extension/src/browser/theia/core/application-shell.ts b/arduino-ide-extension/src/browser/theia/core/application-shell.ts index d3d7cc2f3..9f0ac6b74 100644 --- a/arduino-ide-extension/src/browser/theia/core/application-shell.ts +++ b/arduino-ide-extension/src/browser/theia/core/application-shell.ts @@ -1,28 +1,20 @@ -import { injectable, inject } from '@theia/core/shared/inversify'; -import { EditorWidget } from '@theia/editor/lib/browser'; -import { MessageService } from '@theia/core/lib/common/message-service'; -import { OutputWidget } from '@theia/output/lib/browser/output-widget'; -import { - ConnectionStatusService, - ConnectionStatus, -} from '@theia/core/lib/browser/connection-status-service'; import { ApplicationShell as TheiaApplicationShell, DockPanel, DockPanelRenderer as TheiaDockPanelRenderer, Panel, + SaveOptions, + SHELL_TABBAR_CONTEXT_MENU, TabBar, Widget, - SHELL_TABBAR_CONTEXT_MENU, - SaveOptions, } from '@theia/core/lib/browser'; -import { Sketch } from '../../../common/protocol'; import { - CurrentSketch, - SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; -import { nls } from '@theia/core/lib/common'; -import URI from '@theia/core/lib/common/uri'; + ConnectionStatus, + ConnectionStatusService, +} from '@theia/core/lib/browser/connection-status-service'; +import { nls } from '@theia/core/lib/common/nls'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { ToolbarAwareTabBar } from './tab-bars'; @injectable() @@ -30,40 +22,9 @@ export class ApplicationShell extends TheiaApplicationShell { @inject(MessageService) private readonly messageService: MessageService; - @inject(SketchesServiceClientImpl) - private readonly sketchesServiceClient: SketchesServiceClientImpl; - @inject(ConnectionStatusService) private readonly connectionStatusService: ConnectionStatusService; - protected override track(widget: Widget): void { - super.track(widget); - if (widget instanceof OutputWidget) { - widget.title.closable = false; // TODO: https://arduino.slack.com/archives/C01698YT7S4/p1598011990133700 - } - if (widget instanceof EditorWidget) { - // Make the editor un-closeable asynchronously. - this.sketchesServiceClient.currentSketch().then((sketch) => { - if (CurrentSketch.isValid(sketch)) { - if (!this.isSketchFile(widget.editor.uri, sketch.uri)) { - return; - } - if (Sketch.isInSketch(widget.editor.uri, sketch)) { - widget.title.closable = false; - } - } - }); - } - } - - private isSketchFile(uri: URI, sketchUriString: string): boolean { - const sketchUri = new URI(sketchUriString); - if (uri.parent.isEqual(sketchUri)) { - return true; - } - return false; - } - override async addWidget( widget: Widget, options: Readonly = {} diff --git a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts index 4e8e10131..87d0ae742 100644 --- a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts +++ b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts @@ -7,6 +7,7 @@ import { postConstruct, } from '@theia/core/shared/inversify'; import { EditorWidget } from '@theia/editor/lib/browser'; +import { OutputWidget } from '@theia/output/lib/browser/output-widget'; import deepEqual = require('deep-equal'); import { CurrentSketch, @@ -21,19 +22,50 @@ export class WidgetManager extends TheiaWidgetManager { @postConstruct() protected init(): void { - this.sketchesServiceClient.onCurrentSketchDidChange((currentSketch) => { - if (CurrentSketch.isValid(currentSketch)) { - const sketchFileUris = new Set(Sketch.uris(currentSketch)); - for (const widget of this.widgets.values()) { - if (widget instanceof EditorWidget) { - const uri = widget.editor.uri.toString(); - if (sketchFileUris.has(uri)) { - widget.title.closable = false; - } - } + this.sketchesServiceClient.onCurrentSketchDidChange((sketch) => + this.maybeSetWidgetUncloseable( + sketch, + ...Array.from(this.widgets.values()) + ) + ); + } + + override getOrCreateWidget( + factoryId: string, + options?: unknown + ): Promise { + const unresolvedWidget = super.getOrCreateWidget(factoryId, options); + unresolvedWidget.then(async (widget) => { + const sketch = await this.sketchesServiceClient.currentSketch(); + this.maybeSetWidgetUncloseable(sketch, widget); + }); + return unresolvedWidget; + } + + private maybeSetWidgetUncloseable( + sketch: CurrentSketch, + ...widgets: Widget[] + ): void { + const sketchFileUris = + CurrentSketch.isValid(sketch) && new Set(Sketch.uris(sketch)); + for (const widget of widgets) { + if (widget instanceof OutputWidget) { + this.setWidgetUncloseable(widget); // TODO: https://arduino.slack.com/archives/C01698YT7S4/p1598011990133700 + } else if (widget instanceof EditorWidget) { + // Make the editor un-closeable asynchronously. + const uri = widget.editor.uri.toString(); + if (!!sketchFileUris && sketchFileUris.has(uri)) { + this.setWidgetUncloseable(widget); } } - }); + } + } + + private setWidgetUncloseable(widget: Widget): void { + const { title } = widget; + if (title.closable) { + title.closable = false; + } } /** From 1bc202dc22b28ed16c9dd998d55bf85214236e4a Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 19:56:28 +0200 Subject: [PATCH 09/13] Removed `File` > `Close Editor`. Closes arduino/arduino-ide#660 Signed-off-by: Akos Kitta --- .../src/browser/arduino-ide-frontend-module.ts | 6 ++++++ .../src/browser/theia/editor/editor-file.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 arduino-ide-extension/src/browser/theia/editor/editor-file.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 622819e67..5c6c2bdea 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -321,6 +321,8 @@ import { MonacoThemeServiceIsReady } from './utils/window'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { StatusBarImpl } from './theia/core/status-bar'; import { StatusBarImpl as TheiaStatusBarImpl } from '@theia/core/lib/browser'; +import { EditorMenuContribution } from './theia/editor/editor-file'; +import { EditorMenuContribution as TheiaEditorMenuContribution } from '@theia/editor/lib/browser/editor-menu'; const registerArduinoThemes = () => { const themes: MonacoThemeJson[] = [ @@ -640,6 +642,10 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(WindowContribution).toSelf().inSingletonScope(); rebind(TheiaWindowContribution).toService(WindowContribution); + // To remove `File` > `Close Editor`. + bind(EditorMenuContribution).toSelf().inSingletonScope(); + rebind(TheiaEditorMenuContribution).toService(EditorMenuContribution); + bind(ArduinoDaemon) .toDynamicValue((context) => WebSocketConnectionProvider.createProxy( diff --git a/arduino-ide-extension/src/browser/theia/editor/editor-file.ts b/arduino-ide-extension/src/browser/theia/editor/editor-file.ts new file mode 100644 index 000000000..721a7783d --- /dev/null +++ b/arduino-ide-extension/src/browser/theia/editor/editor-file.ts @@ -0,0 +1,12 @@ +import { MenuModelRegistry } from '@theia/core'; +import { CommonCommands } from '@theia/core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { EditorMenuContribution as TheiaEditorMenuContribution } from '@theia/editor/lib/browser/editor-menu'; + +@injectable() +export class EditorMenuContribution extends TheiaEditorMenuContribution { + override registerMenus(registry: MenuModelRegistry): void { + super.registerMenus(registry); + registry.unregisterMenuAction(CommonCommands.CLOSE_MAIN_TAB.id); + } +} From 80cbb5cd2b6442fa0ac76ab156e4d2a494cd834f Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 25 Aug 2022 20:48:19 +0200 Subject: [PATCH 10/13] removed space from discovery json log. Signed-off-by: Akos Kitta --- arduino-ide-extension/src/node/board-discovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arduino-ide-extension/src/node/board-discovery.ts b/arduino-ide-extension/src/node/board-discovery.ts index 4f4bf40ee..84ac101b6 100644 --- a/arduino-ide-extension/src/node/board-discovery.ts +++ b/arduino-ide-extension/src/node/board-discovery.ts @@ -219,7 +219,7 @@ export class BoardDiscovery } else { throw new Error(`Unhandled object type: ${arg}`); } - return JSON.stringify(object, null, 2); // TODO: remove `space`? + return JSON.stringify(object); } async start(): Promise { From bd49329e52b888b6e22aed479fbffe586767aba5 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Fri, 26 Aug 2022 09:02:48 +0200 Subject: [PATCH 11/13] Fixed dirty indicator of uncloseable widgets. Closes #1034. Signed-off-by: Akos Kitta --- arduino-ide-extension/src/browser/style/editor.css | 5 ++--- .../src/browser/theia/core/widget-manager.ts | 5 +++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/arduino-ide-extension/src/browser/style/editor.css b/arduino-ide-extension/src/browser/style/editor.css index 19bb6f5d8..81c3a3fdd 100644 --- a/arduino-ide-extension/src/browser/style/editor.css +++ b/arduino-ide-extension/src/browser/style/editor.css @@ -1,8 +1,7 @@ /* Show the dirty indicator on unclosable widgets. On hover, it should still show the dot instead of the X. */ /* https://github.com/arduino/arduino-pro-ide/issues/380 */ -.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable.theia-mod-dirty > .p-TabBar-tabCloseIcon:hover { - background-size: 13px; - background-image: var(--theia-icon-circle); +.p-TabBar.theia-app-centers .p-TabBar-tab.p-mod-closable.a-mod-uncloseable.theia-mod-dirty > .p-TabBar-tabCloseIcon:before { + content: "\ea71"; } .monaco-list-row.show-file-icons.focused { diff --git a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts index 87d0ae742..95eb1947b 100644 --- a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts +++ b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts @@ -66,6 +66,11 @@ export class WidgetManager extends TheiaWidgetManager { if (title.closable) { title.closable = false; } + // Show the dirty indicator on uncloseable widgets when hovering over the title. Instead of showing the `X` for close. + const uncloseableClass = 'a-mod-uncloseable'; + if (!title.className.includes(uncloseableClass)) { + title.className += title.className + ` ${uncloseableClass}`; + } } /** From 6acffdcb83893f640152686ed81f0ed1fb9c2eb6 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Fri, 26 Aug 2022 12:14:49 +0200 Subject: [PATCH 12/13] Fixed sketch content changes when renaming a file. Signed-off-by: Akos Kitta --- .../protocol/sketches-service-client-impl.ts | 17 ++++- .../src/common/protocol/sketches-service.ts | 68 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts index 63141ca9f..34da46bfd 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts @@ -93,7 +93,17 @@ export class SketchesServiceClientImpl CurrentSketch.isValid(this._currentSketch) && new URI(this._currentSketch.uri).isEqualOrParent(resource) ) { - if (type === FileChangeType.UPDATED) { + // https://github.com/arduino/arduino-ide/pull/1351#pullrequestreview-1086666656 + // On a sketch file rename, the FS watcher will contain two changes: + // - Deletion of the original file, + // - Update of the new file, + // Hence, `UPDATE` events must be processed but only and if only there is a `DELETED` change in the same event. + // Otherwise, IDE2 would ask CLI to reload the sketch content on every save event in IDE2. + if ( + type === FileChangeType.UPDATED && + event.changes.length === 1 + ) { + // If the event contains only one `UPDATE` change, it cannot be a rename. return; } @@ -112,8 +122,9 @@ export class SketchesServiceClientImpl return; } - // TODO: check if current is the same as reloaded? - this.useCurrentSketch(reloadedSketch, true); + if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) { + this.useCurrentSketch(reloadedSketch, true); + } return; } // We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file. diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index e34aa275f..719ecaacd 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -162,6 +162,74 @@ export namespace Sketch { const { mainFileUri, otherSketchFileUris, additionalFileUris } = sketch; return [mainFileUri, ...otherSketchFileUris, ...additionalFileUris]; } + const primitiveProps: Array = ['name', 'uri', 'mainFileUri']; + const arrayProps: Array = [ + 'additionalFileUris', + 'otherSketchFileUris', + 'rootFolderFileUris', + ]; + export function sameAs(left: Sketch, right: Sketch): boolean { + for (const prop of primitiveProps) { + const leftValue = left[prop]; + const rightValue = right[prop]; + assertIsNotArray(leftValue, prop, left); + assertIsNotArray(rightValue, prop, right); + if (leftValue !== rightValue) { + return false; + } + } + for (const prop of arrayProps) { + const leftValue = left[prop]; + const rightValue = right[prop]; + assertIsArray(leftValue, prop, left); + assertIsArray(rightValue, prop, right); + if (leftValue.length !== rightValue.length) { + return false; + } + } + for (const prop of arrayProps) { + const leftValue = left[prop]; + const rightValue = right[prop]; + assertIsArray(leftValue, prop, left); + assertIsArray(rightValue, prop, right); + if ( + toSortedString(leftValue as string[]) !== + toSortedString(rightValue as string[]) + ) { + return false; + } + } + return true; + } + function toSortedString(array: string[]): string { + return array.slice().sort().join(','); + } + function assertIsNotArray( + toTest: unknown, + prop: keyof Sketch, + object: Sketch + ): void { + if (Array.isArray(toTest)) { + throw new Error( + `Expected a non-array type. Got: ${toTest}. Property was: ${prop}. Object was: ${JSON.stringify( + object + )}` + ); + } + } + function assertIsArray( + toTest: unknown, + prop: keyof Sketch, + object: Sketch + ): void { + if (!Array.isArray(toTest)) { + throw new Error( + `Expected an array type. Got: ${toTest}. Property was: ${prop}. Object was: ${JSON.stringify( + object + )}` + ); + } + } } export interface SketchContainer { From 6a924da0d1dbf559f1a71822b6025f0da63b8821 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Fri, 26 Aug 2022 12:16:44 +0200 Subject: [PATCH 13/13] Can close non-root sketch file editors. Signed-off-by: Akos Kitta --- .../src/browser/theia/core/widget-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts index 95eb1947b..2e98c2bfc 100644 --- a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts +++ b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts @@ -13,7 +13,6 @@ import { CurrentSketch, SketchesServiceClientImpl, } from '../../../common/protocol/sketches-service-client-impl'; -import { Sketch } from '../../contributions/contribution'; @injectable() export class WidgetManager extends TheiaWidgetManager { @@ -47,7 +46,8 @@ export class WidgetManager extends TheiaWidgetManager { ...widgets: Widget[] ): void { const sketchFileUris = - CurrentSketch.isValid(sketch) && new Set(Sketch.uris(sketch)); + CurrentSketch.isValid(sketch) && + new Set([sketch.mainFileUri, ...sketch.rootFolderFileUris]); for (const widget of widgets) { if (widget instanceof OutputWidget) { this.setWidgetUncloseable(widget); // TODO: https://arduino.slack.com/archives/C01698YT7S4/p1598011990133700