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 2cb9d64

Browse files
author
Akos Kitta
committedJan 19, 2023
fix: enforce valid sketch folder name on Save as
Closes #1599 Signed-off-by: Akos Kitta <[email protected]>
1 parent 692f29f commit 2cb9d64

File tree

13 files changed

+433
-71
lines changed

13 files changed

+433
-71
lines changed
 

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

Lines changed: 8 additions & 9 deletions
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,

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

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as remote from '@theia/core/electron-shared/@electron/remote';
33
import * as dateFormat from 'dateformat';
44
import { ArduinoMenus } from '../menu/arduino-menus';
55
import {
6+
Sketch,
67
SketchContribution,
78
URI,
89
Command,
@@ -90,20 +91,9 @@ export class SaveAsSketch extends SketchContribution {
9091
: sketch.name
9192
);
9293
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-
}
94+
const destinationUri = await this.promptSketchFolderDestination(
95+
defaultPath
10296
);
103-
if (!filePath || canceled) {
104-
return false;
105-
}
106-
const destinationUri = await this.fileSystemExt.getUri(filePath);
10797
if (!destinationUri) {
10898
return false;
10999
}
@@ -133,6 +123,67 @@ export class SaveAsSketch extends SketchContribution {
133123
return !!workspaceUri;
134124
}
135125

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

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

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,112 @@
1-
import { injectable } from '@theia/core/shared/inversify';
2-
import { CommandHandler } from '@theia/core/lib/common/command';
3-
import { MenuModelRegistry } from './contribution';
1+
import * as remote from '@theia/core/electron-shared/@electron/remote';
2+
import type { CommandHandler } from '@theia/core/lib/common/command';
3+
import { nls } from '@theia/core/lib/common/nls';
4+
import { waitForEvent } from '@theia/core/lib/common/promise-util';
5+
import { inject, injectable } from '@theia/core/shared/inversify';
6+
import { SketchContainer, SketchesError } from '../../common/protocol';
47
import { ArduinoMenus } from '../menu/arduino-menus';
8+
import { WindowServiceExt } from '../theia/core/window-service-ext';
9+
import { MenuModelRegistry, URI } from './contribution';
510
import { Examples } from './examples';
6-
import { SketchContainer, SketchesError } from '../../common/protocol';
711
import { OpenSketch } from './open-sketch';
8-
import { nls } from '@theia/core/lib/common/nls';
912

