Skip to content

#964 Prompt move when opening invalid sketch outside from IDE2 #1563

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 26, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions arduino-ide-extension/package.json
Original file line number Diff line number Diff line change
@@ -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",
Original file line number Diff line number Diff line change
@@ -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,
113 changes: 109 additions & 4 deletions arduino-ide-extension/src/browser/contributions/open-sketch-files.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
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,
SketchContribution,
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<Sketch | undefined> {
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<void> {
const sketch = await this.sketchService.createNewSketch();
this.workspaceService.open(new URI(sketch.uri), { preserveWindow: true });
@@ -84,15 +135,69 @@ 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<EditorWidget>();
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<string, unknown>;
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',
preview: false,
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;
}
}
}
102 changes: 63 additions & 39 deletions arduino-ide-extension/src/browser/contributions/open-sketch.ts
Original file line number Diff line number Diff line change
@@ -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<Sketch | undefined> {
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());
}
}
Original file line number Diff line number Diff line change
@@ -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<StartupTaskProvider>;

private version?: string;
private _workspaceError: Error | undefined;

async onStart(application: FrontendApplication): Promise<void> {
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<FileStat | undefined> {
@@ -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;
}

10 changes: 10 additions & 0 deletions arduino-ide-extension/src/common/protocol/sketches-service.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined> {
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<string | undefined> {
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<string | undefined> {
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<boolean> {
// 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<BrowserWindow> {
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);
}
}
4 changes: 1 addition & 3 deletions arduino-ide-extension/src/node/arduino-ide-backend-module.ts
Original file line number Diff line number Diff line change
@@ -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';
85 changes: 78 additions & 7 deletions arduino-ide-extension/src/node/sketches-service-impl.ts
Original file line number Diff line number Diff line change
@@ -187,11 +187,22 @@ export class SketchesServiceImpl
const sketch = await new Promise<SketchWithDetails>((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<string | undefined> {
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.
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
@@ -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"