Skip to content

Commit 8405f2e

Browse files
author
Akos Kitta
committed
fix: enforce valid sketch folder name on Save as
Closes #1599 Signed-off-by: Akos Kitta <[email protected]>
1 parent 692f29f commit 8405f2e

File tree

15 files changed

+493
-74
lines changed

15 files changed

+493
-74
lines changed

Diff for: arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts

+8-9
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-ske
3030
import { SketchbookCommands } from '../widgets/sketchbook/sketchbook-commands';
3131
import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget';
3232
import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution';
33-
import { Command, CommandRegistry, Contribution, URI } from './contribution';
33+
import {
34+
Command,
35+
CommandRegistry,
36+
Contribution,
37+
Sketch,
38+
URI,
39+
} from './contribution';
3440

3541
@injectable()
3642
export class NewCloudSketch extends Contribution {
@@ -234,14 +240,7 @@ export class NewCloudSketch extends Contribution {
234240
input
235241
);
236242
}
237-
// This is how https://create.arduino.cc/editor/ works when renaming a sketch.
238-
if (/^[0-9a-zA-Z_]{1,36}$/.test(input)) {
239-
return '';
240-
}
241-
return nls.localize(
242-
'arduino/newCloudSketch/invalidSketchName',
243-
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
244-
);
243+
return Sketch.validateCloudSketchFolderName(input) ?? '';
245244
},
246245
},
247246
this.labelProvider,

Diff for: arduino-ide-extension/src/browser/contributions/save-as-sketch.ts