1013
@injectable()
1114
export class Sketchbook extends Examples {
15+
@inject(WindowServiceExt)
16+
private readonly windowService: WindowServiceExt;
17+
private _currentSketchbookPath: string | undefined;
18+
1219
override onStart(): void {
1320
this.sketchServiceClient.onSketchbookDidChange(() => this.update());
1421
this.configService.onDidChangeSketchDirUri(() => this.update());
22+
// If this window is changing the settings, and sketchbook location is included in the changeset,
23+
// then the users must be warned about the invalid sketches from the new sketchbook.
24+
this.settingsService.onWillChangeSoon(async (unsavedSettings) => {
25+
// The sketchbook location is about to change.
26+
if (unsavedSettings.sketchbookPath !== this._currentSketchbookPath) {
27+
// Listen on both the settings and sketchbook location did change events
28+
const timeout = 5_000;
29+
const results = await Promise.allSettled([
30+
waitForEvent(this.settingsService.onDidChange, timeout),
31+
waitForEvent(this.configService.onDidChangeSketchDirUri, timeout),
32+
]);
33+
if (results.every(({ status }) => status === 'fulfilled')) {
34+
this.doUpdate(true);
35+
} else {
36+
console.warn(
37+
'Expected the sketchbook location to change but it did not.'
38+
);
39+
}
40+
}
41+
});
42+
const maybeUpdateCurrentSketchbookPath = async (
43+
sketchDirUri: URI | undefined = this.configService.tryGetSketchDirUri()
44+
) => {
45+
if (sketchDirUri) {
46+
const candidateSketchbookPath = await this.fileService.fsPath(
47+
sketchDirUri
48+
);
49+
if (candidateSketchbookPath !== this._currentSketchbookPath) {
50+
this._currentSketchbookPath = candidateSketchbookPath;
51+
}
52+
}
53+
};
54+
this.configService.onDidChangeSketchDirUri(
55+
maybeUpdateCurrentSketchbookPath
56+
);
57+
maybeUpdateCurrentSketchbookPath();
1558
}
1659

1760
override async onReady(): Promise<void> {
18-
this.update();
61+
this.windowService
62+
.isFirstWindow()
63+
// only the first window should warn about invalid sketch folder names.
64+
.then((firstWindow) => this.doUpdate(firstWindow));
1965
}
2066

2167
protected override update(): void {
22-
this.sketchService.getSketches({}).then((container) => {
23-
this.register(container);
24-
this.menuManager.update();
25-
});
68+
// on regular menu updates, do not raise warning dialogs about invalid sketch folder names
69+
this.doUpdate(false);
70+
}
71+
72+
private doUpdate(raiseInvalidSketchFoldersWarning?: boolean): void {
73+
this.sketchService
74+
.getSketches({})
75+
.then(({ container, sketchesInInvalidFolder }) => {
76+
if (
77+
raiseInvalidSketchFoldersWarning &&
78+
sketchesInInvalidFolder.length
79+
) {
80+
Promise.all(
81+
sketchesInInvalidFolder.map(async (ref) => {
82+
const fsPath = await this.fileService.fsPath(new URI(ref.uri));
83+
return {
84+
name: ref.name,
85+
path: fsPath,
86+
};
87+
})
88+
).then((dialogInputs) =>
89+
dialogInputs.reduce(async (acc, { name, path }) => {
90+
await acc;
91+
return remote.dialog.showMessageBox(remote.getCurrentWindow(), {
92+
title: nls.localize(
93+
'arduino/sketch/ignoringSketchWithBadNameTitle',
94+
'Ignoring sketch with bad name'
95+
),
96+
message: nls.localize(
97+
'arduino/sketch/ignoringSketchWithBadNameMessage',
98+
'The sketch "{0}" cannot be used. Sketch names must contain only basic letters and numbers. (ASCII-only with no spaces, and it cannot start with a number). To get rid of this message, remove the sketch from {1}',
99+
name,
100+
path
101+
),
102+
type: 'warning',
103+
}) as Promise<unknown>;
104+
}, Promise.resolve())
105+
);
106+
}
107+
this.register(container);
108+
this.menuManager.update();
109+
});
26110
}
27111

28112
override registerMenus(registry: MenuModelRegistry): void {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface VerifySketchParams {
2727
}
2828

2929
/**
30-
* - `"idle"` when neither verify, not upload is running,
30+
* - `"idle"` when neither verify, nor upload is running,
3131
* - `"explicit-verify"` when only verify is running triggered by the user, and
3232
* - `"automatic-verify"` is when the automatic verify phase is running as part of an upload triggered by the user.
3333
*/

‎arduino-ide-extension/src/browser/dialogs/settings/settings.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ export class SettingsService {
112112
readonly onDidChange = this.onDidChangeEmitter.event;
113113
protected readonly onDidResetEmitter = new Emitter<Readonly<Settings>>();
114114
readonly onDidReset = this.onDidResetEmitter.event;
115+
protected readonly onWillChangeSoonEmitter = new Emitter<
116+
Readonly<Settings>
117+
>();
118+
readonly onWillChangeSoon = this.onWillChangeSoonEmitter.event;
115119

116120
protected ready = new Deferred<void>();
117121
protected _settings: Settings;
@@ -304,6 +308,7 @@ export class SettingsService {
304308
(config as any).sketchDirUri = sketchDirUri;
305309
(config as any).network = network;
306310
(config as any).locale = currentLanguage;
311+
this.onWillChangeSoonEmitter.fire(this._settings);
307312

308313
await Promise.all([
309314
this.savePreference('editor.fontSize', editorFontSize),
@@ -319,7 +324,6 @@ export class SettingsService {
319324
this.savePreference(SHOW_ALL_FILES_SETTING, sketchbookShowAllFiles),
320325
this.configService.setConfiguration(config),
321326
]);
322-
this.onDidChangeEmitter.fire(this._settings);
323327

324328
// after saving all the settings, if we need to change the language we need to perform a reload
325329
// Only reload if the language differs from the current locale. `nls.locale === undefined` signals english as well
@@ -335,6 +339,7 @@ export class SettingsService {
335339
this.commandService.executeCommand(ElectronCommands.RELOAD.id);
336340
}
337341

342+
this.onDidChangeEmitter.fire(this._settings);
338343
return true;
339344
}
340345
}

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

Lines changed: 1 addition & 1 deletion
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)) {

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

Lines changed: 53 additions & 2 deletions
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 `sketchesInInvalidFolder`
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+
sketchesInInvalidFolder: 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,51 @@ export interface Sketch extends SketchRef {
140146
readonly rootFolderFileUris: string[]; // `RootFolderFiles` (does not include the main sketch file)
141147
}
142148
export namespace Sketch {
149+
// (non-API) exported for the tests
150+
export const invalidSketchFolderNameMessage = nls.localize(
151+
'arduino/sketch/invalidSketchName',
152+
'Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.'
153+
);
154+
const invalidCloudSketchFolderNameMessage = nls.localize(
155+
'arduino/sketch/invalidCloudSketchName',
156+
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
157+
);
158+
/**
159+
* `undefined` if the candidate sketch folder name is valid. Otherwise, the validation error message.
160+
* Based on the [specs](https://arduino.github.io/arduino-cli/latest/sketch-specification/#sketch-folders-and-files).
161+
*/
162+
export function validateSketchFolderName(
163+
candidate: string
164+
): string | undefined {
165+
return /^[0-9a-zA-Z]{1}[0-9a-zA-Z_\.-]{0,62}$/.test(candidate)
166+
? undefined
167+
: invalidSketchFolderNameMessage;
168+
}
169+
170+
/**
171+
* `undefined` if the candidate cloud sketch folder name is valid. Otherwise, the validation error message.
172+
* Based on how https://create.arduino.cc/editor/ works.
173+
*/
174+
export function validateCloudSketchFolderName(
175+
candidate: string
176+
): string | undefined {
177+
return /^[0-9a-zA-Z_]{1,36}$/.test(candidate)
178+
? undefined
179+
: invalidCloudSketchFolderNameMessage;
180+
}
181+
182+
/**
183+
* 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.
184+
* Throws an error if `candidate` is not valid.
185+
*/
186+
export function toValidCloudSketchFolderName(candidate: string): string {
187+
const errorMessage = validateSketchFolderName(candidate);
188+
if (errorMessage) {
189+
throw new Error(errorMessage);
190+
}
191+
return candidate.replace(/\./g, '_').replace(/-/g, '_').slice(0, 36);
192+
}
193+
143194
export function is(arg: unknown): arg is Sketch {
144195
if (!SketchRef.is(arg)) {
145196
return false;

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

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,16 +79,25 @@ export class SketchesServiceImpl
7979
@inject(IsTempSketch)
8080
private readonly isTempSketch: IsTempSketch;
8181

82-
async getSketches({ uri }: { uri?: string }): Promise<SketchContainer> {
82+
async getSketches({ uri }: { uri?: string }): Promise<{
83+
container: SketchContainer;
84+
sketchesInInvalidFolder: SketchRef[];
85+
}> {
8386
const root = await this.root(uri);
8487
if (!root) {
8588
this.logger.warn(`Could not derive sketchbook root from ${uri}.`);
86-
return SketchContainer.create('');
89+
return {
90+
container: SketchContainer.create(''),
91+
sketchesInInvalidFolder: [],
92+
};
8793
}
8894
const rootExists = await exists(root);
8995
if (!rootExists) {
9096
this.logger.warn(`Sketchbook root ${root} does not exist.`);
91-
return SketchContainer.create('');
97+
return {
98+
container: SketchContainer.create(''),
99+
sketchesInInvalidFolder: [],
100+
};
92101
}
93102
const container = <Mutable<SketchContainer>>(
94103
SketchContainer.create(uri ? path.basename(root) : 'Sketchbook')
@@ -754,7 +763,10 @@ export async function discoverSketches(
754763
root: string,
755764
container: Mutable<SketchContainer>,
756765
logger?: ILogger
757-
): Promise<SketchContainer> {
766+
): Promise<{
767+
container: SketchContainer;
768+
sketchesInInvalidFolder: SketchRef[];
769+
}> {
758770
const pathToAllSketchFiles = await globSketches(
759771
'/!(libraries|hardware)/**/*.{ino,pde}',
760772
root
@@ -818,7 +830,7 @@ export async function discoverSketches(
818830

819831
// If the container has a sketch with the same name, it cannot have a child container.
820832
// See above example about how to ignore nested sketches.
821-
const prune = (
833+
const pruneNestedSketches = (
822834
container: Mutable<SketchContainer>
823835
): Mutable<SketchContainer> => {
824836
for (const sketch of container.sketches) {
@@ -829,7 +841,23 @@ export async function discoverSketches(
829841
container.children.splice(childContainerIndex, 1);
830842
}
831843
}
832-
container.children.forEach(prune);
844+
container.children.forEach(pruneNestedSketches);
845+
return container;
846+
};
847+
848+
const sketchesInInvalidFolder: SketchRef[] = [];
849+
const pruneInvalidSketchFolders = (
850+
container: Mutable<SketchContainer>
851+
): Mutable<SketchContainer> => {
852+
for (let i = container.sketches.length - 1; i >= 0; i--) {
853+
const sketch = container.sketches[i];
854+
const errorMessage = Sketch.validateSketchFolderName(sketch.name);
855+
if (errorMessage) {
856+
container.sketches.splice(i, 1);
857+
sketchesInInvalidFolder.push(sketch);
858+
}
859+
}
860+
container.children.forEach(pruneInvalidSketchFolders);
833861
return container;
834862
};
835863

@@ -876,7 +904,13 @@ export async function discoverSketches(
876904
uri: FileUri.create(path.dirname(pathToSketchFile)).toString(),
877905
});
878906
}
879-
return prune(container);
907+
let pruned = pruneNestedSketches(container);
908+
pruned = pruneInvalidSketchFolders(container);
909+
// Same order as in `File` > `Sketchbook`
910+
sketchesInInvalidFolder.sort((left, right) =>
911+
left.name.toLocaleLowerCase().localeCompare(right.name)
912+
);
913+
return { container: pruned, sketchesInInvalidFolder };
880914
}
881915

882916
async function globSketches(pattern: string, root: string): Promise<string[]> {
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { expect } from 'chai';
2+
import { Sketch } from '../../common/protocol';
3+
4+
describe('sketch', () => {
5+
describe('validateSketchFolderName', () => {
6+
(
7+
[
8+
['sketch', true],
9+
['can-contain-slash-and-dot.ino', true],
10+
['regex++', false],
11+
['dots...', true],
12+
['No Spaces', false],
13+
['_invalidToStartWithUnderscore', false],
14+
['Invalid+Char.ino', false],
15+
['', false],
16+
['/', false],
17+
['//trash/', false],
18+
[
19+
'63Length_012345678901234567890123456789012345678901234567890123',
20+
true,
21+
],
22+
[
23+
'TooLong__0123456789012345678901234567890123456789012345678901234',
24+
false,
25+
],
26+
] as [string, boolean][]
27+
).map(([input, expected]) => {
28+
it(`'${input}' should ${
29+
!expected ? 'not ' : ''
30+
}be a valid sketch folder name`, () => {
31+
const actual = Sketch.validateSketchFolderName(input);
32+
if (expected) {
33+
expect(actual).to.be.undefined;
34+
} else {
35+
expect(actual).to.be.not.undefined;
36+
expect(actual?.length).to.be.greaterThan(0);
37+
}
38+
});
39+
});
40+
});
41+
42+
describe('validateCloudSketchFolderName', () => {
43+
(
44+
[
45+
['sketch', true],
46+
['no-dashes', false],
47+
['no-dots', false],
48+
['No Spaces', false],
49+
['_canStartWithUnderscore', true],
50+
['Invalid+Char.ino', false],
51+
['', false],
52+
['/', false],
53+
['//trash/', false],
54+
['36Length_012345678901234567890123456', true],
55+
['TooLong__0123456789012345678901234567', false],
56+
] as [string, boolean][]
57+
).map(([input, expected]) => {
58+
it(`'${input}' should ${
59+
!expected ? 'not ' : ''
60+
}be a valid cloud sketch folder name`, () => {
61+
const actual = Sketch.validateCloudSketchFolderName(input);
62+
if (expected) {
63+
expect(actual).to.be.undefined;
64+
} else {
65+
expect(actual).to.be.not.undefined;
66+
expect(actual?.length).to.be.greaterThan(0);
67+
}
68+
});
69+
});
70+
});
71+
72+
describe('toValidCloudSketchFolderName', () => {
73+
(
74+
[
75+
['sketch', 'sketch'],
76+
['can-contain-slash-and-dot.ino', 'can_contain_slash_and_dot_ino'],
77+
['regex++'],
78+
['dots...', 'dots___'],
79+
['No Spaces'],
80+
['_invalidToStartWithUnderscore'],
81+
['Invalid+Char.ino'],
82+
[''],
83+
['/'],
84+
['//trash/'],
85+
[
86+
'63Length_012345678901234567890123456789012345678901234567890123',
87+
'63Length_012345678901234567890123456',
88+
],
89+
['TooLong__0123456789012345678901234567890123456789012345678901234'],
90+
] as [string, string?][]
91+
).map(([input, expected]) => {
92+
it(`'${input}' should ${expected ? '' : 'not '}map the ${
93+
!expected ? 'invalid ' : ''
94+
}sketch folder name to a valid cloud sketch folder name${
95+
expected ? `: '${expected}'` : ''
96+
}`, () => {
97+
if (!expected) {
98+
try {
99+
Sketch.toValidCloudSketchFolderName(input);
100+
throw new Error(
101+
`Expected an error when mapping ${input} to a valid sketch folder name.`
102+
);
103+
} catch (err) {
104+
if (err instanceof Error) {
105+
expect(err.message).to.be.equal(
106+
Sketch.invalidSketchFolderNameMessage
107+
);
108+
} else {
109+
throw err;
110+
}
111+
}
112+
} else {
113+
const actual = Sketch.toValidCloudSketchFolderName(input);
114+
expect(actual).to.be.equal(expected);
115+
}
116+
});
117+
});
118+
});
119+
});

‎arduino-ide-extension/src/test/node/__test_sketchbook__/bar++ 2/bar++ 2.ino

Whitespace-only changes.

‎arduino-ide-extension/src/test/node/__test_sketchbook__/bar++/foo++/foo++.ino

Whitespace-only changes.

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

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,41 @@ const emptySketchbook = join(testSketchbook, 'empty');
2121

2222
describe('discover-sketches', () => {
2323
it('should recursively discover all sketches in a folder', async () => {
24-
const actual = await discoverSketches(
24+
const { container, sketchesInInvalidFolder } = await discoverSketches(
2525
testSketchbook,
2626
SketchContainer.create('test')
2727
);
28-
containersDeepEquals(
29-
actual,
28+
objectDeepEquals(
29+
container,
3030
expectedTestSketchbookContainer(
3131
testSketchbook,
3232
testSketchbookContainerTemplate
3333
)
3434
);
35+
assert.equal(sketchesInInvalidFolder.length, 2);
36+
objectDeepEquals(
37+
sketchesInInvalidFolder[0],
38+
adjustUri(testSketchbook, {
39+
name: 'bar++',
40+
uri: 'template://bar%2B%2B',
41+
})
42+
);
43+
objectDeepEquals(
44+
sketchesInInvalidFolder[1],
45+
adjustUri(testSketchbook, {
46+
name: 'bar++ 2',
47+
uri: 'template://bar%2B%2B%202',
48+
})
49+
);
3550
});
3651

3752
it('should handle when the sketchbook is a sketch folder', async () => {
38-
const actual = await discoverSketches(
53+
const { container } = await discoverSketches(
3954
sketchFolderAsSketchbook,
4055
SketchContainer.create('foo-bar')
4156
);
4257
const name = basename(sketchFolderAsSketchbook);
43-
containersDeepEquals(actual, {
58+
objectDeepEquals(container, {
4459
children: [],
4560
label: 'foo-bar',
4661
sketches: [
@@ -53,18 +68,15 @@ describe('discover-sketches', () => {
5368
});
5469

5570
it('should handle empty sketchbook', async () => {
56-
const actual = await discoverSketches(
71+
const { container } = await discoverSketches(
5772
emptySketchbook,
5873
SketchContainer.create('empty')
5974
);
60-
containersDeepEquals(actual, SketchContainer.create('empty'));
75+
objectDeepEquals(container, SketchContainer.create('empty'));
6176
});
6277
});
6378

64-
function containersDeepEquals(
65-
actual: SketchContainer,
66-
expected: SketchContainer
67-
) {
79+
function objectDeepEquals(actual: unknown, expected: unknown) {
6880
const stableActual = JSON.parse(stableJsonStringify(actual));
6981
const stableExpected = JSON.parse(stableJsonStringify(expected));
7082
assert.deepEqual(stableActual, stableExpected);
@@ -81,18 +93,10 @@ function expectedTestSketchbookContainer(
8193
containerTemplate: SketchContainer,
8294
label?: string
8395
): SketchContainer {
84-
let rootUri = FileUri.create(rootPath).toString();
85-
if (rootUri.charAt(rootUri.length - 1) !== '/') {
86-
rootUri += '/';
87-
}
88-
const adjustUri = (sketch: Mutable<SketchRef>) => {
89-
assert.equal(sketch.uri.startsWith('template://'), true);
90-
assert.equal(sketch.uri.startsWith('template:///'), false);
91-
sketch.uri = sketch.uri.replace('template://', rootUri).toString();
92-
return sketch;
93-
};
9496
const adjustContainer = (container: SketchContainer) => {
95-
container.sketches.forEach(adjustUri);
97+
container.sketches.forEach((templateRef) =>
98+
adjustUri(rootPath, templateRef)
99+
);
96100
container.children.forEach(adjustContainer);
97101
return <Mutable<SketchContainer>>container;
98102
};
@@ -103,6 +107,20 @@ function expectedTestSketchbookContainer(
103107
return container;
104108
}
105109

110+
function adjustUri(
111+
rootPath: string,
112+
templateRef: Mutable<SketchRef>
113+
): Mutable<SketchRef> {
114+
let rootUri = FileUri.create(rootPath).toString();
115+
if (rootUri.charAt(rootUri.length - 1) !== '/') {
116+
rootUri += '/';
117+
}
118+
assert.equal(templateRef.uri.startsWith('template://'), true);
119+
assert.equal(templateRef.uri.startsWith('template:///'), false);
120+
templateRef.uri = templateRef.uri.replace('template://', rootUri).toString();
121+
return templateRef;
122+
}
123+
106124
const testSketchbookContainerTemplate: SketchContainer = {
107125
label: 'test',
108126
children: [
@@ -158,10 +176,6 @@ const testSketchbookContainerTemplate: SketchContainer = {
158176
},
159177
],
160178
sketches: [
161-
{
162-
name: 'bar++',
163-
uri: 'template://bar%2B%2B',
164-
},
165179
{
166180
name: 'a_sketch',
167181
uri: 'template://a_sketch',

‎i18n/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,6 @@
310310
"unableToConnectToWebSocket": "Unable to connect to websocket"
311311
},
312312
"newCloudSketch": {
313-
"invalidSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.",
314313
"newSketchTitle": "Name of a new Remote Sketch",
315314
"notFound": "Could not pull the remote sketch '{0}'. It does not exist.",
316315
"sketchAlreadyExists": "Remote sketch '{0}' already exists."
@@ -404,7 +403,13 @@
404403
"createdArchive": "Created archive '{0}'.",
405404
"doneCompiling": "Done compiling.",
406405
"doneUploading": "Done uploading.",
406+
"editInvalidSketchFolderName": "Do you want to try to save the sketch folder with a different name?",
407407
"exportBinary": "Export Compiled Binary",
408+
"ignoringSketchWithBadNameMessage": "The sketch \"{0}\" cannot be used. Sketch names must contain only basic letters and numbers. (ASCII-only with no spaces, and it cannot start with a number). To get rid of this message, remove the sketch from {1}",
409+
"ignoringSketchWithBadNameTitle": "Ignoring sketch with bad name",
410+
"invalidCloudSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.",
411+
"invalidSketchFolderNameTitle": "Invalid sketch folder name: '{0}'",
412+
"invalidSketchName": "Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.",
408413
"moving": "Moving",
409414
"movingMsg": "The file \"{0}\" needs to be inside a sketch folder named \"{1}\".\nCreate this folder, move the file, and continue?",
410415
"new": "New Sketch",

0 commit comments

Comments
 (0)
Please sign in to comment.