From 6b9e3b650aeacf8c17d9def66a42896cc1ca4dcc Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Mon, 17 Oct 2022 18:28:33 +0200 Subject: [PATCH] fix: Prompt sketch move when opening an invalid outside from IDE2 Log IDE2 version on start. Closes #964 Closes #1484 Co-authored-by: Alberto Iannaccone Co-authored-by: Akos Kitta Signed-off-by: Akos Kitta --- arduino-ide-extension/package.json | 1 + .../src/browser/contributions/contribution.ts | 2 +- .../contributions/open-sketch-files.ts | 113 ++++++++++++- .../src/browser/contributions/open-sketch.ts | 102 ++++++----- .../theia/workspace/workspace-service.ts | 31 ++++ .../src/common/protocol/sketches-service.ts | 10 ++ .../theia/electron-main-application.ts | 159 ++++++++++++++---- .../src/node/arduino-ide-backend-module.ts | 4 +- .../src/node/sketches-service-impl.ts | 85 +++++++++- yarn.lock | 5 + 10 files changed, 422 insertions(+), 90 deletions(-) diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 3cac99751..727c77e08 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -45,6 +45,7 @@ "@types/deepmerge": "^2.2.0", "@types/glob": "^7.2.0", "@types/google-protobuf": "^3.7.2", + "@types/is-valid-path": "^0.1.0", "@types/js-yaml": "^3.12.2", "@types/keytar": "^4.4.0", "@types/lodash.debounce": "^4.0.6", diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts index 920e4dfd9..ba57a8587 100644 --- a/arduino-ide-extension/src/browser/contributions/contribution.ts +++ b/arduino-ide-extension/src/browser/contributions/contribution.ts @@ -12,7 +12,6 @@ import { MaybePromise } from '@theia/core/lib/common/types'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; import { MessageService } from '@theia/core/lib/common/message-service'; -import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { open, OpenerService } from '@theia/core/lib/browser/opener-service'; import { @@ -61,6 +60,7 @@ import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { BoardsDataStore } from '../boards/boards-data-store'; import { NotificationManager } from '../theia/messages/notifications-manager'; import { MessageType } from '@theia/core/lib/common/message-service-protocol'; +import { WorkspaceService } from '../theia/workspace/workspace-service'; export { Command, diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts b/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts index 63cea8ca4..858719c41 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts @@ -1,8 +1,8 @@ import { nls } from '@theia/core/lib/common/nls'; -import { injectable } from '@theia/core/shared/inversify'; +import { inject, injectable } from '@theia/core/shared/inversify'; import type { EditorOpenerOptions } from '@theia/editor/lib/browser/editor-manager'; import { Later } from '../../common/nls'; -import { SketchesError } from '../../common/protocol'; +import { Sketch, SketchesError } from '../../common/protocol'; import { Command, CommandRegistry, @@ -10,9 +10,19 @@ import { URI, } from './contribution'; import { SaveAsSketch } from './save-as-sketch'; +import { promptMoveSketch } from './open-sketch'; +import { ApplicationError } from '@theia/core/lib/common/application-error'; +import { Deferred, wait } from '@theia/core/lib/common/promise-util'; +import { EditorWidget } from '@theia/editor/lib/browser/editor-widget'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import { ContextKeyService as VSCodeContextKeyService } from '@theia/monaco-editor-core/esm/vs/platform/contextkey/browser/contextKeyService'; @injectable() export class OpenSketchFiles extends SketchContribution { + @inject(VSCodeContextKeyService) + private readonly contextKeyService: VSCodeContextKeyService; + override registerCommands(registry: CommandRegistry): void { registry.registerCommand(OpenSketchFiles.Commands.OPEN_SKETCH_FILES, { execute: (uri: URI) => this.openSketchFiles(uri), @@ -55,9 +65,25 @@ export class OpenSketchFiles extends SketchContribution { } }); } + const { workspaceError } = this.workspaceService; + // This happens when the IDE2 has been started (from either a terminal or clicking on an `ino` file) with a /path/to/invalid/sketch. (#964) + if (SketchesError.InvalidName.is(workspaceError)) { + await this.promptMove(workspaceError); + } } catch (err) { + // This happens when the user gracefully closed IDE2, all went well + // but the main sketch file was renamed outside of IDE2 and when the user restarts the IDE2 + // the workspace path still exists, but the sketch path is not valid anymore. (#964) + if (SketchesError.InvalidName.is(err)) { + const movedSketch = await this.promptMove(err); + if (!movedSketch) { + // If user did not accept the move, or move was not possible, force reload with a fallback. + return this.openFallbackSketch(); + } + } + if (SketchesError.NotFound.is(err)) { - this.openFallbackSketch(); + return this.openFallbackSketch(); } else { console.error(err); const message = @@ -71,6 +97,31 @@ export class OpenSketchFiles extends SketchContribution { } } + private async promptMove( + err: ApplicationError< + number, + { + invalidMainSketchUri: string; + } + > + ): Promise { + const { invalidMainSketchUri } = err.data; + requestAnimationFrame(() => this.messageService.error(err.message)); + await wait(10); // let IDE2 toast the error message. + const movedSketch = await promptMoveSketch(invalidMainSketchUri, { + fileService: this.fileService, + sketchService: this.sketchService, + labelProvider: this.labelProvider, + }); + if (movedSketch) { + this.workspaceService.open(new URI(movedSketch.uri), { + preserveWindow: true, + }); + return movedSketch; + } + return undefined; + } + private async openFallbackSketch(): Promise { const sketch = await this.sketchService.createNewSketch(); this.workspaceService.open(new URI(sketch.uri), { preserveWindow: true }); @@ -84,8 +135,48 @@ export class OpenSketchFiles extends SketchContribution { const widget = this.editorManager.all.find( (widget) => widget.editor.uri.toString() === uri ); + const disposables = new DisposableCollection(); if (!widget || forceOpen) { - return this.editorManager.open( + const deferred = new Deferred(); + disposables.push( + this.editorManager.onCreated((editor) => { + if (editor.editor.uri.toString() === uri) { + if (editor.isVisible) { + disposables.dispose(); + deferred.resolve(editor); + } else { + // In Theia, the promise resolves after opening the editor, but the editor is neither attached to the DOM, nor visible. + // This is a hack to first get an event from monaco after the widget update request, then IDE2 waits for the next monaco context key event. + // Here, the monaco context key event is not used, but this is the first event after the editor is visible in the UI. + disposables.push( + (editor.editor as MonacoEditor).onDidResize((dimension) => { + if (dimension) { + const isKeyOwner = ( + arg: unknown + ): arg is { key: string } => { + if (typeof arg === 'object') { + const object = arg as Record; + return typeof object['key'] === 'string'; + } + return false; + }; + disposables.push( + this.contextKeyService.onDidChangeContext((e) => { + // `commentIsEmpty` is the first context key change event received from monaco after the editor is for real visible in the UI. + if (isKeyOwner(e) && e.key === 'commentIsEmpty') { + deferred.resolve(editor); + disposables.dispose(); + } + }) + ); + } + }) + ); + } + } + }) + ); + this.editorManager.open( new URI(uri), options ?? { mode: 'reveal', @@ -93,6 +184,20 @@ export class OpenSketchFiles extends SketchContribution { counter: 0, } ); + const timeout = 5_000; // number of ms IDE2 waits for the editor to show up in the UI + const result = await Promise.race([ + deferred.promise, + wait(timeout).then(() => { + disposables.dispose(); + return 'timeout'; + }), + ]); + if (result === 'timeout') { + console.warn( + `Timeout after ${timeout} millis. The editor has not shown up in time. URI: ${uri}` + ); + } + return result; } } } diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-sketch.ts index 5f50daae5..e7e3f77de 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch.ts @@ -1,7 +1,13 @@ import * as remote from '@theia/core/electron-shared/@electron/remote'; import { nls } from '@theia/core/lib/common/nls'; import { injectable } from '@theia/core/shared/inversify'; -import { SketchesError, SketchRef } from '../../common/protocol'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { LabelProvider } from '@theia/core/lib/browser/label-provider'; +import { + SketchesError, + SketchesService, + SketchRef, +} from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; import { Command, @@ -108,45 +114,11 @@ export class OpenSketch extends SketchContribution { return sketch; } if (Sketch.isSketchFile(sketchFileUri)) { - const name = new URI(sketchFileUri).path.name; - const nameWithExt = this.labelProvider.getName(new URI(sketchFileUri)); - const { response } = await remote.dialog.showMessageBox({ - title: nls.localize('arduino/sketch/moving', 'Moving'), - type: 'question', - buttons: [ - nls.localize('vscode/issueMainService/cancel', 'Cancel'), - nls.localize('vscode/issueMainService/ok', 'OK'), - ], - message: nls.localize( - 'arduino/sketch/movingMsg', - 'The file "{0}" needs to be inside a sketch folder named "{1}".\nCreate this folder, move the file, and continue?', - nameWithExt, - name - ), + return promptMoveSketch(sketchFileUri, { + fileService: this.fileService, + sketchService: this.sketchService, + labelProvider: this.labelProvider, }); - if (response === 1) { - // OK - const newSketchUri = new URI(sketchFileUri).parent.resolve(name); - const exists = await this.fileService.exists(newSketchUri); - if (exists) { - await remote.dialog.showMessageBox({ - type: 'error', - title: nls.localize('vscode/dialog/dialogErrorMessage', 'Error'), - message: nls.localize( - 'arduino/sketch/cantOpen', - 'A folder named "{0}" already exists. Can\'t open sketch.', - name - ), - }); - return undefined; - } - await this.fileService.createFolder(newSketchUri); - await this.fileService.move( - new URI(sketchFileUri), - new URI(newSketchUri.resolve(nameWithExt).toString()) - ); - return this.sketchService.getSketchFolder(newSketchUri.toString()); - } } } } @@ -158,3 +130,55 @@ export namespace OpenSketch { }; } } + +export async function promptMoveSketch( + sketchFileUri: string | URI, + options: { + fileService: FileService; + sketchService: SketchesService; + labelProvider: LabelProvider; + } +): Promise { + const { fileService, sketchService, labelProvider } = options; + const uri = + sketchFileUri instanceof URI ? sketchFileUri : new URI(sketchFileUri); + const name = uri.path.name; + const nameWithExt = labelProvider.getName(uri); + const { response } = await remote.dialog.showMessageBox({ + title: nls.localize('arduino/sketch/moving', 'Moving'), + type: 'question', + buttons: [ + nls.localize('vscode/issueMainService/cancel', 'Cancel'), + nls.localize('vscode/issueMainService/ok', 'OK'), + ], + message: nls.localize( + 'arduino/sketch/movingMsg', + 'The file "{0}" needs to be inside a sketch folder named "{1}".\nCreate this folder, move the file, and continue?', + nameWithExt, + name + ), + }); + if (response === 1) { + // OK + const newSketchUri = uri.parent.resolve(name); + const exists = await fileService.exists(newSketchUri); + if (exists) { + await remote.dialog.showMessageBox({ + type: 'error', + title: nls.localize('vscode/dialog/dialogErrorMessage', 'Error'), + message: nls.localize( + 'arduino/sketch/cantOpen', + 'A folder named "{0}" already exists. Can\'t open sketch.', + name + ), + }); + return undefined; + } + await fileService.createFolder(newSketchUri); + await fileService.move( + uri, + new URI(newSketchUri.resolve(nameWithExt).toString()) + ); + return sketchService.getSketchFolder(newSketchUri.toString()); + } +} diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts index e9d1e1e69..22c74728d 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts @@ -16,6 +16,7 @@ import { import { SketchesService, Sketch, + SketchesError, } from '../../../common/protocol/sketches-service'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { @@ -38,6 +39,7 @@ export class WorkspaceService extends TheiaWorkspaceService { private readonly providers: ContributionProvider; private version?: string; + private _workspaceError: Error | undefined; async onStart(application: FrontendApplication): Promise { const info = await this.applicationServer.getApplicationInfo(); @@ -51,6 +53,10 @@ export class WorkspaceService extends TheiaWorkspaceService { this.onCurrentWidgetChange({ newValue, oldValue: null }); } + get workspaceError(): Error | undefined { + return this._workspaceError; + } + protected override async toFileStat( uri: string | URI | undefined ): Promise { @@ -59,6 +65,31 @@ export class WorkspaceService extends TheiaWorkspaceService { const newSketchUri = await this.sketchService.createNewSketch(); return this.toFileStat(newSketchUri.uri); } + // When opening a file instead of a directory, IDE2 (and Theia) expects a workspace JSON file. + // Nothing will work if the workspace file is invalid. Users tend to start (see #964) IDE2 from the `.ino` files, + // so here, IDE2 tries to load the sketch via the CLI from the main sketch file URI. + // If loading the sketch is OK, IDE2 starts and uses the sketch folder as the workspace root instead of the sketch file. + // If loading fails due to invalid name error, IDE2 loads a temp sketch and preserves the startup error, and offers the sketch move to the user later. + // If loading the sketch fails, create a fallback sketch and open the new temp sketch folder as the workspace root. + if (stat.isFile && stat.resource.path.ext === '.ino') { + try { + const sketch = await this.sketchService.loadSketch( + stat.resource.toString() + ); + return this.toFileStat(sketch.uri); + } catch (err) { + if (SketchesError.InvalidName.is(err)) { + this._workspaceError = err; + const newSketchUri = await this.sketchService.createNewSketch(); + return this.toFileStat(newSketchUri.uri); + } else if (SketchesError.NotFound.is(err)) { + this._workspaceError = err; + const newSketchUri = await this.sketchService.createNewSketch(); + return this.toFileStat(newSketchUri.uri); + } + throw err; + } + } return stat; } diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index f1b8f7675..ed93d9fb7 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -4,6 +4,7 @@ import URI from '@theia/core/lib/common/uri'; export namespace SketchesError { export const Codes = { NotFound: 5001, + InvalidName: 5002, }; export const NotFound = ApplicationError.declare( Codes.NotFound, @@ -14,6 +15,15 @@ export namespace SketchesError { }; } ); + export const InvalidName = ApplicationError.declare( + Codes.InvalidName, + (message: string, invalidMainSketchUri: string) => { + return { + message, + data: { invalidMainSketchUri }, + }; + } + ); } export const SketchesServicePath = '/services/sketches-service'; 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 bb85405b3..335899d70 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 @@ -8,8 +8,8 @@ import { } from '@theia/core/electron-shared/electron'; import { fork } from 'child_process'; import { AddressInfo } from 'net'; -import { join, dirname } from 'path'; -import * as fs from 'fs-extra'; +import { join, isAbsolute, resolve } from 'path'; +import { promises as fs, Stats } from 'fs'; import { MaybePromise } from '@theia/core/lib/common/types'; import { ElectronSecurityToken } from '@theia/core/lib/electron-common/electron-token'; import { FrontendApplicationConfig } from '@theia/application-package/lib/application-props'; @@ -27,6 +27,7 @@ import { CLOSE_PLOTTER_WINDOW, SHOW_PLOTTER_WINDOW, } from '../../common/ipc-communication'; +import isValidPath = require('is-valid-path'); app.commandLine.appendSwitch('disable-http-cache'); @@ -69,8 +70,10 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { // Explicitly set the app name to have better menu items on macOS. ("About", "Hide", and "Quit") // See: https://github.com/electron-userland/electron-builder/issues/2468 // Regression in Theia: https://github.com/eclipse-theia/theia/issues/8701 + console.log(`${config.applicationName} ${app.getVersion()}`); app.on('ready', () => app.setName(config.applicationName)); - this.attachFileAssociations(); + const cwd = process.cwd(); + this.attachFileAssociations(cwd); this.useNativeWindowFrame = this.getTitleBarStyle(config) === 'native'; this._config = config; this.hookApplicationEvents(); @@ -84,7 +87,7 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { return this.launch({ secondInstance: false, argv: this.processArgv.getProcessArgvWithoutBin(process.argv), - cwd: process.cwd(), + cwd, }); } @@ -119,7 +122,7 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { let traceFile: string | undefined; if (appPath) { const tracesPath = join(appPath, 'traces'); - await fs.promises.mkdir(tracesPath, { recursive: true }); + await fs.mkdir(tracesPath, { recursive: true }); traceFile = join(tracesPath, `trace-${new Date().toISOString()}.trace`); } console.log('>>> Content tracing has started...'); @@ -135,14 +138,18 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { })(); } - private attachFileAssociations(): void { + private attachFileAssociations(cwd: string): void { // OSX: register open-file event if (os.isOSX) { - app.on('open-file', async (event, uri) => { + app.on('open-file', async (event, path) => { event.preventDefault(); - if (uri.endsWith('.ino') && (await fs.pathExists(uri))) { - this.openFilePromise.reject(); - await this.openSketch(dirname(uri)); + const resolvedPath = await this.resolvePath(path, cwd); + if (resolvedPath) { + const sketchFolderPath = await this.isValidSketchPath(resolvedPath); + if (sketchFolderPath) { + this.openFilePromise.reject(new InterruptWorkspaceRestoreError()); + await this.openSketch(sketchFolderPath); + } } }); setTimeout(() => this.openFilePromise.resolve(), 500); @@ -151,8 +158,68 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { } } - private async isValidSketchPath(uri: string): Promise { - return typeof uri === 'string' && (await fs.pathExists(uri)); + /** + * The `path` argument is valid, if accessible and either pointing to a `.ino` file, + * or it's a directory, and one of the files in the directory is an `.ino` file. + * + * If `undefined`, `path` was pointing to neither an accessible sketch file nor a sketch folder. + * + * The sketch folder name and sketch file name can be different. This method is not sketch folder name compliant. + * The `path` must be an absolute, resolved path. + */ + private async isValidSketchPath(path: string): Promise { + let stats: Stats | undefined = undefined; + try { + stats = await fs.stat(path); + } catch (err) { + if ('code' in err && err.code === 'ENOENT') { + return undefined; + } + throw err; + } + if (!stats) { + return undefined; + } + if (stats.isFile() && path.endsWith('.ino')) { + return path; + } + try { + const entries = await fs.readdir(path, { withFileTypes: true }); + const sketchFilename = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.ino')) + .map(({ name }) => name) + .sort((left, right) => left.localeCompare(right))[0]; + if (sketchFilename) { + return join(path, sketchFilename); + } + // If no sketches found in the folder, but the folder exists, + // return with the path of the empty folder and let IDE2's frontend + // figure out the workspace root. + return path; + } catch (err) { + throw err; + } + } + + private async resolvePath( + maybePath: string, + cwd: string + ): Promise { + if (!isValidPath(maybePath)) { + return undefined; + } + if (isAbsolute(maybePath)) { + return maybePath; + } + try { + const resolved = await fs.realpath(resolve(cwd, maybePath)); + return resolved; + } catch (err) { + if ('code' in err && err.code === 'ENOENT') { + return undefined; + } + throw err; + } } protected override async launch( @@ -163,12 +230,15 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { // 1. The `open-file` command has been received by the app, rejecting the promise // 2. A short timeout resolves the promise automatically, falling back to the usual app launch await this.openFilePromise.promise; - } catch { - // Application has received the `open-file` event and will skip the default application launch - return; + } catch (err) { + if (err instanceof InterruptWorkspaceRestoreError) { + // Application has received the `open-file` event and will skip the default application launch + return; + } + throw err; } - if (!os.isOSX && (await this.launchFromArgs(params))) { + if (await this.launchFromArgs(params)) { // Application has received a file in its arguments and will skip the default application launch return; } @@ -182,7 +252,13 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { `Restoring workspace roots: ${workspaces.map(({ file }) => file)}` ); for (const workspace of workspaces) { - if (await this.isValidSketchPath(workspace.file)) { + const resolvedPath = await this.resolvePath(workspace.file, params.cwd); + if (!resolvedPath) { + continue; + } + const sketchFolderPath = await this.isValidSketchPath(resolvedPath); + if (sketchFolderPath) { + workspace.file = sketchFolderPath; if (this.isTempSketch.is(workspace.file)) { console.info( `Skipped opening sketch. The sketch was detected as temporary. Workspace path: ${workspace.file}.` @@ -205,38 +281,40 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { ): Promise { // Copy to prevent manipulation of original array const argCopy = [...params.argv]; - let uri: string | undefined; - for (const possibleUri of argCopy) { - if ( - possibleUri.endsWith('.ino') && - (await this.isValidSketchPath(possibleUri)) - ) { - uri = possibleUri; + let path: string | undefined; + for (const maybePath of argCopy) { + const resolvedPath = await this.resolvePath(maybePath, params.cwd); + if (!resolvedPath) { + continue; + } + const sketchFolderPath = await this.isValidSketchPath(resolvedPath); + if (sketchFolderPath) { + path = sketchFolderPath; break; } } - if (uri) { - await this.openSketch(dirname(uri)); + if (path) { + await this.openSketch(path); return true; } return false; } private async openSketch( - workspace: WorkspaceOptions | string + workspaceOrPath: WorkspaceOptions | string ): Promise { const options = await this.getLastWindowOptions(); let file: string; - if (typeof workspace === 'object') { - options.x = workspace.x; - options.y = workspace.y; - options.width = workspace.width; - options.height = workspace.height; - options.isMaximized = workspace.isMaximized; - options.isFullScreen = workspace.isFullScreen; - file = workspace.file; + if (typeof workspaceOrPath === 'object') { + options.x = workspaceOrPath.x; + options.y = workspaceOrPath.y; + options.width = workspaceOrPath.width; + options.height = workspaceOrPath.height; + options.isMaximized = workspaceOrPath.isMaximized; + options.isFullScreen = workspaceOrPath.isFullScreen; + file = workspaceOrPath.file; } else { - file = workspace; + file = workspaceOrPath; } const [uri, electronWindow] = await Promise.all([ this.createWindowUri(), @@ -486,3 +564,12 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { return this._firstWindowId; } } + +class InterruptWorkspaceRestoreError extends Error { + constructor() { + super( + "Received 'open-file' event. Interrupting the default launch workflow." + ); + Object.setPrototypeOf(this, InterruptWorkspaceRestoreError.prototype); + } +} 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 33d21c0ff..0106f6f49 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -4,7 +4,6 @@ import { ArduinoFirmwareUploader, ArduinoFirmwareUploaderPath, } from '../common/protocol/arduino-firmware-uploader'; - import { ILogger } from '@theia/core/lib/common/logger'; import { BackendApplicationContribution, @@ -26,7 +25,7 @@ import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connec import { CoreClientProvider } from './core-client-provider'; import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core'; import { DefaultWorkspaceServer } from './theia/workspace/default-workspace-server'; -import { WorkspaceServer as TheiaWorkspaceServer } from '@theia/workspace/lib/common'; +import { WorkspaceServer as TheiaWorkspaceServer } from '@theia/workspace/lib/common/workspace-protocol'; import { SketchesServiceImpl } from './sketches-service-impl'; import { SketchesService, @@ -40,7 +39,6 @@ import { ArduinoDaemon, ArduinoDaemonPath, } from '../common/protocol/arduino-daemon'; - import { ConfigServiceImpl } from './config-service-impl'; import { EnvVariablesServer as TheiaEnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { EnvVariablesServer } from './theia/env-variables/env-variables-server'; diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 06c7931b6..b844c9966 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -187,11 +187,22 @@ export class SketchesServiceImpl const sketch = await new Promise((resolve, reject) => { client.loadSketch(req, async (err, resp) => { if (err) { - reject( - isNotFoundError(err) - ? SketchesError.NotFound(err.details, uri) - : err - ); + let rejectWith: unknown = err; + if (isNotFoundError(err)) { + const invalidMainSketchFilePath = await isInvalidSketchNameError( + err, + requestSketchPath + ); + if (invalidMainSketchFilePath) { + rejectWith = SketchesError.InvalidName( + err.details, + FileUri.create(invalidMainSketchFilePath).toString() + ); + } else { + rejectWith = SketchesError.NotFound(err.details, uri); + } + } + reject(rejectWith); return; } const responseSketchPath = maybeNormalizeDrive(resp.getLocationPath()); @@ -301,7 +312,10 @@ export class SketchesServiceImpl )} before marking it as recently opened.` ); } catch (err) { - if (SketchesError.NotFound.is(err)) { + if ( + SketchesError.NotFound.is(err) || + SketchesError.InvalidName.is(err) + ) { this.logger.debug( `Could not load sketch from '${uri}'. Not marking as recently opened.` ); @@ -515,7 +529,7 @@ void loop() { const sketch = await this.loadSketch(uri); return sketch; } catch (err) { - if (SketchesError.NotFound.is(err)) { + if (SketchesError.NotFound.is(err) || SketchesError.InvalidName.is(err)) { return undefined; } throw err; @@ -647,6 +661,63 @@ 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 } +/** + * Tries to detect whether the error was caused by an invalid main sketch file name. + * IDE2 should handle gracefully when there is an invalid sketch folder name. See the [spec](https://arduino.github.io/arduino-cli/latest/sketch-specification/#sketch-root-folder) for details. + * The CLI does not have error codes (https://github.com/arduino/arduino-cli/issues/1762), so IDE2 parses the error message and tries to guess it. + * Nothing guarantees that the invalid existing main sketch file still exits by the time client performs the sketch move. + */ +async function isInvalidSketchNameError( + cliErr: unknown, + requestSketchPath: string +): Promise { + if (isNotFoundError(cliErr)) { + const ino = requestSketchPath.endsWith('.ino'); + if (ino) { + const sketchFolderPath = path.dirname(requestSketchPath); + const sketchName = path.basename(sketchFolderPath); + const pattern = `${invalidSketchNameErrorRegExpPrefix}${path.join( + sketchFolderPath, + `${sketchName}.ino` + )}`.replace(/\\/g, '\\\\'); // make windows path separator with \\ to have a valid regexp. + if (new RegExp(pattern, 'i').test(cliErr.details)) { + try { + await fs.access(requestSketchPath); + return requestSketchPath; + } catch { + return undefined; + } + } + } else { + try { + const resources = await fs.readdir(requestSketchPath, { + withFileTypes: true, + }); + return ( + resources + .filter((resource) => resource.isFile()) + .filter((resource) => resource.name.endsWith('.ino')) + // A folder might contain multiple sketches. It's OK to ick the first one as IDE2 cannot do much, + // but ensure a deterministic behavior as `readdir(3)` does not guarantee an order. Sort them. + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ) + .map(({ name }) => name) + .map((name) => path.join(requestSketchPath, name))[0] + ); + } catch (err) { + if ('code' in err && err.code === 'ENOTDIR') { + return undefined; + } + throw err; + } + } + } + return undefined; +} +const invalidSketchNameErrorRegExpPrefix = + '.*: main file missing from sketch: '; + /* * When a new sketch is created, add a suffix to distinguish it * from other new sketches I created today. diff --git a/yarn.lock b/yarn.lock index f023cda96..3a7fcb5ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3139,6 +3139,11 @@ resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ== +"@types/is-valid-path@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@types/is-valid-path/-/is-valid-path-0.1.0.tgz#d5c6e96801303112c9626d44268c6fabc72d272f" + integrity sha512-2ontWtpN8O2nf5S7EjDDJ0DwrRa2t7wmS3Wmo322yWYG6yFBYC1QCaLhz4Iz+mzJy8Kf4zP5yVyEd1ANPDmOFQ== + "@types/js-yaml@^3.12.2": version "3.12.7" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.7.tgz#330c5d97a3500e9c903210d6e49f02964af04a0e"