mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-04-19 12:57:17 +00:00
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:
parent
4f07515ee8
commit
d68bc4abdb
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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 {
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)) {
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
@ -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 = {
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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)) {
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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>;
|
||||
}
|
@ -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.
|
||||
*/
|
||||
|
@ -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() {
|
||||
|
||||
}
|
||||
|
||||
`;
|
||||
}
|
||||
|
95
arduino-ide-extension/src/browser/create/create-features.ts
Normal file
95
arduino-ide-extension/src/browser/create/create-features.ts
Normal 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));
|
||||
}
|
||||
}
|
@ -189,10 +189,6 @@ export class CreateFsProvider
|
||||
FileSystemProviderErrorCode.NoPermissions
|
||||
);
|
||||
}
|
||||
|
||||
return this.createApi.init(
|
||||
this.authenticationService,
|
||||
this.arduinoPreferences
|
||||
);
|
||||
return this.createApi;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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()
|
||||
|
@ -1,2 +1,2 @@
|
||||
export const REMOTE_SKETCHBOOK_FOLDER = 'RemoteSketchbook';
|
||||
export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud';
|
||||
export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud';
|
||||
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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(
|
||||
|
@ -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(),
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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]));
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -0,0 +1 @@
|
||||
export const SCHEDULE_DELETION_SIGNAL = 'arduino/scheduleDeletion';
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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) =>
|
||||
|
@ -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();
|
||||
|
300
arduino-ide-extension/src/test/browser/create-api.test.ts
Normal file
300
arduino-ide-extension/src/test/browser/create-api.test.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
209
arduino-ide-extension/src/test/common/sketches-service.test.ts
Normal file
209
arduino-ide-extension/src/test/common/sketches-service.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
@ -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',
|
||||
|
@ -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.
|
||||

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

|
||||
- 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 |
|
||||
|
49
i18n/en.json
49
i18n/en.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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==
|
||||
|
Loading…
x
Reference in New Issue
Block a user