From b381ceefdbd051f8f5215b546f4765c8634ca0d4 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Tue, 25 Oct 2022 17:13:43 +0200 Subject: [PATCH] feat: Create remote sketch Closes #1580 Signed-off-by: Akos Kitta --- .../browser/arduino-ide-frontend-module.ts | 8 + .../src/browser/contributions/close.ts | 2 +- .../browser/contributions/new-cloud-sketch.ts | 247 ++++++++++++++++++ .../src/browser/contributions/new-sketch.ts | 2 +- .../src/browser/contributions/open-sketch.ts | 2 +- .../src/browser/contributions/save-sketch.ts | 2 +- .../src/browser/create/create-uri.ts | 4 +- .../local-cache/local-cache-fs-provider.ts | 3 +- .../src/browser/style/dialogs.css | 2 - .../src/browser/style/sketchbook.css | 16 ++ .../cloud-sketchbook-composite-widget.tsx | 100 +++---- .../cloud-sketchbook-tree-model.ts | 115 ++++++-- .../cloud-sketchbook-tree-widget.tsx | 8 +- .../cloud-sketchbook/cloud-sketchbook-tree.ts | 6 +- .../cloud-sketchbook-widget.ts | 7 +- .../browser/widgets/sketchbook/create-new.tsx | 20 ++ .../sketchbook-composite-widget.tsx | 93 +++++++ .../sketchbook/sketchbook-tree-widget.tsx | 1 + .../widgets/sketchbook/sketchbook-widget.tsx | 22 +- .../theia/core/electron-main-menu-factory.ts | 119 ++++++++- i18n/en.json | 15 +- 21 files changed, 683 insertions(+), 111 deletions(-) create mode 100644 arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts create mode 100644 arduino-ide-extension/src/browser/widgets/sketchbook/create-new.tsx create mode 100644 arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-composite-widget.tsx 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..dfeffb0cb 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,8 @@ 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'; +import { SketchbookCompositeWidget } from './widgets/sketchbook/sketchbook-composite-widget'; const registerArduinoThemes = () => { const themes: MonacoThemeJson[] = [ @@ -751,6 +753,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 @@ -905,6 +908,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { id: 'arduino-sketchbook-widget', createWidget: () => container.get(SketchbookWidget), })); + bind(SketchbookCompositeWidget).toSelf(); + bind(WidgetFactory).toDynamicValue((ctx) => ({ + id: 'sketchbook-composite-widget', + createWidget: () => ctx.container.get(SketchbookCompositeWidget), + })); bind(CloudSketchbookWidget).toSelf(); rebind(SketchbookWidget).toService(CloudSketchbookWidget); diff --git a/arduino-ide-extension/src/browser/contributions/close.ts b/arduino-ide-extension/src/browser/contributions/close.ts index 033d02edd..61885d49c 100644 --- a/arduino-ide-extension/src/browser/contributions/close.ts +++ b/arduino-ide-extension/src/browser/contributions/close.ts @@ -65,7 +65,7 @@ export class Close extends SketchContribution { registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, { commandId: Close.Commands.CLOSE.id, label: nls.localize('vscode/editor.contribution/close', 'Close'), - order: '5', + order: '6', }); } 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..d7fb671bd --- /dev/null +++ b/arduino-ide-extension/src/browser/contributions/new-cloud-sketch.ts @@ -0,0 +1,247 @@ +import { MenuModelRegistry } from '@theia/core/lib/common/menu'; +import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding'; +import { CompositeTreeNode } from '@theia/core/lib/browser/tree'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { nls } from '@theia/core/lib/common/nls'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { MainMenuManager } from '../../common/main-menu-manager'; +import type { AuthenticationSession } from '../../node/auth/types'; +import { AuthenticationClientService } from '../auth/authentication-client-service'; +import { CreateApi } from '../create/create-api'; +import { CreateUri } from '../create/create-uri'; +import { Create } from '../create/typings'; +import { ArduinoMenus } from '../menu/arduino-menus'; +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 widgetContribution: SketchbookWidgetContribution; + @inject(AuthenticationClientService) + private readonly authenticationService: AuthenticationClientService; + @inject(MainMenuManager) + private readonly mainMenuManager: MainMenuManager; + + 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.mainMenuManager.update(); + } + }), + this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => { + if (preferenceName === 'arduino.cloud.enabled') { + const oldEnabled = this._enabled; + this._enabled = Boolean(newValue); + if (this._enabled !== oldEnabled) { + this.mainMenuManager.update(); + } + } + }), + ]); + this._enabled = this.preferences['arduino.cloud.enabled']; + this._session = this.authenticationService.session; + if (this._session) { + this.mainMenuManager.update(); + } + } + + onStop(): void { + this.toDispose.dispose(); + } + + override registerCommands(registry: CommandRegistry): void { + registry.registerCommand(NewCloudSketch.Commands.NEW_CLOUD_SKETCH, { + execute: () => this.createNewSketch(), + isEnabled: () => !!this._session, + isVisible: () => this._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'), + order: '1', + }); + } + + override registerKeybindings(registry: KeybindingRegistry): void { + registry.registerKeybinding({ + command: NewCloudSketch.Commands.NEW_CLOUD_SKETCH.id, + keybinding: 'CtrlCmd+Alt+N', + }); + } + + private async createNewSketch( + initialValue?: string | undefined + ): Promise { + const widget = await this.widgetContribution.widget; + const treeModel = this.treeModelFrom(widget); + if (!treeModel) { + return undefined; + } + const rootNode = CompositeTreeNode.is(treeModel.root) + ? treeModel.root + : undefined; + if (!rootNode) { + return undefined; + } + + const newSketchName = await this.newSketchName(rootNode, 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 treeModel.refresh(); + } + } + + if (result === 'conflict') { + return this.createNewSketch(newSketchName); + } + + if (result) { + return this.open(treeModel, result); + } + return undefined; + } + + private async open( + treeModel: CloudSketchbookTreeModel, + newSketch: Create.Sketch + ): Promise { + const id = CreateUri.toUri(newSketch).path.toString(); + const node = treeModel.getNode(id); + if (!node) { + throw new Error( + `Could not find remote sketchbook tree node with Tree node ID: ${id}.` + ); + } + if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) { + throw new Error( + `Remote sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.` + ); + } + 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 { + const treeWidget = widget.getTreeWidget(); + if (treeWidget instanceof CloudSketchbookTreeWidget) { + const model = treeWidget.model; + if (model instanceof CloudSketchbookTreeModel) { + return model; + } + } + return undefined; + } + + private async newSketchName( + rootNode: CompositeTreeNode, + initialValue?: string | undefined + ): Promise { + 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(); + } +} +export namespace NewCloudSketch { + export namespace Commands { + export const NEW_CLOUD_SKETCH: Command = { + id: 'arduino-new-cloud-sketch', + }; + } +} + +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/contributions/new-sketch.ts b/arduino-ide-extension/src/browser/contributions/new-sketch.ts index bebc67767..c625fd802 100644 --- a/arduino-ide-extension/src/browser/contributions/new-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/new-sketch.ts @@ -21,7 +21,7 @@ export class NewSketch extends SketchContribution { override registerMenus(registry: MenuModelRegistry): void { registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, { commandId: NewSketch.Commands.NEW_SKETCH.id, - label: nls.localize('arduino/sketch/new', 'New'), + label: nls.localize('arduino/sketch/new', 'New Sketch'), order: '0', }); } diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch.ts b/arduino-ide-extension/src/browser/contributions/open-sketch.ts index e7e3f77de..5e41001c3 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch.ts @@ -54,7 +54,7 @@ export class OpenSketch extends SketchContribution { registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, { commandId: OpenSketch.Commands.OPEN_SKETCH.id, label: nls.localize('vscode/workspaceActions/openFileFolder', 'Open...'), - order: '1', + order: '2', }); } diff --git a/arduino-ide-extension/src/browser/contributions/save-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-sketch.ts index 62a6b0f86..5d88433ed 100644 --- a/arduino-ide-extension/src/browser/contributions/save-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-sketch.ts @@ -24,7 +24,7 @@ export class SaveSketch extends SketchContribution { registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, { commandId: SaveSketch.Commands.SAVE_SKETCH.id, label: nls.localize('vscode/fileCommands/save', 'Save'), - order: '6', + order: '7', }); } 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/local-cache/local-cache-fs-provider.ts b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts index 96264378a..5a9a77c71 100644 --- a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts +++ b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts @@ -34,7 +34,6 @@ export class LocalCacheFsProvider @inject(AuthenticationClientService) protected readonly authenticationService: AuthenticationClientService; - // TODO: do we need this? Cannot we `await` on the `init` call from `registerFileSystemProviders`? readonly ready = new Deferred(); private _localCacheRoot: URI; @@ -153,7 +152,7 @@ export class LocalCacheFsProvider return uri; } - private toUri(session: AuthenticationSession): URI { + toUri(session: AuthenticationSession): URI { // Hack: instead of getting the UUID only, we get `auth0|UUID` after the authentication. `|` cannot be part of filesystem path or filename. return this._localCacheRoot.resolve(session.id.split('|')[1]); } diff --git a/arduino-ide-extension/src/browser/style/dialogs.css b/arduino-ide-extension/src/browser/style/dialogs.css index 4d56484e8..668c389b2 100644 --- a/arduino-ide-extension/src/browser/style/dialogs.css +++ b/arduino-ide-extension/src/browser/style/dialogs.css @@ -80,10 +80,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/style/sketchbook.css b/arduino-ide-extension/src/browser/style/sketchbook.css index dcba8d0d2..87143e60c 100644 --- a/arduino-ide-extension/src/browser/style/sketchbook.css +++ b/arduino-ide-extension/src/browser/style/sketchbook.css @@ -33,6 +33,22 @@ height: 100%; } +.sketchbook-trees-container .create-new { + min-height: 58px; + height: 58px; + display: flex; + align-items: center; + justify-content: center; +} +/* +By default, theia-button has a left-margin. IDE2 does not need the left margin +for the _New Remote? Sketch_. Otherwise, the button does not fit the default +widget width. +*/ +.sketchbook-trees-container .create-new .theia-button { + margin-left: unset; +} + .sketchbook-tree__opts { background-color: var(--theia-foreground); -webkit-mask: url(./sketchbook-opts-icon.svg); diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx index 5e0016922..d00ed0545 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx @@ -1,78 +1,78 @@ import * as React from '@theia/core/shared/react'; import * as ReactDOM from '@theia/core/shared/react-dom'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import { 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 { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; import { UserStatus } from './cloud-user-status'; +import { nls } from '@theia/core/lib/common/nls'; import { CloudSketchbookTreeWidget } from './cloud-sketchbook-tree-widget'; import { AuthenticationClientService } from '../../auth/authentication-client-service'; import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model'; -import { nls } from '@theia/core/lib/common'; +import { BaseSketchbookCompositeWidget } from '../sketchbook/sketchbook-composite-widget'; +import { CreateNew } from '../sketchbook/create-new'; +import { AuthenticationSession } from '../../../node/auth/types'; @injectable() -export class CloudSketchbookCompositeWidget extends BaseWidget { +export class CloudSketchbookCompositeWidget extends BaseSketchbookCompositeWidget { @inject(AuthenticationClientService) - protected readonly authenticationService: AuthenticationClientService; - + private readonly authenticationService: AuthenticationClientService; @inject(CloudSketchbookTreeWidget) - protected readonly cloudSketchbookTreeWidget: CloudSketchbookTreeWidget; - - private compositeNode: HTMLElement; - private cloudUserStatusNode: HTMLElement; + private readonly cloudSketchbookTreeWidget: CloudSketchbookTreeWidget; + private _session: AuthenticationSession | undefined; constructor() { super(); - this.compositeNode = document.createElement('div'); - this.compositeNode.classList.add('composite-node'); - this.cloudUserStatusNode = document.createElement('div'); - this.cloudUserStatusNode.classList.add('cloud-status-node'); - this.compositeNode.appendChild(this.cloudUserStatusNode); - this.node.appendChild(this.compositeNode); + this.id = 'cloud-sketchbook-composite-widget'; this.title.caption = nls.localize( 'arduino/cloud/remoteSketchbook', 'Remote Sketchbook' ); this.title.iconClass = 'cloud-sketchbook-tree-icon'; - this.title.closable = false; - this.id = 'cloud-sketchbook-composite-widget'; - } - - public getTreeWidget(): CloudSketchbookTreeWidget { - return this.cloudSketchbookTreeWidget; } - protected override onAfterAttach(message: Message): void { - super.onAfterAttach(message); - Widget.attach(this.cloudSketchbookTreeWidget, this.compositeNode); - ReactDOM.render( - , - this.cloudUserStatusNode - ); - this.toDisposeOnDetach.push( - Disposable.create(() => Widget.detach(this.cloudSketchbookTreeWidget)) + @postConstruct() + protected init(): void { + this.toDispose.push( + this.authenticationService.onSessionDidChange((session) => { + const oldSession = this._session; + this._session = session; + if (!!oldSession !== !!this._session) { + this.updateFooter(); + } + }) ); } - protected override onActivateRequest(msg: Message): void { - super.onActivateRequest(msg); - - /* - Sending a resize message is needed because otherwise the cloudSketchbookTreeWidget - would render empty - */ - this.onResize(Widget.ResizeMessage.UnknownSize); + get treeWidget(): CloudSketchbookTreeWidget { + return this.cloudSketchbookTreeWidget; } - protected override onResize(message: Widget.ResizeMessage): void { - super.onResize(message); - MessageLoop.sendMessage( - this.cloudSketchbookTreeWidget, - Widget.ResizeMessage.UnknownSize + protected renderFooter(footerNode: HTMLElement): void { + ReactDOM.render( + <> + {this._session && ( + + )} + + , + footerNode ); } + + private onDidClickCreateNew: () => void = () => { + this.commandService.executeCommand('arduino-new-cloud-sketch'); + }; } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts index d76c7497f..666ac1ba8 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts @@ -1,20 +1,26 @@ -import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; -import { TreeNode } from '@theia/core/lib/browser/tree'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import { CompositeTreeNode, TreeNode } from '@theia/core/lib/browser/tree'; import { posixSegments, splitSketchPath } from '../../create/create-paths'; import { CreateApi } from '../../create/create-api'; import { CloudSketchbookTree } from './cloud-sketchbook-tree'; import { AuthenticationClientService } from '../../auth/authentication-client-service'; import { SketchbookTreeModel } from '../sketchbook/sketchbook-tree-model'; -import { ArduinoPreferences } from '../../arduino-preferences'; import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree'; import { CreateUri } from '../../create/create-uri'; -import { FileStat } from '@theia/filesystem/lib/common/files'; -import { LocalCacheFsProvider } from '../../local-cache/local-cache-fs-provider'; -import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { FileChangesEvent, FileStat } from '@theia/filesystem/lib/common/files'; +import { + LocalCacheFsProvider, + 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'; +import { nls } from '@theia/core/lib/common/nls'; +import { Deferred } from '@theia/core/lib/common/promise-util'; export function sketchBaseDir(sketch: Create.Sketch): FileStat { // extract the sketch path @@ -52,26 +58,16 @@ export function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] { @injectable() export class CloudSketchbookTreeModel extends SketchbookTreeModel { - @inject(FileService) - protected override readonly fileService: FileService; - - @inject(AuthenticationClientService) - protected readonly authenticationService: AuthenticationClientService; - @inject(CreateApi) - protected readonly createApi: CreateApi; - - @inject(CloudSketchbookTree) - protected readonly cloudSketchbookTree: CloudSketchbookTree; - - @inject(ArduinoPreferences) - protected override readonly arduinoPreferences: ArduinoPreferences; - + private readonly createApi: CreateApi; + @inject(AuthenticationClientService) + private readonly authenticationService: AuthenticationClientService; @inject(LocalCacheFsProvider) - protected readonly localCacheFsProvider: LocalCacheFsProvider; - + private readonly localCacheFsProvider: LocalCacheFsProvider; @inject(SketchCache) - protected readonly sketchCache: SketchCache; + private readonly sketchCache: SketchCache; + + private _localCacheFsProviderReady: Deferred | undefined; @postConstruct() protected override init(): void { @@ -81,6 +77,50 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { ); } + override *getNodesByUri(uri: URI): IterableIterator { + if (uri.scheme === LocalCacheUri.scheme) { + const workspace = this.root; + const { session } = this.authenticationService; + if (session && WorkspaceNode.is(workspace)) { + const currentUri = this.localCacheFsProvider.to(uri); + if (currentUri) { + const rootPath = this.localCacheFsProvider + .toUri(session) + .path.toString(); + const currentPath = currentUri.path.toString(); + if (rootPath === currentPath) { + return workspace; + } + if (currentPath.startsWith(rootPath)) { + const id = currentPath.substring(rootPath.length); + const node = this.getNode(id); + if (node) { + yield node; + } + } + } + } + } + } + + protected override isRootAffected(changes: FileChangesEvent): boolean { + return changes.changes + .map(({ resource }) => resource) + .some( + (uri) => uri.parent.toString().startsWith(LocalCacheUri.root.toString()) // all files under the root might affect the tree + ); + } + + override async refresh( + parent?: Readonly + ): Promise { + if (parent) { + return super.refresh(parent); + } + await this.updateRoot(); + return super.refresh(); + } + override async createRoot(): Promise { const { session } = this.authenticationService; if (!session) { @@ -89,7 +129,10 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { } this.createApi.init(this.authenticationService, this.arduinoPreferences); this.sketchCache.init(); - const sketches = await this.createApi.sketches(); + const [sketches] = await Promise.all([ + this.createApi.sketches(), + this.ensureLocalFsProviderReady(), + ]); const rootFileStats = sketchesToFileStats(sketches); if (this.workspaceService.opened) { const workspaceNode = WorkspaceNode.createRoot( @@ -108,7 +151,9 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { return this.tree as CloudSketchbookTree; } - protected override recursivelyFindSketchRoot(node: TreeNode): any { + protected override recursivelyFindSketchRoot( + node: TreeNode + ): TreeNode | false { if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) { return node; } @@ -122,13 +167,25 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { } override async revealFile(uri: URI): Promise { + await this.localCacheFsProvider.ready.promise; // we use remote uris as keys for the tree // convert local URIs - const remoteuri = this.localCacheFsProvider.from(uri); - if (remoteuri) { - return super.revealFile(remoteuri); + const remoteUri = this.localCacheFsProvider.from(uri); + if (remoteUri) { + return super.revealFile(remoteUri); } else { return super.revealFile(uri); } } + + private async ensureLocalFsProviderReady(): Promise { + if (this._localCacheFsProviderReady) { + return this._localCacheFsProviderReady.promise; + } + this._localCacheFsProviderReady = new Deferred(); + this.fileService + .access(LocalCacheUri.root) + .then(() => this._localCacheFsProviderReady?.resolve()); + return this._localCacheFsProviderReady.promise; + } } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx index 7bb0abc73..b160842e4 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx @@ -1,5 +1,5 @@ import * as React from '@theia/core/shared/react'; -import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { TreeModel } from '@theia/core/lib/browser/tree/tree-model'; import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model'; import { AuthenticationClientService } from '../../auth/authentication-client-service'; @@ -27,12 +27,6 @@ export class CloudSketchbookTreeWidget extends SketchbookTreeWidget { @inject(CloudSketchbookTree) protected readonly cloudSketchbookTree: CloudSketchbookTree; - @postConstruct() - protected override async init(): Promise { - await super.init(); - this.addClass('tree-container'); // Adds `height: 100%` to the tree. Otherwise you cannot see it. - } - protected override renderTree(model: TreeModel): React.ReactNode { if (this.shouldShowWelcomeView()) return this.renderViewWelcome(); if (this.shouldShowEmptyView()) return this.renderEmptyView(); 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..7f4b44115 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( @@ -269,7 +269,7 @@ export class CloudSketchbookTree extends SketchbookTree { return prev; } - // do not map "do_not_sync" files/directoris and their descendants + // do not map "do_not_sync" files/directories and their descendants const segments = path[1].split(posix.sep) || []; if ( segments.some((segment) => Create.do_not_sync_files.includes(segment)) diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts index 22239a227..6e112eefa 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts @@ -2,6 +2,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify' import { CloudSketchbookCompositeWidget } from './cloud-sketchbook-composite-widget'; import { SketchbookWidget } from '../sketchbook/sketchbook-widget'; import { ArduinoPreferences } from '../../arduino-preferences'; +import { BaseSketchbookCompositeWidget } from '../sketchbook/sketchbook-composite-widget'; @injectable() export class CloudSketchbookWidget extends SketchbookWidget { @@ -19,8 +20,8 @@ export class CloudSketchbookWidget extends SketchbookWidget { override getTreeWidget(): any { const widget: any = this.sketchbookTreesContainer.selectedWidgets().next(); - if (widget && typeof widget.getTreeWidget !== 'undefined') { - return (widget as CloudSketchbookCompositeWidget).getTreeWidget(); + if (widget instanceof BaseSketchbookCompositeWidget) { + return widget.treeWidget; } return widget; } @@ -30,7 +31,7 @@ export class CloudSketchbookWidget extends SketchbookWidget { this.sketchbookTreesContainer.activateWidget(this.widget); } else { this.sketchbookTreesContainer.activateWidget( - this.localSketchbookTreeWidget + this.sketchbookCompositeWidget ); } this.setDocumentMode(); diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/create-new.tsx b/arduino-ide-extension/src/browser/widgets/sketchbook/create-new.tsx new file mode 100644 index 000000000..d70d5b9a4 --- /dev/null +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/create-new.tsx @@ -0,0 +1,20 @@ +import * as React from '@theia/core/shared/react'; + +export class CreateNew extends React.Component { + override render(): React.ReactNode { + return ( +
+ +
+ ); + } +} + +export namespace CreateNew { + export interface Props { + readonly label: string; + readonly onClick: () => void; + } +} diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-composite-widget.tsx b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-composite-widget.tsx new file mode 100644 index 000000000..cdec965e6 --- /dev/null +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-composite-widget.tsx @@ -0,0 +1,93 @@ +import * as React from '@theia/core/shared/react'; +import * as ReactDOM from '@theia/core/shared/react-dom'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { nls } from '@theia/core/lib/common/nls'; +import { 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 { CommandService } from '@theia/core/lib/common/command'; +import { SketchbookTreeWidget } from './sketchbook-tree-widget'; +import { CreateNew } from '../sketchbook/create-new'; + +@injectable() +export abstract class BaseSketchbookCompositeWidget< + TW extends SketchbookTreeWidget +> extends BaseWidget { + @inject(CommandService) + protected readonly commandService: CommandService; + + private readonly compositeNode: HTMLElement; + private readonly footerNode: HTMLElement; + + constructor() { + super(); + this.compositeNode = document.createElement('div'); + this.compositeNode.classList.add('composite-node'); + this.footerNode = document.createElement('div'); + this.footerNode.classList.add('footer-node'); + this.compositeNode.appendChild(this.footerNode); + this.node.appendChild(this.compositeNode); + this.title.closable = false; + } + + abstract get treeWidget(): TW; + protected abstract renderFooter(footerNode: HTMLElement): void; + protected updateFooter(): void { + this.renderFooter(this.footerNode); + } + + protected override onAfterAttach(message: Message): void { + super.onAfterAttach(message); + Widget.attach(this.treeWidget, this.compositeNode); + this.renderFooter(this.footerNode); + this.toDisposeOnDetach.push( + Disposable.create(() => Widget.detach(this.treeWidget)) + ); + } + + protected override onActivateRequest(message: Message): void { + super.onActivateRequest(message); + // Sending a resize message is needed because otherwise the tree widget would render empty + this.onResize(Widget.ResizeMessage.UnknownSize); + } + + protected override onResize(message: Widget.ResizeMessage): void { + super.onResize(message); + MessageLoop.sendMessage(this.treeWidget, Widget.ResizeMessage.UnknownSize); + } +} + +@injectable() +export class SketchbookCompositeWidget extends BaseSketchbookCompositeWidget { + @inject(SketchbookTreeWidget) + private readonly sketchbookTreeWidget: SketchbookTreeWidget; + + constructor() { + super(); + this.id = 'sketchbook-composite-widget'; + this.title.caption = nls.localize( + 'arduino/sketch/titleLocalSketchbook', + 'Local Sketchbook' + ); + this.title.iconClass = 'sketchbook-tree-icon'; + } + + get treeWidget(): SketchbookTreeWidget { + return this.sketchbookTreeWidget; + } + + protected renderFooter(footerNode: HTMLElement): void { + ReactDOM.render( + , + footerNode + ); + } + + private onDidClickCreateNew: () => void = () => { + this.commandService.executeCommand('arduino-new-sketch'); + }; +} diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx index bb1a69914..599cac893 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx @@ -59,6 +59,7 @@ export class SketchbookTreeWidget extends FileTreeWidget { 'Local Sketchbook' ); this.title.closable = false; + this.addClass('tree-container'); // Adds `height: 100%` to the tree. Otherwise you cannot see it. } @postConstruct() 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..eb0059590 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx @@ -11,15 +11,21 @@ import { Disposable } from '@theia/core/lib/common/disposable'; import { BaseWidget } from '@theia/core/lib/browser/widgets/widget'; import { SketchbookTreeWidget } from './sketchbook-tree-widget'; import { nls } from '@theia/core/lib/common'; -import { CloudSketchbookCompositeWidget } from '../cloud-sketchbook/cloud-sketchbook-composite-widget'; import { URI } from '../../contributions/contribution'; +import { + BaseSketchbookCompositeWidget, + SketchbookCompositeWidget, +} from './sketchbook-composite-widget'; @injectable() export class SketchbookWidget extends BaseWidget { - static LABEL = nls.localize('arduino/sketch/titleSketchbook', 'Sketchbook'); + static readonly LABEL = nls.localize( + 'arduino/sketch/titleSketchbook', + 'Sketchbook' + ); - @inject(SketchbookTreeWidget) - protected readonly localSketchbookTreeWidget: SketchbookTreeWidget; + @inject(SketchbookCompositeWidget) + protected readonly sketchbookCompositeWidget: SketchbookCompositeWidget; protected readonly sketchbookTreesContainer: DockPanel; @@ -36,7 +42,7 @@ export class SketchbookWidget extends BaseWidget { @postConstruct() protected init(): void { - this.sketchbookTreesContainer.addWidget(this.localSketchbookTreeWidget); + this.sketchbookTreesContainer.addWidget(this.sketchbookCompositeWidget); } protected override onAfterAttach(message: Message): void { @@ -48,7 +54,7 @@ export class SketchbookWidget extends BaseWidget { } getTreeWidget(): SketchbookTreeWidget { - return this.localSketchbookTreeWidget; + return this.sketchbookCompositeWidget.treeWidget; } activeTreeWidgetId(): string | undefined { @@ -80,8 +86,8 @@ export class SketchbookWidget extends BaseWidget { if (widget instanceof SketchbookTreeWidget) { return widget; } - if (widget instanceof CloudSketchbookCompositeWidget) { - return widget.getTreeWidget(); + if (widget instanceof BaseSketchbookCompositeWidget) { + return widget.treeWidget; } return undefined; }; diff --git a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts index 98f8b76e0..842518062 100644 --- a/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts +++ b/arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts @@ -2,8 +2,10 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import * as remote from '@theia/core/electron-shared/@electron/remote'; import { isOSX } from '@theia/core/lib/common/os'; import { + ActionMenuNode, CompositeMenuNode, MAIN_MENU_BAR, + MenuNode, MenuPath, } from '@theia/core/lib/common/menu'; import { @@ -134,7 +136,7 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { } protected override handleElectronDefault( - menuNode: CompositeMenuNode, + menuNode: MenuNode, args: any[] = [], options?: ElectronMenuOptions ): Electron.MenuItemConstructorOptions[] { @@ -149,4 +151,119 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory { } return []; } + + // Copied from 1.25.0 Theia as is to customize the enablement of the menu items. + // Source: https://github.com/eclipse-theia/theia/blob/ca417a31e402bd35717d3314bf6254049d1dae44/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts#L125-L220 + // See https://github.com/arduino/arduino-ide/issues/1533 + protected override fillMenuTemplate( + items: Electron.MenuItemConstructorOptions[], + menuModel: CompositeMenuNode, + args: any[] = [], + options?: ElectronMenuOptions + ): Electron.MenuItemConstructorOptions[] { + const showDisabled = + options?.showDisabled === undefined ? true : options?.showDisabled; + for (const menu of menuModel.children) { + if (menu instanceof CompositeMenuNode) { + if (menu.children.length > 0) { + // do not render empty nodes + + if (menu.isSubmenu) { + // submenu node + + const submenu = this.fillMenuTemplate([], menu, args, options); + if (submenu.length === 0) { + continue; + } + + items.push({ + label: menu.label, + submenu, + }); + } else { + // group node + + // process children + const submenu = this.fillMenuTemplate([], menu, args, options); + if (submenu.length === 0) { + continue; + } + + if (items.length > 0) { + // do not put a separator above the first group + + items.push({ + type: 'separator', + }); + } + + // render children + items.push(...submenu); + } + } + } else if (menu instanceof ActionMenuNode) { + const node = + menu.altNode && this.context.altPressed ? menu.altNode : menu; + const commandId = node.action.commandId; + + // That is only a sanity check at application startup. + if (!this.commandRegistry.getCommand(commandId)) { + console.debug( + `Skipping menu item with missing command: "${commandId}".` + ); + continue; + } + + if ( + !this.commandRegistry.isVisible(commandId, ...args) || + (!!node.action.when && + !this.contextKeyService.match(node.action.when)) + ) { + continue; + } + + // We should omit rendering context-menu items which are disabled. + if ( + !showDisabled && + !this.commandRegistry.isEnabled(commandId, ...args) + ) { + continue; + } + + const bindings = + this.keybindingRegistry.getKeybindingsForCommand(commandId); + + const accelerator = bindings[0] && this.acceleratorFor(bindings[0]); + + const menuItem: Electron.MenuItemConstructorOptions = { + id: node.id, + label: node.label, + type: this.commandRegistry.getToggledHandler(commandId, ...args) + ? 'checkbox' + : 'normal', + checked: this.commandRegistry.isToggled(commandId, ...args), + enabled: this.commandRegistry.isEnabled(commandId, ...args), // Unlike Theia https://github.com/eclipse-theia/theia/blob/ca417a31e402bd35717d3314bf6254049d1dae44/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts#L197 + visible: true, + accelerator, + click: () => this.execute(commandId, args), + }; + + if (isOSX) { + const role = this.roleFor(node.id); + if (role) { + menuItem.role = role; + delete menuItem.click; + } + } + items.push(menuItem); + + if (this.commandRegistry.getToggledHandler(commandId, ...args)) { + this._toggledCommands.add(commandId); + } + } else { + items.push(...this.handleElectronDefault(menu, args, options)); + } + } + return items; + } } diff --git a/i18n/en.json b/i18n/en.json index 3f64fd926..418a5ae80 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -119,6 +119,9 @@ "syncEditSketches": "Sync and edit your Arduino Cloud Sketches", "visitArduinoCloud": "Visit Arduino Cloud to create Cloud Sketches." }, + "cloudSketch": { + "new": "New Remote Sketch" + }, "common": { "all": "All", "contributed": "Contributed", @@ -299,6 +302,12 @@ "unableToCloseWebSocket": "Unable to close websocket", "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." + }, "portProtocol": { "network": "Network", "serial": "Serial" @@ -388,7 +397,7 @@ "exportBinary": "Export Compiled Binary", "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", + "new": "New Sketch", "openFolder": "Open Folder", "openRecent": "Open Recent", "openSketchInNewWindow": "Open Sketch in New Window", @@ -407,6 +416,10 @@ "verify": "Verify", "verifyOrCompile": "Verify/Compile" }, + "sketchbook": { + "newRemoteSketch": "New Remote Sketch", + "newSketch": "New Sketch" + }, "survey": { "answerSurvey": "Answer survey", "dismissSurvey": "Don't show again",