diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 5e87af4fc..e479464c9 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -335,6 +335,7 @@ import { UserFields } from './contributions/user-fields'; import { UpdateIndexes } from './contributions/update-indexes'; import { InterfaceScale } from './contributions/interface-scale'; import { OpenHandler } from '@theia/core/lib/browser/opener-service'; +import { NewCloudSketch } from './contributions/new-cloud-sketch'; const registerArduinoThemes = () => { const themes: MonacoThemeJson[] = [ @@ -751,6 +752,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, DeleteSketch); Contribution.configure(bind, UpdateIndexes); Contribution.configure(bind, InterfaceScale); + Contribution.configure(bind, NewCloudSketch); bindContributionProvider(bind, StartupTaskProvider); bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window diff --git a/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts b/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts new file mode 100644 index 000000000..173caa991 --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts @@ -0,0 +1,267 @@ +import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { CompositeTreeNode } from '@theia/core/lib/browser/tree'; +import { codicon } from '@theia/core/lib/browser/widgets/widget'; +import { + Disposable, + DisposableCollection, +} from '@theia/core/lib/common/disposable'; +import { Emitter } from '@theia/core/lib/common/event'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { CreateApi } from '../create/create-api'; +import { CreateUri } from '../create/create-uri'; +import { Create } from '../create/typings'; +import { WorkspaceInputDialog } 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'; + +@injectable() +export class NewCloudSketch extends Contribution { + @inject(CreateApi) + private readonly createApi: CreateApi; + @inject(SketchbookWidgetContribution) + private readonly sketchbookWidgetContribution: SketchbookWidgetContribution; + + private toDisposeOnNewTreeModel: Disposable | undefined; + private treeModel: CloudSketchbookTreeModel | undefined; + private readonly onDidChangeEmitter = new Emitter(); + private readonly toDisposeOnStop = new DisposableCollection( + this.onDidChangeEmitter + ); + + override onReady(): void { + const handleCurrentTreeDidChange = (widget: SketchbookWidget) => { + this.toDisposeOnStop.push( + widget.onCurrentTreeDidChange(() => this.onDidChangeEmitter.fire()) + ); + const treeWidget = widget.getTreeWidget(); + if (treeWidget instanceof CloudSketchbookTreeWidget) { + this.onDidChangeEmitter.fire(); + } + }; + const widget = this.sketchbookWidgetContribution.tryGetWidget(); + if (widget) { + handleCurrentTreeDidChange(widget); + } else { + this.sketchbookWidgetContribution.widget.then(handleCurrentTreeDidChange); + } + } + + onStop(): void { + this.toDisposeOnStop.dispose(); + if (this.toDisposeOnNewTreeModel) { + this.toDisposeOnNewTreeModel.dispose(); + } + } + + override registerCommands(registry: CommandRegistry): void { + registry.registerCommand(NewCloudSketch.Commands.CREATE_SKETCH, { + execute: () => this.createNewSketch(), + isEnabled: () => !!this.treeModel, + }); + registry.registerCommand(NewCloudSketch.Commands.CREATE_SKETCH_TOOLBAR, { + execute: () => + this.commandService.executeCommand( + NewCloudSketch.Commands.CREATE_SKETCH.id + ), + isVisible: (arg: unknown) => { + if (arg instanceof SketchbookWidget) { + const treeWidget = arg.getTreeWidget(); + if (treeWidget instanceof CloudSketchbookTreeWidget) { + const model = treeWidget.model; + if (model instanceof CloudSketchbookTreeModel) { + if (this.treeModel !== model) { + this.treeModel = model; + if (this.toDisposeOnNewTreeModel) { + this.toDisposeOnNewTreeModel.dispose(); + this.toDisposeOnNewTreeModel = this.treeModel.onChanged(() => + this.onDidChangeEmitter.fire() + ); + } + } + } + } + return ( + !!this.treeModel && treeWidget instanceof CloudSketchbookTreeWidget + ); + } + return false; + }, + }); + } + + override registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: NewCloudSketch.Commands.CREATE_SKETCH_TOOLBAR.id, + command: NewCloudSketch.Commands.CREATE_SKETCH_TOOLBAR.id, + tooltip: NewCloudSketch.Commands.CREATE_SKETCH_TOOLBAR.label, + onDidChange: this.onDidChangeEmitter.event, + }); + } + + private async createNewSketch( + initialValue?: string | undefined + ): Promise { + if (!this.treeModel) { + return undefined; + } + const newSketchName = await this.newSketchName(initialValue); + if (!newSketchName) { + return undefined; + } + let result: Create.Sketch | undefined | 'conflict'; + try { + result = await this.createApi.createSketch(newSketchName); + } catch (err) { + if (isConflict(err)) { + result = 'conflict'; + } else { + throw err; + } + } finally { + if (result) { + await this.treeModel.updateRoot(); + await this.treeModel.refresh(); + } + } + + if (result === 'conflict') { + return this.createNewSketch(newSketchName); + } + + if (result) { + const newSketch = result; + const treeModel = this.treeModel; + const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes'); + this.messageService + .info( + nls.localize( + 'arduino/newCloudSketch/openNewSketch', + 'Do you want to pull the new remote sketch {0} and open it in a new window?', + newSketchName + ), + yes + ) + .then(async (answer) => { + if (answer === yes) { + const node = treeModel.getNode( + CreateUri.toUri(newSketch).path.toString() + ); + if (!node) { + return; + } + if (CloudSketchbookTree.CloudSketchDirNode.is(node)) { + try { + await treeModel.sketchbookTree().pull({ node }); + } catch (err) { + if (isNotFound(err)) { + await treeModel.updateRoot(); + await treeModel.refresh(); + this.messageService.error( + nls.localize( + 'arduino/newCloudSketch/notFound', + `Could not pull the remote sketch {0}. It does not exist.`, + newSketchName + ) + ); + return; + } + throw err; + } + return this.commandService.executeCommand( + SketchbookCommands.OPEN_NEW_WINDOW.id, + { node } + ); + } + } + }); + } + return undefined; + } + + private async newSketchName( + initialValue?: string | undefined + ): Promise { + const rootNode = this.rootNode(); + if (!rootNode) { + return undefined; + } + const existingNames = rootNode.children + .filter(CloudSketchbookTree.CloudSketchDirNode.is) + .map(({ fileStat }) => fileStat.name); + return new WorkspaceInputDialog( + { + 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.' + ); + }, + }, + this.labelProvider + ).open(); + } + + private rootNode(): CompositeTreeNode | undefined { + return this.treeModel && CompositeTreeNode.is(this.treeModel.root) + ? this.treeModel.root + : undefined; + } +} +export namespace NewCloudSketch { + export namespace Commands { + export const CREATE_SKETCH = Command.toLocalizedCommand( + { + id: 'arduino-cloud-sketchbook--create-sketch', + label: 'New Remote Sketch...', + category: 'Arduino', + }, + 'arduino/newCloudSketch/createSketch' + ) as Command & { label: string }; + export const CREATE_SKETCH_TOOLBAR: Command & { label: string } = { + ...CREATE_SKETCH, + id: `${CREATE_SKETCH.id}-toolbar`, + iconClass: codicon('new-folder'), + }; + } +} + +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; +} diff --git a/arduino-ide-extension/src/browser/create/create-uri.ts b/arduino-ide-extension/src/browser/create/create-uri.ts index 1d60ffff2..658a65ac1 100644 --- a/arduino-ide-extension/src/browser/create/create-uri.ts +++ b/arduino-ide-extension/src/browser/create/create-uri.ts @@ -7,7 +7,9 @@ export namespace CreateUri { export const scheme = 'arduino-create'; export const root = toUri(posix.sep); - export function toUri(posixPathOrResource: string | Create.Resource): URI { + export function toUri( + posixPathOrResource: string | Create.Resource | Create.Sketch + ): URI { const posixPath = typeof posixPathOrResource === 'string' ? posixPathOrResource diff --git a/arduino-ide-extension/src/browser/style/dialogs.css b/arduino-ide-extension/src/browser/style/dialogs.css index 4d56484e8..99b749158 100644 --- a/arduino-ide-extension/src/browser/style/dialogs.css +++ b/arduino-ide-extension/src/browser/style/dialogs.css @@ -29,6 +29,10 @@ min-height: 0; } +.p-Widget.dialogOverlay .dialogBlock .dialogControl .error { + word-break: normal; +} + .p-Widget.dialogOverlay .dialogBlock .dialogContent { padding: 0; overflow: auto; @@ -80,10 +84,8 @@ opacity: .4; } - @media only screen and (max-height: 560px) { .p-Widget.dialogOverlay .dialogBlock { max-height: 400px; } } - \ No newline at end of file diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts index 7204df632..f03f83071 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts @@ -136,7 +136,7 @@ export class CloudSketchbookTree extends SketchbookTree { return; } } - this.runWithState(node, 'pulling', async (node) => { + return this.runWithState(node, 'pulling', async (node) => { const commandsCopy = node.commands; node.commands = []; @@ -196,7 +196,7 @@ export class CloudSketchbookTree extends SketchbookTree { return; } } - this.runWithState(node, 'pushing', async (node) => { + return this.runWithState(node, 'pushing', async (node) => { if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) { throw new Error( nls.localize( diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx index 0b7b920a1..a4234ba12 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx @@ -8,11 +8,13 @@ import { IDragEvent } from '@theia/core/shared/@phosphor/dragdrop'; import { DockPanel, Widget } from '@theia/core/shared/@phosphor/widgets'; import { Message, MessageLoop } from '@theia/core/shared/@phosphor/messaging'; import { Disposable } from '@theia/core/lib/common/disposable'; -import { BaseWidget } from '@theia/core/lib/browser/widgets/widget'; +import { BaseWidget, Title } from '@theia/core/lib/browser/widgets/widget'; import { SketchbookTreeWidget } from './sketchbook-tree-widget'; -import { nls } from '@theia/core/lib/common'; +import { nls } from '@theia/core/lib/common/nls'; +import { Event, Emitter } from '@theia/core/lib/common/event'; import { CloudSketchbookCompositeWidget } from '../cloud-sketchbook/cloud-sketchbook-composite-widget'; import { URI } from '../../contributions/contribution'; +import { TheiaDockPanel } from '@theia/core/lib/browser/shell/theia-dock-panel'; @injectable() export class SketchbookWidget extends BaseWidget { @@ -21,7 +23,8 @@ export class SketchbookWidget extends BaseWidget { @inject(SketchbookTreeWidget) protected readonly localSketchbookTreeWidget: SketchbookTreeWidget; - protected readonly sketchbookTreesContainer: DockPanel; + protected readonly sketchbookTreesContainer: NoopDragOverDockPanel; + private readonly onCurrentTreeDidChangeEmitter: Emitter; constructor() { super(); @@ -32,6 +35,15 @@ export class SketchbookWidget extends BaseWidget { this.title.closable = true; this.node.tabIndex = 0; this.sketchbookTreesContainer = this.createTreesContainer(); + this.onCurrentTreeDidChangeEmitter = new Emitter(); + this.toDispose.pushAll([ + this.onCurrentTreeDidChangeEmitter, + this.sketchbookTreesContainer.onCurrentDidChange((title) => + this.onCurrentTreeDidChangeEmitter.fire( + title?.owner instanceof SketchbookTreeWidget ? title.owner : undefined + ) + ), + ]); } @postConstruct() @@ -47,6 +59,10 @@ export class SketchbookWidget extends BaseWidget { ); } + get onCurrentTreeDidChange(): Event { + return this.onCurrentTreeDidChangeEmitter.event; + } + getTreeWidget(): SketchbookTreeWidget { return this.localSketchbookTreeWidget; } @@ -129,7 +145,7 @@ export class SketchbookWidget extends BaseWidget { this.onResize(Widget.ResizeMessage.UnknownSize); } - protected createTreesContainer(): DockPanel { + protected createTreesContainer(): NoopDragOverDockPanel { const panel = new NoopDragOverDockPanel({ spacing: 0, mode: 'single-document', @@ -140,13 +156,28 @@ export class SketchbookWidget extends BaseWidget { } } -export class NoopDragOverDockPanel extends DockPanel { +class NoopDragOverDockPanel extends TheiaDockPanel { + private readonly onCurrentDidChangeEmitter = new Emitter< + Title | undefined + >(); + readonly onCurrentDidChange = this.onCurrentDidChangeEmitter.event; + constructor(options?: DockPanel.IOptions) { super(options); - NoopDragOverDockPanel.prototype['_evtDragOver'] = (event: IDragEvent) => { + super['_evtDragOver'] = (event: IDragEvent) => { event.preventDefault(); event.stopPropagation(); event.dropAction = 'none'; }; } + + override markAsCurrent(title: Title | undefined): void { + super.markAsCurrent(title); + this.onCurrentDidChangeEmitter.fire(title); + } + + override dispose(): void { + super.dispose(); + this.onCurrentDidChangeEmitter.dispose(); + } } diff --git a/i18n/en.json b/i18n/en.json index ea6db769a..4793b3a0c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -299,6 +299,13 @@ "unableToCloseWebSocket": "Unable to close websocket", "unableToConnectToWebSocket": "Unable to connect to websocket" }, + "newCloudSketch": { + "createSketch": "New Remote Sketch...", + "invalidSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.", + "newSketchTitle": "Name of a new remote sketch", + "openNewSketch": "Do you want to pull the new remote sketch {0} and open it in a new window?", + "sketchAlreadyExists": "Remote sketch '{0}' already exists." + }, "portProtocol": { "network": "Network", "serial": "Serial"