feat: rename, deletion, and validation support

Closes #1599
Closes #1825
Closes #649
Closes #1847
Closes #1882

Co-authored-by: Akos Kitta <a.kitta@arduino.cc>
Co-authored-by: per1234 <accounts@perglass.com>

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta 2023-01-17 14:03:07 +01:00 committed by Akos Kitta
parent 4f07515ee8
commit d68bc4abdb
71 changed files with 2905 additions and 874 deletions

2
.vscode/launch.json vendored
View File

@ -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",

View File

@ -67,11 +67,13 @@
"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",
"fast-json-stable-stringify": "^2.1.0",
"fast-safe-stringify": "^2.1.1",
"filename-reserved-regex": "^2.0.0",
"glob": "^7.1.6",
"google-protobuf": "^3.20.1",
"hash.js": "^1.1.7",

View File

@ -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();

View File

@ -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<ConfigState> {
return this.delegate.getConfiguration();
}
/**
* CLI config related error messages if any.
*/

View File

@ -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 {

View File

@ -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',

View File

@ -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<false | Sketch> {
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;
}

View File

@ -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<CloudSketchbookTree.CloudSketchDirNode | undefined> {
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;
}
}

View File

@ -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;

View File

@ -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<boolean> {
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)
);

View File

@ -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<void> {
const sketch = await this.loadSketch(uri);
if (!sketch) {
console.info(`Sketch not found at ${uri}. Skipping deletion.`);
private async deleteSketch(params: DeleteSketchParams): Promise<void> {
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<Sketch | undefined> {
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<void> {
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<T extends Widget>(
widgets: Iterable<T>,
context: MaybeArray<URI>
): [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;
}

View File

@ -201,7 +201,7 @@ export abstract class Examples extends SketchContribution {
private async clone(uri: string): Promise<Sketch | undefined> {
try {
const sketch = await this.sketchService.cloneExample(uri);
const sketch = await this.sketchesService.cloneExample(uri);
return sketch;
} catch (err) {
if (SketchesError.NotFound.is(err)) {

View File

@ -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 {

View File

@ -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';

View File

@ -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<unknown> {
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;
}
return this.openWizard(rootNode, treeModel, initialValue);
}
private withProgress(
value: string,
treeModel: CloudSketchbookTreeModel
): (progress: Progress) => Promise<unknown> {
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(
treeModel: CloudSketchbookTreeModel,
newSketch: Create.Sketch
): Promise<URI | undefined> {
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}.`
): Promise<void> {
const treeModel = await this.treeModel();
if (treeModel) {
const rootNode = treeModel.root;
return this.openWizard(
rootNode,
treeModel,
skipShowErrorMessageOnOpen,
initialValue
);
}
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}.`
);
}
try {
await treeModel.sketchbookTree().pull({ node });
} catch (err) {
if (isNotFound(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;
}
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,
treeModel: CloudSketchbookTreeModel,
skipShowErrorMessageOnOpen: boolean,
initialValue?: string | undefined
): Promise<unknown> {
): Promise<void> {
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'
),
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.'
);
const taskFactory = new TaskFactoryImpl((value) =>
this.createNewSketchWithProgress(treeModel, value)
);
try {
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,
(value) => this.withProgress(value, treeModel)
).open();
this.labelProvider,
taskFactory
);
await dialog.open(skipShowErrorMessageOnOpen);
if (dialog.taskResult) {
this.openInNewWindow(dialog.taskResult);
}
} catch (err) {
if (isConflict(err)) {
await treeModel.refresh();
return this.createNewSketch(false, taskFactory.value ?? initialValue);
}
throw err;
}
}
private createNewSketchWithProgress(
treeModel: CloudSketchbookTreeModel,
value: string
): (
progress: Progress
) => Promise<CloudSketchbookTree.CloudSketchDirNode | undefined> {
return async (progress: Progress) => {
progress.report({
message: nls.localize(
'arduino/cloudSketch/creating',
"Creating cloud sketch '{0}'...",
value
),
});
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<void> {
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<unknown>
) {
super(props, labelProvider);
}
protected override async accept(): Promise<void> {
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;
}
}
}
}

View File

@ -35,7 +35,7 @@ export class NewSketch extends SketchContribution {
async newSketch(): Promise<void> {
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());

View File

@ -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));
}

View File

@ -39,7 +39,7 @@ export class OpenSketchFiles extends SketchContribution {
focusMainSketchFile = false
): Promise<void> {
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<void> {
const sketch = await this.sketchService.createNewSketch();
const sketch = await this.sketchesService.createNewSketch();
this.workspaceService.open(new URI(sketch.uri), { preserveWindow: true });
}

View File

@ -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<Sketch | undefined> {
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());
}
}

View File

@ -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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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',
};
}
}

