diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index b4f11b72a..4d39c5536 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -344,6 +344,7 @@ import { DebugViewModel } from '@theia/debug/lib/browser/view/debug-view-model'; import { DebugSessionWidget } from '@theia/debug/lib/browser/view/debug-session-widget'; import { DebugConfigurationWidget } from '@theia/debug/lib/browser/view/debug-configuration-widget'; import { ConfigServiceClient } from './config/config-service-client'; +import { ValidateSketch } from './contributions/validate-sketch'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Commands and toolbar items @@ -729,6 +730,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, UpdateIndexes); Contribution.configure(bind, InterfaceScale); Contribution.configure(bind, NewCloudSketch); + Contribution.configure(bind, ValidateSketch); bindContributionProvider(bind, StartupTaskProvider); bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window diff --git a/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts b/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts index 55fabeaca..c57d68d68 100644 --- a/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts @@ -30,7 +30,13 @@ import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-ske import { SketchbookCommands } from '../widgets/sketchbook/sketchbook-commands'; import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget'; import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution'; -import { Command, CommandRegistry, Contribution, URI } from './contribution'; +import { + Command, + CommandRegistry, + Contribution, + Sketch, + URI, +} from './contribution'; @injectable() export class NewCloudSketch extends Contribution { @@ -234,14 +240,7 @@ export class NewCloudSketch extends Contribution { input ); } - // This is how https://create.arduino.cc/editor/ works when renaming a sketch. - if (/^[0-9a-zA-Z_]{1,36}$/.test(input)) { - return ''; - } - return nls.localize( - 'arduino/newCloudSketch/invalidSketchName', - 'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.' - ); + return Sketch.validateCloudSketchFolderName(input) ?? ''; }, }, this.labelProvider, diff --git a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts index 6802e400f..b9ccc55bf 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -3,6 +3,7 @@ import * as remote from '@theia/core/electron-shared/@electron/remote'; import * as dateFormat from 'dateformat'; import { ArduinoMenus } from '../menu/arduino-menus'; import { + Sketch, SketchContribution, URI, Command, @@ -90,20 +91,9 @@ export class SaveAsSketch extends SketchContribution { : sketch.name ); const defaultPath = await this.fileService.fsPath(defaultUri); - const { filePath, canceled } = await remote.dialog.showSaveDialog( - remote.getCurrentWindow(), - { - title: nls.localize( - 'arduino/sketch/saveFolderAs', - 'Save sketch folder as...' - ), - defaultPath, - } + const destinationUri = await this.promptSketchFolderDestination( + defaultPath ); - if (!filePath || canceled) { - return false; - } - const destinationUri = await this.fileSystemExt.getUri(filePath); if (!destinationUri) { return false; } @@ -133,6 +123,67 @@ export class SaveAsSketch extends SketchContribution { return !!workspaceUri; } + /** + * Prompts for the new sketch folder name until a valid one is give, + * then resolves with the destination sketch folder URI string, + * or `undefined` if the operation was canceled. + */ + private async promptSketchFolderDestination( + defaultPath: string + ): Promise { + let sketchFolderDestinationUri: string | undefined; + while (!sketchFolderDestinationUri) { + const { filePath } = await remote.dialog.showSaveDialog( + remote.getCurrentWindow(), + { + title: nls.localize( + 'arduino/sketch/saveFolderAs', + 'Save sketch folder as...' + ), + defaultPath, + } + ); + if (!filePath) { + return undefined; + } + const destinationUri = await this.fileSystemExt.getUri(filePath); + const sketchFolderName = new URI(destinationUri).path.base; + const errorMessage = Sketch.validateSketchFolderName(sketchFolderName); + if (errorMessage) { + const message = ` +${nls.localize( + 'arduino/sketch/invalidSketchFolderNameTitle', + "Invalid sketch folder name: '{0}'", + sketchFolderName +)} + +${errorMessage} + +${nls.localize( + 'arduino/sketch/editInvalidSketchFolderName', + 'Do you want to try to save the sketch folder with a different name?' +)}`.trim(); + defaultPath = filePath; + const { response } = await remote.dialog.showMessageBox( + remote.getCurrentWindow(), + { + message, + buttons: [ + nls.localize('vscode/issueMainService/cancel', 'Cancel'), + nls.localize('vscode/extensionsUtils/yes', 'Yes'), + ], + }); + // cancel + if (response === 0) { + return undefined; + } + } else { + sketchFolderDestinationUri = destinationUri; + } + } + return sketchFolderDestinationUri; + } + private async saveOntoCopiedSketch(mainFileUri: string, sketchUri: string, newSketchUri: string): Promise { const widgets = this.applicationShell.widgets; const snapshots = new Map(); diff --git a/arduino-ide-extension/src/browser/contributions/validate-sketch.ts b/arduino-ide-extension/src/browser/contributions/validate-sketch.ts new file mode 100644 index 000000000..6b507e606 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/validate-sketch.ts @@ -0,0 +1,171 @@ +import * as remote from '@theia/core/electron-shared/@electron/remote'; +import { Dialog } from '@theia/core/lib/browser/dialogs'; +import { nls } from '@theia/core/lib/common/nls'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { injectable } from '@theia/core/shared/inversify'; +import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands'; +import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { Sketch, SketchContribution, URI } from './contribution'; +import { SaveAsSketch } from './save-as-sketch'; + +@injectable() +export class ValidateSketch extends SketchContribution { + override onReady(): void { + this.validate(); + } + + private async validate(): Promise { + const result = await this.promptFixActions(); + if (!result) { + const yes = await this.prompt( + nls.localize('arduino/validateSketch/abortFixTitle', 'Invalid sketch'), + nls.localize( + 'arduino/validateSketch/abortFixMessage', + "The sketch is still invalid. Do you want to fix the remaining problems? By clicking '{0}', a new sketch will open.", + Dialog.NO + ), + [Dialog.NO, Dialog.YES] + ); + if (yes) { + return this.validate(); + } + const sketch = await this.sketchService.createNewSketch(); + this.workspaceService.open(new URI(sketch.uri), { + preserveWindow: true, + }); + } + } + + /** + * Returns with an array of actions the user has to perform to fix the invalid sketch. + */ + private validateSketch(sketch: Sketch): FixAction[] { + // sketch folder + main sketch file (requires `Save as...` and window reload) + const sketchFolderName = new URI(sketch.uri).path.base; + const sketchFolderNameError = + Sketch.validateSketchFolderName(sketchFolderName); + if (sketchFolderNameError) { + return [ + { + execute: async () => { + const unknown = + (await this.promptRenameSketch(sketch)) && + (await this.commandService.executeCommand( + SaveAsSketch.Commands.SAVE_AS_SKETCH.id, + { + markAsRecentlyOpened: true, + openAfterMove: true, + wipeOriginal: true, + } + )); + return !!unknown; + }, + }, + ]; + } + + // sketch code files (does not require window reload) + return Sketch.uris(sketch) + .filter((uri) => uri !== sketch.mainFileUri) + .map((uri) => new URI(uri)) + .filter((uri) => Sketch.Extensions.CODE_FILES.includes(uri.path.ext)) + .map((uri) => ({ + uri, + error: Sketch.validateSketchFolderName(uri.path.name), + })) + .filter(({ error }) => Boolean(error)) + .map(({ uri }) => ({ + execute: async () => { + const unknown = + (await this.promptRenameSketchFile(uri)) && + (await this.commandService.executeCommand( + WorkspaceCommands.FILE_RENAME.id, + uri + )); + return !!unknown; + }, + })); + } + + private async currentSketch(): Promise { + const sketch = this.sketchServiceClient.tryGetCurrentSketch(); + if (CurrentSketch.isValid(sketch)) { + return sketch; + } + const deferred = new Deferred(); + const disposable = this.sketchServiceClient.onCurrentSketchDidChange( + (sketch) => { + if (CurrentSketch.isValid(sketch)) { + disposable.dispose(); + deferred.resolve(sketch); + } + } + ); + return deferred.promise; + } + + private async promptFixActions(): Promise { + const sketch = await this.currentSketch(); + const fixActions = this.validateSketch(sketch); + for (const fixAction of fixActions) { + const result = await fixAction.execute(); + if (!result) { + return false; + } + } + return true; + } + + private async promptRenameSketch(sketch: Sketch): Promise { + return this.prompt( + nls.localize( + 'arduino/validateSketch/renameSketchFolderTitle', + 'Invalid sketch name' + ), + nls.localize( + 'arduino/validateSketch/renameSketchFolderMessage', + "The sketch '{0}' cannot be used. Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters. To get rid of this message, rename the sketch. Do you want to rename the sketch now?", + sketch.name + ) + ); + } + + private async promptRenameSketchFile(uri: URI): Promise { + return this.prompt( + nls.localize( + 'arduino/validateSketch/renameSketchFileTitle', + 'Invalid sketch filename' + ), + nls.localize( + 'arduino/validateSketch/renameSketchFileMessage', + "The sketch file '{0}' cannot be used. Sketch filenames must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters without the file extension. To get rid of this message, rename the sketch file. Do you want to rename the sketch file now?", + uri.path.base + ) + ); + } + + private async prompt( + title: string, + message: string, + buttons: string[] = [Dialog.CANCEL, Dialog.OK] + ): Promise { + const { response } = await remote.dialog.showMessageBox( + remote.getCurrentWindow(), + { + title, + message, + type: 'warning', + buttons, + } + ); + // cancel + if (response === 0) { + return false; + } + return true; + } +} + +interface FixAction { + execute(): Promise; +} diff --git a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts index b616d6169..2090c7590 100644 --- a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts @@ -27,7 +27,7 @@ export interface VerifySketchParams { } /** - * - `"idle"` when neither verify, not upload is running, + * - `"idle"` when neither verify, nor upload is running, * - `"explicit-verify"` when only verify is running triggered by the user, and * - `"automatic-verify"` is when the automatic verify phase is running as part of an upload triggered by the user. */ diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-commands.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-commands.ts index 2fbcbdb26..6d936a515 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-commands.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-commands.ts @@ -1,34 +1,32 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; -import URI from '@theia/core/lib/common/uri'; import { open } from '@theia/core/lib/browser/opener-service'; -import { FileStat } from '@theia/filesystem/lib/common/files'; +import { nls } from '@theia/core/lib/common'; import { CommandRegistry, CommandService, } from '@theia/core/lib/common/command'; +import { Path } from '@theia/core/lib/common/path'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { FileStat } from '@theia/filesystem/lib/common/files'; import { WorkspaceCommandContribution as TheiaWorkspaceCommandContribution, WorkspaceCommands, } from '@theia/workspace/lib/browser/workspace-commands'; import { Sketch, SketchesService } from '../../../common/protocol'; -import { WorkspaceInputDialog } from './workspace-input-dialog'; import { CurrentSketch, SketchesServiceClientImpl, } from '../../../common/protocol/sketches-service-client-impl'; -import { SaveAsSketch } from '../../contributions/save-as-sketch'; -import { nls } from '@theia/core/lib/common'; +import { WorkspaceInputDialog } from './workspace-input-dialog'; @injectable() export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribution { - @inject(SketchesServiceClientImpl) - protected readonly sketchesServiceClient: SketchesServiceClientImpl; - @inject(CommandService) - protected readonly commandService: CommandService; - + private readonly commandService: CommandService; @inject(SketchesService) - protected readonly sketchService: SketchesService; + private readonly sketchService: SketchesService; + @inject(SketchesServiceClientImpl) + private readonly sketchesServiceClient: SketchesServiceClientImpl; override registerCommands(registry: CommandRegistry): void { super.registerCommands(registry); @@ -48,7 +46,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut ); } - protected async newFile(uri: URI | undefined): Promise { + private async newFile(uri: URI | undefined): Promise { if (!uri) { return; } @@ -68,50 +66,39 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut ); const name = await dialog.open(); - const nameWithExt = this.maybeAppendInoExt(name); - if (nameWithExt) { - const fileUri = parentUri.resolve(nameWithExt); - await this.fileService.createFile(fileUri); - this.fireCreateNewFile({ parent: parentUri, uri: fileUri }); - open(this.openerService, fileUri); + if (!name) { + return; } + const nameWithExt = this.maybeAppendInoExt(name); + const fileUri = parentUri.resolve(nameWithExt); + await this.fileService.createFile(fileUri); + this.fireCreateNewFile({ parent: parentUri, uri: fileUri }); + open(this.openerService, fileUri); } protected override async validateFileName( - name: string, + userInput: string, parent: FileStat, recursive = false ): Promise { - // In the Java IDE the followings are the rules: - // - `name` without an extension should default to `name.ino`. - // - `name` with a single trailing `.` also defaults to `name.ino`. - const nameWithExt = this.maybeAppendInoExt(name); - const errorMessage = await super.validateFileName( - nameWithExt, - parent, - recursive - ); - if (errorMessage) { - return errorMessage; + // If name does not have extension or ends with trailing dot (from IDE 1.x), treat it as an .ino file. + // If has extension, + // - if unsupported extension -> error + // - if has a code file extension -> apply folder name validation without the extension and use the Theia-based validation + // - if has any additional file extension -> use the default Theia-based validation + const fileInput = parseFileInput(userInput); + const { name, extension } = fileInput; + if (!Sketch.Extensions.ALL.includes(extension)) { + return invalidExtension(extension); } - const extension = nameWithExt.split('.').pop(); - if (!extension) { - return nls.localize( - 'theia/workspace/invalidFilename', - 'Invalid filename.' - ); // XXX: this should not happen as we forcefully append `.ino` if it's not there. - } - if (Sketch.Extensions.ALL.indexOf(`.${extension}`) === -1) { - return nls.localize( - 'theia/workspace/invalidExtension', - '.{0} is not a valid extension', - extension - ); + let errorMessage: string | undefined = undefined; + if (Sketch.Extensions.CODE_FILES.includes(extension)) { + errorMessage = Sketch.validateSketchFolderName(name); } - return ''; + return errorMessage ?? super.validateFileName(userInput, parent, recursive); } - protected maybeAppendInoExt(name: string | undefined): string { + private maybeAppendInoExt(name: string): string { if (!name) { return ''; } @@ -126,7 +113,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut return name; } - protected async renameFile(uri: URI | undefined): Promise { + protected async renameFile(uri: URI | undefined): Promise { if (!uri) { return; } @@ -149,11 +136,10 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut openAfterMove: true, wipeOriginal: true, }; - await this.commandService.executeCommand( - SaveAsSketch.Commands.SAVE_AS_SKETCH.id, + return await this.commandService.executeCommand( + 'arduino-save-as-sketch', options ); - return; } const parent = await this.getParent(uri); if (!parent) { @@ -180,12 +166,57 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut }, this.labelProvider ); - const newName = await dialog.open(); - const newNameWithExt = this.maybeAppendInoExt(newName); - if (newNameWithExt) { - const oldUri = uri; - const newUri = uri.parent.resolve(newNameWithExt); - this.fileService.move(oldUri, newUri); + const name = await dialog.open(); + if (!name) { + return; } + const nameWithExt = this.maybeAppendInoExt(name); + const oldUri = uri; + const newUri = uri.parent.resolve(nameWithExt); + return this.fileService.move(oldUri, newUri); + } +} + +export function invalidExtension( + extension: string +): string | PromiseLike { + return nls.localize( + 'theia/workspace/invalidExtension', + '.{0} is not a valid extension', + extension.charAt(0) === '.' ? extension.slice(1) : extension + ); +} + +interface FileInput { + /** + * The raw text the user enters in the ``. + */ + readonly raw: string; + /** + * This is the name without the extension. If raw is `'lib.cpp'`, then `name` will be `'lib'`. If raw is `'foo'` or `'foo.'` this value is `'foo'`. + */ + readonly name: string; + /** + * With the leading dot. For example `'.ino'` or `'.cpp'`. + */ + readonly extension: string; +} +export function parseFileInput(userInput: string): FileInput { + if (!userInput) { + return { + raw: '', + name: '', + extension: Sketch.Extensions.DEFAULT, + }; + } + const path = new Path(userInput); + let extension = path.ext; + if (extension.trim() === '' || extension.trim() === '.') { + extension = Sketch.Extensions.DEFAULT; } + return { + raw: userInput, + name: path.name, + extension, + }; } diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index 9334c20ce..cd334d094 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -1,4 +1,5 @@ import { ApplicationError } from '@theia/core/lib/common/application-error'; +import { nls } from '@theia/core/lib/common/nls'; import URI from '@theia/core/lib/common/uri'; export namespace SketchesError { @@ -151,6 +152,51 @@ export interface Sketch extends SketchRef { readonly rootFolderFileUris: string[]; // `RootFolderFiles` (does not include the main sketch file) } export namespace Sketch { + // (non-API) exported for the tests + export const invalidSketchFolderNameMessage = nls.localize( + 'arduino/sketch/invalidSketchName', + 'Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.' + ); + const invalidCloudSketchFolderNameMessage = nls.localize( + 'arduino/sketch/invalidCloudSketchName', + 'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.' + ); + /** + * `undefined` if the candidate sketch folder name is valid. Otherwise, the validation error message. + * Based on the [specs](https://arduino.github.io/arduino-cli/latest/sketch-specification/#sketch-folders-and-files). + */ + export function validateSketchFolderName( + candidate: string + ): string | undefined { + return /^[0-9a-zA-Z]{1}[0-9a-zA-Z_\.-]{0,62}$/.test(candidate) + ? undefined + : invalidSketchFolderNameMessage; + } + + /** + * `undefined` if the candidate cloud sketch folder name is valid. Otherwise, the validation error message. + * Based on how https://create.arduino.cc/editor/ works. + */ + export function validateCloudSketchFolderName( + candidate: string + ): string | undefined { + return /^[0-9a-zA-Z_]{1,36}$/.test(candidate) + ? undefined + : invalidCloudSketchFolderNameMessage; + } + + /** + * 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. + * Throws an error if `candidate` is not valid. + */ + export function toValidCloudSketchFolderName(candidate: string): string { + const errorMessage = validateSketchFolderName(candidate); + if (errorMessage) { + throw new Error(errorMessage); + } + return candidate.replace(/\./g, '_').replace(/-/g, '_').slice(0, 36); + } + export function is(arg: unknown): arg is Sketch { if (!SketchRef.is(arg)) { return false; @@ -172,7 +218,8 @@ export namespace Sketch { return false; } export namespace Extensions { - export const MAIN = ['.ino', '.pde']; + export const DEFAULT = '.ino'; + export const MAIN = [DEFAULT, '.pde']; export const SOURCE = ['.c', '.cpp', '.S']; export const CODE_FILES = [...MAIN, ...SOURCE, '.h', '.hh', '.hpp']; export const ADDITIONAL = [...CODE_FILES, '.json', '.md', '.adoc']; diff --git a/arduino-ide-extension/src/test/browser/workspace-commands.test.ts b/arduino-ide-extension/src/test/browser/workspace-commands.test.ts new file mode 100644 index 000000000..5d0321a1e --- /dev/null +++ b/arduino-ide-extension/src/test/browser/workspace-commands.test.ts @@ -0,0 +1,215 @@ +import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +const disableJSDOM = enableJSDOM(); + +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +FrontendApplicationConfigProvider.set({}); + +import { + FrontendApplication, + LabelProvider, + OpenerService, +} from '@theia/core/lib/browser'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { ApplicationServer } from '@theia/core/lib/common/application-protocol'; +import { CommandService } from '@theia/core/lib/common/command'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { nls } from '@theia/core/lib/common/nls'; +import { OS } from '@theia/core/lib/common/os'; +import { SelectionService } from '@theia/core/lib/common/selection-service'; +import URI from '@theia/core/lib/common/uri'; +import { Container } from '@theia/core/shared/inversify'; +import { FileDialogService } from '@theia/filesystem/lib/browser'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { FileStat } from '@theia/filesystem/lib/common/files'; +import { WorkspaceCompareHandler } from '@theia/workspace/lib/browser/workspace-compare-handler'; +import { WorkspaceDeleteHandler } from '@theia/workspace/lib/browser/workspace-delete-handler'; +import { WorkspaceDuplicateHandler } from '@theia/workspace/lib/browser/workspace-duplicate-handler'; +import { WorkspacePreferences } from '@theia/workspace/lib/browser/workspace-preferences'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { expect } from 'chai'; +import { + invalidExtension as invalidExtensionMessage, + parseFileInput, + WorkspaceCommandContribution, +} from '../../browser/theia/workspace/workspace-commands'; +import { Sketch, SketchesService } from '../../common/protocol'; +import { SketchesServiceClientImpl } from '../../common/protocol/sketches-service-client-impl'; + +disableJSDOM(); + +describe('workspace-commands', () => { + describe('parseFileInput', () => { + it("should parse input without extension as '.ino'", () => { + const actual = parseFileInput('foo'); + expect(actual).to.be.deep.equal({ + raw: 'foo', + name: 'foo', + extension: '.ino', + }); + }); + it("should parse input with a trailing dot as '.ino'", () => { + const actual = parseFileInput('foo.'); + expect(actual).to.be.deep.equal({ + raw: 'foo.', + name: 'foo', + extension: '.ino', + }); + }); + it('should parse input with a valid extension', () => { + const actual = parseFileInput('lib.cpp'); + expect(actual).to.be.deep.equal({ + raw: 'lib.cpp', + name: 'lib', + extension: '.cpp', + }); + }); + it('should calculate the file extension based on the last dot index', () => { + const actual = parseFileInput('lib.ino.x'); + expect(actual).to.be.deep.equal({ + raw: 'lib.ino.x', + name: 'lib.ino', + extension: '.x', + }); + }); + it('should ignore trailing spaces after the last dot', () => { + const actual = parseFileInput(' foo. '); + expect(actual).to.be.deep.equal({ + raw: ' foo. ', + name: ' foo', + extension: '.ino', + }); + }); + }); + + describe('validateFileName', () => { + const child: FileStat = { + isFile: true, + isDirectory: false, + isSymbolicLink: false, + resource: new URI('sketch/sketch.ino'), + name: 'sketch.ino', + }; + const parent: FileStat = { + isFile: false, + isDirectory: true, + isSymbolicLink: false, + resource: new URI('sketch'), + name: 'sketch', + children: [child], + }; + + let workspaceCommands: WorkspaceCommandContribution; + const trimmedName = (name: string) => + workspaceCommands['trimFileName'](name); + + async function testMe(userInput: string): Promise { + return workspaceCommands['validateFileName'](userInput, parent); + } + + function createContainer(): Container { + const container = new Container(); + container.bind(FileDialogService).toConstantValue({}); + container.bind(FileService).toConstantValue({ + async exists(resource: URI): Promise { + return ( + resource.path.base.includes('_sketch') || + resource.path.base.includes('sketch') + ); + }, + }); + container + .bind(FrontendApplication) + .toConstantValue({}); + container.bind(LabelProvider).toConstantValue({}); + container.bind(MessageService).toConstantValue({}); + container.bind(OpenerService).toConstantValue({}); + container.bind(SelectionService).toConstantValue({}); + container.bind(WorkspaceCommandContribution).toSelf().inSingletonScope(); + container + .bind(WorkspaceCompareHandler) + .toConstantValue({}); + container + .bind(WorkspaceDeleteHandler) + .toConstantValue({}); + container + .bind(WorkspaceDuplicateHandler) + .toConstantValue({}); + container + .bind(WorkspacePreferences) + .toConstantValue({}); + container.bind(WorkspaceService).toConstantValue({}); + container.bind(ClipboardService).toConstantValue({}); + container.bind(ApplicationServer).toConstantValue({ + async getBackendOS(): Promise { + return OS.type(); + }, + }); + container.bind(CommandService).toConstantValue({}); + container.bind(SketchesService).toConstantValue({}); + container + .bind(SketchesServiceClientImpl) + .toConstantValue({}); + return container; + } + + beforeEach(() => { + workspaceCommands = createContainer().get( + WorkspaceCommandContribution + ); + }); + + it("should validate input string without an extension as an '.ino' file", async () => { + const actual = await testMe('valid'); + expect(actual).to.be.empty; + }); + + it('code files cannot start with number (no extension)', async () => { + const actual = await testMe('_invalid'); + expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage); + }); + + it('code files cannot start with number (trailing dot)', async () => { + const actual = await testMe('_invalid.'); + expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage); + }); + + it('code files cannot start with number (trailing dot)', async () => { + const actual = await testMe('_invalid.cpp'); + expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage); + }); + + it('should warn about invalid extension first', async () => { + const actual = await testMe('_invalid.xxx'); + expect(actual).to.be.equal(invalidExtensionMessage('.xxx')); + }); + + it('should not warn about invalid file extension for empty input', async () => { + const actual = await testMe(''); + expect(actual).to.be.equal(Sketch.invalidSketchFolderNameMessage); + }); + + it('should ignore non-code filename validation from the spec', async () => { + const actual = await testMe('_invalid.json'); + expect(actual).to.be.empty; + }); + + it('non-code files should be validated against default new file validation rules', async () => { + const name = ' invalid.json'; + const actual = await testMe(name); + const expected = nls.localizeByDefault( + 'Leading or trailing whitespace detected in file or folder name.' + ); + expect(actual).to.be.equal(expected); + }); + + it('should warn about existing resource', async () => { + const name = 'sketch.ino'; + const actual = await testMe(name); + const expected = nls.localizeByDefault( + 'A file or folder **{0}** already exists at this location. Please choose a different name.', + trimmedName(name) + ); + expect(actual).to.be.equal(expected); + }); + }); +}); diff --git a/arduino-ide-extension/src/test/common/sketches-service.test.ts b/arduino-ide-extension/src/test/common/sketches-service.test.ts new file mode 100644 index 000000000..ad3933cd6 --- /dev/null +++ b/arduino-ide-extension/src/test/common/sketches-service.test.ts @@ -0,0 +1,119 @@ +import { expect } from 'chai'; +import { Sketch } from '../../common/protocol'; + +describe('sketch', () => { + describe('validateSketchFolderName', () => { + ( + [ + ['sketch', true], + ['can-contain-slash-and-dot.ino', true], + ['regex++', false], + ['dots...', true], + ['No Spaces', false], + ['_invalidToStartWithUnderscore', false], + ['Invalid+Char.ino', false], + ['', false], + ['/', false], + ['//trash/', false], + [ + '63Length_012345678901234567890123456789012345678901234567890123', + true, + ], + [ + 'TooLong__0123456789012345678901234567890123456789012345678901234', + false, + ], + ] as [string, boolean][] + ).map(([input, expected]) => { + it(`'${input}' should ${ + !expected ? 'not ' : '' + }be a valid sketch folder name`, () => { + const actual = Sketch.validateSketchFolderName(input); + if (expected) { + expect(actual).to.be.undefined; + } else { + expect(actual).to.be.not.undefined; + expect(actual?.length).to.be.greaterThan(0); + } + }); + }); + }); + + describe('validateCloudSketchFolderName', () => { + ( + [ + ['sketch', true], + ['no-dashes', false], + ['no-dots', false], + ['No Spaces', false], + ['_canStartWithUnderscore', true], + ['Invalid+Char.ino', false], + ['', false], + ['/', false], + ['//trash/', false], + ['36Length_012345678901234567890123456', true], + ['TooLong__0123456789012345678901234567', false], + ] as [string, boolean][] + ).map(([input, expected]) => { + it(`'${input}' should ${ + !expected ? 'not ' : '' + }be a valid cloud sketch folder name`, () => { + const actual = Sketch.validateCloudSketchFolderName(input); + if (expected) { + expect(actual).to.be.undefined; + } else { + expect(actual).to.be.not.undefined; + expect(actual?.length).to.be.greaterThan(0); + } + }); + }); + }); + + describe('toValidCloudSketchFolderName', () => { + ( + [ + ['sketch', 'sketch'], + ['can-contain-slash-and-dot.ino', 'can_contain_slash_and_dot_ino'], + ['regex++'], + ['dots...', 'dots___'], + ['No Spaces'], + ['_invalidToStartWithUnderscore'], + ['Invalid+Char.ino'], + [''], + ['/'], + ['//trash/'], + [ + '63Length_012345678901234567890123456789012345678901234567890123', + '63Length_012345678901234567890123456', + ], + ['TooLong__0123456789012345678901234567890123456789012345678901234'], + ] as [string, string?][] + ).map(([input, expected]) => { + it(`'${input}' should ${expected ? '' : 'not '}map the ${ + !expected ? 'invalid ' : '' + }sketch folder name to a valid cloud sketch folder name${ + expected ? `: '${expected}'` : '' + }`, () => { + if (!expected) { + try { + Sketch.toValidCloudSketchFolderName(input); + throw new Error( + `Expected an error when mapping ${input} to a valid sketch folder name.` + ); + } catch (err) { + if (err instanceof Error) { + expect(err.message).to.be.equal( + Sketch.invalidSketchFolderNameMessage + ); + } else { + throw err; + } + } + } else { + const actual = Sketch.toValidCloudSketchFolderName(input); + expect(actual).to.be.equal(expected); + } + }); + }); + }); +}); diff --git a/arduino-ide-extension/src/test/node/__test_sketchbook__/bar++ 2/bar++ 2.ino b/arduino-ide-extension/src/test/node/__test_sketchbook__/bar++ 2/bar++ 2.ino new file mode 100644 index 000000000..e69de29bb diff --git a/arduino-ide-extension/src/test/node/__test_sketchbook__/bar++/foo++/foo++.ino b/arduino-ide-extension/src/test/node/__test_sketchbook__/bar++/foo++/foo++.ino new file mode 100644 index 000000000..e69de29bb diff --git a/arduino-ide-extension/src/test/node/sketches-service-impl.test.ts b/arduino-ide-extension/src/test/node/sketches-service-impl.test.ts index c1e0dbb90..eb3d24997 100644 --- a/arduino-ide-extension/src/test/node/sketches-service-impl.test.ts +++ b/arduino-ide-extension/src/test/node/sketches-service-impl.test.ts @@ -162,6 +162,10 @@ const testSketchbookContainerTemplate: SketchContainer = { name: 'bar++', uri: 'template://bar%2B%2B', }, + { + name: 'bar++ 2', + uri: 'template://bar%2B%2B%202', + }, { name: 'a_sketch', uri: 'template://a_sketch', diff --git a/i18n/en.json b/i18n/en.json index e6cb59679..7ed7b3e67 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -311,7 +311,6 @@ "unableToConnectToWebSocket": "Unable to connect to websocket" }, "newCloudSketch": { - "invalidSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.", "newSketchTitle": "Name of a new Remote Sketch", "notFound": "Could not pull the remote sketch '{0}'. It does not exist.", "sketchAlreadyExists": "Remote sketch '{0}' already exists." @@ -405,7 +404,11 @@ "createdArchive": "Created archive '{0}'.", "doneCompiling": "Done compiling.", "doneUploading": "Done uploading.", + "editInvalidSketchFolderName": "Do you want to try to save the sketch folder with a different name?", "exportBinary": "Export Compiled Binary", + "invalidCloudSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.", + "invalidSketchFolderNameTitle": "Invalid sketch folder name: '{0}'", + "invalidSketchName": "Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters.", "moving": "Moving", "movingMsg": "The file \"{0}\" needs to be inside a sketch folder named \"{1}\".\nCreate this folder, move the file, and continue?", "new": "New Sketch", @@ -448,6 +451,14 @@ "cancel": "Cancel", "enterField": "Enter {0}", "upload": "Upload" + }, + "validateSketch": { + "abortFixMessage": "The sketch is still invalid. Do you want to fix the remaining problems? By clicking '{0}', a new sketch will open.", + "abortFixTitle": "Invalid sketch", + "renameSketchFileMessage": "The sketch file '{0}' cannot be used. Sketch filenames must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters without the file extension. To get rid of this message, rename the sketch file. Do you want to rename the sketch file now?", + "renameSketchFileTitle": "Invalid sketch filename", + "renameSketchFolderMessage": "The sketch '{0}' cannot be used. Sketch names must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 63 characters. To get rid of this message, rename the sketch. Do you want to rename the sketch now?", + "renameSketchFolderTitle": "Invalid sketch name" } }, "theia": { @@ -470,7 +481,6 @@ "deleteCurrentSketch": "Do you want to delete the current sketch?", "fileNewName": "Name for new file", "invalidExtension": ".{0} is not a valid extension", - "invalidFilename": "Invalid filename.", "newFileName": "New name for file" } }