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 7f937059c..99ccbd085 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts @@ -102,7 +102,7 @@ export class OpenSketchFiles extends SketchContribution { ): Promise { const { invalidMainSketchUri } = err.data; requestAnimationFrame(() => this.messageService.error(err.message)); - await wait(10); // let IDE2 toast the error message. + await wait(250); // let IDE2 open the editor and toast the error message, then open the modal dialog const movedSketch = await promptMoveSketch(invalidMainSketchUri, { fileService: this.fileService, sketchService: this.sketchService, 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 784d8fca9..6cc4ad114 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 @@ -9,7 +9,7 @@ import { import { fork } from 'child_process'; import { AddressInfo } from 'net'; import { join, isAbsolute, resolve } from 'path'; -import { promises as fs, Stats } from 'fs'; +import { promises as fs } 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'; @@ -28,6 +28,7 @@ import { SHOW_PLOTTER_WINDOW, } from '../../common/ipc-communication'; import { ErrnoException } from '../../node/utils/errors'; +import { isAccessibleSketchPath } from '../../node/sketches-service-impl'; app.commandLine.appendSwitch('disable-http-cache'); @@ -145,7 +146,10 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { event.preventDefault(); const resolvedPath = await this.resolvePath(path, cwd); if (resolvedPath) { - const sketchFolderPath = await this.isValidSketchPath(resolvedPath); + const sketchFolderPath = await isAccessibleSketchPath( + resolvedPath, + true + ); if (sketchFolderPath) { this.openFilePromise.reject(new InterruptWorkspaceRestoreError()); await this.openSketch(sketchFolderPath); @@ -158,49 +162,6 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { } } - /** - * 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 (ErrnoException.isENOENT(err)) { - return undefined; - } - throw err; - } - if (!stats) { - return undefined; - } - if (stats.isFile()) { - return path.endsWith('.ino') ? path : undefined; - } - 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 @@ -253,7 +214,10 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { if (!resolvedPath) { continue; } - const sketchFolderPath = await this.isValidSketchPath(resolvedPath); + const sketchFolderPath = await isAccessibleSketchPath( + resolvedPath, + true + ); if (sketchFolderPath) { workspace.file = sketchFolderPath; if (this.isTempSketch.is(workspace.file)) { @@ -284,7 +248,7 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { if (!resolvedPath) { continue; } - const sketchFolderPath = await this.isValidSketchPath(resolvedPath); + const sketchFolderPath = await isAccessibleSketchPath(resolvedPath, true); if (sketchFolderPath) { path = sketchFolderPath; break; diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 091bf3455..f20723f01 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -734,62 +734,68 @@ function isNotFoundError(err: unknown): err is ServiceError { /** * 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. + * 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), + * IDE2 cannot parse the error message (https://github.com/arduino/arduino-cli/issues/1968#issuecomment-1306936142) + * so it checks if a sketch even if it's invalid can be discovered from the requested path. * 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 = escapeRegExpCharacters( - `${invalidSketchNameErrorRegExpPrefix}${path.join( - sketchFolderPath, - `${sketchName}.ino` - )}` - ); - 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 (ErrnoException.isENOENT(err) || ErrnoException.isENOTDIR(err)) { - return undefined; - } - throw err; - } + return isNotFoundError(cliErr) + ? isAccessibleSketchPath(requestSketchPath) + : undefined; +} + +/** + * 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. + * + * `undefined` if `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. This method does not handle EACCES (Permission denied) errors. + * + * When `fallbackToInvalidFolderPath` is `true`, and the `path` is an accessible folder without any sketch files, + * this method returns with the `path` argument instead of `undefined`. + */ +export async function isAccessibleSketchPath( + path: string, + fallbackToInvalidFolderPath = false +): Promise { + let stats: Stats | undefined = undefined; + try { + stats = await fs.stat(path); + } catch (err) { + if (ErrnoException.isENOENT(err)) { + return undefined; } + throw err; + } + if (!stats) { + return undefined; + } + if (stats.isFile()) { + return path.endsWith('.ino') ? path : undefined; + } + const entries = await fs.readdir(path, { withFileTypes: true }); + const sketchFilename = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.ino')) + .map(({ name }) => name) + // A folder might contain multiple sketches. It's OK to pick the first one as IDE2 cannot do much, + // but ensure a deterministic behavior as `readdir(3)` does not guarantee an order. Sort them. + .sort((left, right) => left.localeCompare(right))[0]; + if (sketchFilename) { + return join(path, sketchFilename); } - return undefined; + // 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 fallbackToInvalidFolderPath ? path : undefined; } -const invalidSketchNameErrorRegExpPrefix = - '.*: main file missing from sketch: '; /* * When a new sketch is created, add a suffix to distinguish it