+72-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { inject, injectable } from '@theia/core/shared/inversify';
2-
import * as remote from '@theia/core/electron-shared/@electron/remote';
2+
import {
3+
dialog,
4+
getCurrentWindow,
5+
} from '@theia/core/electron-shared/@electron/remote';
36
import * as dateFormat from 'dateformat';
47
import { ArduinoMenus } from '../menu/arduino-menus';
58
import {
@@ -9,9 +12,14 @@ import {
912
CommandRegistry,
1013
MenuModelRegistry,
1114
KeybindingRegistry,
15+
Sketch,
1216
} from './contribution';
1317
import { nls } from '@theia/core/lib/common';
14-
import { ApplicationShell, NavigatableWidget, Saveable } from '@theia/core/lib/browser';
18+
import {
19+
ApplicationShell,
20+
NavigatableWidget,
21+
Saveable,
22+
} from '@theia/core/lib/browser';
1523
import { WindowService } from '@theia/core/lib/browser/window/window-service';
1624
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
1725
import { WorkspaceInput } from '@theia/workspace/lib/browser';
@@ -90,28 +98,19 @@ export class SaveAsSketch extends SketchContribution {
9098
: sketch.name
9199
);
92100
const defaultPath = await this.fileService.fsPath(defaultUri);
93-
const { filePath, canceled } = await remote.dialog.showSaveDialog(
94-
remote.getCurrentWindow(),
95-
{
96-
title: nls.localize(
97-
'arduino/sketch/saveFolderAs',
98-
'Save sketch folder as...'
99-
),
100-
defaultPath,
101-
}
102-
);
103-
if (!filePath || canceled) {
104-
return false;
105-
}
106-
const destinationUri = await this.fileSystemExt.getUri(filePath);
101+
const destinationUri = await this.promptForSketchFolderName(defaultPath);
107102
if (!destinationUri) {
108103
return false;
109104
}
110105
const workspaceUri = await this.sketchService.copy(sketch, {
111106
destinationUri,
112107
});
113108
if (workspaceUri) {
114-
await this.saveOntoCopiedSketch(sketch.mainFileUri, sketch.uri, workspaceUri);
109+
await this.saveOntoCopiedSketch(
110+
sketch.mainFileUri,
111+
sketch.uri,
112+
workspaceUri
113+
);
115114
if (markAsRecentlyOpened) {
116115
this.sketchService.markAsRecentlyOpened(workspaceUri);
117116
}
@@ -133,6 +132,62 @@ export class SaveAsSketch extends SketchContribution {
133132
return !!workspaceUri;
134133
}
135134

135+
/**
136+
* Prompts for the new sketch folder name until a valid one is give,
137+
* then resolves with the destination sketch folder URI string,
138+
* or `undefined` if the operation was canceled.
139+
*/
140+
private async promptForSketchFolderName(
141+
defaultPath: string
142+
): Promise<string | undefined> {
143+
let sketchFolderDestinationUri: string | undefined;
144+
while (!sketchFolderDestinationUri) {
145+
const { filePath } = await dialog.showSaveDialog(getCurrentWindow(), {
146+
title: nls.localize(
147+
'arduino/sketch/saveFolderAs',
148+
'Save sketch folder as...'
149+
),
150+
defaultPath,
151+
});
152+
if (!filePath) {
153+
return undefined;
154+
}
155+
const destinationUri = await this.fileSystemExt.getUri(filePath);
156+
const sketchFolderName = new URI(destinationUri).path.base;
157+
const errorMessage = Sketch.validateSketchFolderName(sketchFolderName);
158+
if (errorMessage) {
159+
const message = `
160+
${nls.localize(
161+
'arduino/sketch/invalidSketchFolderNameTitle',
162+
"Invalid sketch folder name: '{0}'",
163+
sketchFolderName
164+
)}
165+
166+
${errorMessage}
167+
168+
${nls.localize(
169+
'arduino/sketch/editInvalidSketchFolderName',
170+
'Do you want to try to save the sketch folder with a different name?'
171+
)}`.trim();
172+
defaultPath = filePath;
173+
const { response } = await dialog.showMessageBox(getCurrentWindow(), {
174+
message,
175+
buttons: [
176+
nls.localize('vscode/issueMainService/cancel', 'Cancel'),
177+
nls.localize('vscode/extensionsUtils/yes', 'Yes'),
178+
],
179+
});
180+
// cancel
181+
if (response === 0) {
182+
return undefined;
183+
}
184+
} else {
185+
sketchFolderDestinationUri = destinationUri;
186+
}
187+
}
188+
return sketchFolderDestinationUri;
189+
}
190+
136191
private async saveOntoCopiedSketch(mainFileUri: string, sketchUri: string, newSketchUri: string): Promise<void> {
137192
const widgets = this.applicationShell.widgets;
138193
const snapshots = new Map<string, object>();

Diff for: arduino-ide-extension/src/browser/contributions/sketchbook.ts

+85-8
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1-
import { injectable } from '@theia/core/shared/inversify';
1+
import { dialog } from '@theia/core/electron-shared/@electron/remote';
22
import { CommandHandler } from '@theia/core/lib/common/command';
3-
import { MenuModelRegistry } from './contribution';
3+
import { nls } from '@theia/core/lib/common/nls';
4+
import { inject, injectable } from '@theia/core/shared/inversify';
5+
import { SketchContainer, SketchesError } from '../../common/protocol';
46
import { ArduinoMenus } from '../menu/arduino-menus';
7+
import { WindowServiceExt } from '../theia/core/window-service-ext';
8+
import { MenuModelRegistry, URI } from './contribution';
59
import { Examples } from './examples';
6-
import { SketchContainer, SketchesError } from '../../common/protocol';
710
import { OpenSketch } from './open-sketch';
8-
import { nls } from '@theia/core/lib/common/nls';
911

1012
@injectable()
1113
export class Sketchbook extends Examples {
14+
@inject(WindowServiceExt)
15+
private readonly windowService: WindowServiceExt;
16+
17+
/**
18+
* Prompt invalid sketch folder names only once per sketchbook.
19+
*/
20+
private _sketchbookUriOfInvalidSketches: string | undefined;
21+
1222
override onStart(): void {
1323
this.sketchServiceClient.onSketchbookDidChange(() => this.update());
1424
this.configService.onDidChangeSketchDirUri(() => this.update());
@@ -19,10 +29,77 @@ export class Sketchbook extends Examples {
1929
}
2030

2131
protected override update(): void {
22-
this.sketchService.getSketches({}).then((container) => {
23-
this.register(container);
24-
this.menuManager.update();
25-
});
32+
this.sketchService
33+
.getSketches({})
34+
.then(({ container, sketchesWithInvalidFolderName }) => {
35+
const currentSketchbookUri = this.configService
36+
.tryGetSketchDirUri()
37+
?.toString();
38+
if (
39+
currentSketchbookUri &&
40+
currentSketchbookUri !== this._sketchbookUriOfInvalidSketches
41+
) {
42+
this._sketchbookUriOfInvalidSketches = currentSketchbookUri;
43+
// Logs invalid sketch folder names once per sketchbook
44+
// If sketchbook changes, logs the invalid sketch folder names, if any, again.
45+
nls.localize(
46+
'arduino/sketch/ignoringSketchWithBadNameMessage',
47+
'The sketch "{0}" cannot be used.\nSketch names must contain only basic letters and numbers\n(ASCII-only with no spaces, and it cannot start with a number).\nTo get rid of this message, remove the sketch from\n{1}',
48+
'name',
49+
'path'
50+
);
51+
if (sketchesWithInvalidFolderName.length) {
52+
this.windowService
53+
.isPrimaryWindow()
54+
.then(async (isLastFocusedWindow) => {
55+
if (!isLastFocusedWindow) {
56+
return;
57+
}
58+
const dialogInputs = await Promise.all(
59+
sketchesWithInvalidFolderName.map(async (ref) => {
60+
const fsPath = await this.fileService.fsPath(
61+
new URI(ref.uri)
62+
);
63+
return {
64+
name: ref.name,
65+
path: fsPath,
66+
};
67+
})
68+
);
69+
dialogInputs.forEach(({ name, path }) =>
70+
dialog.showErrorBox(
71+
nls.localize(
72+
'arduino/sketch/ignoringSketchWithBadNameTitle',
73+
'Ignoring sketch with bad name'
74+
),
75+
`
76+
${nls.localize(
77+
'arduino/sketch/ignoringSketchWithBadNameMessage0',
78+
'The sketch "{0}" cannot be used.',
79+
name
80+
)}
81+
${nls.localize(
82+
'arduino/sketch/ignoringSketchWithBadNameMessage1',
83+
'Sketch names must contain only basic letters and numbers'
84+
)}
85+
${nls.localize(
86+
'arduino/sketch/ignoringSketchWithBadNameMessage2',
87+
'(ASCII-only with no spaces, and it cannot start with a number).'
88+
)}
89+
${nls.localize(
90+
'arduino/sketch/ignoringSketchWithBadNameMessage3',
91+
'To get rid of this message, remove the sketch from'
92+
)}
93+
${path}
94+
`.trim()
95+
)
96+
);
97+
});
98+
}
99+
}
100+
this.register(container);
101+
this.menuManager.update();
102+
});
26103
}
27104

28105
override registerMenus(registry: MenuModelRegistry): void {

Diff for: arduino-ide-extension/src/browser/theia/core/window-service-ext.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,10 @@ export interface WindowServiceExt {
66
* Returns with a promise that resolves to `true` if the current window is the first window.
77
*/
88
isFirstWindow(): Promise<boolean>;
9+
/**
10+
* If the first window is still open and the caller is the first window, it is `true`. Otherwise, `false`.
11+
* If the first window is closed, calling this method will be `true` only for the window which has last focused.
12+
*/
13+
isPrimaryWindow(): Promise<boolean>;
914
reload(options?: StartupTask.Owner): void;
1015
}

Diff for: arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export class SketchesServiceClientImpl
9090
if (!sketchDirUri) {
9191
return;
9292
}
93-
const container = await this.sketchService.getSketches({
93+
const { container } = await this.sketchService.getSketches({
9494
uri: sketchDirUri.toString(),
9595
});
9696
for (const sketch of SketchContainer.toArray(container)) {

Diff for: arduino-ide-extension/src/common/protocol/sketches-service.ts

+52-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ApplicationError } from '@theia/core/lib/common/application-error';
2+
import { nls } from '@theia/core/lib/common/nls';
23
import URI from '@theia/core/lib/common/uri';
34

45
export namespace SketchesError {
@@ -31,9 +32,14 @@ export const SketchesService = Symbol('SketchesService');
3132
export interface SketchesService {
3233
/**
3334
* Resolves to a sketch container representing the hierarchical structure of the sketches.
34-
* If `uri` is not given, `directories.user` will be user instead.
35+
* If `uri` is not given, `directories.user` will be user instead. The `sketchesWithInvalidFolderName`
36+
* array might contain sketches that were discovered, but due to their invalid name they were removed
37+
* from the `container`.
3538
*/
36-
getSketches({ uri }: { uri?: string }): Promise<SketchContainer>;
39+
getSketches({ uri }: { uri?: string }): Promise<{
40+
container: SketchContainer;
41+
sketchesWithInvalidFolderName: SketchRef[];
42+
}>;
3743

3844
/**
3945
* This is the TS implementation of `SketchLoad` from the CLI and should be replaced with a gRPC call eventually.
@@ -140,6 +146,50 @@ export interface Sketch extends SketchRef {
140146
readonly rootFolderFileUris: string[]; // `RootFolderFiles` (does not include the main sketch file)
141147
}
142148
export namespace Sketch {
149+
export const invalidSketchFolderNameMessage = nls.localize(
150+
'arduino/sketch/invalidSketchName',
151+
'Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.'
152+
);
153+
const invalidCloudSketchFolderNameMessage = nls.localize(
154+
'arduino/sketch/invalidCloudSketchName',
155+
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
156+
);
157+
/**
158+
* `undefined` if the candidate sketch name is valid. Otherwise, the validation error message.
159+
* Based on the [specs](https://arduino.github.io/arduino-cli/latest/sketch-specification/#sketch-folders-and-files).
160+
*/
161+
export function validateSketchFolderName(
162+
candidate: string
163+
): string | undefined {
164+
return /^[0-9a-zA-Z]{1}[0-9a-zA-Z_\.-]{0,62}$/.test(candidate)
165+
? undefined
166+
: invalidSketchFolderNameMessage;
167+
}
168+
169+
/**
170+
* `undefined` if the candidate sketch name is valid. Otherwise, the validation error message.
171+
* Based on how https://create.arduino.cc/editor/ works.
172+
*/
173+
export function validateCloudSketchFolderName(
174+
candidate: string
175+
): string | undefined {
176+
return /^[0-9a-zA-Z_]{1,36}$/.test(candidate)
177+
? undefined
178+
: invalidCloudSketchFolderNameMessage;
179+
}
180+
181+
/**
182+
* Transforms the valid local sketch name into a valid cloud sketch name by replacing dots and dashes with underscore and trimming the length after 36 characters.
183+
* Throws an error if `candidate` is not valid.
184+
*/
185+
export function toValidCloudSketchFolderName(candidate: string): string {
186+
const errorMessage = validateSketchFolderName(candidate);
187+
if (errorMessage) {
188+
throw new Error(errorMessage);
189+
}
190+
return candidate.replace(/\./g, '_').replace(/-/g, '_').slice(0, 36);
191+
}
192+
143193
export function is(arg: unknown): arg is Sketch {
144194
if (!SketchRef.is(arg)) {
145195
return false;

Diff for: arduino-ide-extension/src/electron-browser/theia/core/electron-window-service.ts

+5
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ export class ElectronWindowService
7777
return this._firstWindow.promise;
7878
}
7979

80+
async isPrimaryWindow(): Promise<boolean> {
81+
const windowId = remote.getCurrentWindow().id;
82+
return this.mainWindowServiceExt.isPrimaryWindow(windowId);
83+
}
84+
8085
// Overridden because the default Theia implementation destroys the additional properties of the `options` arg, such as `tasks`.
8186
override openNewWindow(url: string, options?: NewWindowOptions): undefined {
8287
return this.delegate.openNewWindow(url, options);

Diff for: arduino-ide-extension/src/electron-common/electron-main-window-service-ext.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export const ElectronMainWindowServiceExt = Symbol(
44
);
55
export interface ElectronMainWindowServiceExt {
66
isFirstWindow(windowId: number): Promise<boolean>;
7+
isPrimaryWindow(windowId: number): Promise<boolean>;
78
}

0 commit comments

Comments
 (0)