View File

@ -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<string | undefined> {
return this.commandService.executeCommand<string>(
RenameCloudSketch.Commands.RENAME_CLOUD_SKETCH.id,
params
);
}
private async createLocalCopy(
sketch: Sketch,
execOnlyIfTemp?: boolean
): Promise<string | undefined> {
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,91 +142,157 @@ 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);
}
}
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],
});
}
this.workspaceService.open(new URI(workspaceUri), options);
}
return !!workspaceUri;
return await this.promptLocalSketchFolderDestination(sketch, defaultPath);
}
private async saveOntoCopiedSketch(mainFileUri: string, sketchUri: string, newSketchUri: string): Promise<void> {
/**
* 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(
sketch: Sketch,
defaultPath: string
): Promise<string | undefined> {
let sketchFolderDestinationUri: string | undefined;
while (!sketchFolderDestinationUri) {
const { filePath } = await remote.dialog.showSaveDialog(
remote.getCurrentWindow(),
{
title: nls.localize(
'arduino/sketch/saveFolderAs',
'Save sketch folder as...'
),
defaultPath,
}
);
if (!filePath) {
return undefined;
}
const destinationUri = await this.fileSystemExt.getUri(filePath);
// The new location of the sketch cannot be inside the location of current sketch.
// https://github.com/arduino/arduino-ide/issues/1882
let dialogContent: InvalidSketchFolderDialogContent | undefined;
if (new URI(sketch.uri).isEqualOrParent(new URI(destinationUri))) {
dialogContent = {
message: nls.localize(
'arduino/sketch/invalidSketchFolderLocationMessage',
"Invalid sketch folder location: '{0}'",
filePath
),
details: nls.localize(
'arduino/sketch/invalidSketchFolderLocationDetails',
'You cannot save a sketch into a folder inside itself.'
),
question: nls.localize(
'arduino/sketch/editInvalidSketchFolderLocationQuestion',
'Do you want to try saving the sketch to a different location?'
),
};
}
if (!dialogContent) {
const sketchFolderName = new URI(destinationUri).path.base;
const errorMessage = Sketch.validateSketchFolderName(sketchFolderName);
if (errorMessage) {
dialogContent = {
message: nls.localize(
'arduino/sketch/invalidSketchFolderNameMessage',
"Invalid sketch folder name: '{0}'",
sketchFolderName
),
details: errorMessage,
question: nls.localize(
'arduino/sketch/editInvalidSketchFolderQuestion',
'Do you want to try saving the sketch with a different name?'
),
};
}
}
if (dialogContent) {
const message = `
${dialogContent.message}
${dialogContent.details}
${dialogContent.question}`.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;
}
}
return sketchFolderDestinationUri;
}
private async saveOntoCopiedSketch(
sketch: Sketch,
newSketchFolderUri: string
): Promise<void> {
const widgets = this.applicationShell.widgets;
const snapshots = new Map<string, object>();
const snapshots = new Map<string, Saveable.Snapshot>();
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);
}
}));
})
);
}
}
interface InvalidSketchFolderDialogContent {
readonly message: string;
readonly details: string;
readonly question: string;
}
export namespace SaveAsSketch {
export namespace Commands {
export const SAVE_AS_SKETCH: Command = {

View File

@ -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,

View File

@ -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();
let parentElement: HTMLElement | undefined = undefined;
const target = document.getElementById(
SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id
);
if (target instanceof HTMLElement) {
parentElement = target.parentElement ?? undefined;
}
if (!parentElement) {
return;
}
const sketch = await this.sketchServiceClient.currentSketch();
if (!CurrentSketch.isValid(sketch)) {
return;
}
const target = document.getElementById(
SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id
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.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
)
)
);
if (!(target instanceof HTMLElement)) {
return;
}
const { parentElement } = target;
if (!parentElement) {
return;
}
const { mainFileUri, rootFolderFileUris } = sketch;
const uris = [mainFileUri, ...rootFolderFileUris];
const parentSketchUri = this.editorManager.currentEditor
?.getResourceUri()
?.toString();
const parentSketch = await this.sketchService.getSketchFolder(
parentSketchUri || ''
);
// if the current file is in the current opened sketch, show extra menus
if (
sketch &&
parentSketch &&
parentSketch.uri === sketch.uri &&
this.allowRename(parentSketch.uri)
) {
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
)
)
);
} 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)
)
);
}
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 {

View File

@ -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)) {

View File

@ -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();
});

View File

@ -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';

View File

@ -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<void> {
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,
<SaveAsSketch.Options>{
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<Sketch> {
const sketch = this.sketchServiceClient.tryGetCurrentSketch();
if (CurrentSketch.isValid(sketch)) {
return sketch;
}
const deferred = new Deferred<Sketch>();
const disposable = this.sketchServiceClient.onCurrentSketchDidChange(
(sketch) => {
if (CurrentSketch.isValid(sketch)) {
disposable.dispose();
deferred.resolve(sketch);
}
}
);
return deferred.promise;
}
private async promptFixActions(): Promise<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean>;
}

View File

@ -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.
*/

View File

@ -1,12 +1,16 @@
import { injectable, inject } from '@theia/core/shared/inversify';
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 { AuthenticationClientService } from '../auth/authentication-client-service';
import { ArduinoPreferences } from '../arduino-preferences';
import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache';
import { Create, CreateError } from './typings';
export interface ResponseResultProvider {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response: Response): Promise<any>;
}
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<string> = this.sketchesService.defaultInoContent()
): Promise<Create.Sketch> {
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<void> {
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<void> {
const url = new URL(
`${this.domain()}/files/${type}/$HOME/sketches_v2${posixPath}`
@ -475,14 +492,12 @@ export class CreateApi {
}
private async run<T>(
requestInfo: RequestInfo | URL,
requestInfo: URL,
init: RequestInit | undefined,
resultProvider: ResponseResultProvider = ResponseResultProvider.JSON
): Promise<T> {
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() {
}
`;
}

View File

@ -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<boolean>();
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<AuthenticationSession | undefined> {
return this.onDidChangeSessionEmitter.event;
}
get onDidChangeEnabled(): Event<boolean> {
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));
}
}

View File

@ -189,10 +189,6 @@ export class CreateFsProvider
FileSystemProviderErrorCode.NoPermissions
);
}
return this.createApi.init(
this.authenticationService,
this.arduinoPreferences
);
return this.createApi;
}
}

View File

@ -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;
}

View File

@ -90,7 +90,7 @@ export class LocalCacheFsProvider
protected async init(fileService: FileService): Promise<void> {
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) {

View File

@ -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);

View File

@ -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 {

View File

@ -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,

View File

@ -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';

View File

@ -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';

View File

@ -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<MonacoEditorModel> {
protected override async createModel(
resource: Resource
): Promise<MonacoEditorModel> {
const factory = this.factories
.getContributions()
.find(({ scheme }) => resource.uri.scheme === scheme);

View File

@ -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<void> {
private async newFile(uri: URI | undefined): Promise<void> {
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<string> {
// 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;
return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput);
}
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.
errorMessage = await super.validateFileName(userInput, parent, recursive); // run the default Theia validation with the raw input.
if (errorMessage) {
return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput);
}
if (Sketch.Extensions.ALL.indexOf(`.${extension}`) === -1) {
return nls.localize(
'theia/workspace/invalidExtension',
'.{0} is not a valid extension',
extension
);
// It's a legacy behavior from IDE 1.x. Validate the file as if 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 '';
return this.maybeRemapAlreadyExistsMessage(errorMessage ?? '', userInput);
}
protected maybeAppendInoExt(name: string | undefined): string {
// Remaps the Theia-based `A file or folder **$fileName** already exists at this location. Please choose a different name.` to a custom one.
private maybeRemapAlreadyExistsMessage(
errorMessage: string,
userInput: string
): string {
if (
errorMessage ===
nls.localizeByDefault(
'A file or folder **{0}** already exists at this location. Please choose a different name.',
this['trimFileName'](userInput)
)
) {
return fileAlreadyExists(userInput);
}
return errorMessage;
}
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<void> {
protected async renameFile(uri: URI | undefined): Promise<unknown> {
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<string>(
'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<URI>
): UriAwareCommandHandler<URI> {
return this.createUriAwareCommandHandler(handler);
}
protected override newMultiUriAwareCommandHandler(
handler: UriCommandHandler<URI[]>
): UriAwareCommandHandler<URI[]> {
return this.createUriAwareCommandHandler(handler, true);
}
private createUriAwareCommandHandler<T extends MaybeArray<URI>>(
delegate: UriCommandHandler<T>,
multi = false
): UriAwareCommandHandler<T> {
return new UriAwareCommandHandlerWithCurrentEditorFallback(
delegate,
this.selectionService,
this.shell,
this.sketchesServiceClient,
this.configServiceClient,
this.createFeatures,
{ multi }
);
}
private async openDialog(
dialog: WorkspaceInputDialog,
uri: URI
): Promise<string | undefined> {
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 `<input>`.
*/
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<URI>
> extends UriAwareCommandHandler<T> {
constructor(
delegate: UriCommandHandler<T>,
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);
}
}

View File

@ -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<void> {
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);
}
}

View File

@ -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<DialogError> {
if (value !== '') {
this.wasTouched = true;
}
return super.isValid(value, mode);
override isValid(value: string): MaybePromise<DialogError> {
return super.isValid(value, 'open');
}
override open(
skipShowErrorMessageOnOpen = false
): Promise<string | undefined> {
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<T> {
createTask(value: string): (progress: Progress) => Promise<T>;
}
export class TaskFactoryImpl<T> implements TaskFactory<T> {
private _value: string | undefined;
constructor(private readonly task: TaskFactory<T>['createTask']) {}
get value(): string | undefined {
return this._value;
}
createTask(value: string): (progress: Progress) => Promise<T> {
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<T>
) {
super(props, labelProvider);
}
get taskResult(): T | undefined {
return this._taskResult;
}
protected override async accept(): Promise<void> {
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;
}
}
}
}

View File

@ -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<FileStat | undefined> {
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;

View File

@ -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()

View File

@ -1,2 +1,2 @@
export const REMOTE_SKETCHBOOK_FOLDER = 'RemoteSketchbook';
export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud';
export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud';

View File

@ -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,
});
}
}

View File

@ -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 && (
<CreateNew
label={nls.localize(
'arduino/sketchbook/newRemoteSketch',
'New Remote Sketch'
'arduino/sketchbook/newCloudSketch',
'New Cloud Sketch'
)}
onClick={this.onDidClickCreateNew}
/>

View File

@ -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(

View File

@ -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<string, FileStat> = {};
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<void> | 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(),

View File

@ -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<void> {
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<URI[]> {
private async recursiveURIs(uri: URI): Promise<URI[]> {
// 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<Record<string, URI>> {
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<FilesToSync> {
private async treeDiff(source: URI, dest: URI): Promise<FilesToSync> {
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<void> {
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<TreeNode[]> {
override async resolveChildren(
parent: CompositeTreeNode
): Promise<TreeNode[]> {
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<void> {
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<boolean> {
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);
}

View File

@ -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<URI | undefined> {
override async move(
source: TreeNode,
target: TreeNode
): Promise<URI | undefined> {
if (source.parent && WorkspaceRootNode.is(source)) {
// do not support moving a root folder
return undefined;

View File

@ -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';

View File

@ -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';

View File

@ -1,5 +1,8 @@
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';
const filenameReservedRegex = require('filename-reserved-regex');
export namespace SketchesError {
export const Codes = {
@ -52,6 +55,11 @@ export interface SketchesService {
*/
createNewSketch(): Promise<Sketch>;
/**
* The default content when creating a new `.ino` file. Either the built-in or the user defined (`arduino.sketch.inoBlueprint`) content.
*/
defaultInoContent(): Promise<string>;
/**
* Creates a new sketch with existing content. Rejects if `uri` is not pointing to a valid sketch folder.
*/
@ -101,11 +109,6 @@ export interface SketchesService {
*/
getIdeTempFolderUri(sketch: Sketch): Promise<string>;
/**
* Recursively deletes the sketch folder with all its content.
*/
deleteSketch(sketch: Sketch): Promise<void>;
/**
* 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 +154,142 @@ 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 function reservedFilename(name: string): string {
return nls.localize(
'arduino/sketch/reservedFilename',
"'{0}' is a reserved filename.",
name
);
}
// (non-API) exported for the tests
export const noTrailingPeriod = nls.localize(
'arduino/sketch/noTrailingPeriod',
'A filename cannot end with a dot'
);
// (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 start with a letter or number, followed by letters, numbers, dashes, dots and 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 {
const validFilenameError = isValidFilename(candidate);
if (validFilenameError) {
return validFilenameError;
}
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.
*/
export function validateCloudSketchFolderName(
candidate: string
): string | undefined {
const validFilenameError = isValidFilename(candidate);
if (validFilenameError) {
return validFilenameError;
}
return /^[0-9a-zA-Z]{1}[0-9a-zA-Z_\.-]{0,35}$/.test(candidate)
? undefined
: invalidCloudSketchFolderNameMessage;
}
function isValidFilename(candidate: string): string | undefined {
if (isReservedFilename(candidate)) {
return reservedFilename(candidate);
}
if (endsWithPeriod(candidate)) {
return noTrailingPeriod;
}
return undefined;
}
function endsWithPeriod(candidate: string): boolean {
return candidate.length > 1 && candidate[candidate.length - 1] === '.';
}
function isReservedFilename(candidate: string): boolean {
return (
filenameReservedRegex().test(candidate) ||
filenameReservedRegex.windowsNames().test(candidate)
);
}
/**
* 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 {
if (
!appendTimestampSuffix &&
filenameReservedRegex.windowsNames().test(candidate)
) {
return defaultSketchFolderName;
}
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 {
if (filenameReservedRegex.windowsNames().test(candidate)) {
return defaultSketchFolderName;
}
return candidate
? candidate
.replace(/^[^0-9a-zA-Z]{1}/g, defaultFallbackFirstChar)
.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 +311,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]));
}

View File

@ -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;
}
}

View File

@ -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,
});

View File

@ -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);

View File

@ -0,0 +1 @@
export const SCHEDULE_DELETION_SIGNAL = 'arduino/scheduleDeletion';

View File

@ -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<void> {
// 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<void> {
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<void>((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 {

View File

@ -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);

View File

@ -7,7 +7,6 @@ import {
BoardsPackage,
Board,
BoardDetails,
Tool,
ConfigOption,
ConfigValue,
Programmer,
@ -99,14 +98,11 @@ export class BoardsServiceImpl
const debuggingSupported = detailsResp.getDebuggingSupported();
const requiredTools = detailsResp.getToolsDependenciesList().map(
(t) =>
<Tool>{
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) =>

View File

@ -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<string> {
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<void> {
return new Promise<void>((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<Record<string, unknown> | undefined> {
const configDirUri = await this.envVariableServer.getConfigDirUri();

View File

@ -0,0 +1,300 @@
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>(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 <AuthenticationSession>{
accessToken,
};
},
});
bind(ArduinoPreferences).toConstantValue(<ArduinoPreferences>{
'arduino.cloud.sketchSyncEndpoint':
'https://api-dev.arduino.cc/create',
});
bind(SketchesService).toConstantValue(<SketchesService>{});
})
);
return container;
}
async function login(
credentials: Credentials | undefined = moduleCredentials() ??
envCredentials()
): Promise<string> {
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<void> {
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((<CreateError>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((<CreateError>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;
});
['.', '-', '_'].map((char) => {
it(`should create a new sketch with '${char}' in the sketch folder name although it's disallowed from the Create Editor`, async () => {
const name = `sketch${char}`;
const posixPath = toPosix(name);
const newSketch = await createApi.createSketch(
posixPath,
'void setup(){} void loop(){}'
);
expect(newSketch).to.be.not.undefined;
expect(newSketch.name).to.be.equal(name);
let sketches = await createApi.sketches();
let sketch = findByName(name, sketches);
expect(sketch).to.be.not.undefined;
// TODO: Cannot do deep equals because the Create API responses with different objects on POST and GET
// `"libraries": [null]` vs `"libraries": []`
// `"created_at": "2023-02-08T09:39:32.16994555Z"` vs `"created_at": "2023-02-08T09:39:32.169946Z"`
// expect(newSketch).to.be.deep.equal(sketch);
expect(newSketch.path).to.be.equal(sketch?.path);
expect(newSketch.name).to.be.equal(sketch?.name);
await createApi.deleteSketch(sketch?.path!);
sketches = await createApi.sketches();
sketch = findByName(name, sketches);
expect(sketch).to.be.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);
}
}

View File

@ -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<string> {
return workspaceCommands['validateFileName'](userInput, parent);
}
function createContainer(): Container {
const container = new Container();
container.bind(FileDialogService).toConstantValue(<FileDialogService>{});
container.bind(FileService).toConstantValue(<FileService>{
async exists(resource: URI): Promise<boolean> {
return (
resource.path.base.includes('_sketch') ||
resource.path.base.includes('sketch')
);
},
});
container
.bind(FrontendApplication)
.toConstantValue(<FrontendApplication>{});
container.bind(LabelProvider).toConstantValue(<LabelProvider>{});
container.bind(MessageService).toConstantValue(<MessageService>{});
container.bind(OpenerService).toConstantValue(<OpenerService>{});
container.bind(SelectionService).toConstantValue(<SelectionService>{});
container.bind(WorkspaceCommandContribution).toSelf().inSingletonScope();
container
.bind(WorkspaceCompareHandler)
.toConstantValue(<WorkspaceCompareHandler>{});
container
.bind(WorkspaceDeleteHandler)
.toConstantValue(<WorkspaceDeleteHandler>{});
container
.bind(WorkspaceDuplicateHandler)
.toConstantValue(<WorkspaceDuplicateHandler>{});
container
.bind(WorkspacePreferences)
.toConstantValue(<WorkspacePreferences>{});
container.bind(WorkspaceService).toConstantValue(<WorkspaceService>{});
container.bind(ClipboardService).toConstantValue(<ClipboardService>{});
container.bind(ApplicationServer).toConstantValue(<ApplicationServer>{
async getBackendOS(): Promise<OS.Type> {
return OS.type();
},
});
container.bind(CommandService).toConstantValue(<CommandService>{});
container.bind(SketchesService).toConstantValue(<SketchesService>{});
container
.bind(SketchesServiceClientImpl)
.toConstantValue(<SketchesServiceClientImpl>{});
container.bind(CreateFeatures).toConstantValue(<CreateFeatures>{});
container.bind(ApplicationShell).toConstantValue(<ApplicationShell>{});
container
.bind(ConfigServiceClient)
.toConstantValue(<ConfigServiceClient>{});
return container;
}
beforeEach(() => {
workspaceCommands = createContainer().get<WorkspaceCommandContribution>(
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);
});
});
});

