diff --git a/.vscode/launch.json b/.vscode/launch.json index f892c3fc5..2894c7572 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,6 @@ ".", "--log-level=debug", "--hostname=localhost", - "--no-cluster", "--app-project-path=${workspaceRoot}/electron-app", "--remote-debugging-port=9222", "--no-app-auto-install", @@ -52,7 +51,6 @@ ".", "--log-level=debug", "--hostname=localhost", - "--no-cluster", "--app-project-path=${workspaceRoot}/electron-app", "--remote-debugging-port=9222", "--no-app-auto-install", diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 08b884ab1..a7e322b1b 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -67,6 +67,7 @@ "auth0-js": "^9.14.0", "btoa": "^1.2.1", "classnames": "^2.3.1", + "cross-fetch": "^3.1.5", "dateformat": "^3.0.3", "deepmerge": "2.0.1", "electron-updater": "^4.6.5", 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..4743f3f84 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -23,7 +23,7 @@ import { SketchesService, SketchesServicePath, } from '../common/protocol/sketches-service'; -import { SketchesServiceClientImpl } from '../common/protocol/sketches-service-client-impl'; +import { SketchesServiceClientImpl } from './sketches-service-client-impl'; import { CoreService, CoreServicePath } from '../common/protocol/core-service'; import { BoardsListWidget } from './boards/boards-list-widget'; import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution'; @@ -344,6 +344,9 @@ 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'; +import { RenameCloudSketch } from './contributions/rename-cloud-sketch'; +import { CreateFeatures } from './create/create-features'; export default new ContainerModule((bind, unbind, isBound, rebind) => { // Commands and toolbar items @@ -729,6 +732,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, UpdateIndexes); Contribution.configure(bind, InterfaceScale); Contribution.configure(bind, NewCloudSketch); + Contribution.configure(bind, ValidateSketch); + Contribution.configure(bind, RenameCloudSketch); bindContributionProvider(bind, StartupTaskProvider); bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window @@ -889,6 +894,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ); bind(CreateApi).toSelf().inSingletonScope(); bind(SketchCache).toSelf().inSingletonScope(); + bind(CreateFeatures).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(CreateFeatures); bind(ShareSketchDialog).toSelf().inSingletonScope(); bind(AuthenticationClientService).toSelf().inSingletonScope(); diff --git a/arduino-ide-extension/src/browser/contributions/add-file.ts b/arduino-ide-extension/src/browser/contributions/add-file.ts index 5605ba7d5..dacbd4e86 100644 --- a/arduino-ide-extension/src/browser/contributions/add-file.ts +++ b/arduino-ide-extension/src/browser/contributions/add-file.ts @@ -11,7 +11,7 @@ import { } from './contribution'; import { FileDialogService } from '@theia/filesystem/lib/browser'; import { nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; @injectable() export class AddFile extends SketchContribution { diff --git a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts index e1d11e507..1f69bb3da 100644 --- a/arduino-ide-extension/src/browser/contributions/archive-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/archive-sketch.ts @@ -9,7 +9,7 @@ import { MenuModelRegistry, } from './contribution'; import { nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; @injectable() export class ArchiveSketch extends SketchContribution { @@ -56,7 +56,7 @@ export class ArchiveSketch extends SketchContribution { if (!destinationUri) { return; } - await this.sketchService.archive(sketch, destinationUri.toString()); + await this.sketchesService.archive(sketch, destinationUri.toString()); this.messageService.info( nls.localize( 'arduino/sketch/createdArchive', diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts index 61885d49c..b6d8f91ec 100644 --- a/arduino-ide-extension/src/browser/contributions/close.ts +++ b/arduino-ide-extension/src/browser/contributions/close.ts @@ -20,7 +20,7 @@ import { URI, } from './contribution'; import { Dialog } from '@theia/core/lib/browser/dialogs'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { SaveAsSketch } from './save-as-sketch'; /** @@ -185,7 +185,7 @@ export class Close extends SketchContribution { private async isCurrentSketchTemp(): Promise { const currentSketch = await this.sketchServiceClient.currentSketch(); if (CurrentSketch.isValid(currentSketch)) { - const isTemp = await this.sketchService.isTemp(currentSketch); + const isTemp = await this.sketchesService.isTemp(currentSketch); if (isTemp) { return currentSketch; } diff --git a/arduino-ide-extension/src/browser/contributions/cloud-contribution.ts b/arduino-ide-extension/src/browser/contributions/cloud-contribution.ts new file mode 100644 index 000000000..c13abd89c --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/cloud-contribution.ts @@ -0,0 +1,121 @@ +import { CompositeTreeNode } from '@theia/core/lib/browser/tree'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { CreateApi } from '../create/create-api'; +import { CreateFeatures } from '../create/create-features'; +import { CreateUri } from '../create/create-uri'; +import { Create, isNotFound } from '../create/typings'; +import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree'; +import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model'; +import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-widget'; +import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget'; +import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution'; +import { SketchContribution } from './contribution'; + +export function sketchAlreadyExists(input: string): string { + return nls.localize( + 'arduino/cloudSketch/alreadyExists', + "Cloud sketch '{0}' already exists.", + input + ); +} +export function sketchNotFound(input: string): string { + return nls.localize( + 'arduino/cloudSketch/notFound', + "Could not pull the cloud sketch '{0}'. It does not exist.", + input + ); +} +export const synchronizingSketchbook = nls.localize( + 'arduino/cloudSketch/synchronizingSketchbook', + 'Synchronizing sketchbook...' +); +export function pullingSketch(input: string): string { + return nls.localize( + 'arduino/cloudSketch/pulling', + "Synchronizing sketchbook, pulling '{0}'...", + input + ); +} +export function pushingSketch(input: string): string { + return nls.localize( + 'arduino/cloudSketch/pushing', + "Synchronizing sketchbook, pushing '{0}'...", + input + ); +} + +@injectable() +export abstract class CloudSketchContribution extends SketchContribution { + @inject(SketchbookWidgetContribution) + private readonly widgetContribution: SketchbookWidgetContribution; + @inject(CreateApi) + protected readonly createApi: CreateApi; + @inject(CreateFeatures) + protected readonly createFeatures: CreateFeatures; + + protected async treeModel(): Promise< + (CloudSketchbookTreeModel & { root: CompositeTreeNode }) | undefined + > { + const { enabled, session } = this.createFeatures; + if (enabled && session) { + const widget = await this.widgetContribution.widget; + const treeModel = this.treeModelFrom(widget); + if (treeModel) { + const root = treeModel.root; + if (CompositeTreeNode.is(root)) { + return treeModel as CloudSketchbookTreeModel & { + root: CompositeTreeNode; + }; + } + } + } + return undefined; + } + + protected async pull( + sketch: Create.Sketch + ): Promise { + const treeModel = await this.treeModel(); + if (!treeModel) { + return undefined; + } + const id = CreateUri.toUri(sketch).path.toString(); + const node = treeModel.getNode(id); + if (!node) { + throw new Error( + `Could not find cloud sketchbook tree node with ID: ${id}.` + ); + } + if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) { + throw new Error( + `Cloud sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.` + ); + } + try { + await treeModel.sketchbookTree().pull({ node }); + return node; + } catch (err) { + if (isNotFound(err)) { + await treeModel.refresh(); + this.messageService.error(sketchNotFound(sketch.name)); + return undefined; + } + throw err; + } + } + + private treeModelFrom( + widget: SketchbookWidget + ): CloudSketchbookTreeModel | undefined { + for (const treeWidget of widget.getTreeWidgets()) { + if (treeWidget instanceof CloudSketchbookTreeWidget) { + const model = treeWidget.model; + if (model instanceof CloudSketchbookTreeModel) { + return model; + } + } + } + return undefined; + } +} diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts index 8c646254e..3260ab7b9 100644 --- a/arduino-ide-extension/src/browser/contributions/contribution.ts +++ b/arduino-ide-extension/src/browser/contributions/contribution.ts @@ -41,7 +41,7 @@ import { SettingsService } from '../dialogs/settings/settings'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../common/protocol/sketches-service-client-impl'; +} from '../sketches-service-client-impl'; import { SketchesService, FileSystemExt, @@ -147,7 +147,7 @@ export abstract class SketchContribution extends Contribution { protected readonly configService: ConfigServiceClient; @inject(SketchesService) - protected readonly sketchService: SketchesService; + protected readonly sketchesService: SketchesService; @inject(OpenerService) protected readonly openerService: OpenerService; diff --git a/arduino-ide-extension/src/browser/contributions/debug.ts b/arduino-ide-extension/src/browser/contributions/debug.ts index 15881b98d..f43f00426 100644 --- a/arduino-ide-extension/src/browser/contributions/debug.ts +++ b/arduino-ide-extension/src/browser/contributions/debug.ts @@ -18,7 +18,7 @@ import { TabBarToolbarRegistry, } from './contribution'; import { MaybePromise, MenuModelRegistry, nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { ArduinoMenus } from '../menu/arduino-menus'; const COMPILE_FOR_DEBUG_KEY = 'arduino-compile-for-debug'; @@ -187,7 +187,7 @@ export class Debug extends SketchContribution { if (!CurrentSketch.isValid(sketch)) { return; } - const ideTempFolderUri = await this.sketchService.getIdeTempFolderUri( + const ideTempFolderUri = await this.sketchesService.getIdeTempFolderUri( sketch ); const [cliPath, sketchPath, configPath] = await Promise.all([ @@ -246,7 +246,7 @@ export class Debug extends SketchContribution { ): Promise { if (err instanceof Error) { try { - const tempBuildPaths = await this.sketchService.tempBuildPath(sketch); + const tempBuildPaths = await this.sketchesService.tempBuildPath(sketch); return tempBuildPaths.some((tempBuildPath) => err.message.includes(tempBuildPath) ); diff --git a/arduino-ide-extension/src/browser/contributions/delete-sketch.ts b/arduino-ide-extension/src/browser/contributions/delete-sketch.ts index c72b77df9..98f619045 100644 --- a/arduino-ide-extension/src/browser/contributions/delete-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/delete-sketch.ts @@ -1,32 +1,131 @@ -import { injectable } from '@theia/core/shared/inversify'; +import * as remote from '@theia/core/electron-shared/@electron/remote'; +import { ipcRenderer } from '@theia/core/electron-shared/electron'; +import { Dialog } from '@theia/core/lib/browser/dialogs'; +import { NavigatableWidget } from '@theia/core/lib/browser/navigatable-types'; +import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; +import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { nls } from '@theia/core/lib/common/nls'; +import type { MaybeArray } from '@theia/core/lib/common/types'; +import URI from '@theia/core/lib/common/uri'; +import type { Widget } from '@theia/core/shared/@phosphor/widgets'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { SketchesError } from '../../common/protocol'; -import { - Command, - CommandRegistry, - SketchContribution, - Sketch, -} from './contribution'; +import { SCHEDULE_DELETION_SIGNAL } from '../../electron-common/electron-messages'; +import { Sketch } from '../contributions/contribution'; +import { isNotFound } from '../create/typings'; +import { Command, CommandRegistry } from './contribution'; +import { CloudSketchContribution } from './cloud-contribution'; + +export interface DeleteSketchParams { + /** + * Either the URI of the sketch folder or the sketch to delete. + */ + readonly toDelete: string | Sketch; + /** + * If `true`, the currently opened sketch is expected to be deleted. + * Hence, the editors must be closed, the sketch will be scheduled + * for deletion, and the browser window will close or navigate away. + * If `false`, the sketch will be scheduled for deletion, + * but the current window remains open. If `force`, the window will + * navigate away, but IDE2 won't open any confirmation dialogs. + */ + readonly willNavigateAway?: boolean | 'force'; +} @injectable() -export class DeleteSketch extends SketchContribution { +export class DeleteSketch extends CloudSketchContribution { + @inject(ApplicationShell) + private readonly shell: ApplicationShell; + @inject(WindowService) + private readonly windowService: WindowService; + override registerCommands(registry: CommandRegistry): void { registry.registerCommand(DeleteSketch.Commands.DELETE_SKETCH, { - execute: (uri: string) => this.deleteSketch(uri), + execute: (params: DeleteSketchParams) => this.deleteSketch(params), }); } - private async deleteSketch(uri: string): Promise { - const sketch = await this.loadSketch(uri); - if (!sketch) { - console.info(`Sketch not found at ${uri}. Skipping deletion.`); + private async deleteSketch(params: DeleteSketchParams): Promise { + const { toDelete, willNavigateAway } = params; + let sketch: Sketch; + if (typeof toDelete === 'string') { + const resolvedSketch = await this.loadSketch(toDelete); + if (!resolvedSketch) { + console.info( + `Failed to load the sketch. It was not found at '${toDelete}'. Skipping deletion.` + ); + return; + } + sketch = resolvedSketch; + } else { + sketch = toDelete; + } + if (!willNavigateAway) { + this.scheduleDeletion(sketch); return; } - return this.sketchService.deleteSketch(sketch); + const cloudUri = this.createFeatures.cloudUri(sketch); + if (willNavigateAway !== 'force') { + const { response } = await remote.dialog.showMessageBox({ + title: nls.localizeByDefault('Delete'), + type: 'question', + buttons: [Dialog.CANCEL, Dialog.OK], + message: cloudUri + ? nls.localize( + 'theia/workspace/deleteCloudSketch', + "The cloud sketch '{0}' will be permanently deleted from the Arduino servers and the local caches. This action is irreversible. Do you want to delete the current sketch?", + sketch.name + ) + : nls.localize( + 'theia/workspace/deleteCurrentSketch', + "The sketch '{0}' will be permanently deleted. This action is irreversible. Do you want to delete the current sketch?", + sketch.name + ), + }); + // cancel + if (response === 0) { + return; + } + } + if (cloudUri) { + const posixPath = cloudUri.path.toString(); + const cloudSketch = this.createApi.sketchCache.getSketch(posixPath); + if (!cloudSketch) { + throw new Error( + `Cloud sketch with path '${posixPath}' was not cached. Cache: ${this.createApi.sketchCache.toString()}` + ); + } + try { + // IDE2 cannot use DELETE directory as the server responses with HTTP 500 if it's missing. + // https://github.com/arduino/arduino-ide/issues/1825#issuecomment-1406301406 + await this.createApi.deleteSketch(cloudSketch.path); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } else { + console.info( + `Could not delete the cloud sketch with path '${posixPath}'. It does not exist.` + ); + } + } + } + await Promise.all([ + ...Sketch.uris(sketch).map((uri) => + this.closeWithoutSaving(new URI(uri)) + ), + ]); + this.windowService.setSafeToShutDown(); + this.scheduleDeletion(sketch); + return window.close(); + } + + private scheduleDeletion(sketch: Sketch): void { + ipcRenderer.send(SCHEDULE_DELETION_SIGNAL, sketch); } private async loadSketch(uri: string): Promise { try { - const sketch = await this.sketchService.loadSketch(uri); + const sketch = await this.sketchesService.loadSketch(uri); return sketch; } catch (err) { if (SketchesError.NotFound.is(err)) { @@ -35,6 +134,13 @@ export class DeleteSketch extends SketchContribution { throw err; } } + + // fix: https://github.com/eclipse-theia/theia/issues/12107 + private async closeWithoutSaving(uri: URI): Promise { + const affected = getAffected(this.shell.widgets, uri); + const toClose = [...affected].map(([, widget]) => widget); + await this.shell.closeMany(toClose, { save: false }); + } } export namespace DeleteSketch { export namespace Commands { @@ -43,3 +149,20 @@ export namespace DeleteSketch { }; } } + +function getAffected( + widgets: Iterable, + context: MaybeArray +): [URI, T & NavigatableWidget][] { + const uris = Array.isArray(context) ? context : [context]; + const result: [URI, T & NavigatableWidget][] = []; + for (const widget of widgets) { + if (NavigatableWidget.is(widget)) { + const resourceUri = widget.getResourceUri(); + if (resourceUri && uris.some((uri) => uri.isEqualOrParent(resourceUri))) { + result.push([resourceUri, widget]); + } + } + } + return result; +} diff --git a/arduino-ide-extension/src/browser/contributions/examples.ts b/arduino-ide-extension/src/browser/contributions/examples.ts index 3d93ecb11..a87c58860 100644 --- a/arduino-ide-extension/src/browser/contributions/examples.ts +++ b/arduino-ide-extension/src/browser/contributions/examples.ts @@ -201,7 +201,7 @@ export abstract class Examples extends SketchContribution { private async clone(uri: string): Promise { try { - const sketch = await this.sketchService.cloneExample(uri); + const sketch = await this.sketchesService.cloneExample(uri); return sketch; } catch (err) { if (SketchesError.NotFound.is(err)) { diff --git a/arduino-ide-extension/src/browser/contributions/include-library.ts b/arduino-ide-extension/src/browser/contributions/include-library.ts index 0efe114d7..a8a2f3f4b 100644 --- a/arduino-ide-extension/src/browser/contributions/include-library.ts +++ b/arduino-ide-extension/src/browser/contributions/include-library.ts @@ -17,7 +17,7 @@ import { SketchContribution, Command, CommandRegistry } from './contribution'; import { NotificationCenter } from '../notification-center'; import { nls } from '@theia/core/lib/common'; import * as monaco from '@theia/monaco-editor-core'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; @injectable() export class IncludeLibrary extends SketchContribution { diff --git a/arduino-ide-extension/src/browser/contributions/ino-language.ts b/arduino-ide-extension/src/browser/contributions/ino-language.ts index 6a783b494..c5096e64f 100644 --- a/arduino-ide-extension/src/browser/contributions/ino-language.ts +++ b/arduino-ide-extension/src/browser/contributions/ino-language.ts @@ -8,7 +8,7 @@ import { ExecutableService, sanitizeFqbn, } from '../../common/protocol'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { BoardsConfig } from '../boards/boards-config'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { HostedPluginEvents } from '../hosted-plugin-events'; 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..4d449b121 100644 --- a/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts @@ -1,72 +1,38 @@ -import { DialogError } from '@theia/core/lib/browser/dialogs'; import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; -import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { CompositeTreeNode } from '@theia/core/lib/browser/tree'; -import { Widget } from '@theia/core/lib/browser/widgets/widget'; -import { CancellationTokenSource } from '@theia/core/lib/common/cancellation'; -import { - Disposable, - DisposableCollection, -} from '@theia/core/lib/common/disposable'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { MenuModelRegistry } from '@theia/core/lib/common/menu'; -import { - Progress, - ProgressUpdate, -} from '@theia/core/lib/common/message-service-protocol'; +import { Progress } from '@theia/core/lib/common/message-service-protocol'; import { nls } from '@theia/core/lib/common/nls'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import { WorkspaceInputDialogProps } from '@theia/workspace/lib/browser/workspace-input-dialog'; -import { v4 } from 'uuid'; -import type { AuthenticationSession } from '../../node/auth/types'; -import { AuthenticationClientService } from '../auth/authentication-client-service'; -import { CreateApi } from '../create/create-api'; +import { injectable } from '@theia/core/shared/inversify'; import { CreateUri } from '../create/create-uri'; -import { Create } from '../create/typings'; +import { isConflict } from '../create/typings'; import { ArduinoMenus } from '../menu/arduino-menus'; -import { WorkspaceInputDialog } from '../theia/workspace/workspace-input-dialog'; +import { + TaskFactoryImpl, + WorkspaceInputDialogWithProgress, +} from '../theia/workspace/workspace-input-dialog'; import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree'; import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model'; -import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-widget'; 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, Sketch } from './contribution'; +import { + CloudSketchContribution, + pullingSketch, + sketchAlreadyExists, + synchronizingSketchbook, +} from './cloud-contribution'; @injectable() -export class NewCloudSketch extends Contribution { - @inject(CreateApi) - private readonly createApi: CreateApi; - @inject(SketchbookWidgetContribution) - private readonly widgetContribution: SketchbookWidgetContribution; - @inject(AuthenticationClientService) - private readonly authenticationService: AuthenticationClientService; - +export class NewCloudSketch extends CloudSketchContribution { private readonly toDispose = new DisposableCollection(); - private _session: AuthenticationSession | undefined; - private _enabled: boolean; override onReady(): void { this.toDispose.pushAll([ - this.authenticationService.onSessionDidChange((session) => { - const oldSession = this._session; - this._session = session; - if (!!oldSession !== !!this._session) { - this.menuManager.update(); - } - }), - this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => { - if (preferenceName === 'arduino.cloud.enabled') { - const oldEnabled = this._enabled; - this._enabled = Boolean(newValue); - if (this._enabled !== oldEnabled) { - this.menuManager.update(); - } - } - }), + this.createFeatures.onDidChangeEnabled(() => this.menuManager.update()), + this.createFeatures.onDidChangeSession(() => this.menuManager.update()), ]); - this._enabled = this.preferences['arduino.cloud.enabled']; - this._session = this.authenticationService.session; - if (this._session) { + if (this.createFeatures.session) { this.menuManager.update(); } } @@ -77,16 +43,16 @@ export class NewCloudSketch extends Contribution { override registerCommands(registry: CommandRegistry): void { registry.registerCommand(NewCloudSketch.Commands.NEW_CLOUD_SKETCH, { - execute: () => this.createNewSketch(), - isEnabled: () => !!this._session, - isVisible: () => this._enabled, + execute: () => this.createNewSketch(true), + isEnabled: () => Boolean(this.createFeatures.session), + isVisible: () => this.createFeatures.enabled, }); } override registerMenus(registry: MenuModelRegistry): void { registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, { commandId: NewCloudSketch.Commands.NEW_CLOUD_SKETCH.id, - label: nls.localize('arduino/cloudSketch/new', 'New Remote Sketch'), + label: nls.localize('arduino/cloudSketch/new', 'New Cloud Sketch'), order: '1', }); } @@ -99,154 +65,95 @@ export class NewCloudSketch extends Contribution { } private async createNewSketch( + skipShowErrorMessageOnOpen: boolean, initialValue?: string | undefined - ): Promise { - const widget = await this.widgetContribution.widget; - const treeModel = this.treeModelFrom(widget); - if (!treeModel) { - return undefined; - } - const rootNode = CompositeTreeNode.is(treeModel.root) - ? treeModel.root - : undefined; - if (!rootNode) { - return undefined; + ): Promise { + const treeModel = await this.treeModel(); + if (treeModel) { + const rootNode = treeModel.root; + return this.openWizard( + rootNode, + treeModel, + skipShowErrorMessageOnOpen, + initialValue + ); } - return this.openWizard(rootNode, treeModel, initialValue); } - private withProgress( - value: string, - treeModel: CloudSketchbookTreeModel - ): (progress: Progress) => Promise { - return async (progress: Progress) => { - let result: Create.Sketch | undefined | 'conflict'; - try { - progress.report({ - message: nls.localize( - 'arduino/cloudSketch/creating', - "Creating remote sketch '{0}'...", - value - ), - }); - result = await this.createApi.createSketch(value); - } catch (err) { - if (isConflict(err)) { - result = 'conflict'; - } else { - throw err; - } - } finally { - if (result) { - progress.report({ - message: nls.localize( - 'arduino/cloudSketch/synchronizing', - "Synchronizing sketchbook, pulling '{0}'...", - value - ), - }); - await treeModel.refresh(); - } - } - if (result === 'conflict') { - return this.createNewSketch(value); - } - if (result) { - return this.open(treeModel, result); - } - return undefined; - }; - } - - private async open( + private async openWizard( + rootNode: CompositeTreeNode, treeModel: CloudSketchbookTreeModel, - newSketch: Create.Sketch - ): Promise { - const id = CreateUri.toUri(newSketch).path.toString(); - const node = treeModel.getNode(id); - if (!node) { - throw new Error( - `Could not find remote sketchbook tree node with Tree node ID: ${id}.` - ); - } - if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) { - throw new Error( - `Remote sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.` - ); - } + skipShowErrorMessageOnOpen: boolean, + initialValue?: string | undefined + ): Promise { + const existingNames = rootNode.children + .filter(CloudSketchbookTree.CloudSketchDirNode.is) + .map(({ fileStat }) => fileStat.name); + const taskFactory = new TaskFactoryImpl((value) => + this.createNewSketchWithProgress(treeModel, value) + ); try { - await treeModel.sketchbookTree().pull({ node }); + const dialog = new WorkspaceInputDialogWithProgress( + { + title: nls.localize( + 'arduino/newCloudSketch/newSketchTitle', + 'Name of the new Cloud Sketch' + ), + parentUri: CreateUri.root, + initialValue, + validate: (input) => { + if (existingNames.includes(input)) { + return sketchAlreadyExists(input); + } + return Sketch.validateCloudSketchFolderName(input) ?? ''; + }, + }, + this.labelProvider, + taskFactory + ); + await dialog.open(skipShowErrorMessageOnOpen); + if (dialog.taskResult) { + this.openInNewWindow(dialog.taskResult); + } } catch (err) { - if (isNotFound(err)) { + if (isConflict(err)) { await treeModel.refresh(); - this.messageService.error( - nls.localize( - 'arduino/newCloudSketch/notFound', - "Could not pull the remote sketch '{0}'. It does not exist.", - newSketch.name - ) - ); - return undefined; + return this.createNewSketch(false, taskFactory.value ?? initialValue); } throw err; } - return this.commandService.executeCommand( - SketchbookCommands.OPEN_NEW_WINDOW.id, - { node } - ); - } - - private treeModelFrom( - widget: SketchbookWidget - ): CloudSketchbookTreeModel | undefined { - for (const treeWidget of widget.getTreeWidgets()) { - if (treeWidget instanceof CloudSketchbookTreeWidget) { - const model = treeWidget.model; - if (model instanceof CloudSketchbookTreeModel) { - return model; - } - } - } - return undefined; } - private async openWizard( - rootNode: CompositeTreeNode, + private createNewSketchWithProgress( treeModel: CloudSketchbookTreeModel, - initialValue?: string | undefined - ): Promise { - const existingNames = rootNode.children - .filter(CloudSketchbookTree.CloudSketchDirNode.is) - .map(({ fileStat }) => fileStat.name); - return new NewCloudSketchDialog( - { - title: nls.localize( - 'arduino/newCloudSketch/newSketchTitle', - 'Name of a new Remote Sketch' + value: string + ): ( + progress: Progress + ) => Promise { + return async (progress: Progress) => { + progress.report({ + message: nls.localize( + 'arduino/cloudSketch/creating', + "Creating cloud sketch '{0}'...", + value ), - parentUri: CreateUri.root, - initialValue, - validate: (input) => { - if (existingNames.includes(input)) { - return nls.localize( - 'arduino/newCloudSketch/sketchAlreadyExists', - "Remote sketch '{0}' already exists.", - 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.' - ); - }, - }, - this.labelProvider, - (value) => this.withProgress(value, treeModel) - ).open(); + }); + const sketch = await this.createApi.createSketch(value); + progress.report({ message: synchronizingSketchbook }); + await treeModel.refresh(); + progress.report({ message: pullingSketch(sketch.name) }); + const node = await this.pull(sketch); + return node; + }; + } + + private openInNewWindow( + node: CloudSketchbookTree.CloudSketchDirNode + ): Promise { + return this.commandService.executeCommand( + SketchbookCommands.OPEN_NEW_WINDOW.id, + { node } + ); } } export namespace NewCloudSketch { @@ -256,115 +163,3 @@ export namespace NewCloudSketch { }; } } - -function isConflict(err: unknown): boolean { - return isErrorWithStatusOf(err, 409); -} -function isNotFound(err: unknown): boolean { - return isErrorWithStatusOf(err, 404); -} -function isErrorWithStatusOf( - err: unknown, - status: number -): err is Error & { status: number } { - if (err instanceof Error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const object = err as any; - return 'status' in object && object.status === status; - } - return false; -} - -@injectable() -class NewCloudSketchDialog extends WorkspaceInputDialog { - constructor( - @inject(WorkspaceInputDialogProps) - protected override readonly props: WorkspaceInputDialogProps, - @inject(LabelProvider) - protected override readonly labelProvider: LabelProvider, - private readonly withProgress: ( - value: string - ) => (progress: Progress) => Promise - ) { - super(props, labelProvider); - } - protected override async accept(): Promise { - if (!this.resolve) { - return; - } - this.acceptCancellationSource.cancel(); - this.acceptCancellationSource = new CancellationTokenSource(); - const token = this.acceptCancellationSource.token; - const value = this.value; - const error = await this.isValid(value, 'open'); - if (token.isCancellationRequested) { - return; - } - if (!DialogError.getResult(error)) { - this.setErrorMessage(error); - } else { - const spinner = document.createElement('div'); - spinner.classList.add('spinner'); - const disposables = new DisposableCollection(); - try { - this.toggleButtons(true); - disposables.push(Disposable.create(() => this.toggleButtons(false))); - - const closeParent = this.closeCrossNode.parentNode; - closeParent?.removeChild(this.closeCrossNode); - disposables.push( - Disposable.create(() => { - closeParent?.appendChild(this.closeCrossNode); - }) - ); - - this.errorMessageNode.classList.add('progress'); - disposables.push( - Disposable.create(() => - this.errorMessageNode.classList.remove('progress') - ) - ); - - const errorParent = this.errorMessageNode.parentNode; - errorParent?.insertBefore(spinner, this.errorMessageNode); - disposables.push( - Disposable.create(() => errorParent?.removeChild(spinner)) - ); - - const cancellationSource = new CancellationTokenSource(); - const progress: Progress = { - id: v4(), - cancel: () => cancellationSource.cancel(), - report: (update: ProgressUpdate) => { - this.setProgressMessage(update); - }, - result: Promise.resolve(value), - }; - await this.withProgress(value)(progress); - } finally { - disposables.dispose(); - } - this.resolve(value); - Widget.detach(this); - } - } - - private toggleButtons(disabled: boolean): void { - if (this.acceptButton) { - this.acceptButton.disabled = disabled; - } - if (this.closeButton) { - this.closeButton.disabled = disabled; - } - } - - private setProgressMessage(update: ProgressUpdate): void { - if (update.work && update.work.done === update.work.total) { - this.errorMessageNode.innerText = ''; - } else { - if (update.message) { - this.errorMessageNode.innerText = update.message; - } - } - } -} diff --git a/arduino-ide-extension/src/browser/contributions/new-sketch.ts b/arduino-ide-extension/src/browser/contributions/new-sketch.ts index c625fd802..e026d552d 100644 --- a/arduino-ide-extension/src/browser/contributions/new-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/new-sketch.ts @@ -35,7 +35,7 @@ export class NewSketch extends SketchContribution { async newSketch(): Promise { try { - const sketch = await this.sketchService.createNewSketch(); + const sketch = await this.sketchesService.createNewSketch(); this.workspaceService.open(new URI(sketch.uri)); } catch (e) { await this.messageService.error(e.toString()); diff --git a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts index 21232f055..a14d6a541 100644 --- a/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-recent-sketch.ts @@ -47,7 +47,7 @@ export class OpenRecentSketch extends SketchContribution { } private update(forceUpdate?: boolean): void { - this.sketchService + this.sketchesService .recentlyOpenedSketches(forceUpdate) .then((sketches) => this.refreshMenu(sketches)); } diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts b/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts index 6d4ffa600..01fa997d3 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch-files.ts @@ -39,7 +39,7 @@ export class OpenSketchFiles extends SketchContribution { focusMainSketchFile = false ): Promise { try { - const sketch = await this.sketchService.loadSketch(uri.toString()); + const sketch = await this.sketchesService.loadSketch(uri.toString()); const { mainFileUri, rootFolderFileUris } = sketch; for (const uri of [mainFileUri, ...rootFolderFileUris]) { await this.ensureOpened(uri); @@ -112,7 +112,7 @@ export class OpenSketchFiles extends SketchContribution { await wait(250); // let IDE2 open the editor and toast the error message, then open the modal dialog const movedSketch = await promptMoveSketch(invalidMainSketchUri, { fileService: this.fileService, - sketchService: this.sketchService, + sketchesService: this.sketchesService, labelProvider: this.labelProvider, }); if (movedSketch) { @@ -125,7 +125,7 @@ export class OpenSketchFiles extends SketchContribution { } private async openFallbackSketch(): Promise { - const sketch = await this.sketchService.createNewSketch(); + const sketch = await this.sketchesService.createNewSketch(); this.workspaceService.open(new URI(sketch.uri), { preserveWindow: true }); } diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-sketch.ts index af2ecad67..2b8fc07c8 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch.ts @@ -71,7 +71,7 @@ export class OpenSketch extends SketchContribution { } const uri = SketchLocation.toUri(toOpen); try { - await this.sketchService.loadSketch(uri.toString()); + await this.sketchesService.loadSketch(uri.toString()); } catch (err) { if (SketchesError.NotFound.is(err)) { this.messageService.error(err.message); @@ -106,14 +106,14 @@ export class OpenSketch extends SketchContribution { } const sketchFilePath = filePaths[0]; const sketchFileUri = await this.fileSystemExt.getUri(sketchFilePath); - const sketch = await this.sketchService.getSketchFolder(sketchFileUri); + const sketch = await this.sketchesService.getSketchFolder(sketchFileUri); if (sketch) { return sketch; } if (Sketch.isSketchFile(sketchFileUri)) { return promptMoveSketch(sketchFileUri, { fileService: this.fileService, - sketchService: this.sketchService, + sketchesService: this.sketchesService, labelProvider: this.labelProvider, }); } @@ -132,11 +132,11 @@ export async function promptMoveSketch( sketchFileUri: string | URI, options: { fileService: FileService; - sketchService: SketchesService; + sketchesService: SketchesService; labelProvider: LabelProvider; } ): Promise { - const { fileService, sketchService, labelProvider } = options; + const { fileService, sketchesService, labelProvider } = options; const uri = sketchFileUri instanceof URI ? sketchFileUri : new URI(sketchFileUri); const name = uri.path.name; @@ -176,6 +176,6 @@ export async function promptMoveSketch( uri, new URI(newSketchUri.resolve(nameWithExt).toString()) ); - return sketchService.getSketchFolder(newSketchUri.toString()); + return sketchesService.getSketchFolder(newSketchUri.toString()); } } diff --git a/arduino-ide-extension/src/browser/contributions/rename-cloud-sketch.ts b/arduino-ide-extension/src/browser/contributions/rename-cloud-sketch.ts new file mode 100644 index 000000000..faed8d070 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/rename-cloud-sketch.ts @@ -0,0 +1,166 @@ +import { CompositeTreeNode } from '@theia/core/lib/browser/tree'; +import { Progress } from '@theia/core/lib/common/message-service-protocol'; +import { nls } from '@theia/core/lib/common/nls'; +import { injectable } from '@theia/core/shared/inversify'; +import { CreateUri } from '../create/create-uri'; +import { isConflict } from '../create/typings'; +import { + TaskFactoryImpl, + WorkspaceInputDialogWithProgress, +} from '../theia/workspace/workspace-input-dialog'; +import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree'; +import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model'; +import { + CloudSketchContribution, + pullingSketch, + pushingSketch, + sketchAlreadyExists, + synchronizingSketchbook, +} from './cloud-contribution'; +import { Command, CommandRegistry, Sketch, URI } from './contribution'; + +export interface RenameCloudSketchParams { + readonly cloudUri: URI; + readonly sketch: Sketch; +} + +@injectable() +export class RenameCloudSketch extends CloudSketchContribution { + override registerCommands(registry: CommandRegistry): void { + registry.registerCommand(RenameCloudSketch.Commands.RENAME_CLOUD_SKETCH, { + execute: (params: RenameCloudSketchParams) => + this.renameSketch(params, true), + }); + } + + private async renameSketch( + params: RenameCloudSketchParams, + skipShowErrorMessageOnOpen: boolean, + initValue: string = params.sketch.name + ): Promise { + const treeModel = await this.treeModel(); + if (treeModel) { + const posixPath = params.cloudUri.path.toString(); + const node = treeModel.getNode(posixPath); + const parentNode = node?.parent; + if ( + CloudSketchbookTree.CloudSketchDirNode.is(node) && + CompositeTreeNode.is(parentNode) + ) { + return this.openWizard( + params, + node, + parentNode, + treeModel, + skipShowErrorMessageOnOpen, + initValue + ); + } + } + return undefined; + } + + private async openWizard( + params: RenameCloudSketchParams, + node: CloudSketchbookTree.CloudSketchDirNode, + parentNode: CompositeTreeNode, + treeModel: CloudSketchbookTreeModel, + skipShowErrorMessageOnOpen: boolean, + initialValue?: string | undefined + ): Promise { + const parentUri = CloudSketchbookTree.CloudSketchDirNode.is(parentNode) + ? parentNode.uri + : CreateUri.root; + const existingNames = parentNode.children + .filter(CloudSketchbookTree.CloudSketchDirNode.is) + .map(({ fileStat }) => fileStat.name); + const taskFactory = new TaskFactoryImpl((value) => + this.renameSketchWithProgress(params, node, treeModel, value) + ); + try { + const dialog = new WorkspaceInputDialogWithProgress( + { + title: nls.localize( + 'arduino/renameCloudSketch/renameSketchTitle', + 'New name of the Cloud Sketch' + ), + parentUri, + initialValue, + validate: (input) => { + if (existingNames.includes(input)) { + return sketchAlreadyExists(input); + } + return Sketch.validateCloudSketchFolderName(input) ?? ''; + }, + }, + this.labelProvider, + taskFactory + ); + await dialog.open(skipShowErrorMessageOnOpen); + return dialog.taskResult; + } catch (err) { + if (isConflict(err)) { + await treeModel.refresh(); + return this.renameSketch( + params, + false, + taskFactory.value ?? initialValue + ); + } + throw err; + } + } + + private renameSketchWithProgress( + params: RenameCloudSketchParams, + node: CloudSketchbookTree.CloudSketchDirNode, + treeModel: CloudSketchbookTreeModel, + value: string + ): (progress: Progress) => Promise { + return async (progress: Progress) => { + const fromName = params.cloudUri.path.name; + const fromPosixPath = params.cloudUri.path.toString(); + const toPosixPath = params.cloudUri.parent.resolve(value).path.toString(); + // push + progress.report({ message: pushingSketch(params.sketch.name) }); + await treeModel.sketchbookTree().push(node); + + // rename + progress.report({ + message: nls.localize( + 'arduino/cloudSketch/renaming', + "Renaming cloud sketch from '{0}' to '{1}'...", + fromName, + value + ), + }); + await this.createApi.rename(fromPosixPath, toPosixPath); + + // sync + progress.report({ + message: synchronizingSketchbook, + }); + this.createApi.sketchCache.init(); // invalidate the cache + await this.createApi.sketches(); // IDE2 must pull all sketches to find the new one + const sketch = this.createApi.sketchCache.getSketch(toPosixPath); + if (!sketch) { + return undefined; + } + await treeModel.refresh(); + + // pull + progress.report({ message: pullingSketch(sketch.name) }); + const pulledNode = await this.pull(sketch); + return pulledNode + ? node.uri.parent.resolve(sketch.name).toString() + : undefined; + }; + } +} +export namespace RenameCloudSketch { + export namespace Commands { + export const RENAME_CLOUD_SKETCH: Command = { + id: 'arduino-rename-cloud-sketch', + }; + } +} 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..b964c162f 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -1,28 +1,34 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; -import * as dateFormat from 'dateformat'; +import { Dialog } from '@theia/core/lib/browser/dialogs'; +import { NavigatableWidget } from '@theia/core/lib/browser/navigatable'; +import { Saveable } from '@theia/core/lib/browser/saveable'; +import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; +import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { WorkspaceInput } from '@theia/workspace/lib/browser/workspace-service'; +import { StartupTask } from '../../electron-common/startup-task'; import { ArduinoMenus } from '../menu/arduino-menus'; +import { CurrentSketch } from '../sketches-service-client-impl'; +import { CloudSketchContribution } from './cloud-contribution'; import { - SketchContribution, - URI, Command, CommandRegistry, - MenuModelRegistry, KeybindingRegistry, + MenuModelRegistry, + Sketch, + URI, } from './contribution'; -import { nls } from '@theia/core/lib/common'; -import { ApplicationShell, NavigatableWidget, Saveable } from '@theia/core/lib/browser'; -import { WindowService } from '@theia/core/lib/browser/window/window-service'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; -import { WorkspaceInput } from '@theia/workspace/lib/browser'; -import { StartupTask } from '../../electron-common/startup-task'; import { DeleteSketch } from './delete-sketch'; +import { + RenameCloudSketch, + RenameCloudSketchParams, +} from './rename-cloud-sketch'; @injectable() -export class SaveAsSketch extends SketchContribution { +export class SaveAsSketch extends CloudSketchContribution { @inject(ApplicationShell) private readonly applicationShell: ApplicationShell; - @inject(WindowService) private readonly windowService: WindowService; @@ -35,7 +41,7 @@ export class SaveAsSketch extends SketchContribution { override registerMenus(registry: MenuModelRegistry): void { registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, { commandId: SaveAsSketch.Commands.SAVE_AS_SKETCH.id, - label: nls.localize('vscode/fileCommands/saveAs', 'Save As...'), + label: nls.localizeByDefault('Save As...'), order: '7', }); } @@ -63,11 +69,63 @@ export class SaveAsSketch extends SketchContribution { return false; } - const isTemp = await this.sketchService.isTemp(sketch); - if (!isTemp && !!execOnlyIfTemp) { + let destinationUri: string | undefined; + const cloudUri = this.createFeatures.cloudUri(sketch); + if (cloudUri) { + destinationUri = await this.createCloudCopy({ cloudUri, sketch }); + } else { + destinationUri = await this.createLocalCopy(sketch, execOnlyIfTemp); + } + if (!destinationUri) { return false; } + const newWorkspaceUri = await this.sketchesService.copy(sketch, { + destinationUri, + }); + if (!newWorkspaceUri) { + return false; + } + + await this.saveOntoCopiedSketch(sketch, newWorkspaceUri); + if (markAsRecentlyOpened) { + this.sketchesService.markAsRecentlyOpened(newWorkspaceUri); + } + const options: WorkspaceInput & StartupTask.Owner = { + preserveWindow: true, + tasks: [], + }; + if (openAfterMove) { + this.windowService.setSafeToShutDown(); + if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) { + options.tasks.push({ + command: DeleteSketch.Commands.DELETE_SKETCH.id, + args: [{ toDelete: sketch.uri }], + }); + } + this.workspaceService.open(new URI(newWorkspaceUri), options); + } + return !!newWorkspaceUri; + } + + private async createCloudCopy( + params: RenameCloudSketchParams + ): Promise { + return this.commandService.executeCommand( + RenameCloudSketch.Commands.RENAME_CLOUD_SKETCH.id, + params + ); + } + + private async createLocalCopy( + sketch: Sketch, + execOnlyIfTemp?: boolean + ): Promise { + const isTemp = await this.sketchesService.isTemp(sketch); + if (!isTemp && !!execOnlyIfTemp) { + return undefined; + } + const sketchUri = new URI(sketch.uri); const sketchbookDirUri = await this.defaultUri(); // If the sketch is temp, IDE2 proposes the default sketchbook folder URI. @@ -84,88 +142,118 @@ export class SaveAsSketch extends SketchContribution { // If target does not exist, propose a `directories.user`/${sketch.name} path // If target exists, propose `directories.user`/${sketch.name}_copy_${yyyymmddHHMMss} + // IDE2 must never prompt an invalid sketch folder name (https://github.com/arduino/arduino-ide/pull/1833#issuecomment-1412569252) const defaultUri = containerDirUri.resolve( - exists - ? `${sketch.name}_copy_${dateFormat(new Date(), 'yyyymmddHHMMss')}` - : sketch.name + Sketch.toValidSketchFolderName(sketch.name, exists) ); 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, - } - ); - if (!filePath || canceled) { - return false; - } - const destinationUri = await this.fileSystemExt.getUri(filePath); - if (!destinationUri) { - return false; - } - const workspaceUri = await this.sketchService.copy(sketch, { - destinationUri, - }); - if (workspaceUri) { - await this.saveOntoCopiedSketch(sketch.mainFileUri, sketch.uri, workspaceUri); - if (markAsRecentlyOpened) { - this.sketchService.markAsRecentlyOpened(workspaceUri); + return await this.promptLocalSketchFolderDestination(defaultPath); + } + + /** + * 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 promptLocalSketchFolderDestination( + 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 options: WorkspaceInput & StartupTask.Owner = { - preserveWindow: true, - tasks: [], - }; - if (workspaceUri && openAfterMove) { - this.windowService.setSafeToShutDown(); - if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) { - options.tasks.push({ - command: DeleteSketch.Commands.DELETE_SKETCH.id, - args: [sketch.uri], - }); + 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: [Dialog.CANCEL, Dialog.YES], + } + ); + // cancel + if (response === 0) { + return undefined; + } + } else { + sketchFolderDestinationUri = destinationUri; } - this.workspaceService.open(new URI(workspaceUri), options); } - return !!workspaceUri; + return sketchFolderDestinationUri; } - private async saveOntoCopiedSketch(mainFileUri: string, sketchUri: string, newSketchUri: string): Promise { + private async saveOntoCopiedSketch( + sketch: Sketch, + newSketchFolderUri: string + ): Promise { const widgets = this.applicationShell.widgets; - const snapshots = new Map(); + const snapshots = new Map(); for (const widget of widgets) { const saveable = Saveable.getDirty(widget); const uri = NavigatableWidget.getUri(widget); - const uriString = uri?.toString(); + if (!uri) { + continue; + } + const uriString = uri.toString(); let relativePath: string; - if (uri && uriString!.includes(sketchUri) && saveable && saveable.createSnapshot) { + if ( + uriString.includes(sketch.uri) && + saveable && + saveable.createSnapshot + ) { // The main file will change its name during the copy process // We need to store the new name in the map - if (mainFileUri === uriString) { - const lastPart = new URI(newSketchUri).path.base + uri.path.ext; + if (sketch.mainFileUri === uriString) { + const lastPart = new URI(newSketchFolderUri).path.base + uri.path.ext; relativePath = '/' + lastPart; } else { - relativePath = uri.toString().substring(sketchUri.length); + relativePath = uri.toString().substring(sketch.uri.length); } snapshots.set(relativePath, saveable.createSnapshot()); } } - await Promise.all(Array.from(snapshots.entries()).map(async ([path, snapshot]) => { - const widgetUri = new URI(newSketchUri + path); - try { - const widget = await this.editorManager.getOrCreateByUri(widgetUri); - const saveable = Saveable.get(widget); - if (saveable && saveable.applySnapshot) { - saveable.applySnapshot(snapshot); - await saveable.save(); + await Promise.all( + Array.from(snapshots.entries()).map(async ([path, snapshot]) => { + const widgetUri = new URI(newSketchFolderUri + path); + try { + const widget = await this.editorManager.getOrCreateByUri(widgetUri); + const saveable = Saveable.get(widget); + if (saveable && saveable.applySnapshot) { + saveable.applySnapshot(snapshot); + await saveable.save(); + } + } catch (e) { + console.error(e); } - } catch (e) { - console.error(e); - } - })); + }) + ); } } diff --git a/arduino-ide-extension/src/browser/contributions/save-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-sketch.ts index 5d88433ed..d05a47982 100644 --- a/arduino-ide-extension/src/browser/contributions/save-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-sketch.ts @@ -10,7 +10,7 @@ import { KeybindingRegistry, } from './contribution'; import { nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; @injectable() export class SaveSketch extends SketchContribution { @@ -40,7 +40,7 @@ export class SaveSketch extends SketchContribution { if (!CurrentSketch.isValid(sketch)) { return; } - const isTemp = await this.sketchService.isTemp(sketch); + const isTemp = await this.sketchesService.isTemp(sketch); if (isTemp) { return this.commandService.executeCommand( SaveAsSketch.Commands.SAVE_AS_SKETCH.id, diff --git a/arduino-ide-extension/src/browser/contributions/sketch-control.ts b/arduino-ide-extension/src/browser/contributions/sketch-control.ts index eef34817e..a8805f440 100644 --- a/arduino-ide-extension/src/browser/contributions/sketch-control.ts +++ b/arduino-ide-extension/src/browser/contributions/sketch-control.ts @@ -18,33 +18,19 @@ import { open, } from './contribution'; import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus'; -import { EditorManager } from '@theia/editor/lib/browser/editor-manager'; -import { - CurrentSketch, - SketchesServiceClientImpl, -} from '../../common/protocol/sketches-service-client-impl'; -import { LocalCacheFsProvider } from '../local-cache/local-cache-fs-provider'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { nls } from '@theia/core/lib/common'; @injectable() export class SketchControl extends SketchContribution { @inject(ApplicationShell) - protected readonly shell: ApplicationShell; + private readonly shell: ApplicationShell; @inject(MenuModelRegistry) - protected readonly menuRegistry: MenuModelRegistry; + private readonly menuRegistry: MenuModelRegistry; @inject(ContextMenuRenderer) - protected readonly contextMenuRenderer: ContextMenuRenderer; - - @inject(EditorManager) - protected override readonly editorManager: EditorManager; - - @inject(SketchesServiceClientImpl) - protected readonly sketchesServiceClient: SketchesServiceClientImpl; - - @inject(LocalCacheFsProvider) - protected readonly localCacheFsProvider: LocalCacheFsProvider; + private readonly contextMenuRenderer: ContextMenuRenderer; protected readonly toDisposeBeforeCreateNewContextMenu = new DisposableCollection(); @@ -79,17 +65,12 @@ export class SketchControl extends SketchContribution { const parentSketchUri = this.editorManager.currentEditor ?.getResourceUri() ?.toString(); - const parentSketch = await this.sketchService.getSketchFolder( + const parentSketch = await this.sketchesService.getSketchFolder( parentSketchUri || '' ); // if the current file is in the current opened sketch, show extra menus - if ( - sketch && - parentSketch && - parentSketch.uri === sketch.uri && - this.allowRename(parentSketch.uri) - ) { + if (sketch && parentSketch && parentSketch.uri === sketch.uri) { this.menuRegistry.registerMenuAction( ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, { @@ -121,12 +102,7 @@ export class SketchControl extends SketchContribution { ); } - if ( - sketch && - parentSketch && - parentSketch.uri === sketch.uri && - this.allowDelete(parentSketch.uri) - ) { + if (sketch && parentSketch && parentSketch.uri === sketch.uri) { this.menuRegistry.registerMenuAction( ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, { @@ -249,27 +225,6 @@ export class SketchControl extends SketchContribution { command: SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id, }); } - - protected isCloudSketch(uri: string): boolean { - try { - const cloudCacheLocation = this.localCacheFsProvider.from(new URI(uri)); - - if (cloudCacheLocation) { - return true; - } - return false; - } catch { - return false; - } - } - - protected allowRename(uri: string): boolean { - return !this.isCloudSketch(uri); - } - - protected allowDelete(uri: string): boolean { - return !this.isCloudSketch(uri); - } } export namespace SketchControl { diff --git a/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts b/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts index 3c7daea48..d7c1c68d6 100644 --- a/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts +++ b/arduino-ide-extension/src/browser/contributions/sketch-files-tracker.ts @@ -3,7 +3,7 @@ import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { inject, injectable } from '@theia/core/shared/inversify'; import { FileSystemFrontendContribution } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution'; import { FileChangeType } from '@theia/filesystem/lib/common/files'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { Sketch, SketchContribution } from './contribution'; import { OpenSketchFiles } from './open-sketch-files'; @@ -38,7 +38,7 @@ export class SketchFilesTracker extends SketchContribution { type === FileChangeType.ADDED && resource.parent.toString() === sketch.uri ) { - const reloadedSketch = await this.sketchService.loadSketch( + const reloadedSketch = await this.sketchesService.loadSketch( sketch.uri ); if (Sketch.isInSketch(resource, reloadedSketch)) { diff --git a/arduino-ide-extension/src/browser/contributions/sketchbook.ts b/arduino-ide-extension/src/browser/contributions/sketchbook.ts index e76b64bc1..3e6bca2ec 100644 --- a/arduino-ide-extension/src/browser/contributions/sketchbook.ts +++ b/arduino-ide-extension/src/browser/contributions/sketchbook.ts @@ -19,7 +19,7 @@ export class Sketchbook extends Examples { } protected override update(): void { - this.sketchService.getSketches({}).then((container) => { + this.sketchesService.getSketches({}).then((container) => { this.register(container); this.menuManager.update(); }); diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index 3bef23f62..034ea87d3 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -12,7 +12,7 @@ import { CoreServiceContribution, } from './contribution'; import { deepClone, nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; import type { VerifySketchParams } from './verify-sketch'; import { UserFields } from './user-fields'; 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..bc0f22f18 --- /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 '../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.sketchesService.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..bd6b60ff8 100644 --- a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts @@ -11,7 +11,7 @@ import { TabBarToolbarRegistry, } from './contribution'; import { nls } from '@theia/core/lib/common'; -import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { CoreService } from '../../common/protocol'; import { CoreErrorHandler } from './core-error-handler'; @@ -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/create/create-api.ts b/arduino-ide-extension/src/browser/create/create-api.ts index 1faf05754..536e7f4bc 100644 --- a/arduino-ide-extension/src/browser/create/create-api.ts +++ b/arduino-ide-extension/src/browser/create/create-api.ts @@ -1,12 +1,16 @@ -import { injectable, inject } from '@theia/core/shared/inversify'; -import * as createPaths from './create-paths'; -import { posix } from './create-paths'; -import { AuthenticationClientService } from '../auth/authentication-client-service'; +import { MaybePromise } from '@theia/core/lib/common/types'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { fetch } from 'cross-fetch'; +import { SketchesService } from '../../common/protocol'; import { ArduinoPreferences } from '../arduino-preferences'; +import { AuthenticationClientService } from '../auth/authentication-client-service'; import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache'; +import * as createPaths from './create-paths'; +import { posix } from './create-paths'; import { Create, CreateError } from './typings'; export interface ResponseResultProvider { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (response: Response): Promise; } export namespace ResponseResultProvider { @@ -15,6 +19,8 @@ export namespace ResponseResultProvider { export const JSON: ResponseResultProvider = (response) => response.json(); } +// TODO: check if this is still needed: https://github.com/electron/electron/issues/18733 +// The original issue was reported for Electron 5.x and 6.x. Theia uses 15.x export function Utf8ArrayToStr(array: Uint8Array): string { let out, i, c; let char2, char3; @@ -61,20 +67,13 @@ type ResourceType = 'f' | 'd'; @injectable() export class CreateApi { @inject(SketchCache) - protected sketchCache: SketchCache; - - protected authenticationService: AuthenticationClientService; - protected arduinoPreferences: ArduinoPreferences; - - public init( - authenticationService: AuthenticationClientService, - arduinoPreferences: ArduinoPreferences - ): CreateApi { - this.authenticationService = authenticationService; - this.arduinoPreferences = arduinoPreferences; - - return this; - } + readonly sketchCache: SketchCache; + @inject(AuthenticationClientService) + private readonly authenticationService: AuthenticationClientService; + @inject(ArduinoPreferences) + private readonly arduinoPreferences: ArduinoPreferences; + @inject(SketchesService) + private readonly sketchesService: SketchesService; getSketchSecretStat(sketch: Create.Sketch): Create.Resource { return { @@ -129,10 +128,13 @@ export class CreateApi { async createSketch( posixPath: string, - content: string = CreateApi.defaultInoContent + contentProvider: MaybePromise = this.sketchesService.defaultInoContent() ): Promise { const url = new URL(`${this.domain()}/sketches`); - const headers = await this.headers(); + const [headers, content] = await Promise.all([ + this.headers(), + contentProvider, + ]); const payload = { ino: btoa(content), path: posixPath, @@ -291,7 +293,7 @@ export class CreateApi { this.sketchCache.addSketch(sketch); let file = ''; - if (sketch && sketch.secrets) { + if (sketch.secrets) { for (const item of sketch.secrets) { file += `#define ${item.name} "${item.value}"\r\n`; } @@ -381,7 +383,7 @@ export class CreateApi { return; } - // do not upload "do_not_sync" files/directoris and their descendants + // do not upload "do_not_sync" files/directories and their descendants const segments = posixPath.split(posix.sep) || []; if ( segments.some((segment) => Create.do_not_sync_files.includes(segment)) @@ -415,6 +417,21 @@ export class CreateApi { await this.delete(posixPath, 'd'); } + /** + * `sketchPath` is not the POSIX path but the path with the user UUID, username, etc. + * See [Create.Resource#path](./typings.ts). Unlike other endpoints, it does not support the `$HOME` + * variable substitution. The DELETE directory endpoint is bogus and responses with HTTP 500 + * instead of 404 when deleting a non-existing resource. + */ + async deleteSketch(sketchPath: string): Promise { + const url = new URL(`${this.domain()}/sketches/byPath/${sketchPath}`); + const headers = await this.headers(); + await this.run(url, { + method: 'DELETE', + headers, + }); + } + private async delete(posixPath: string, type: ResourceType): Promise { const url = new URL( `${this.domain()}/files/${type}/$HOME/sketches_v2${posixPath}` @@ -475,14 +492,12 @@ export class CreateApi { } private async run( - requestInfo: RequestInfo | URL, + requestInfo: URL, init: RequestInit | undefined, resultProvider: ResponseResultProvider = ResponseResultProvider.JSON ): Promise { - const response = await fetch( - requestInfo instanceof URL ? requestInfo.toString() : requestInfo, - init - ); + console.debug(`HTTP ${init?.method}: ${requestInfo.toString()}`); + const response = await fetch(requestInfo.toString(), init); if (!response.ok) { let details: string | undefined = undefined; try { @@ -516,19 +531,3 @@ export class CreateApi { return this.authenticationService.session?.accessToken || ''; } } - -export namespace CreateApi { - export const defaultInoContent = `/* - -*/ - -void setup() { - -} - -void loop() { - -} - -`; -} diff --git a/arduino-ide-extension/src/browser/create/create-features.ts b/arduino-ide-extension/src/browser/create/create-features.ts new file mode 100644 index 000000000..3b51a450c --- /dev/null +++ b/arduino-ide-extension/src/browser/create/create-features.ts @@ -0,0 +1,81 @@ +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { Emitter, Event } from '@theia/core/lib/common/event'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Sketch } from '../../common/protocol'; +import { AuthenticationSession } from '../../node/auth/types'; +import { ArduinoPreferences } from '../arduino-preferences'; +import { AuthenticationClientService } from '../auth/authentication-client-service'; +import { LocalCacheFsProvider } from '../local-cache/local-cache-fs-provider'; + +@injectable() +export class CreateFeatures implements FrontendApplicationContribution { + @inject(ArduinoPreferences) + private readonly preferences: ArduinoPreferences; + @inject(AuthenticationClientService) + private readonly authenticationService: AuthenticationClientService; + @inject(LocalCacheFsProvider) + private readonly localCacheFsProvider: LocalCacheFsProvider; + + private readonly onDidChangeSessionEmitter = new Emitter< + AuthenticationSession | undefined + >(); + private readonly onDidChangeEnabledEmitter = new Emitter(); + private readonly toDispose = new DisposableCollection( + this.onDidChangeSessionEmitter, + this.onDidChangeEnabledEmitter + ); + private _enabled: boolean; + private _session: AuthenticationSession | undefined; + + onStart(): void { + this.toDispose.pushAll([ + this.authenticationService.onSessionDidChange((session) => { + const oldSession = this._session; + this._session = session; + if (!!oldSession !== !!this._session) { + this.onDidChangeSessionEmitter.fire(this._session); + } + }), + this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => { + if (preferenceName === 'arduino.cloud.enabled') { + const oldEnabled = this._enabled; + this._enabled = Boolean(newValue); + if (this._enabled !== oldEnabled) { + this.onDidChangeEnabledEmitter.fire(this._enabled); + } + } + }), + ]); + this._enabled = this.preferences['arduino.cloud.enabled']; + this._session = this.authenticationService.session; + } + + onStop(): void { + this.toDispose.dispose(); + } + + get onDidChangeSession(): Event { + return this.onDidChangeSessionEmitter.event; + } + + get onDidChangeEnabled(): Event { + return this.onDidChangeEnabledEmitter.event; + } + + get enabled(): boolean { + return this._enabled; + } + + get session(): AuthenticationSession | undefined { + return this._session; + } + + cloudUri(sketch: Sketch): URI | undefined { + if (!this.session) { + return undefined; + } + return this.localCacheFsProvider.from(new URI(sketch.uri)); + } +} diff --git a/arduino-ide-extension/src/browser/create/create-fs-provider.ts b/arduino-ide-extension/src/browser/create/create-fs-provider.ts index 0d0d1ecb3..41ef5ab04 100644 --- a/arduino-ide-extension/src/browser/create/create-fs-provider.ts +++ b/arduino-ide-extension/src/browser/create/create-fs-provider.ts @@ -189,10 +189,6 @@ export class CreateFsProvider FileSystemProviderErrorCode.NoPermissions ); } - - return this.createApi.init( - this.authenticationService, - this.arduinoPreferences - ); + return this.createApi; } } diff --git a/arduino-ide-extension/src/browser/create/typings.ts b/arduino-ide-extension/src/browser/create/typings.ts index b5fb0e2d3..fe2ca1af8 100644 --- a/arduino-ide-extension/src/browser/create/typings.ts +++ b/arduino-ide-extension/src/browser/create/typings.ts @@ -71,3 +71,23 @@ export class CreateError extends Error { Object.setPrototypeOf(this, CreateError.prototype); } } + +export type ConflictError = CreateError & { status: 409 }; +export function isConflict(err: unknown): err is ConflictError { + return isErrorWithStatusOf(err, 409); +} + +export type NotFoundError = CreateError & { status: 404 }; +export function isNotFound(err: unknown): err is NotFoundError { + return isErrorWithStatusOf(err, 404); +} + +function isErrorWithStatusOf( + err: unknown, + status: number +): err is CreateError & { status: number } { + if (err instanceof CreateError) { + return err.status === status; + } + return false; +} diff --git a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts index 583da52ef..e65c56e85 100644 --- a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts +++ b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts @@ -90,7 +90,7 @@ export class LocalCacheFsProvider protected async init(fileService: FileService): Promise { const { config } = await this.configService.getConfiguration(); // Any possible CLI config errors are ignored here. IDE2 does not verify the `directories.data` folder. - // If the data dir is accessible, IDE2 creates the cache folder for the remote sketches. Otherwise, it does not. + // If the data dir is accessible, IDE2 creates the cache folder for the cloud sketches. Otherwise, it does not. // The data folder can be configured outside of the IDE2, and the new data folder will be picked up with a // subsequent IDE2 start. if (!config?.dataDirUri) { diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/browser/sketches-service-client-impl.ts similarity index 94% rename from arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts rename to arduino-ide-extension/src/browser/sketches-service-client-impl.ts index 59e88d740..f0186454c 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/browser/sketches-service-client-impl.ts @@ -10,13 +10,17 @@ import { DisposableCollection, } from '@theia/core/lib/common/disposable'; import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; -import { Sketch, SketchesService } from '.'; -import { ConfigServiceClient } from '../../browser/config/config-service-client'; -import { SketchContainer, SketchesError, SketchRef } from './sketches-service'; +import { Sketch, SketchesService } from '../common/protocol'; +import { ConfigServiceClient } from './config/config-service-client'; +import { + SketchContainer, + SketchesError, + SketchRef, +} from '../common/protocol/sketches-service'; import { ARDUINO_CLOUD_FOLDER, REMOTE_SKETCHBOOK_FOLDER, -} from '../../browser/utils/constants'; +} from './utils/constants'; import * as monaco from '@theia/monaco-editor-core'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @@ -38,7 +42,7 @@ export class SketchesServiceClientImpl @inject(FileService) private readonly fileService: FileService; @inject(SketchesService) - private readonly sketchService: SketchesService; + private readonly sketchesService: SketchesService; @inject(WorkspaceService) private readonly workspaceService: WorkspaceService; @inject(ConfigServiceClient) @@ -90,7 +94,7 @@ export class SketchesServiceClientImpl if (!sketchDirUri) { return; } - const container = await this.sketchService.getSketches({ + const container = await this.sketchesService.getSketches({ uri: sketchDirUri.toString(), }); for (const sketch of SketchContainer.toArray(container)) { @@ -123,7 +127,7 @@ export class SketchesServiceClientImpl let reloadedSketch: Sketch | undefined = undefined; try { - reloadedSketch = await this.sketchService.loadSketch( + reloadedSketch = await this.sketchesService.loadSketch( this._currentSketch.uri ); } catch (err) { @@ -146,7 +150,7 @@ export class SketchesServiceClientImpl if (Sketch.isSketchFile(resource)) { if (type === FileChangeType.ADDED) { try { - const toAdd = await this.sketchService.loadSketch( + const toAdd = await this.sketchesService.loadSketch( resource.parent.toString() ); if (!this.sketches.has(toAdd.uri)) { @@ -197,7 +201,7 @@ export class SketchesServiceClientImpl this.workspaceService .tryGetRoots() .map(({ resource }) => - this.sketchService.getSketchFolder(resource.toString()) + this.sketchesService.getSketchFolder(resource.toString()) ) ) ).filter(notEmpty); diff --git a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts index 038b046a1..1ab8a2921 100644 --- a/arduino-ide-extension/src/browser/theia/core/widget-manager.ts +++ b/arduino-ide-extension/src/browser/theia/core/widget-manager.ts @@ -10,7 +10,7 @@ import { OutputWidget } from '@theia/output/lib/browser/output-widget'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; @injectable() export class WidgetManager extends TheiaWidgetManager { diff --git a/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts b/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts index 2523c99c8..eda81804b 100644 --- a/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts +++ b/arduino-ide-extension/src/browser/theia/debug/debug-configuration-manager.ts @@ -14,7 +14,7 @@ import { SketchesService } from '../../../common/protocol'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; import { DebugConfigurationModel } from './debug-configuration-model'; import { FileOperationError, diff --git a/arduino-ide-extension/src/browser/theia/editor/editor-widget-factory.ts b/arduino-ide-extension/src/browser/theia/editor/editor-widget-factory.ts index 3df32188c..14016c167 100644 --- a/arduino-ide-extension/src/browser/theia/editor/editor-widget-factory.ts +++ b/arduino-ide-extension/src/browser/theia/editor/editor-widget-factory.ts @@ -6,7 +6,7 @@ import { EditorWidgetFactory as TheiaEditorWidgetFactory } from '@theia/editor/l import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; import { SketchesService, Sketch } from '../../../common/protocol'; import { nls } from '@theia/core/lib/common'; diff --git a/arduino-ide-extension/src/browser/theia/monaco/monaco-editor-provider.ts b/arduino-ide-extension/src/browser/theia/monaco/monaco-editor-provider.ts index b93036a3c..2718b0579 100644 --- a/arduino-ide-extension/src/browser/theia/monaco/monaco-editor-provider.ts +++ b/arduino-ide-extension/src/browser/theia/monaco/monaco-editor-provider.ts @@ -6,7 +6,7 @@ import { } from '@theia/core/lib/common/disposable'; import { EditorServiceOverrides, MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import { MonacoEditorProvider as TheiaMonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; -import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; +import { SketchesServiceClientImpl } from '../../sketches-service-client-impl'; import * as monaco from '@theia/monaco-editor-core'; import type { ReferencesModel } from '@theia/monaco-editor-core/esm/vs/editor/contrib/gotoSymbol/browser/referencesModel'; diff --git a/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts b/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts index 7a8ece561..ec775685a 100644 --- a/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts +++ b/arduino-ide-extension/src/browser/theia/monaco/monaco-text-model-service.ts @@ -6,14 +6,16 @@ import { EditorPreferences } from '@theia/editor/lib/browser/editor-preferences' import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter'; import { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter'; import { MonacoTextModelService as TheiaMonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; -import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; +import { SketchesServiceClientImpl } from '../../sketches-service-client-impl'; @injectable() export class MonacoTextModelService extends TheiaMonacoTextModelService { @inject(SketchesServiceClientImpl) protected readonly sketchesServiceClient: SketchesServiceClientImpl; - protected override async createModel(resource: Resource): Promise { + protected override async createModel( + resource: Resource + ): Promise { const factory = this.factories .getContributions() .find(({ scheme }) => resource.uri.scheme === scheme); 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..97412ad48 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,49 @@ -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 { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { CommandRegistry, CommandService, } from '@theia/core/lib/common/command'; +import { nls } from '@theia/core/lib/common/nls'; +import { Path } from '@theia/core/lib/common/path'; +import { SelectionService } from '@theia/core/lib/common/selection-service'; +import { MaybeArray } from '@theia/core/lib/common/types'; +import URI from '@theia/core/lib/common/uri'; +import { + UriAwareCommandHandler, + UriCommandHandler, +} from '@theia/core/lib/common/uri-command-handler'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { EditorWidget } from '@theia/editor/lib/browser/editor-widget'; +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 { Sketch } from '../../../common/protocol'; +import { CreateFeatures } from '../../create/create-features'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; -import { SaveAsSketch } from '../../contributions/save-as-sketch'; -import { nls } from '@theia/core/lib/common'; +} from '../../sketches-service-client-impl'; +import { WorkspaceInputDialog } from './workspace-input-dialog'; + +interface ValidationContext { + sketch: Sketch; + cloudUri: URI | undefined; +} @injectable() export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribution { - @inject(SketchesServiceClientImpl) - protected readonly sketchesServiceClient: SketchesServiceClientImpl; - @inject(CommandService) - protected readonly commandService: CommandService; - - @inject(SketchesService) - protected readonly sketchService: SketchesService; + private readonly commandService: CommandService; + @inject(SketchesServiceClientImpl) + private readonly sketchesServiceClient: SketchesServiceClientImpl; + @inject(CreateFeatures) + private readonly createFeatures: CreateFeatures; + @inject(ApplicationShell) + private readonly shell: ApplicationShell; + private _validationContext: ValidationContext | undefined; override registerCommands(registry: CommandRegistry): void { super.registerCommands(registry); @@ -46,9 +61,14 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut execute: (uri) => this.renameFile(uri), }) ); + registry.unregisterCommand(WorkspaceCommands.FILE_DELETE); + registry.registerCommand( + WorkspaceCommands.FILE_DELETE, + this.newMultiUriAwareCommandHandler(this.deleteHandler) + ); } - protected async newFile(uri: URI | undefined): Promise { + private async newFile(uri: URI | undefined): Promise { if (!uri) { return; } @@ -67,51 +87,72 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut this.labelProvider ); - 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); + const name = await this.openDialog(dialog, parentUri); + 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 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); + } + let errorMessage: string | undefined = undefined; + if (Sketch.Extensions.CODE_FILES.includes(extension)) { + errorMessage = this._validationContext?.cloudUri + ? Sketch.validateCloudSketchFolderName(name) + : Sketch.validateSketchFolderName(name); + } if (errorMessage) { - return errorMessage; - } - 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 - ); + return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput); } - return ''; + errorMessage = await super.validateFileName(userInput, parent, recursive); // run the default Theia validation with the raw input. + if (errorMessage) { + return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput); + } + // It's a legacy behavior from IDE 1.x. Validate the file it were an `.ino` file. + // If user did not write the `.ino` extension or ended the user input with dot, run the default Theia validation with the inferred name. + if (extension === '.ino' && !userInput.endsWith('.ino')) { + userInput = `${name}${extension}`; + errorMessage = await super.validateFileName(userInput, parent, recursive); + } + return this.maybeRemapAlreadyExistsMessage(errorMessage ?? '', userInput); + } + + // Remaps the Theia-based `A file or folder **$fileName** already exists at this location. Please choose a different name.` to a custom one. + private maybeRemapAlreadyExistsMessage( + errorMessage: string, + userInput: string + ): string { + if ( + errorMessage === + nls.localizeByDefault( + 'A file or folder **{0}** already exists at this location. Please choose a different name.', + this['trimFileName'](userInput) + ) + ) { + return fileAlreadyExists(userInput); + } + return errorMessage; } - protected maybeAppendInoExt(name: string | undefined): string { + private maybeAppendInoExt(name: string): string { if (!name) { return ''; } @@ -126,7 +167,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; } @@ -136,10 +177,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut } // file belongs to another sketch, do not allow rename - const parentSketch = await this.sketchService.getSketchFolder( - uri.toString() - ); - if (parentSketch && parentSketch.uri !== sketch.uri) { + if (!Sketch.isInSketch(uri, sketch)) { return; } @@ -149,11 +187,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 +217,155 @@ 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 this.openDialog(dialog, uri); + if (!name) { + return; + } + const nameWithExt = this.maybeAppendInoExt(name); + const oldUri = uri; + const newUri = uri.parent.resolve(nameWithExt); + return this.fileService.move(oldUri, newUri); + } + + protected override newUriAwareCommandHandler( + handler: UriCommandHandler + ): UriAwareCommandHandler { + return this.createUriAwareCommandHandler(handler); + } + + protected override newMultiUriAwareCommandHandler( + handler: UriCommandHandler + ): UriAwareCommandHandler { + return this.createUriAwareCommandHandler(handler, true); + } + + private createUriAwareCommandHandler>( + delegate: UriCommandHandler, + multi = false + ): UriAwareCommandHandler { + return new UriAwareCommandHandlerWithCurrentEditorFallback( + delegate, + this.selectionService, + this.shell, + { multi } + ); + } + + private async openDialog( + dialog: WorkspaceInputDialog, + uri: URI + ): Promise { + try { + this.acquireValidationContext(uri); + const name = await dialog.open(true); + return name; + } finally { + this._validationContext = undefined; + } + } + + private acquireValidationContext(uri: URI): void { + const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); + if ( + CurrentSketch.isValid(sketch) && + new URI(sketch.uri).isEqualOrParent(uri) + ) { + const cloudUri = this.createFeatures.cloudUri(sketch); + this._validationContext = { sketch, cloudUri }; + } + } +} + +// (non-API) exported for tests +export function fileAlreadyExists(userInput: string): string { + return nls.localize( + 'arduino/workspace/alreadyExists', + "'{0}' already exists.", + userInput + ); +} + +// (non-API) exported for tests +export function invalidExtension(extension: string): string { + 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, + }; +} + +/** + * By default, the Theia-based URI-aware command handler tries to retrieve the URI from the selection service. + * Delete/Rename from the tab-bar toolbar (`...`) is not active if the selection was never inside an editor. + * This implementation falls back to the current current title of the main panel if no URI can be retrieved from the parent classes. + * - https://github.com/arduino/arduino-ide/issues/1847 + * - https://github.com/eclipse-theia/theia/issues/12139 + */ +class UriAwareCommandHandlerWithCurrentEditorFallback< + T extends MaybeArray +> extends UriAwareCommandHandler { + constructor( + delegate: UriCommandHandler, + selectionService: SelectionService, + private readonly shell: ApplicationShell, + options?: UriAwareCommandHandler.Options + ) { + super(selectionService, delegate, options); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected override getUri(...args: any[]): T | undefined { + const uri = super.getUri(args); + if (!uri || (Array.isArray(uri) && !uri.length)) { + const fallbackUri = this.currentTitleOwnerUriFromMainPanel; + if (fallbackUri) { + return (this.isMulti() ? [fallbackUri] : fallbackUri) as T; + } } + return uri; + } + + // The `currentEditor` is broken after a rename. (https://github.com/eclipse-theia/theia/issues/12139) + // `ApplicationShell#currentWidget` might provide a wrong result just as the `getFocusedCodeEditor` and `getFocusedCodeEditor` of the `MonacoEditorService` + // Try to extract the URI from the current title of the main panel if it's an editor widget. + private get currentTitleOwnerUriFromMainPanel(): URI | undefined { + const owner = this.shell.mainPanel.currentTitle?.owner; + return owner instanceof EditorWidget + ? owner.editor.getResourceUri() + : undefined; } } diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-delete-handler.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-delete-handler.ts index e3461c379..fe844d252 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-delete-handler.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-delete-handler.ts @@ -1,55 +1,36 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; -import * as remote from '@theia/core/electron-shared/@electron/remote'; +import { CommandService } from '@theia/core/lib/common/command'; import URI from '@theia/core/lib/common/uri'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { WorkspaceDeleteHandler as TheiaWorkspaceDeleteHandler } from '@theia/workspace/lib/browser/workspace-delete-handler'; +import { DeleteSketch } from '../../contributions/delete-sketch'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; -import { nls } from '@theia/core/lib/common'; +} from '../../sketches-service-client-impl'; @injectable() export class WorkspaceDeleteHandler extends TheiaWorkspaceDeleteHandler { + @inject(CommandService) + private readonly commandService: CommandService; @inject(SketchesServiceClientImpl) - protected readonly sketchesServiceClient: SketchesServiceClientImpl; + private readonly sketchesServiceClient: SketchesServiceClientImpl; override async execute(uris: URI[]): Promise { const sketch = await this.sketchesServiceClient.currentSketch(); if (!CurrentSketch.isValid(sketch)) { return; } - // Deleting the main sketch file. - if ( - uris - .map((uri) => uri.toString()) - .some((uri) => uri === sketch.mainFileUri) - ) { - const { response } = await remote.dialog.showMessageBox({ - title: nls.localize('vscode/fileActions/delete', 'Delete'), - type: 'question', - buttons: [ - nls.localize('vscode/issueMainService/cancel', 'Cancel'), - nls.localize('vscode/issueMainService/ok', 'OK'), - ], - message: nls.localize( - 'theia/workspace/deleteCurrentSketch', - 'Do you want to delete the current sketch?' - ), - }); - if (response === 1) { - // OK - await Promise.all( - [ - ...sketch.additionalFileUris, - ...sketch.otherSketchFileUris, - sketch.mainFileUri, - ].map((uri) => this.closeWithoutSaving(new URI(uri))) - ); - await this.fileService.delete(new URI(sketch.uri)); - window.close(); - } - return; + // Deleting the main sketch file means deleting the sketch folder and all its content. + if (uris.some((uri) => uri.toString() === sketch.mainFileUri)) { + return this.commandService.executeCommand( + DeleteSketch.Commands.DELETE_SKETCH.id, + { + toDelete: sketch, + willNavigateAway: true, + } + ); } + // Individual file deletion(s). return super.execute(uris); } } diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-input-dialog.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-input-dialog.ts index 5f4deb5b2..8a8e82eec 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-input-dialog.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-input-dialog.ts @@ -1,15 +1,25 @@ -import { inject } from '@theia/core/shared/inversify'; -import { MaybePromise } from '@theia/core/lib/common/types'; +import { MaybePromise } from '@theia/core'; +import { Dialog, DialogError } from '@theia/core/lib/browser/dialogs'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; -import { DialogError, DialogMode } from '@theia/core/lib/browser/dialogs'; +import { CancellationTokenSource } from '@theia/core/lib/common/cancellation'; +import { + Disposable, + DisposableCollection, +} from '@theia/core/lib/common/disposable'; +import type { + Progress, + ProgressUpdate, +} from '@theia/core/lib/common/message-service-protocol'; +import { Widget } from '@theia/core/shared/@phosphor/widgets'; +import { inject } from '@theia/core/shared/inversify'; import { WorkspaceInputDialog as TheiaWorkspaceInputDialog, WorkspaceInputDialogProps, } from '@theia/workspace/lib/browser/workspace-input-dialog'; -import { nls } from '@theia/core/lib/common'; +import { v4 } from 'uuid'; export class WorkspaceInputDialog extends TheiaWorkspaceInputDialog { - protected wasTouched = false; + private skipShowErrorMessageOnOpen: boolean; constructor( @inject(WorkspaceInputDialogProps) @@ -19,27 +29,31 @@ export class WorkspaceInputDialog extends TheiaWorkspaceInputDialog { ) { super(props, labelProvider); this.node.classList.add('workspace-input-dialog'); - this.appendCloseButton( - nls.localize('vscode/issueMainService/cancel', 'Cancel') - ); + this.appendCloseButton(Dialog.CANCEL); } protected override appendParentPath(): void { // NOOP } - override isValid(value: string, mode: DialogMode): MaybePromise { - if (value !== '') { - this.wasTouched = true; - } - return super.isValid(value, mode); + override isValid(value: string): MaybePromise { + return super.isValid(value, 'open'); + } + + override open( + skipShowErrorMessageOnOpen = false + ): Promise { + this.skipShowErrorMessageOnOpen = skipShowErrorMessageOnOpen; + return super.open(); } protected override setErrorMessage(error: DialogError): void { if (this.acceptButton) { this.acceptButton.disabled = !DialogError.getResult(error); } - if (this.wasTouched) { + if (this.skipShowErrorMessageOnOpen) { + this.skipShowErrorMessageOnOpen = false; + } else { this.errorMessageNode.innerText = DialogError.getMessage(error); } } @@ -54,3 +68,133 @@ export class WorkspaceInputDialog extends TheiaWorkspaceInputDialog { return this.closeButton; } } + +interface TaskFactory { + createTask(value: string): (progress: Progress) => Promise; +} + +export class TaskFactoryImpl implements TaskFactory { + private _value: string | undefined; + + constructor(private readonly task: TaskFactory['createTask']) {} + + get value(): string | undefined { + return this._value; + } + + createTask(value: string): (progress: Progress) => Promise { + this._value = value; + return this.task(this._value); + } +} + +/** + * Workspace input dialog executing a long running operation with indefinite progress. + */ +export class WorkspaceInputDialogWithProgress< + T = unknown +> extends WorkspaceInputDialog { + private _taskResult: T | undefined; + + constructor( + protected override readonly props: WorkspaceInputDialogProps, + protected override readonly labelProvider: LabelProvider, + /** + * The created task will provide the result. See `#taskResult`. + */ + private readonly taskFactory: TaskFactory + ) { + super(props, labelProvider); + } + + get taskResult(): T | undefined { + return this._taskResult; + } + + protected override async accept(): Promise { + if (!this.resolve) { + return; + } + this.acceptCancellationSource.cancel(); + this.acceptCancellationSource = new CancellationTokenSource(); + const token = this.acceptCancellationSource.token; + const value = this.value; + const error = await this.isValid(value); + if (token.isCancellationRequested) { + return; + } + if (!DialogError.getResult(error)) { + this.setErrorMessage(error); + } else { + const spinner = document.createElement('div'); + spinner.classList.add('spinner'); + const disposables = new DisposableCollection(); + try { + this.toggleButtons(true); + disposables.push(Disposable.create(() => this.toggleButtons(false))); + + const closeParent = this.closeCrossNode.parentNode; + closeParent?.removeChild(this.closeCrossNode); + disposables.push( + Disposable.create(() => { + closeParent?.appendChild(this.closeCrossNode); + }) + ); + + this.errorMessageNode.classList.add('progress'); + disposables.push( + Disposable.create(() => + this.errorMessageNode.classList.remove('progress') + ) + ); + + const errorParent = this.errorMessageNode.parentNode; + errorParent?.insertBefore(spinner, this.errorMessageNode); + disposables.push( + Disposable.create(() => errorParent?.removeChild(spinner)) + ); + + const cancellationSource = new CancellationTokenSource(); + const progress: Progress = { + id: v4(), + cancel: () => cancellationSource.cancel(), + report: (update: ProgressUpdate) => { + this.setProgressMessage(update); + }, + result: Promise.resolve(value), + }; + const task = this.taskFactory.createTask(value); + this._taskResult = await task(progress); + this.resolve(value); + } catch (err) { + if (this.reject) { + this.reject(err); + } else { + throw err; + } + } finally { + Widget.detach(this); + disposables.dispose(); + } + } + } + + private toggleButtons(disabled: boolean): void { + if (this.acceptButton) { + this.acceptButton.disabled = disabled; + } + if (this.closeButton) { + this.closeButton.disabled = disabled; + } + } + + private setProgressMessage(update: ProgressUpdate): void { + if (update.work && update.work.done === update.work.total) { + this.errorMessageNode.innerText = ''; + } else { + if (update.message) { + this.errorMessageNode.innerText = update.message; + } + } + } +} diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts index 1310610a1..ec8da1f97 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts @@ -23,7 +23,7 @@ import { WindowServiceExt } from '../core/window-service-ext'; @injectable() export class WorkspaceService extends TheiaWorkspaceService { @inject(SketchesService) - private readonly sketchService: SketchesService; + private readonly sketchesService: SketchesService; @inject(WindowServiceExt) private readonly windowServiceExt: WindowServiceExt; @inject(ContributionProvider) @@ -41,7 +41,7 @@ export class WorkspaceService extends TheiaWorkspaceService { ): Promise { const stat = await super.toFileStat(uri); if (!stat) { - const newSketchUri = await this.sketchService.createNewSketch(); + const newSketchUri = await this.sketchesService.createNewSketch(); return this.toFileStat(newSketchUri.uri); } // When opening a file instead of a directory, IDE2 (and Theia) expects a workspace JSON file. @@ -52,18 +52,18 @@ export class WorkspaceService extends TheiaWorkspaceService { // If loading the sketch fails, create a fallback sketch and open the new temp sketch folder as the workspace root. if (stat.isFile && stat.resource.path.ext === '.ino') { try { - const sketch = await this.sketchService.loadSketch( + const sketch = await this.sketchesService.loadSketch( stat.resource.toString() ); return this.toFileStat(sketch.uri); } catch (err) { if (SketchesError.InvalidName.is(err)) { this._workspaceError = err; - const newSketchUri = await this.sketchService.createNewSketch(); + const newSketchUri = await this.sketchesService.createNewSketch(); return this.toFileStat(newSketchUri.uri); } else if (SketchesError.NotFound.is(err)) { this._workspaceError = err; - const newSketchUri = await this.sketchService.createNewSketch(); + const newSketchUri = await this.sketchesService.createNewSketch(); return this.toFileStat(newSketchUri.uri); } throw err; diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts index 2f9c88800..c14027791 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-variable-contribution.ts @@ -9,7 +9,7 @@ import { Sketch } from '../../../common/protocol'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; @injectable() diff --git a/arduino-ide-extension/src/browser/utils/constants.ts b/arduino-ide-extension/src/browser/utils/constants.ts index f4bf28e31..570929d35 100644 --- a/arduino-ide-extension/src/browser/utils/constants.ts +++ b/arduino-ide-extension/src/browser/utils/constants.ts @@ -1,2 +1,2 @@ export const REMOTE_SKETCHBOOK_FOLDER = 'RemoteSketchbook'; -export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud'; \ No newline at end of file +export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud'; diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts index 6bcf07b72..21469d2de 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts @@ -39,4 +39,11 @@ export class SketchCache { getSketch(path: string): Create.Sketch | null { return this.sketches[path] || null; } + + toString(): string { + return JSON.stringify({ + sketches: this.sketches, + fileStats: this.fileStats, + }); + } } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx index 22cac80ee..960df9ec3 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx @@ -26,8 +26,8 @@ export class CloudSketchbookCompositeWidget extends BaseSketchbookCompositeWidge super(); this.id = 'cloud-sketchbook-composite-widget'; this.title.caption = nls.localize( - 'arduino/cloud/remoteSketchbook', - 'Remote Sketchbook' + 'arduino/cloud/cloudSketchbook', + 'Cloud Sketchbook' ); this.title.iconClass = 'cloud-sketchbook-tree-icon'; } @@ -55,8 +55,8 @@ export class CloudSketchbookCompositeWidget extends BaseSketchbookCompositeWidge {this._session && ( diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts index 54594b2dc..80971f04d 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts @@ -26,7 +26,7 @@ import { SketchbookCommands } from '../sketchbook/sketchbook-commands'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; import { Contribution } from '../../contributions/contribution'; import { ArduinoPreferences } from '../../arduino-preferences'; import { MainMenuManager } from '../../../common/main-menu-manager'; @@ -67,9 +67,9 @@ export namespace CloudSketchbookCommands { export const TOGGLE_CLOUD_SKETCHBOOK = Command.toLocalizedCommand( { id: 'arduino-cloud-sketchbook--disable', - label: 'Show/Hide Remote Sketchbook', + label: 'Show/Hide Cloud Sketchbook', }, - 'arduino/cloud/showHideRemoveSketchbook' + 'arduino/cloud/showHideSketchbook' ); export const PULL_SKETCH = Command.toLocalizedCommand( diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts index 666ac1ba8..3211a85f0 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts @@ -17,12 +17,11 @@ import { LocalCacheUri, } from '../../local-cache/local-cache-fs-provider'; import URI from '@theia/core/lib/common/uri'; -import { SketchCache } from './cloud-sketch-cache'; import { Create } from '../../create/typings'; import { nls } from '@theia/core/lib/common/nls'; import { Deferred } from '@theia/core/lib/common/promise-util'; -export function sketchBaseDir(sketch: Create.Sketch): FileStat { +function sketchBaseDir(sketch: Create.Sketch): FileStat { // extract the sketch path const [, path] = splitSketchPath(sketch.path); const dirs = posixSegments(path); @@ -42,7 +41,7 @@ export function sketchBaseDir(sketch: Create.Sketch): FileStat { return baseDir; } -export function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] { +function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] { const sketchesBaseDirs: Record = {}; for (const sketch of sketches) { @@ -64,8 +63,6 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { private readonly authenticationService: AuthenticationClientService; @inject(LocalCacheFsProvider) private readonly localCacheFsProvider: LocalCacheFsProvider; - @inject(SketchCache) - private readonly sketchCache: SketchCache; private _localCacheFsProviderReady: Deferred | undefined; @@ -127,8 +124,7 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { this.tree.root = undefined; return; } - this.createApi.init(this.authenticationService, this.arduinoPreferences); - this.sketchCache.init(); + this.createApi.sketchCache.init(); const [sketches] = await Promise.all([ this.createApi.sketches(), this.ensureLocalFsProviderReady(), diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts index 7f4b44115..e3dd60e70 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts @@ -1,8 +1,6 @@ -import { SketchCache } from './cloud-sketch-cache'; import { inject, injectable } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { MaybePromise } from '@theia/core/lib/common/types'; -import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStatNode } from '@theia/filesystem/lib/browser/file-tree'; import { Command } from '@theia/core/lib/common/command'; import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; @@ -28,8 +26,6 @@ import { CloudSketchbookCommands } from './cloud-sketchbook-contributions'; import { DoNotAskAgainConfirmDialog } from '../../dialogs/do-not-ask-again-dialog'; import { SketchbookTree } from '../sketchbook/sketchbook-tree'; import { firstToUpperCase } from '../../../common/utils'; -import { ArduinoPreferences } from '../../arduino-preferences'; -import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree'; import { posix, splitSketchPath } from '../../create/create-paths'; @@ -46,29 +42,17 @@ type FilesToSync = { }; @injectable() export class CloudSketchbookTree extends SketchbookTree { - @inject(FileService) - protected override readonly fileService: FileService; - @inject(LocalCacheFsProvider) - protected readonly localCacheFsProvider: LocalCacheFsProvider; - - @inject(SketchCache) - protected readonly sketchCache: SketchCache; - - @inject(ArduinoPreferences) - protected override readonly arduinoPreferences: ArduinoPreferences; + private readonly localCacheFsProvider: LocalCacheFsProvider; @inject(PreferenceService) - protected readonly preferenceService: PreferenceService; + private readonly preferenceService: PreferenceService; @inject(MessageService) - protected readonly messageService: MessageService; - - @inject(SketchesServiceClientImpl) - protected readonly sketchServiceClient: SketchesServiceClientImpl; + private readonly messageService: MessageService; @inject(CreateApi) - protected readonly createApi: CreateApi; + private readonly createApi: CreateApi; async pushPublicWarn( node: CloudSketchbookTree.CloudSketchDirNode @@ -93,15 +77,13 @@ export class CloudSketchbookTree extends SketchbookTree { PreferenceScope.User ), }).open(); - if (!ok) { - return false; - } - return true; + return Boolean(ok); } else { return true; } } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any async pull(arg: any): Promise { const { // model, @@ -145,7 +127,7 @@ export class CloudSketchbookTree extends SketchbookTree { ); await this.sync(node.remoteUri, localUri); - this.sketchCache.purgeByPath(node.remoteUri.path.toString()); + this.createApi.sketchCache.purgeByPath(node.remoteUri.path.toString()); node.commands = commandsCopy; this.messageService.info( @@ -213,7 +195,7 @@ export class CloudSketchbookTree extends SketchbookTree { ); await this.sync(localUri, node.remoteUri); - this.sketchCache.purgeByPath(node.remoteUri.path.toString()); + this.createApi.sketchCache.purgeByPath(node.remoteUri.path.toString()); node.commands = commandsCopy; this.messageService.info( @@ -229,7 +211,7 @@ export class CloudSketchbookTree extends SketchbookTree { }); } - async recursiveURIs(uri: URI): Promise { + private async recursiveURIs(uri: URI): Promise { // remote resources can be fetched one-shot via api if (CreateUri.is(uri)) { const resources = await this.createApi.readDirectory( @@ -286,7 +268,7 @@ export class CloudSketchbookTree extends SketchbookTree { }, {}); } - async getUrisMap(uri: URI) { + private async getUrisMap(uri: URI): Promise> { const basepath = uri.toString(); const exists = await this.fileService.exists(uri); const uris = @@ -294,7 +276,7 @@ export class CloudSketchbookTree extends SketchbookTree { return uris; } - async treeDiff(source: URI, dest: URI): Promise { + private async treeDiff(source: URI, dest: URI): Promise { const [sourceURIs, destURIs] = await Promise.all([ this.getUrisMap(source), this.getUrisMap(dest), @@ -356,7 +338,7 @@ export class CloudSketchbookTree extends SketchbookTree { } } - async sync(source: URI, dest: URI) { + private async sync(source: URI, dest: URI): Promise { const { filesToWrite, filesToDelete } = await this.treeDiff(source, dest); await Promise.all( filesToWrite.map(async ({ source, dest }) => { @@ -375,7 +357,9 @@ export class CloudSketchbookTree extends SketchbookTree { ); } - override async resolveChildren(parent: CompositeTreeNode): Promise { + override async resolveChildren( + parent: CompositeTreeNode + ): Promise { return (await super.resolveChildren(parent)).sort((a, b) => { if ( WorkspaceNode.is(parent) && @@ -416,14 +400,16 @@ export class CloudSketchbookTree extends SketchbookTree { CreateUri.is(node.remoteUri) ) { let remoteFileStat: FileStat; - const cacheHit = this.sketchCache.getItem(node.remoteUri.path.toString()); + const cacheHit = this.createApi.sketchCache.getItem( + node.remoteUri.path.toString() + ); if (cacheHit) { remoteFileStat = cacheHit; } else { // not found, fetch and add it for future calls remoteFileStat = await this.fileService.resolve(node.remoteUri); if (remoteFileStat) { - this.sketchCache.addItem(remoteFileStat); + this.createApi.sketchCache.addItem(remoteFileStat); } } @@ -453,6 +439,7 @@ export class CloudSketchbookTree extends SketchbookTree { if (!CreateUri.is(childFs.resource)) { let refUri = node.fileStat.resource; if (node.fileStat.hasOwnProperty('remoteUri')) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any refUri = (node.fileStat as any).remoteUri; } remoteUri = refUri.resolve(childFs.name); @@ -471,6 +458,7 @@ export class CloudSketchbookTree extends SketchbookTree { } protected override toNode( + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any fileStat: any, parent: CompositeTreeNode ): FileNode | DirNode { @@ -530,7 +518,7 @@ export class CloudSketchbookTree extends SketchbookTree { * @returns */ protected override async augmentSketchNode(node: DirNode): Promise { - const sketch = this.sketchCache.getSketch( + const sketch = this.createApi.sketchCache.getSketch( node.fileStat.resource.path.toString() ); @@ -594,7 +582,7 @@ export class CloudSketchbookTree extends SketchbookTree { protected override async isSketchNode(node: DirNode): Promise { if (DirNode.is(node)) { - const sketch = this.sketchCache.getSketch( + const sketch = this.createApi.sketchCache.getSketch( node.fileStat.resource.path.toString() ); return !!sketch; @@ -621,6 +609,7 @@ export class CloudSketchbookTree extends SketchbookTree { if (DecoratedTreeNode.is(node)) { for (const property of Object.keys(decorationData)) { if (node.decorationData.hasOwnProperty(property)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (node.decorationData as any)[property]; } } @@ -661,7 +650,7 @@ export namespace CloudSketchbookTree { commands?: Command[]; } export namespace CloudSketchDirNode { - export function is(node: TreeNode): node is CloudSketchDirNode { + export function is(node: TreeNode | undefined): node is CloudSketchDirNode { return SketchbookTree.SketchDirNode.is(node); } diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts index 34263df8a..df1d52964 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts @@ -1,4 +1,8 @@ -import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; @@ -13,7 +17,10 @@ import { } from '@theia/core/lib/browser/tree'; import { SketchbookCommands } from './sketchbook-commands'; import { OpenerService, open } from '@theia/core/lib/browser'; -import { CurrentSketch, SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; +import { + CurrentSketch, + SketchesServiceClientImpl, +} from '../../sketches-service-client-impl'; import { CommandRegistry } from '@theia/core/lib/common/command'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @@ -195,7 +202,10 @@ export class SketchbookTreeModel extends FileTreeModel { /** * Move the given source file or directory to the given target directory. */ - override async move(source: TreeNode, target: TreeNode): Promise { + override async move( + source: TreeNode, + target: TreeNode + ): Promise { if (source.parent && WorkspaceRootNode.is(source)) { // do not support moving a root folder return undefined; diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx index 599cac893..1c3323227 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx @@ -21,7 +21,7 @@ import { ArduinoPreferences } from '../../arduino-preferences'; import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; import { SelectableTreeNode } from '@theia/core/lib/browser/tree/tree-selection'; import { Sketch } from '../../contributions/contribution'; import { nls } from '@theia/core/lib/common'; diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts index 74b782887..ba3794233 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts @@ -26,7 +26,7 @@ import { import { CurrentSketch, SketchesServiceClientImpl, -} from '../../../common/protocol/sketches-service-client-impl'; +} from '../../sketches-service-client-impl'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { URI } from '../../contributions/contribution'; import { WorkspaceInput } from '@theia/workspace/lib/browser'; diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index 9334c20ce..e4b924cce 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -1,5 +1,7 @@ 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'; +import * as dateFormat from 'dateformat'; export namespace SketchesError { export const Codes = { @@ -52,6 +54,11 @@ export interface SketchesService { */ createNewSketch(): Promise; + /** + * The default content when creating a new `.ino` file. Either the built-in or the user defined (`arduino.sketch.inoBlueprint`) content. + */ + defaultInoContent(): Promise; + /** * Creates a new sketch with existing content. Rejects if `uri` is not pointing to a valid sketch folder. */ @@ -101,11 +108,6 @@ export interface SketchesService { */ getIdeTempFolderUri(sketch: Sketch): Promise; - /** - * Recursively deletes the sketch folder with all its content. - */ - deleteSketch(sketch: Sketch): Promise; - /** * This is the JS/TS re-implementation of [`GenBuildPath`](https://github.com/arduino/arduino-cli/blob/c0d4e4407d80aabad81142693513b3306759cfa6/arduino/sketch/sketch.go#L296-L306) of the CLI. * Pass in a sketch and get the build temporary folder filesystem path calculated from the main sketch file location. Can be multiple ones. This method does not check the existence of the sketch. @@ -151,6 +153,89 @@ 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 defaultSketchFolderName = 'sketch'; + // (non-API) exported for the tests + export const defaultFallbackFirstChar = '0'; + // (non-API) exported for the tests + export const defaultFallbackChar = '_'; + // (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 `candidate` argument into a valid sketch folder name by replacing all invalid characters with underscore (`_`) and trimming the string after 63 characters. + * If the argument is falsy, returns with `"sketch"`. + */ + export function toValidSketchFolderName( + candidate: string, + /** + * Type of `Date` is only for tests. Use boolean for production. + */ + appendTimestampSuffix: boolean | Date = false + ): string { + const validName = candidate + ? candidate + .replace(/^[^0-9a-zA-Z]{1}/g, defaultFallbackFirstChar) + .replace(/[^0-9a-zA-Z_\.-]/g, defaultFallbackChar) + .slice(0, 63) + : defaultSketchFolderName; + if (appendTimestampSuffix) { + return `${validName.slice(0, 63 - timestampSuffixLength)}${ + typeof appendTimestampSuffix === 'boolean' + ? timestampSuffix() + : timestampSuffix(appendTimestampSuffix) + }`; + } + return validName; + } + + const copy = '_copy_'; + const datetimeFormat = 'yyyymmddHHMMss'; + const timestampSuffixLength = copy.length + datetimeFormat.length; + // (non-API) + export function timestampSuffix(now = new Date()): string { + return `${copy}${dateFormat(now, datetimeFormat)}`; + } + + /** + * Transforms the `candidate` argument into a valid cloud sketch folder name by replacing all invalid characters with underscore and trimming the string after 36 characters. + */ + export function toValidCloudSketchFolderName(candidate: string): string { + return candidate + ? candidate.replace(/[^0-9a-zA-Z_]/g, defaultFallbackChar).slice(0, 36) + : defaultSketchFolderName; + } + export function is(arg: unknown): arg is Sketch { if (!SketchRef.is(arg)) { return false; @@ -172,9 +257,18 @@ 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 CODE_FILES = [ + ...MAIN, + ...SOURCE, + '.h', + '.hh', + '.hpp', + '.tpp', + '.ipp', + ]; export const ADDITIONAL = [...CODE_FILES, '.json', '.md', '.adoc']; export const ALL = Array.from(new Set([...MAIN, ...SOURCE, ...ADDITIONAL])); } diff --git a/arduino-ide-extension/src/electron-common/electron-messages.ts b/arduino-ide-extension/src/electron-common/electron-messages.ts new file mode 100644 index 000000000..b17f85333 --- /dev/null +++ b/arduino-ide-extension/src/electron-common/electron-messages.ts @@ -0,0 +1 @@ +export const SCHEDULE_DELETION_SIGNAL = 'arduino/scheduleDeletion'; diff --git a/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts b/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts index 6cc4ad114..84736969e 100644 --- a/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts +++ b/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts @@ -9,7 +9,7 @@ import { import { fork } from 'child_process'; import { AddressInfo } from 'net'; import { join, isAbsolute, resolve } from 'path'; -import { promises as fs } from 'fs'; +import { promises as fs, rm, rmSync } from 'fs'; import { MaybePromise } from '@theia/core/lib/common/types'; import { ElectronSecurityToken } from '@theia/core/lib/electron-common/electron-token'; import { FrontendApplicationConfig } from '@theia/application-package/lib/application-props'; @@ -29,6 +29,13 @@ import { } from '../../common/ipc-communication'; import { ErrnoException } from '../../node/utils/errors'; import { isAccessibleSketchPath } from '../../node/sketches-service-impl'; +import { SCHEDULE_DELETION_SIGNAL } from '../../electron-common/electron-messages'; +import { FileUri } from '@theia/core/lib/node/file-uri'; +import { + Disposable, + DisposableCollection, +} from '@theia/core/lib/common/disposable'; +import { Sketch } from '../../common/protocol'; app.commandLine.appendSwitch('disable-http-cache'); @@ -66,6 +73,34 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { private startup = false; private _firstWindowId: number | undefined; private openFilePromise = new Deferred(); + /** + * It contains all things the IDE2 must clean up before a normal stop. + * + * When deleting the sketch, the IDE2 must close the browser window and + * recursively delete the sketch folder from the filesystem. The sketch + * cannot be deleted when the window is open because that is the currently + * opened workspace. IDE2 cannot delete the sketch folder from the + * filesystem after closing the browser window because the window can be + * the last, and when the last window closes, the application quits. + * There is no way to clean up the undesired resources. + * + * This array contains disposable instances wrapping synchronous sketch + * delete operations. When IDE2 closes the browser window, it schedules + * the sketch deletion, and the window closes. + * + * When IDE2 schedules a sketch for deletion, it creates a synchronous + * folder deletion as a disposable instance and pushes it into this + * array. After the push, IDE2 starts the sketch deletion in an + * asynchronous way. When the deletion completes, the disposable is + * removed. If the app quits when the asynchronous deletion is still in + * progress, it disposes the elements of this array. Since it is + * synchronous, it is [ensured by Theia](https://github.com/eclipse-theia/theia/blob/678e335644f1b38cb27522cc27a3b8209293cf31/packages/core/src/node/backend-application.ts#L91-L97) + * that IDE2 won't quit before the cleanup is done. It works only in normal + * quit. + */ + // TODO: Why is it here and not in the Theia backend? + // https://github.com/eclipse-theia/theia/discussions/12135 + private readonly scheduledDeletions: Disposable[] = []; override async start(config: FrontendApplicationConfig): Promise { // Explicitly set the app name to have better menu items on macOS. ("About", "Hide", and "Quit") @@ -309,6 +344,13 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { ipcMain.on(Restart, ({ sender }) => { this.restart(sender.id); }); + ipcMain.on(SCHEDULE_DELETION_SIGNAL, (event, sketch: unknown) => { + if (Sketch.is(sketch)) { + console.log(`Sketch ${sketch.uri} was scheduled for deletion`); + // TODO: remove deleted sketch from closedWorkspaces? + this.delete(sketch); + } + }); } protected override async onSecondInstance( @@ -511,6 +553,16 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { `Stored workspaces roots: ${workspaces.map(({ file }) => file)}` ); + if (this.scheduledDeletions.length) { + console.log( + '>>> Finishing scheduled sketch deletions before app quit...' + ); + new DisposableCollection(...this.scheduledDeletions).dispose(); + console.log('<<< Successfully finishing scheduled sketch deletions.'); + } else { + console.log('No sketches were scheduled for deletion.'); + } + super.onWillQuit(event); } @@ -521,6 +573,59 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { get firstWindowId(): number | undefined { return this._firstWindowId; } + + private async delete(sketch: Sketch): Promise { + const sketchPath = FileUri.fsPath(sketch.uri); + const disposable = Disposable.create(() => { + try { + this.deleteSync(sketchPath); + } catch (err) { + console.error( + `Could not delete sketch ${sketchPath} on app quit.`, + err + ); + } + }); + this.scheduledDeletions.push(disposable); + return new Promise((resolve, reject) => { + rm(sketchPath, { recursive: true, maxRetries: 5 }, (error) => { + if (error) { + console.error(`Failed to delete sketch ${sketchPath}`, error); + reject(error); + } else { + console.info(`Successfully deleted sketch ${sketchPath}`); + resolve(); + const index = this.scheduledDeletions.indexOf(disposable); + if (index >= 0) { + this.scheduledDeletions.splice(index, 1); + console.info( + `Successfully completed the scheduled sketch deletion: ${sketchPath}` + ); + } else { + console.warn( + `Could not find the scheduled sketch deletion: ${sketchPath}` + ); + } + } + }); + }); + } + + private deleteSync(sketchPath: string): void { + console.info( + `>>> Running sketch deletion ${sketchPath} before app quit...` + ); + try { + rmSync(sketchPath, { recursive: true, maxRetries: 5 }); + console.info(`<<< Deleted sketch ${sketchPath}`); + } catch (err) { + if (!ErrnoException.isENOENT(err)) { + throw err; + } else { + console.info(`<<< Sketch ${sketchPath} did not exist.`); + } + } + } } class InterruptWorkspaceRestoreError extends Error { diff --git a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts index 17ba65ace..812761f77 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -344,7 +344,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { MonitorServiceName, ].forEach((name) => bindChildLogger(bind, name)); - // Remote sketchbook bindings + // Cloud sketchbook bindings bind(AuthenticationServiceImpl).toSelf().inSingletonScope(); bind(AuthenticationService).toService(AuthenticationServiceImpl); bind(BackendApplicationContribution).toService(AuthenticationServiceImpl); diff --git a/arduino-ide-extension/src/node/boards-service-impl.ts b/arduino-ide-extension/src/node/boards-service-impl.ts index 03c59eb6a..5e2f090b2 100644 --- a/arduino-ide-extension/src/node/boards-service-impl.ts +++ b/arduino-ide-extension/src/node/boards-service-impl.ts @@ -7,7 +7,6 @@ import { BoardsPackage, Board, BoardDetails, - Tool, ConfigOption, ConfigValue, Programmer, @@ -97,14 +96,11 @@ export class BoardsServiceImpl const debuggingSupported = detailsResp.getDebuggingSupported(); - const requiredTools = detailsResp.getToolsDependenciesList().map( - (t) => - { - name: t.getName(), - packager: t.getPackager(), - version: t.getVersion(), - } - ); + const requiredTools = detailsResp.getToolsDependenciesList().map((t) => ({ + name: t.getName(), + packager: t.getPackager(), + version: t.getVersion(), + })); const configOptions = detailsResp.getConfigOptionsList().map( (c) => diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 7ece34961..a1e53a9ec 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -1,5 +1,5 @@ import { injectable, inject, named } from '@theia/core/shared/inversify'; -import { promises as fs, realpath, lstat, Stats, constants, rm } from 'fs'; +import { promises as fs, realpath, lstat, Stats, constants } from 'fs'; import * as os from 'os'; import * as temp from 'temp'; import * as path from 'path'; @@ -427,6 +427,10 @@ export class SketchesServiceImpl return this.doLoadSketch(FileUri.create(sketchDir).toString(), false); } + defaultInoContent(): Promise { + return this.loadInoContent(); + } + /** * Creates a temp folder and returns with a promise that resolves with the canonicalized absolute pathname of the newly created temp folder. * This method ensures that the file-system path pointing to the new temp directory is fully resolved. @@ -628,21 +632,6 @@ export class SketchesServiceImpl return folderName; } - async deleteSketch(sketch: Sketch): Promise { - return new Promise((resolve, reject) => { - const sketchPath = FileUri.fsPath(sketch.uri); - rm(sketchPath, { recursive: true, maxRetries: 5 }, (error) => { - if (error) { - this.logger.error(`Failed to delete sketch at ${sketchPath}.`, error); - reject(error); - } else { - this.logger.info(`Successfully deleted sketch at ${sketchPath}.`); - resolve(); - } - }); - }); - } - // Returns the default.ino from the settings or from default folder. private async readSettings(): Promise | undefined> { const configDirUri = await this.envVariableServer.getConfigDirUri(); diff --git a/arduino-ide-extension/src/test/browser/create-api.test.ts b/arduino-ide-extension/src/test/browser/create-api.test.ts new file mode 100644 index 000000000..357c6c9be --- /dev/null +++ b/arduino-ide-extension/src/test/browser/create-api.test.ts @@ -0,0 +1,271 @@ +import { Container, ContainerModule } from '@theia/core/shared/inversify'; +import { assert, expect } from 'chai'; +import fetch from 'cross-fetch'; +import { v4 } from 'uuid'; +import { ArduinoPreferences } from '../../browser/arduino-preferences'; +import { AuthenticationClientService } from '../../browser/auth/authentication-client-service'; +import { CreateApi } from '../../browser/create/create-api'; +import { splitSketchPath } from '../../browser/create/create-paths'; +import { Create, CreateError } from '../../browser/create/typings'; +import { SketchCache } from '../../browser/widgets/cloud-sketchbook/cloud-sketch-cache'; +import { SketchesService } from '../../common/protocol'; +import { AuthenticationSession } from '../../node/auth/types'; +import queryString = require('query-string'); + +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +const timeout = 60 * 1_000; + +describe('create-api', () => { + let createApi: CreateApi; + + before(async function () { + this.timeout(timeout); + try { + const accessToken = await login(); + createApi = createContainer(accessToken).get(CreateApi); + } catch (err) { + if (err instanceof LoginFailed) { + return this.skip(); + } + throw err; + } + }); + + beforeEach(async function () { + this.timeout(timeout); + await cleanAllSketches(); + }); + + function createContainer(accessToken: string): Container { + const container = new Container({ defaultScope: 'Singleton' }); + container.load( + new ContainerModule((bind) => { + bind(CreateApi).toSelf().inSingletonScope(); + bind(SketchCache).toSelf().inSingletonScope(); + bind(AuthenticationClientService).toConstantValue(< + AuthenticationClientService + >{ + get session(): AuthenticationSession | undefined { + return { + accessToken, + }; + }, + }); + bind(ArduinoPreferences).toConstantValue({ + 'arduino.cloud.sketchSyncEndpoint': + 'https://api-dev.arduino.cc/create', + }); + bind(SketchesService).toConstantValue({}); + }) + ); + return container; + } + + async function login( + credentials: Credentials | undefined = moduleCredentials() ?? + envCredentials() + ): Promise { + if (!credentials) { + throw new LoginFailed('The credentials are not available to log in.'); + } + const { username, password, clientSecret: client_secret } = credentials; + const response = await fetch('https://login.oniudra.cc/oauth/token', { + method: 'POST', + headers: { + 'Content-type': 'application/x-www-form-urlencoded', + }, + body: queryString.stringify({ + grant_type: 'password', + username, + password, + audience: 'https://api.arduino.cc', + client_id: 'a4Nge0BdTyFsNnsU0HcZI4hfKN5y9c5A', + client_secret, + }), + }); + const body = await response.json(); + if ('access_token' in body) { + const { access_token } = body; + return access_token; + } + throw new LoginFailed( + body.error ?? + `'access_token' was not part of the response object: ${JSON.stringify( + body + )}` + ); + } + + function toPosix(segment: string): string { + return `/${segment}`; + } + + /** + * Does not handle folders. A sketch with `MySketch` name can be under `/MySketch` and `/MyFolder/MySketch`. + */ + function findByName( + name: string, + sketches: Create.Sketch[] + ): Create.Sketch | undefined { + return sketches.find((sketch) => sketch.name === name); + } + + async function cleanAllSketches(): Promise { + let sketches = await createApi.sketches(); + // Cannot delete the sketches with `await Promise.all` as all delete promise successfully resolve, but the sketch is not deleted from the server. + await sketches + .map(({ path }) => createApi.deleteSketch(path)) + .reduce(async (acc, curr) => { + await acc; + return curr; + }, Promise.resolve()); + sketches = await createApi.sketches(); + expect(sketches).to.be.empty; + } + + it('should delete sketch', async () => { + const name = v4(); + const content = 'alma\nkorte'; + const posixPath = toPosix(name); + + let sketches = await createApi.sketches(); + let sketch = findByName(name, sketches); + expect(sketch).to.be.undefined; + + sketch = await createApi.createSketch(posixPath, content); + + sketches = await createApi.sketches(); + sketch = findByName(name, sketches); + expect(sketch).to.be.not.empty; + expect(sketch?.path).to.be.not.empty; + const [, path] = splitSketchPath(sketch?.path!); + expect(path).to.be.equal(posixPath); + + const sketchContent = await createApi.readFile( + posixPath + posixPath + '.ino' + ); + expect(sketchContent).to.be.equal(content); + + await createApi.deleteSketch(sketch?.path!); + + sketches = await createApi.sketches(); + sketch = findByName(name, sketches); + expect(sketch).to.be.undefined; + }); + + it('should error with HTTP 404 (Not Found) if deleting a non-existing sketch', async () => { + try { + await createApi.deleteSketch('/does-not-exist'); + assert.fail('Expected HTTP 404'); + } catch (err) { + expect(err).to.be.an.instanceOf(CreateError); + expect((err).status).to.be.equal(404); + } + }); + + it('should rename a sketch folder with all its content', async () => { + const name = v4(); + const newName = v4(); + const content = 'void setup(){} void loop(){}'; + const posixPath = toPosix(name); + const newPosixPath = toPosix(newName); + + await createApi.createSketch(posixPath, content); + + let sketches = await createApi.sketches(); + expect(sketches.length).to.be.equal(1); + expect(sketches[0].name).to.be.equal(name); + + let sketchContent = await createApi.readFile( + posixPath + posixPath + '.ino' + ); + expect(sketchContent).to.be.equal(content); + + await createApi.rename(posixPath, newPosixPath); + sketches = await createApi.sketches(); + expect(sketches.length).to.be.equal(1); + expect(sketches[0].name).to.be.equal(newName); + + sketchContent = await createApi.readFile( + newPosixPath + newPosixPath + '.ino' + ); + expect(sketchContent).to.be.equal(content); + }); + + it('should error with HTTP 409 (Conflict) when renaming a sketch and the target already exists', async () => { + const name = v4(); + const otherName = v4(); + const content = 'void setup(){} void loop(){}'; + const posixPath = toPosix(name); + const otherPosixPath = toPosix(otherName); + + await createApi.createSketch(posixPath, content); + await createApi.createSketch(otherPosixPath, content); + + let sketches = await createApi.sketches(); + expect(sketches.length).to.be.equal(2); + expect(findByName(name, sketches)).to.be.not.undefined; + expect(findByName(otherName, sketches)).to.be.not.undefined; + + try { + await createApi.rename(posixPath, otherPosixPath); + assert.fail('Expected HTTP 409'); + } catch (err) { + expect(err).to.be.an.instanceOf(CreateError); + expect((err).status).to.be.equal(409); + } + + sketches = await createApi.sketches(); + expect(sketches.length).to.be.equal(2); + expect(findByName(name, sketches)).to.be.not.undefined; + expect(findByName(otherName, sketches)).to.be.not.undefined; + }); +}); + +// Using environment variables is recommended for testing but you can modify the module too. +// Put your credential here for local testing. Otherwise, they will be picked up from the environment. +const username = ''; +const password = ''; +const clientSecret = ''; + +interface Credentials { + readonly username: string; + readonly password: string; + readonly clientSecret: string; +} + +function moduleCredentials(): Credentials | undefined { + if (!!username && !!password && !!clientSecret) { + console.log('Using credentials from the module variables.'); + return { + username, + password, + clientSecret, + }; + } + return undefined; +} + +function envCredentials(): Credentials | undefined { + const username = process.env.CREATE_USERNAME; + const password = process.env.CREATE_PASSWORD; + const clientSecret = process.env.CREATE_CLIENT_SECRET; + if (!!username && !!password && !!clientSecret) { + console.log('Using credentials from the environment variables.'); + return { + username, + password, + clientSecret, + }; + } + return undefined; +} + +class LoginFailed extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, LoginFailed.prototype); + } +} 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..066437fdd --- /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 { + ApplicationShell, + 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 { CreateFeatures } from '../../browser/create/create-features'; +import { SketchesServiceClientImpl } from '../../browser/sketches-service-client-impl'; +import { + fileAlreadyExists, + invalidExtension as invalidExtensionMessage, + parseFileInput, + WorkspaceCommandContribution, +} from '../../browser/theia/workspace/workspace-commands'; +import { Sketch, SketchesService } from '../../common/protocol'; + +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; + + 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({}); + container.bind(CreateFeatures).toConstantValue({}); + container.bind(ApplicationShell).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 = fileAlreadyExists(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..f2e905ccc --- /dev/null +++ b/arduino-ide-extension/src/test/common/sketches-service.test.ts @@ -0,0 +1,145 @@ +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('toValidSketchFolderName', () => { + [ + ['', Sketch.defaultSketchFolderName], + [' ', Sketch.defaultFallbackFirstChar], + [' ', Sketch.defaultFallbackFirstChar + Sketch.defaultFallbackChar], + [ + '0123456789012345678901234567890123456789012345678901234567890123', + '012345678901234567890123456789012345678901234567890123456789012', + ], + ['foo bar', 'foo_bar'], + ['vAlid', 'vAlid'], + ].map(([input, expected]) => + toMapIt(input, expected, Sketch.toValidSketchFolderName) + ); + }); + + describe('toValidSketchFolderName with timestamp suffix', () => { + const epoch = new Date(0); + const epochSuffix = Sketch.timestampSuffix(epoch); + [ + ['', Sketch.defaultSketchFolderName + epochSuffix], + [' ', Sketch.defaultFallbackFirstChar + epochSuffix], + [ + ' ', + Sketch.defaultFallbackFirstChar + + Sketch.defaultFallbackChar + + epochSuffix, + ], + [ + '0123456789012345678901234567890123456789012345678901234567890123', + '0123456789012345678901234567890123456789012' + epochSuffix, + ], + ['foo bar', 'foo_bar' + epochSuffix], + ['vAlid', 'vAlid' + epochSuffix], + ].map(([input, expected]) => + toMapIt(input, expected, (input: string) => + Sketch.toValidSketchFolderName(input, epoch) + ) + ); + }); + + describe('toValidCloudSketchFolderName', () => { + [ + ['sketch', 'sketch'], + ['can-contain-slash-and-dot.ino', 'can_contain_slash_and_dot_ino'], + ['regex++', 'regex__'], + ['dots...', 'dots___'], + ['No Spaces', 'No_Spaces'], + ['_startsWithUnderscore', '_startsWithUnderscore'], + ['Invalid+Char.ino', 'Invalid_Char_ino'], + ['', 'sketch'], + ['/', '_'], + ['//trash/', '__trash_'], + [ + '63Length_012345678901234567890123456789012345678901234567890123', + '63Length_012345678901234567890123456', + ], + ].map(([input, expected]) => + toMapIt(input, expected, Sketch.toValidCloudSketchFolderName, true) + ); + }); +}); + +function toMapIt( + input: string, + expected: string, + testMe: (input: string) => string, + cloud = false +): Mocha.Test { + return it(`should map the '${input}' ${ + cloud ? 'cloud ' : '' + }sketch folder name to '${expected}'`, () => + expect(testMe(input)).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/docs/README.md b/docs/README.md index 6c6f59479..802441583 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,12 +1,12 @@ -# Remote Sketchbook +# Cloud Sketchbook -Arduino IDE provides a Remote Sketchbook feature that can be used to upload sketches to Arduino Cloud. +Arduino IDE provides a Cloud Sketchbook feature that can be used to upload sketches to Arduino Cloud. ![](assets/remote.png) In order to use this feature, a user must be registered on [Arduino Cloud](https://store.arduino.cc/digital/create) and logged in. -This feature is completely optional and can be disabled in the IDE via the _"File > Advanced > Hide Remote Sketchbook"_ menu item. +This feature is completely optional and can be disabled in the IDE via the _"File > Advanced > Hide Cloud Sketchbook"_ menu item. ## Developer guide A developer could use the content of this repo to create a customized version of this feature and implement a different remote storage as follows: @@ -14,9 +14,9 @@ A developer could use the content of this repo to create a customized version of ### 1. Changing remote connection parameters in the Preferences panel (be careful while editing the Preferences panel!) Here a screenshot of the Preferences panel ![](assets/preferences.png) -- The settings under _Arduino > Auth_ should be edited to match the OAuth2 configuration of your custom remote sketchbook storage -- The setting under _Arduino > Sketch Sync Endpoint_ should be edited to point to your custom remote sketchbook storage service -### 2. Implementing the Arduino Cloud Store APIs for your custom remote sketchbook storage +- The settings under _Arduino > Auth_ should be edited to match the OAuth2 configuration of your custom cloud sketchbook storage +- The setting under _Arduino > Sketch Sync Endpoint_ should be edited to point to your custom cloud sketchbook storage service +### 2. Implementing the Arduino Cloud Store APIs for your custom cloud sketchbook storage Following the API Reference below: | API Call | OpenAPI documentation | diff --git a/i18n/en.json b/i18n/en.json index e6cb59679..7051890c3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -84,6 +84,7 @@ "cloud": { "account": "Account", "chooseSketchVisibility": "Choose visibility of your Sketch:", + "cloudSketchbook": "Cloud Sketchbook", "connected": "Connected", "continue": "Continue", "donePulling": "Done pulling ‘{0}’.", @@ -108,10 +109,9 @@ "pushSketch": "Push Sketch", "pushSketchMsg": "This is a Public Sketch. Before pushing, make sure any sensitive information is defined in arduino_secrets.h files. You can make a Sketch private from the Share panel.", "remote": "Remote", - "remoteSketchbook": "Remote Sketchbook", "share": "Share...", "shareSketch": "Share Sketch", - "showHideRemoveSketchbook": "Show/Hide Remote Sketchbook", + "showHideSketchbook": "Show/Hide Cloud Sketchbook", "signIn": "SIGN IN", "signInToCloud": "Sign in to Arduino Cloud", "signOut": "Sign Out", @@ -120,9 +120,14 @@ "visitArduinoCloud": "Visit Arduino Cloud to create Cloud Sketches." }, "cloudSketch": { - "creating": "Creating remote sketch '{0}'...", - "new": "New Remote Sketch", - "synchronizing": "Synchronizing sketchbook, pulling '{0}'..." + "alreadyExists": "Cloud sketch '{0}' already exists.", + "creating": "Creating cloud sketch '{0}'...", + "new": "New Cloud Sketch", + "notFound": "Could not pull the cloud sketch '{0}'. It does not exist.", + "pulling": "Synchronizing sketchbook, pulling '{0}'...", + "pushing": "Synchronizing sketchbook, pushing '{0}'...", + "renaming": "Renaming cloud sketch from '{0}' to '{1}'...", + "synchronizingSketchbook": "Synchronizing sketchbook..." }, "common": { "all": "All", @@ -311,10 +316,7 @@ "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." + "newSketchTitle": "Name of the new Cloud Sketch" }, "portProtocol": { "network": "Network", @@ -382,6 +384,9 @@ "deprecationMessage": "Deprecated. Use 'window.zoomLevel' instead." } }, + "renameCloudSketch": { + "renameSketchTitle": "New name of the Cloud Sketch" + }, "replaceMsg": "Replace the existing version of {0}?", "selectZip": "Select a zip file containing the library you'd like to add", "serial": { @@ -405,7 +410,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", @@ -428,7 +437,7 @@ "verifyOrCompile": "Verify/Compile" }, "sketchbook": { - "newRemoteSketch": "New Remote Sketch", + "newCloudSketch": "New Cloud Sketch", "newSketch": "New Sketch" }, "survey": { @@ -448,6 +457,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": { @@ -467,10 +484,10 @@ "expand": "Expand" }, "workspace": { - "deleteCurrentSketch": "Do you want to delete the current sketch?", + "deleteCloudSketch": "The cloud sketch '{0}' will be permanently deleted from the Arduino servers and the local caches. This action is irreversible. Do you want to delete the current sketch?", + "deleteCurrentSketch": "The sketch '{0}' will be permanently deleted. This action is irreversible. 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" } } diff --git a/yarn.lock b/yarn.lock index ac7787a68..e77dd480d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5966,6 +5966,13 @@ cross-env@^7.0.2: dependencies: cross-spawn "^7.0.1" +cross-fetch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + cross-spawn-async@^2.1.1: version "2.2.5" resolved "https://registry.yarnpkg.com/cross-spawn-async/-/cross-spawn-async-2.2.5.tgz#845ff0c0834a3ded9d160daca6d390906bb288cc" @@ -10902,7 +10909,7 @@ node-environment-flags@1.0.6: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" -node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==