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/config/config-service-client.ts b/arduino-ide-extension/src/browser/config/config-service-client.ts index b0ff79fd8..65e678180 100644 --- a/arduino-ide-extension/src/browser/config/config-service-client.ts +++ b/arduino-ide-extension/src/browser/config/config-service-client.ts @@ -38,7 +38,7 @@ export class ConfigServiceClient implements FrontendApplicationContribution { @postConstruct() protected init(): void { this.appStateService.reachedState('ready').then(async () => { - const config = await this.fetchConfig(); + const config = await this.delegate.getConfiguration(); this.use(config); }); } @@ -59,10 +59,6 @@ export class ConfigServiceClient implements FrontendApplicationContribution { return this.didChangeDataDirUriEmitter.event; } - async fetchConfig(): Promise { - return this.delegate.getConfiguration(); - } - /** * CLI config related error messages if any. */ 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..64bbb1ce9 100644 --- a/arduino-ide-extension/src/browser/contributions/sketch-control.ts +++ b/arduino-ide-extension/src/browser/contributions/sketch-control.ts @@ -1,50 +1,34 @@ -import { inject, injectable } from '@theia/core/shared/inversify'; import { CommonCommands } from '@theia/core/lib/browser/common-frontend-contribution'; -import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; -import { WorkspaceCommands } from '@theia/workspace/lib/browser'; import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer'; +import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { Disposable, DisposableCollection, } from '@theia/core/lib/common/disposable'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands'; +import { ArduinoMenus } from '../menu/arduino-menus'; +import { CurrentSketch } from '../sketches-service-client-impl'; import { - URI, - SketchContribution, Command, CommandRegistry, - MenuModelRegistry, KeybindingRegistry, - TabBarToolbarRegistry, + MenuModelRegistry, open, + SketchContribution, + TabBarToolbarRegistry, + URI, } 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 { 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(); @@ -57,107 +41,57 @@ export class SketchControl extends SketchContribution { this.shell.getWidgets('main').indexOf(widget) !== -1, execute: async () => { this.toDisposeBeforeCreateNewContextMenu.dispose(); - const sketch = await this.sketchServiceClient.currentSketch(); - if (!CurrentSketch.isValid(sketch)) { - return; - } + let parentElement: HTMLElement | undefined = undefined; const target = document.getElementById( SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id ); - if (!(target instanceof HTMLElement)) { - return; + if (target instanceof HTMLElement) { + parentElement = target.parentElement ?? undefined; } - const { parentElement } = target; if (!parentElement) { return; } - const { mainFileUri, rootFolderFileUris } = sketch; - const uris = [mainFileUri, ...rootFolderFileUris]; + const sketch = await this.sketchServiceClient.currentSketch(); + if (!CurrentSketch.isValid(sketch)) { + return; + } - const parentSketchUri = this.editorManager.currentEditor - ?.getResourceUri() - ?.toString(); - const parentSketch = await this.sketchService.getSketchFolder( - parentSketchUri || '' + this.menuRegistry.registerMenuAction( + ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, + { + commandId: WorkspaceCommands.FILE_RENAME.id, + label: nls.localize('vscode/fileActions/rename', 'Rename'), + order: '1', + } ); - - // if the current file is in the current opened sketch, show extra menus - if ( - sketch && - parentSketch && - parentSketch.uri === sketch.uri && - this.allowRename(parentSketch.uri) - ) { - this.menuRegistry.registerMenuAction( - ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, - { - commandId: WorkspaceCommands.FILE_RENAME.id, - label: nls.localize('vscode/fileActions/rename', 'Rename'), - order: '1', - } - ); - this.toDisposeBeforeCreateNewContextMenu.push( - Disposable.create(() => - this.menuRegistry.unregisterMenuAction( - WorkspaceCommands.FILE_RENAME - ) + this.toDisposeBeforeCreateNewContextMenu.push( + Disposable.create(() => + this.menuRegistry.unregisterMenuAction( + WorkspaceCommands.FILE_RENAME ) - ); - } else { - const renamePlaceholder = new PlaceholderMenuNode( - ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, - nls.localize('vscode/fileActions/rename', 'Rename') - ); - this.menuRegistry.registerMenuNode( - ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, - renamePlaceholder - ); - this.toDisposeBeforeCreateNewContextMenu.push( - Disposable.create(() => - this.menuRegistry.unregisterMenuNode(renamePlaceholder.id) - ) - ); - } + ) + ); - if ( - sketch && - parentSketch && - parentSketch.uri === sketch.uri && - this.allowDelete(parentSketch.uri) - ) { - this.menuRegistry.registerMenuAction( - ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, - { - commandId: WorkspaceCommands.FILE_DELETE.id, // TODO: customize delete. Wipe sketch if deleting main file. Close window. - label: nls.localize('vscode/fileActions/delete', 'Delete'), - order: '2', - } - ); - this.toDisposeBeforeCreateNewContextMenu.push( - Disposable.create(() => - this.menuRegistry.unregisterMenuAction( - WorkspaceCommands.FILE_DELETE - ) - ) - ); - } else { - const deletePlaceholder = new PlaceholderMenuNode( - ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, - nls.localize('vscode/fileActions/delete', 'Delete') - ); - this.menuRegistry.registerMenuNode( - ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, - deletePlaceholder - ); - this.toDisposeBeforeCreateNewContextMenu.push( - Disposable.create(() => - this.menuRegistry.unregisterMenuNode(deletePlaceholder.id) + this.menuRegistry.registerMenuAction( + ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP, + { + commandId: WorkspaceCommands.FILE_DELETE.id, + label: nls.localize('vscode/fileActions/delete', 'Delete'), + order: '2', + } + ); + this.toDisposeBeforeCreateNewContextMenu.push( + Disposable.create(() => + this.menuRegistry.unregisterMenuAction( + WorkspaceCommands.FILE_DELETE ) - ); - } + ) + ); + const { mainFileUri, rootFolderFileUris } = sketch; + const uris = [mainFileUri, ...rootFolderFileUris]; for (let i = 0; i < uris.length; i++) { const uri = new URI(uris[i]); @@ -193,6 +127,7 @@ export class SketchControl extends SketchContribution { parentElement.getBoundingClientRect().top + parentElement.offsetHeight, }, + showDisabled: true, }; this.contextMenuRenderer.render(options); }, @@ -249,27 +184,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..16b29929a --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/validate-sketch.ts @@ -0,0 +1,202 @@ +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, waitForEvent } 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 { CloudSketchContribution } from './cloud-contribution'; +import { Sketch, URI } from './contribution'; +import { SaveAsSketch } from './save-as-sketch'; + +@injectable() +export class ValidateSketch extends CloudSketchContribution { + 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, + dataDirUri: URI | undefined + ): FixAction[] { + // sketch code file validation errors first as they do not require window reload + const actions = 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: this.doValidate(sketch, dataDirUri, uri.path.name), + })) + .filter(({ error }) => Boolean(error)) + .map((object) => <{ uri: URI; error: string }>object) + .map(({ uri, error }) => ({ + execute: async () => { + const unknown = + (await this.promptRenameSketchFile(uri, error)) && + (await this.commandService.executeCommand( + WorkspaceCommands.FILE_RENAME.id, + uri + )); + return !!unknown; + }, + })); + + // sketch folder + main sketch file last as it requires a `Save as...` and the window reload + const sketchFolderName = new URI(sketch.uri).path.base; + const sketchFolderNameError = this.doValidate( + sketch, + dataDirUri, + sketchFolderName + ); + if (sketchFolderNameError) { + actions.push({ + execute: async () => { + const unknown = + (await this.promptRenameSketch(sketch, sketchFolderNameError)) && + (await this.commandService.executeCommand( + SaveAsSketch.Commands.SAVE_AS_SKETCH.id, + { + markAsRecentlyOpened: true, + openAfterMove: true, + wipeOriginal: true, + } + )); + return !!unknown; + }, + }); + } + return actions; + } + + private doValidate( + sketch: Sketch, + dataDirUri: URI | undefined, + toValidate: string + ): string | undefined { + const cloudUri = this.createFeatures.isCloud(sketch, dataDirUri); + return cloudUri + ? Sketch.validateCloudSketchFolderName(toValidate) + : Sketch.validateSketchFolderName(toValidate); + } + + 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 maybeDataDirUri = this.configService.tryGetDataDirUri(); + const [sketch, dataDirUri] = await Promise.all([ + this.currentSketch(), + maybeDataDirUri ?? + waitForEvent(this.configService.onDidChangeDataDirUri, 5_000), + ]); + const fixActions = this.validateSketch(sketch, dataDirUri); + for (const fixAction of fixActions) { + const result = await fixAction.execute(); + if (!result) { + return false; + } + } + return true; + } + + private async promptRenameSketch( + sketch: Sketch, + error: string + ): Promise { + return this.prompt( + nls.localize( + 'arduino/validateSketch/renameSketchFolderTitle', + 'Invalid sketch name' + ), + nls.localize( + 'arduino/validateSketch/renameSketchFolderMessage', + "The sketch '{0}' cannot be used. {1} To get rid of this message, rename the sketch. Do you want to rename the sketch now?", + sketch.name, + error + ) + ); + } + + private async promptRenameSketchFile( + uri: URI, + error: string + ): Promise { + return this.prompt( + nls.localize( + 'arduino/validateSketch/renameSketchFileTitle', + 'Invalid sketch filename' + ), + nls.localize( + 'arduino/validateSketch/renameSketchFileMessage', + "The sketch file '{0}' cannot be used. {1} Do you want to rename the sketch file now?", + uri.path.base, + error + ) + ); + } + + 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..bb61de5b5 --- /dev/null +++ b/arduino-ide-extension/src/browser/create/create-features.ts @@ -0,0 +1,95 @@ +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; + } + + /** + * `true` if the sketch is under `directories.data/RemoteSketchbook`. Otherwise, `false`. + * Returns with `undefined` if `dataDirUri` is `undefined`. + */ + isCloud(sketch: Sketch, dataDirUri: URI | undefined): boolean | undefined { + if (!dataDirUri) { + console.warn( + `Could not decide whether the sketch ${sketch.uri} is cloud or local. The 'directories.data' location was not available from the CLI config.` + ); + return undefined; + } + return dataDirUri.isEqualOrParent(new URI(sketch.uri)); + } + + 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..1dc821723 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,53 @@ -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 { waitForEvent } from '@theia/core/lib/common/promise-util'; +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 { ConfigServiceClient } from '../../config/config-service-client'; +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; + isCloud: boolean | 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; + @inject(ConfigServiceClient) + private readonly configServiceClient: ConfigServiceClient; + private _validationContext: ValidationContext | undefined; override registerCommands(registry: CommandRegistry): void { super.registerCommands(registry); @@ -46,9 +65,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 +91,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?.isCloud + ? 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); + } + 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 ''; + return errorMessage; } - protected maybeAppendInoExt(name: string | undefined): string { + private maybeAppendInoExt(name: string): string { if (!name) { return ''; } @@ -126,7 +171,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 +181,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 +191,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 +221,243 @@ 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, + this.sketchesServiceClient, + this.configServiceClient, + this.createFeatures, + { multi } + ); + } + + private async openDialog( + dialog: WorkspaceInputDialog, + uri: URI + ): Promise { + try { + let dataDirUri = this.configServiceClient.tryGetDataDirUri(); + if (!dataDirUri) { + dataDirUri = await waitForEvent( + this.configServiceClient.onDidChangeDataDirUri, + 2_000 + ); + } + this.acquireValidationContext(uri, dataDirUri); + const name = await dialog.open(true); + return name; + } finally { + this._validationContext = undefined; + } + } + + private acquireValidationContext( + uri: URI, + dataDirUri: URI | undefined + ): void { + const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); + if ( + CurrentSketch.isValid(sketch) && + new URI(sketch.uri).isEqualOrParent(uri) + ) { + const isCloud = this.createFeatures.isCloud(sketch, dataDirUri); + this._validationContext = { sketch, isCloud }; + } + } +} + +// (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, + private readonly sketchesServiceClient: SketchesServiceClientImpl, + private readonly configServiceClient: ConfigServiceClient, + private readonly createFeatures: CreateFeatures, + 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; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + override isEnabled(...args: any[]): boolean { + const [uri, ...others] = this.getArgsWithUri(...args); + if (uri) { + if (!this.isInSketch(uri)) { + return false; + } + if (this.affectsCloudSketchFolderWhenSignedOut(uri)) { + return false; + } + if (this.handler.isEnabled) { + return this.handler.isEnabled(uri, ...others); + } + return true; + } + return false; + } + + // 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; + } + + private isInSketch(uri: T | undefined): boolean { + if (!uri) { + return false; + } + const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); + if (!CurrentSketch.isValid(sketch)) { + return false; + } + if (this.isMulti() && Array.isArray(uri)) { + return uri.every((u) => Sketch.isInSketch(u, sketch)); + } + if (!this.isMulti() && uri instanceof URI) { + return Sketch.isInSketch(uri, sketch); + } + return false; + } + + /** + * If the user is not logged in, deleting/renaming the main sketch file or the sketch folder of a cloud sketch is disabled. + */ + private affectsCloudSketchFolderWhenSignedOut(uri: T | undefined): boolean { + return ( + !Boolean(this.createFeatures.session) && + Boolean(this.isCurrentSketchCloud()) && + this.affectsSketchFolder(uri) + ); + } + + private affectsSketchFolder(uri: T | undefined): boolean { + if (!uri) { + return false; + } + const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); + if (!CurrentSketch.isValid(sketch)) { + return false; + } + if (this.isMulti() && Array.isArray(uri)) { + return uri.map((u) => u.toString()).includes(sketch.mainFileUri); + } + if (!this.isMulti()) { + return sketch.mainFileUri === uri.toString(); + } + return false; + } + + private isCurrentSketchCloud(): boolean | undefined { + const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); + if (!CurrentSketch.isValid(sketch)) { + return false; } + const dataDirUri = this.configServiceClient.tryGetDataDirUri(); + return this.createFeatures.isCloud(sketch, dataDirUri); } } 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..b71a6b0fa 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', + 'The name 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. 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-browser/theia/core/electron-context-menu-renderer.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-context-menu-renderer.ts new file mode 100644 index 000000000..9ecb228ff --- /dev/null +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-context-menu-renderer.ts @@ -0,0 +1,60 @@ +import { webFrame } from '@theia/core/electron-shared/electron/'; +import { + ContextMenuAccess, + coordinateFromAnchor, + RenderContextMenuOptions, +} from '@theia/core/lib/browser/context-menu-renderer'; +import { + ElectronContextMenuAccess, + ElectronContextMenuRenderer as TheiaElectronContextMenuRenderer, +} from '@theia/core/lib/electron-browser/menu/electron-context-menu-renderer'; +import { injectable } from '@theia/core/shared/inversify'; + +@injectable() +export class ElectronContextMenuRenderer extends TheiaElectronContextMenuRenderer { + protected override doRender( + options: RenderContextMenuOptions + ): ContextMenuAccess { + if (this.useNativeStyle) { + const { menuPath, anchor, args, onHide, context } = options; + const menu = this['electronMenuFactory'].createElectronContextMenu( + menuPath, + args, + context, + this.showDisabled(options) + ); + const { x, y } = coordinateFromAnchor(anchor); + const zoom = webFrame.getZoomFactor(); + // TODO: Remove the offset once Electron fixes https://github.com/electron/electron/issues/31641 + const offset = process.platform === 'win32' ? 0 : 2; + // x and y values must be Ints or else there is a conversion error + menu.popup({ + x: Math.round(x * zoom) + offset, + y: Math.round(y * zoom) + offset, + }); + // native context menu stops the event loop, so there is no keyboard events + this.context.resetAltPressed(); + if (onHide) { + menu.once('menu-will-close', () => onHide()); + } + return new ElectronContextMenuAccess(menu); + } else { + return super.doRender(options); + } + } + + /** + * Theia does not allow selectively control whether disabled menu items are visible or not. This is a workaround. + * Attach the `showDisabled: true` to the `RenderContextMenuOptions` object, and you can control it. + * https://github.com/eclipse-theia/theia/blob/d59d5279b93e5050c2cbdd4b6726cab40187c50e/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts#L134. + */ + private showDisabled(options: RenderContextMenuOptions): boolean { + if ('showDisabled' in options) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const object = options as any; + const showDisabled = object['showDisabled'] as unknown; + return typeof showDisabled === 'boolean' && Boolean(showDisabled); + } + return false; + } +} diff --git a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts index 6baa5901a..bcb313a6f 100644 --- a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts @@ -74,11 +74,12 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { menuPath: MenuPath, // eslint-disable-next-line @typescript-eslint/no-explicit-any args?: any[], - context?: HTMLElement + context?: HTMLElement, + showDisabled?: boolean ): Electron.Menu { const menuModel = this.menuProvider.getMenu(menuPath); const template = this.fillMenuTemplate([], menuModel, args, { - showDisabled: false, + showDisabled, context, rootMenuPath: menuPath, }); diff --git a/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts index 7e827ff4a..a306f47da 100644 --- a/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-menu-module.ts @@ -1,13 +1,17 @@ -import { ContainerModule } from '@theia/core/shared/inversify'; +import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer'; import { ElectronMainMenuFactory as TheiaElectronMainMenuFactory } from '@theia/core/lib/electron-browser/menu/electron-main-menu-factory'; import { ElectronMenuContribution as TheiaElectronMenuContribution } from '@theia/core/lib/electron-browser/menu/electron-menu-contribution'; +import { ContainerModule } from '@theia/core/shared/inversify'; import { MainMenuManager } from '../../../common/main-menu-manager'; +import { ElectronContextMenuRenderer } from './electron-context-menu-renderer'; import { ElectronMainMenuFactory } from './electron-main-menu-factory'; import { ElectronMenuContribution } from './electron-menu-contribution'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ElectronMenuContribution).toSelf().inSingletonScope(); bind(MainMenuManager).toService(ElectronMenuContribution); + bind(ElectronContextMenuRenderer).toSelf().inSingletonScope(); + rebind(ContextMenuRenderer).toService(ElectronContextMenuRenderer); rebind(TheiaElectronMenuContribution).toService(ElectronMenuContribution); bind(ElectronMainMenuFactory).toSelf().inSingletonScope(); rebind(TheiaElectronMainMenuFactory).toService(ElectronMainMenuFactory); 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..234515e76 --- /dev/null +++ b/arduino-ide-extension/src/test/browser/workspace-commands.test.ts @@ -0,0 +1,219 @@ +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 { ConfigServiceClient } from '../../browser/config/config-service-client'; +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({}); + container + .bind(ConfigServiceClient) + .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..39699c4e7 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. Maximum length is 36 characters.", + "invalidSketchFolderNameTitle": "Invalid sketch folder name: '{0}'", + "invalidSketchName": "The name 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,17 @@ "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. {1} Do you want to rename the sketch file now?", + "renameSketchFileTitle": "Invalid sketch filename", + "renameSketchFolderMessage": "The sketch '{0}' cannot be used. {1} To get rid of this message, rename the sketch. Do you want to rename the sketch now?", + "renameSketchFolderTitle": "Invalid sketch name" + }, + "workspace": { + "alreadyExists": "'{0}' already exists." } }, "theia": { @@ -467,10 +487,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==