View File

@ -0,0 +1,209 @@
import { expect } from 'chai';
import { Sketch } from '../../common/protocol';
const windowsReservedFileNames = [
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
];
const windowsInvalidFilenames = ['trailingPeriod.', 'trailingSpace '];
const invalidFilenames = [
...windowsInvalidFilenames,
...windowsReservedFileNames,
].map((name) => <[string, boolean]>[name, false]);
describe('sketch', () => {
describe('validateSketchFolderName', () => {
(
[
...invalidFilenames,
['com1', false], // Do not assume case sensitivity. (https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions)
['sketch', true],
['can-contain-slash-and-dot.ino', true],
['regex++', false],
['trailing.dots...', false],
['no.trailing.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', () => {
(
[
...invalidFilenames,
['sketch', true],
['can-contain-dashes', true],
['can.contain.dots', true],
['-cannot-start-with-dash', false],
['.cannot.start.with.dash', false],
['_cannot_start_with_underscore', false],
['No Spaces', false],
['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'],
['_foobar', '0foobar'],
['vAlid', 'vAlid'],
['COM1', Sketch.defaultSketchFolderName],
['COM1.', 'COM1_'],
['period.', 'period_'],
].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],
['.foobar', '0foobar' + epochSuffix],
['-fooBar', '0fooBar' + epochSuffix],
['foobar.', 'foobar_' + epochSuffix],
['fooBar-', 'fooBar_' + epochSuffix],
['fooBar+', 'fooBar_' + epochSuffix],
['vAlid', 'vAlid' + epochSuffix],
['COM1', 'COM1' + epochSuffix],
['COM1.', 'COM1_' + epochSuffix],
['period.', 'period_' + epochSuffix],
].map(([input, expected]) =>
toMapIt(input, expected, (input: string) =>
Sketch.toValidSketchFolderName(input, epoch)
)
);
});
describe('toValidCloudSketchFolderName', () => {
[
['sketch', 'sketch'],
['only_underscore-is+ok.ino', 'only_underscore_is_ok_ino'],
['regex++', 'regex__'],
['dots...', 'dots___'],
['.dots...', '0dots___'],
['-dashes---', '0dashes___'],
['_underscore___', '0underscore___'],
['No Spaces', 'No_Spaces'],
['_startsWithUnderscore', '0startsWithUnderscore'],
['Invalid+Char.ino', 'Invalid_Char_ino'],
['', 'sketch'],
['/', '0'],
[
'/-1////////////////////+//////////////-/',
'0_1_________________________________',
],
['//trash/', '0_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}'`, () => {
const actual = testMe(input);
expect(actual).to.be.equal(expected);
const errorMessage = Sketch.validateSketchFolderName(actual);
try {
expect(errorMessage).to.be.undefined;
} catch (err) {
console.log('HELLO', actual, errorMessage);
throw err;
}
});
}

View File

@ -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',

View File

@ -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 |

View File

@ -85,6 +85,7 @@
"cloud": {
"account": "Account",
"chooseSketchVisibility": "Choose visibility of your Sketch:",
"cloudSketchbook": "Cloud Sketchbook",
"connected": "Connected",
"continue": "Continue",
"donePulling": "Done pulling {0}.",
@ -109,10 +110,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",
@ -121,9 +121,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",
@ -312,10 +317,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",
@ -383,6 +385,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": {
@ -406,13 +411,22 @@
"createdArchive": "Created archive '{0}'.",
"doneCompiling": "Done compiling.",
"doneUploading": "Done uploading.",
"editInvalidSketchFolderLocationQuestion": "Do you want to try saving the sketch to a different location?",
"editInvalidSketchFolderQuestion": "Do you want to try saving the sketch with a different name?",
"exportBinary": "Export Compiled Binary",
"invalidCloudSketchName": "The name must start with a letter or number, followed by letters, numbers, dashes, dots and underscores. Maximum length is 36 characters.",
"invalidSketchFolderLocationDetails": "You cannot save a sketch into a folder inside itself.",
"invalidSketchFolderLocationMessage": "Invalid sketch folder location: '{0}'",
"invalidSketchFolderNameMessage": "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",
"noTrailingPeriod": "A filename cannot end with a dot",
"openFolder": "Open Folder",
"openRecent": "Open Recent",
"openSketchInNewWindow": "Open Sketch in New Window",
"reservedFilename": "'{0}' is a reserved filename.",
"saveFolderAs": "Save sketch folder as...",
"saveSketch": "Save your sketch to open it again later.",
"saveSketchAs": "Save sketch folder as...",
@ -429,7 +443,7 @@
"verifyOrCompile": "Verify/Compile"
},
"sketchbook": {
"newRemoteSketch": "New Remote Sketch",
"newCloudSketch": "New Cloud Sketch",
"newSketch": "New Sketch"
},
"survey": {
@ -449,6 +463,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": {
@ -468,10 +493,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"
}
}

View File

@ -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==