Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6b9e3b6

Browse files
author
Akos Kitta
committedOct 26, 2022
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 <[email protected]> Co-authored-by: Akos Kitta <[email protected]> Signed-off-by: Akos Kitta <[email protected]>
1 parent b55cfc2 commit 6b9e3b6

File tree

10 files changed

+422
-90
lines changed

10 files changed

+422
-90
lines changed
 

‎arduino-ide-extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@types/deepmerge": "^2.2.0",
4646
"@types/glob": "^7.2.0",
4747
"@types/google-protobuf": "^3.7.2",
48+
"@types/is-valid-path": "^0.1.0",
4849
"@types/js-yaml": "^3.12.2",
4950
"@types/keytar": "^4.4.0",
5051
"@types/lodash.debounce": "^4.0.6",

‎arduino-ide-extension/src/browser/contributions/contribution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { MaybePromise } from '@theia/core/lib/common/types';
1212
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
1313
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
1414
import { MessageService } from '@theia/core/lib/common/message-service';
15-
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
1615
import { open, OpenerService } from '@theia/core/lib/browser/opener-service';
1716

1817
import {
@@ -61,6 +60,7 @@ import { BoardsServiceProvider } from '../boards/boards-service-provider';
6160
import { BoardsDataStore } from '../boards/boards-data-store';
6261
import { NotificationManager } from '../theia/messages/notifications-manager';
6362
import { MessageType } from '@theia/core/lib/common/message-service-protocol';
63+
import { WorkspaceService } from '../theia/workspace/workspace-service';
6464

6565
export {
6666
Command,

‎arduino-ide-extension/src/browser/contributions/open-sketch-files.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { nls } from '@theia/core/lib/common/nls';
2-
import { injectable } from '@theia/core/shared/inversify';
2+
import { inject, injectable } from '@theia/core/shared/inversify';
33
import type { EditorOpenerOptions } from '@theia/editor/lib/browser/editor-manager';
44
import { Later } from '../../common/nls';
5-
import { SketchesError } from '../../common/protocol';
5+
import { Sketch, SketchesError } from '../../common/protocol';
66
import {
77
Command,
88
CommandRegistry,
99
SketchContribution,
1010
URI,
1111
} from './contribution';
1212
import { SaveAsSketch } from './save-as-sketch';
13+
import { promptMoveSketch } from './open-sketch';
14+
import { ApplicationError } from '@theia/core/lib/common/application-error';
15+
import { Deferred, wait } from '@theia/core/lib/common/promise-util';
16+
import { EditorWidget } from '@theia/editor/lib/browser/editor-widget';
17+
import { DisposableCollection } from '@theia/core/lib/common/disposable';
18+
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
19+
import { ContextKeyService as VSCodeContextKeyService } from '@theia/monaco-editor-core/esm/vs/platform/contextkey/browser/contextKeyService';
1320

1421
@injectable()
1522
export class OpenSketchFiles extends SketchContribution {
23+
@inject(VSCodeContextKeyService)
24+
private readonly contextKeyService: VSCodeContextKeyService;
25+
1626
override registerCommands(registry: CommandRegistry): void {
1727
registry.registerCommand(OpenSketchFiles.Commands.OPEN_SKETCH_FILES, {
1828
execute: (uri: URI) => this.openSketchFiles(uri),
@@ -55,9 +65,25 @@ export class OpenSketchFiles extends SketchContribution {
5565
}
5666
});
5767
}
68+
const { workspaceError } = this.workspaceService;
69+
// 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)
70+
if (SketchesError.InvalidName.is(workspaceError)) {
71+
await this.promptMove(workspaceError);
72+
}
5873
} catch (err) {
74+
// This happens when the user gracefully closed IDE2, all went well
75+
// but the main sketch file was renamed outside of IDE2 and when the user restarts the IDE2
76+
// the workspace path still exists, but the sketch path is not valid anymore. (#964)
77+
if (SketchesError.InvalidName.is(err)) {
78+
const movedSketch = await this.promptMove(err);
79+
if (!movedSketch) {
80+
// If user did not accept the move, or move was not possible, force reload with a fallback.
81+
return this.openFallbackSketch();
82+
}
83+
}
84+
5985
if (SketchesError.NotFound.is(err)) {
60-
this.openFallbackSketch();
86+
return this.openFallbackSketch();
6187
} else {
6288
console.error(err);
6389
const message =
@@ -71,6 +97,31 @@ export class OpenSketchFiles extends SketchContribution {
7197
}
7298
}
7399

100+
private async promptMove(
101+
err: ApplicationError<
102+
number,
103+
{
104+
invalidMainSketchUri: string;
105+
}
106+
>
107+
): Promise<Sketch | undefined> {
108+
const { invalidMainSketchUri } = err.data;
109+
requestAnimationFrame(() => this.messageService.error(err.message));
110+
await wait(10); // let IDE2 toast the error message.
111+
const movedSketch = await promptMoveSketch(invalidMainSketchUri, {
112+
fileService: this.fileService,
113+
sketchService: this.sketchService,
114+
labelProvider: this.labelProvider,
115+
});
116+
if (movedSketch) {
117+
this.workspaceService.open(new URI(movedSketch.uri), {
118+
preserveWindow: true,
119+
});
120+
return movedSketch;
121+
}
122+
return undefined;
123+
}
124+
74125
private async openFallbackSketch(): Promise<void> {
75126
const sketch = await this.sketchService.createNewSketch();
76127
this.workspaceService.open(new URI(sketch.uri), { preserveWindow: true });
@@ -84,15 +135,69 @@ export class OpenSketchFiles extends SketchContribution {
84135
const widget = this.editorManager.all.find(
85136
(widget) => widget.editor.uri.toString() === uri
86137
);
138+
const disposables = new DisposableCollection();
87139
if (!widget || forceOpen) {
88-
return this.editorManager.open(
140+
const deferred = new Deferred<EditorWidget>();
141+
disposables.push(
142+
this.editorManager.onCreated((editor) => {
143+
if (editor.editor.uri.toString() === uri) {
144+
if (editor.isVisible) {
145+
disposables.dispose();
146+
deferred.resolve(editor);
147+
} else {
148+
// In Theia, the promise resolves after opening the editor, but the editor is neither attached to the DOM, nor visible.
149+
// 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.
150+
// Here, the monaco context key event is not used, but this is the first event after the editor is visible in the UI.
151+
disposables.push(
152+
(editor.editor as MonacoEditor).onDidResize((dimension) => {
153+
if (dimension) {
154+
const isKeyOwner = (
155+
arg: unknown
156+
): arg is { key: string } => {
157+
if (typeof arg === 'object') {
158+
const object = arg as Record<string, unknown>;
159+
return typeof object['key'] === 'string';
160+
}
161+
return false;
162+
};
163+
disposables.push(
164+
this.contextKeyService.onDidChangeContext((e) => {
165+
// `commentIsEmpty` is the first context key change event received from monaco after the editor is for real visible in the UI.
166+
if (isKeyOwner(e) && e.key === 'commentIsEmpty') {
167+
deferred.resolve(editor);
168+
disposables.dispose();
169+
}
170+
})
171+
);
172+
}
173+
})
174+
);
175+
}
176+
}
177+
})
178+
);
179+
this.editorManager.open(
89180
new URI(uri),
90181
options ?? {
91182
mode: 'reveal',
92183
preview: false,
93184
counter: 0,
94185
}
95186
);
187+
const timeout = 5_000; // number of ms IDE2 waits for the editor to show up in the UI
188+
const result = await Promise.race([
189+
deferred.promise,
190+
wait(timeout).then(() => {
191+
disposables.dispose();
192+
return 'timeout';
193+
}),
194+
]);
195+
if (result === 'timeout') {
196+
console.warn(
197+
`Timeout after ${timeout} millis. The editor has not shown up in time. URI: ${uri}`
198+
);
199+
}
200+
return result;
96201
}
97202
}
98203
}

‎arduino-ide-extension/src/browser/contributions/open-sketch.ts

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import * as remote from '@theia/core/electron-shared/@electron/remote';
22
import { nls } from '@theia/core/lib/common/nls';
33
import { injectable } from '@theia/core/shared/inversify';
4-
import { SketchesError, SketchRef } from '../../common/protocol';
4+
import { FileService } from '@theia/filesystem/lib/browser/file-service';
5+
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
6+
import {
7+
SketchesError,
8+
SketchesService,
9+
SketchRef,
10+
} from '../../common/protocol';
511
import { ArduinoMenus } from '../menu/arduino-menus';
612
import {
713
Command,
@@ -108,45 +114,11 @@ export class OpenSketch extends SketchContribution {
108114
return sketch;
109115
}
110116
if (Sketch.isSketchFile(sketchFileUri)) {
111-
const name = new URI(sketchFileUri).path.name;
112-
const nameWithExt = this.labelProvider.getName(new URI(sketchFileUri));
113-
const { response } = await remote.dialog.showMessageBox({
114-
title: nls.localize('arduino/sketch/moving', 'Moving'),
115-
type: 'question',
116-
buttons: [
117-
nls.localize('vscode/issueMainService/cancel', 'Cancel'),
118-
nls.localize('vscode/issueMainService/ok', 'OK'),
119-
],
120-
message: nls.localize(
121-
'arduino/sketch/movingMsg',
122-
'The file "{0}" needs to be inside a sketch folder named "{1}".\nCreate this folder, move the file, and continue?',
123-
nameWithExt,
124-
name
125-
),
117+
return promptMoveSketch(sketchFileUri, {
118+
fileService: this.fileService,
119+
sketchService: this.sketchService,
120+
labelProvider: this.labelProvider,
126121
});
127-
if (response === 1) {
128-
// OK
129-
const newSketchUri = new URI(sketchFileUri).parent.resolve(name);
130-
const exists = await this.fileService.exists(newSketchUri);
131-
if (exists) {
132-
await remote.dialog.showMessageBox({
133-
type: 'error',
134-
title: nls.localize('vscode/dialog/dialogErrorMessage', 'Error'),
135-
message: nls.localize(
136-
'arduino/sketch/cantOpen',
137-
'A folder named "{0}" already exists. Can\'t open sketch.',
138-
name
139-
),
140-
});
141-
return undefined;
142-
}
143-
await this.fileService.createFolder(newSketchUri);
144-
await this.fileService.move(
145-
new URI(sketchFileUri),
146-
new URI(newSketchUri.resolve(nameWithExt).toString())
147-
);
148-
return this.sketchService.getSketchFolder(newSketchUri.toString());
149-
}
150122
}
151123
}
152124
}
@@ -158,3 +130,55 @@ export namespace OpenSketch {
158130
};
159131
}
160132
}
133+
134+
export async function promptMoveSketch(
135+
sketchFileUri: string | URI,
136+
options: {
137+
fileService: FileService;
138+
sketchService: SketchesService;
139+
labelProvider: LabelProvider;
140+
}
141+
): Promise<Sketch | undefined> {
142+
const { fileService, sketchService, labelProvider } = options;
143+
const uri =
144+
sketchFileUri instanceof URI ? sketchFileUri : new URI(sketchFileUri);
145+
const name = uri.path.name;
146+
const nameWithExt = labelProvider.getName(uri);
147+
const { response } = await remote.dialog.showMessageBox({
148+
title: nls.localize('arduino/sketch/moving', 'Moving'),
149+
type: 'question',
150+
buttons: [
151+
nls.localize('vscode/issueMainService/cancel', 'Cancel'),
152+
nls.localize('vscode/issueMainService/ok', 'OK'),
153+
],
154+
message: nls.localize(
155+
'arduino/sketch/movingMsg',
156+
'The file "{0}" needs to be inside a sketch folder named "{1}".\nCreate this folder, move the file, and continue?',
157+
nameWithExt,
158+
name
159+
),
160+
});
161+
if (response === 1) {
162+
// OK
163+
const newSketchUri = uri.parent.resolve(name);
164+
const exists = await fileService.exists(newSketchUri);
165+
if (exists) {
166+
await remote.dialog.showMessageBox({
167+
type: 'error',
168+
title: nls.localize('vscode/dialog/dialogErrorMessage', 'Error'),
169+
message: nls.localize(
170+
'arduino/sketch/cantOpen',
171+
'A folder named "{0}" already exists. Can\'t open sketch.',
172+
name
173+
),
174+
});
175+
return undefined;
176+
}
177+
await fileService.createFolder(newSketchUri);
178+
await fileService.move(
179+
uri,
180+
new URI(newSketchUri.resolve(nameWithExt).toString())
181+
);
182+
return sketchService.getSketchFolder(newSketchUri.toString());
183+
}
184+
}

‎arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import {
1717
SketchesService,
1818
Sketch,
19+
SketchesError,
1920
} from '../../../common/protocol/sketches-service';
2021
import { FileStat } from '@theia/filesystem/lib/common/files';
2122
import {
@@ -38,6 +39,7 @@ export class WorkspaceService extends TheiaWorkspaceService {
3839
private readonly providers: ContributionProvider<StartupTaskProvider>;
3940

4041
private version?: string;
42+
private _workspaceError: Error | undefined;
4143

4244
async onStart(application: FrontendApplication): Promise<void> {
4345
const info = await this.applicationServer.getApplicationInfo();
@@ -51,6 +53,10 @@ export class WorkspaceService extends TheiaWorkspaceService {
5153
this.onCurrentWidgetChange({ newValue, oldValue: null });
5254
}
5355

56+
get workspaceError(): Error | undefined {
57+
return this._workspaceError;
58+
}
59+
5460
protected override async toFileStat(
5561
uri: string | URI | undefined
5662
): Promise<FileStat | undefined> {
@@ -59,6 +65,31 @@ export class WorkspaceService extends TheiaWorkspaceService {
5965
const newSketchUri = await this.sketchService.createNewSketch();
6066
return this.toFileStat(newSketchUri.uri);
6167
}
68+
// When opening a file instead of a directory, IDE2 (and Theia) expects a workspace JSON file.
69+
// Nothing will work if the workspace file is invalid. Users tend to start (see #964) IDE2 from the `.ino` files,
70+
// so here, IDE2 tries to load the sketch via the CLI from the main sketch file URI.
71+
// If loading the sketch is OK, IDE2 starts and uses the sketch folder as the workspace root instead of the sketch file.
72+
// 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.
73+
// If loading the sketch fails, create a fallback sketch and open the new temp sketch folder as the workspace root.
74+
if (stat.isFile && stat.resource.path.ext === '.ino') {
75+
try {
76+
const sketch = await this.sketchService.loadSketch(
77+
stat.resource.toString()
78+
);
79+
return this.toFileStat(sketch.uri);
80+
} catch (err) {
81+
if (SketchesError.InvalidName.is(err)) {
82+
this._workspaceError = err;
83+
const newSketchUri = await this.sketchService.createNewSketch();
84+
return this.toFileStat(newSketchUri.uri);
85+
} else if (SketchesError.NotFound.is(err)) {
86+
this._workspaceError = err;
87+
const newSketchUri = await this.sketchService.createNewSketch();
88+
return this.toFileStat(newSketchUri.uri);
89+
}
90+
throw err;
91+
}
92+
}
6293
return stat;
6394
}
6495

‎arduino-ide-extension/src/common/protocol/sketches-service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import URI from '@theia/core/lib/common/uri';
44
export namespace SketchesError {
55
export const Codes = {
66
NotFound: 5001,
7+
InvalidName: 5002,
78
};
89
export const NotFound = ApplicationError.declare(
910
Codes.NotFound,
@@ -14,6 +15,15 @@ export namespace SketchesError {
1415
};
1516
}
1617
);
18+
export const InvalidName = ApplicationError.declare(
19+
Codes.InvalidName,
20+
(message: string, invalidMainSketchUri: string) => {
21+
return {
22+
message,
23+
data: { invalidMainSketchUri },
24+
};
25+
}
26+
);
1727
}
1828

1929
export const SketchesServicePath = '/services/sketches-service';

‎arduino-ide-extension/src/electron-main/theia/electron-main-application.ts

Lines changed: 123 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import {
88
} from '@theia/core/electron-shared/electron';
99
import { fork } from 'child_process';
1010
import { AddressInfo } from 'net';
11-
import { join, dirname } from 'path';
12-
import * as fs from 'fs-extra';
11+
import { join, isAbsolute, resolve } from 'path';
12+
import { promises as fs, Stats } from 'fs';
1313
import { MaybePromise } from '@theia/core/lib/common/types';
1414
import { ElectronSecurityToken } from '@theia/core/lib/electron-common/electron-token';
1515
import { FrontendApplicationConfig } from '@theia/application-package/lib/application-props';
@@ -27,6 +27,7 @@ import {
2727
CLOSE_PLOTTER_WINDOW,
2828
SHOW_PLOTTER_WINDOW,
2929
} from '../../common/ipc-communication';
30+
import isValidPath = require('is-valid-path');
3031

3132
app.commandLine.appendSwitch('disable-http-cache');
3233

@@ -69,8 +70,10 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
6970
// Explicitly set the app name to have better menu items on macOS. ("About", "Hide", and "Quit")
7071
// See: https://github.com/electron-userland/electron-builder/issues/2468
7172
// Regression in Theia: https://github.com/eclipse-theia/theia/issues/8701
73+
console.log(`${config.applicationName} ${app.getVersion()}`);
7274
app.on('ready', () => app.setName(config.applicationName));
73-
this.attachFileAssociations();
75+
const cwd = process.cwd();
76+
this.attachFileAssociations(cwd);
7477
this.useNativeWindowFrame = this.getTitleBarStyle(config) === 'native';
7578
this._config = config;
7679
this.hookApplicationEvents();
@@ -84,7 +87,7 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
8487
return this.launch({
8588
secondInstance: false,
8689
argv: this.processArgv.getProcessArgvWithoutBin(process.argv),
87-
cwd: process.cwd(),
90+
cwd,
8891
});
8992
}
9093

@@ -119,7 +122,7 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
119122
let traceFile: string | undefined;
120123
if (appPath) {
121124
const tracesPath = join(appPath, 'traces');
122-
await fs.promises.mkdir(tracesPath, { recursive: true });
125+
await fs.mkdir(tracesPath, { recursive: true });
123126
traceFile = join(tracesPath, `trace-${new Date().toISOString()}.trace`);
124127
}
125128
console.log('>>> Content tracing has started...');
@@ -135,14 +138,18 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
135138
})();
136139
}
137140

138-
private attachFileAssociations(): void {
141+
private attachFileAssociations(cwd: string): void {
139142
// OSX: register open-file event
140143
if (os.isOSX) {
141-
app.on('open-file', async (event, uri) => {
144+
app.on('open-file', async (event, path) => {
142145
event.preventDefault();
143-
if (uri.endsWith('.ino') && (await fs.pathExists(uri))) {
144-
this.openFilePromise.reject();
145-
await this.openSketch(dirname(uri));
146+
const resolvedPath = await this.resolvePath(path, cwd);
147+
if (resolvedPath) {
148+
const sketchFolderPath = await this.isValidSketchPath(resolvedPath);
149+
if (sketchFolderPath) {
150+
this.openFilePromise.reject(new InterruptWorkspaceRestoreError());
151+
await this.openSketch(sketchFolderPath);
152+
}
146153
}
147154
});
148155
setTimeout(() => this.openFilePromise.resolve(), 500);
@@ -151,8 +158,68 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
151158
}
152159
}
153160

154-
private async isValidSketchPath(uri: string): Promise<boolean | undefined> {
155-
return typeof uri === 'string' && (await fs.pathExists(uri));
161+
/**
162+
* The `path` argument is valid, if accessible and either pointing to a `.ino` file,
163+
* or it's a directory, and one of the files in the directory is an `.ino` file.
164+
*
165+
* If `undefined`, `path` was pointing to neither an accessible sketch file nor a sketch folder.
166+
*
167+
* The sketch folder name and sketch file name can be different. This method is not sketch folder name compliant.
168+
* The `path` must be an absolute, resolved path.
169+
*/
170+
private async isValidSketchPath(path: string): Promise<string | undefined> {
171+
let stats: Stats | undefined = undefined;
172+
try {
173+
stats = await fs.stat(path);
174+
} catch (err) {
175+
if ('code' in err && err.code === 'ENOENT') {
176+
return undefined;
177+
}
178+
throw err;
179+
}
180+
if (!stats) {
181+
return undefined;
182+
}
183+
if (stats.isFile() && path.endsWith('.ino')) {
184+
return path;
185+
}
186+
try {
187+
const entries = await fs.readdir(path, { withFileTypes: true });
188+
const sketchFilename = entries
189+
.filter((entry) => entry.isFile() && entry.name.endsWith('.ino'))
190+
.map(({ name }) => name)
191+
.sort((left, right) => left.localeCompare(right))[0];
192+
if (sketchFilename) {
193+
return join(path, sketchFilename);
194+
}
195+
// If no sketches found in the folder, but the folder exists,
196+
// return with the path of the empty folder and let IDE2's frontend
197+
// figure out the workspace root.
198+
return path;
199+
} catch (err) {
200+
throw err;
201+
}
202+
}
203+
204+
private async resolvePath(
205+
maybePath: string,
206+
cwd: string
207+
): Promise<string | undefined> {
208+
if (!isValidPath(maybePath)) {
209+
return undefined;
210+
}
211+
if (isAbsolute(maybePath)) {
212+
return maybePath;
213+
}
214+
try {
215+
const resolved = await fs.realpath(resolve(cwd, maybePath));
216+
return resolved;
217+
} catch (err) {
218+
if ('code' in err && err.code === 'ENOENT') {
219+
return undefined;
220+
}
221+
throw err;
222+
}
156223
}
157224

158225
protected override async launch(
@@ -163,12 +230,15 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
163230
// 1. The `open-file` command has been received by the app, rejecting the promise
164231
// 2. A short timeout resolves the promise automatically, falling back to the usual app launch
165232
await this.openFilePromise.promise;
166-
} catch {
167-
// Application has received the `open-file` event and will skip the default application launch
168-
return;
233+
} catch (err) {
234+
if (err instanceof InterruptWorkspaceRestoreError) {
235+
// Application has received the `open-file` event and will skip the default application launch
236+
return;
237+
}
238+
throw err;
169239
}
170240

171-
if (!os.isOSX && (await this.launchFromArgs(params))) {
241+
if (await this.launchFromArgs(params)) {
172242
// Application has received a file in its arguments and will skip the default application launch
173243
return;
174244
}
@@ -182,7 +252,13 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
182252
`Restoring workspace roots: ${workspaces.map(({ file }) => file)}`
183253
);
184254
for (const workspace of workspaces) {
185-
if (await this.isValidSketchPath(workspace.file)) {
255+
const resolvedPath = await this.resolvePath(workspace.file, params.cwd);
256+
if (!resolvedPath) {
257+
continue;
258+
}
259+
const sketchFolderPath = await this.isValidSketchPath(resolvedPath);
260+
if (sketchFolderPath) {
261+
workspace.file = sketchFolderPath;
186262
if (this.isTempSketch.is(workspace.file)) {
187263
console.info(
188264
`Skipped opening sketch. The sketch was detected as temporary. Workspace path: ${workspace.file}.`
@@ -205,38 +281,40 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
205281
): Promise<boolean> {
206282
// Copy to prevent manipulation of original array
207283
const argCopy = [...params.argv];
208-
let uri: string | undefined;
209-
for (const possibleUri of argCopy) {
210-
if (
211-
possibleUri.endsWith('.ino') &&
212-
(await this.isValidSketchPath(possibleUri))
213-
) {
214-
uri = possibleUri;
284+
let path: string | undefined;
285+
for (const maybePath of argCopy) {
286+
const resolvedPath = await this.resolvePath(maybePath, params.cwd);
287+
if (!resolvedPath) {
288+
continue;
289+
}
290+
const sketchFolderPath = await this.isValidSketchPath(resolvedPath);
291+
if (sketchFolderPath) {
292+
path = sketchFolderPath;
215293
break;
216294
}
217295
}
218-
if (uri) {
219-
await this.openSketch(dirname(uri));
296+
if (path) {
297+
await this.openSketch(path);
220298
return true;
221299
}
222300
return false;
223301
}
224302

225303
private async openSketch(
226-
workspace: WorkspaceOptions | string
304+
workspaceOrPath: WorkspaceOptions | string
227305
): Promise<BrowserWindow> {
228306
const options = await this.getLastWindowOptions();
229307
let file: string;
230-
if (typeof workspace === 'object') {
231-
options.x = workspace.x;
232-
options.y = workspace.y;
233-
options.width = workspace.width;
234-
options.height = workspace.height;
235-
options.isMaximized = workspace.isMaximized;
236-
options.isFullScreen = workspace.isFullScreen;
237-
file = workspace.file;
308+
if (typeof workspaceOrPath === 'object') {
309+
options.x = workspaceOrPath.x;
310+
options.y = workspaceOrPath.y;
311+
options.width = workspaceOrPath.width;
312+
options.height = workspaceOrPath.height;
313+
options.isMaximized = workspaceOrPath.isMaximized;
314+
options.isFullScreen = workspaceOrPath.isFullScreen;
315+
file = workspaceOrPath.file;
238316
} else {
239-
file = workspace;
317+
file = workspaceOrPath;
240318
}
241319
const [uri, electronWindow] = await Promise.all([
242320
this.createWindowUri(),
@@ -486,3 +564,12 @@ export class ElectronMainApplication extends TheiaElectronMainApplication {
486564
return this._firstWindowId;
487565
}
488566
}
567+
568+
class InterruptWorkspaceRestoreError extends Error {
569+
constructor() {
570+
super(
571+
"Received 'open-file' event. Interrupting the default launch workflow."
572+
);
573+
Object.setPrototypeOf(this, InterruptWorkspaceRestoreError.prototype);
574+
}
575+
}

‎arduino-ide-extension/src/node/arduino-ide-backend-module.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
ArduinoFirmwareUploader,
55
ArduinoFirmwareUploaderPath,
66
} from '../common/protocol/arduino-firmware-uploader';
7-
87
import { ILogger } from '@theia/core/lib/common/logger';
98
import {
109
BackendApplicationContribution,
@@ -26,7 +25,7 @@ import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connec
2625
import { CoreClientProvider } from './core-client-provider';
2726
import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core';
2827
import { DefaultWorkspaceServer } from './theia/workspace/default-workspace-server';
29-
import { WorkspaceServer as TheiaWorkspaceServer } from '@theia/workspace/lib/common';
28+
import { WorkspaceServer as TheiaWorkspaceServer } from '@theia/workspace/lib/common/workspace-protocol';
3029
import { SketchesServiceImpl } from './sketches-service-impl';
3130
import {
3231
SketchesService,
@@ -40,7 +39,6 @@ import {
4039
ArduinoDaemon,
4140
ArduinoDaemonPath,
4241
} from '../common/protocol/arduino-daemon';
43-
4442
import { ConfigServiceImpl } from './config-service-impl';
4543
import { EnvVariablesServer as TheiaEnvVariablesServer } from '@theia/core/lib/common/env-variables';
4644
import { EnvVariablesServer } from './theia/env-variables/env-variables-server';

‎arduino-ide-extension/src/node/sketches-service-impl.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,22 @@ export class SketchesServiceImpl
187187
const sketch = await new Promise<SketchWithDetails>((resolve, reject) => {
188188
client.loadSketch(req, async (err, resp) => {
189189
if (err) {
190-
reject(
191-
isNotFoundError(err)
192-
? SketchesError.NotFound(err.details, uri)
193-
: err
194-
);
190+
let rejectWith: unknown = err;
191+
if (isNotFoundError(err)) {
192+
const invalidMainSketchFilePath = await isInvalidSketchNameError(
193+
err,
194+
requestSketchPath
195+
);
196+
if (invalidMainSketchFilePath) {
197+
rejectWith = SketchesError.InvalidName(
198+
err.details,
199+
FileUri.create(invalidMainSketchFilePath).toString()
200+
);
201+
} else {
202+
rejectWith = SketchesError.NotFound(err.details, uri);
203+
}
204+
}
205+
reject(rejectWith);
195206
return;
196207
}
197208
const responseSketchPath = maybeNormalizeDrive(resp.getLocationPath());
@@ -301,7 +312,10 @@ export class SketchesServiceImpl
301312
)} before marking it as recently opened.`
302313
);
303314
} catch (err) {
304-
if (SketchesError.NotFound.is(err)) {
315+
if (
316+
SketchesError.NotFound.is(err) ||
317+
SketchesError.InvalidName.is(err)
318+
) {
305319
this.logger.debug(
306320
`Could not load sketch from '${uri}'. Not marking as recently opened.`
307321
);
@@ -515,7 +529,7 @@ void loop() {
515529
const sketch = await this.loadSketch(uri);
516530
return sketch;
517531
} catch (err) {
518-
if (SketchesError.NotFound.is(err)) {
532+
if (SketchesError.NotFound.is(err) || SketchesError.InvalidName.is(err)) {
519533
return undefined;
520534
}
521535
throw err;
@@ -647,6 +661,63 @@ function isNotFoundError(err: unknown): err is ServiceError {
647661
return ServiceError.is(err) && err.code === 5; // `NOT_FOUND` https://grpc.github.io/grpc/core/md_doc_statuscodes.html
648662
}
649663

664+
/**
665+
* Tries to detect whether the error was caused by an invalid main sketch file name.
666+
* 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.
667+
* 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.
668+
* Nothing guarantees that the invalid existing main sketch file still exits by the time client performs the sketch move.
669+
*/
670+
async function isInvalidSketchNameError(
671+
cliErr: unknown,
672+
requestSketchPath: string
673+
): Promise<string | undefined> {
674+
if (isNotFoundError(cliErr)) {
675+
const ino = requestSketchPath.endsWith('.ino');
676+
if (ino) {
677+
const sketchFolderPath = path.dirname(requestSketchPath);
678+
const sketchName = path.basename(sketchFolderPath);
679+
const pattern = `${invalidSketchNameErrorRegExpPrefix}${path.join(
680+
sketchFolderPath,
681+
`${sketchName}.ino`
682+
)}`.replace(/\\/g, '\\\\'); // make windows path separator with \\ to have a valid regexp.
683+
if (new RegExp(pattern, 'i').test(cliErr.details)) {
684+
try {
685+
await fs.access(requestSketchPath);
686+
return requestSketchPath;
687+
} catch {
688+
return undefined;
689+
}
690+
}
691+
} else {
692+
try {
693+
const resources = await fs.readdir(requestSketchPath, {
694+
withFileTypes: true,
695+
});
696+
return (
697+
resources
698+
.filter((resource) => resource.isFile())
699+
.filter((resource) => resource.name.endsWith('.ino'))
700+
// A folder might contain multiple sketches. It's OK to ick the first one as IDE2 cannot do much,
701+
// but ensure a deterministic behavior as `readdir(3)` does not guarantee an order. Sort them.
702+
.sort(({ name: left }, { name: right }) =>
703+
left.localeCompare(right)
704+
)
705+
.map(({ name }) => name)
706+
.map((name) => path.join(requestSketchPath, name))[0]
707+
);
708+
} catch (err) {
709+
if ('code' in err && err.code === 'ENOTDIR') {
710+
return undefined;
711+
}
712+
throw err;
713+
}
714+
}
715+
}
716+
return undefined;
717+
}
718+
const invalidSketchNameErrorRegExpPrefix =
719+
'.*: main file missing from sketch: ';
720+
650721
/*
651722
* When a new sketch is created, add a suffix to distinguish it
652723
* from other new sketches I created today.

‎yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3139,6 +3139,11 @@
31393139
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812"
31403140
integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==
31413141

3142+
"@types/is-valid-path@^0.1.0":
3143+
version "0.1.0"
3144+
resolved "https://registry.yarnpkg.com/@types/is-valid-path/-/is-valid-path-0.1.0.tgz#d5c6e96801303112c9626d44268c6fabc72d272f"
3145+
integrity sha512-2ontWtpN8O2nf5S7EjDDJ0DwrRa2t7wmS3Wmo322yWYG6yFBYC1QCaLhz4Iz+mzJy8Kf4zP5yVyEd1ANPDmOFQ==
3146+
31423147
"@types/js-yaml@^3.12.2":
31433148
version "3.12.7"
31443149
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.7.tgz#330c5d97a3500e9c903210d6e49f02964af04a0e"

0 commit comments

Comments
 (0)
Please sign in to comment.