Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 7d6a2d5

Browse files
Akos Kittakittaakos
Akos Kitta
authored andcommittedNov 10, 2022
feat: Create remote sketch
Closes #1580 Signed-off-by: Akos Kitta <[email protected]>
1 parent 6984c52 commit 7d6a2d5

21 files changed

+683
-111
lines changed
 

‎arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,8 @@ import { UserFields } from './contributions/user-fields';
335335
import { UpdateIndexes } from './contributions/update-indexes';
336336
import { InterfaceScale } from './contributions/interface-scale';
337337
import { OpenHandler } from '@theia/core/lib/browser/opener-service';
338+
import { NewCloudSketch } from './contributions/new-cloud-sketch';
339+
import { SketchbookCompositeWidget } from './widgets/sketchbook/sketchbook-composite-widget';
338340

339341
const registerArduinoThemes = () => {
340342
const themes: MonacoThemeJson[] = [
@@ -751,6 +753,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
751753
Contribution.configure(bind, DeleteSketch);
752754
Contribution.configure(bind, UpdateIndexes);
753755
Contribution.configure(bind, InterfaceScale);
756+
Contribution.configure(bind, NewCloudSketch);
754757

755758
bindContributionProvider(bind, StartupTaskProvider);
756759
bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window
@@ -905,6 +908,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
905908
id: 'arduino-sketchbook-widget',
906909
createWidget: () => container.get(SketchbookWidget),
907910
}));
911+
bind(SketchbookCompositeWidget).toSelf();
912+
bind<WidgetFactory>(WidgetFactory).toDynamicValue((ctx) => ({
913+
id: 'sketchbook-composite-widget',
914+
createWidget: () => ctx.container.get(SketchbookCompositeWidget),
915+
}));
908916

909917
bind(CloudSketchbookWidget).toSelf();
910918
rebind(SketchbookWidget).toService(CloudSketchbookWidget);

‎arduino-ide-extension/src/browser/contributions/close.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export class Close extends SketchContribution {
6565
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
6666
commandId: Close.Commands.CLOSE.id,
6767
label: nls.localize('vscode/editor.contribution/close', 'Close'),
68-
order: '5',
68+
order: '6',
6969
});
7070
}
7171

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
2+
import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding';
3+
import { CompositeTreeNode } from '@theia/core/lib/browser/tree';
4+
import { DisposableCollection } from '@theia/core/lib/common/disposable';
5+
import { nls } from '@theia/core/lib/common/nls';
6+
import { inject, injectable } from '@theia/core/shared/inversify';
7+
import { MainMenuManager } from '../../common/main-menu-manager';
8+
import type { AuthenticationSession } from '../../node/auth/types';
9+
import { AuthenticationClientService } from '../auth/authentication-client-service';
10+
import { CreateApi } from '../create/create-api';
11+
import { CreateUri } from '../create/create-uri';
12+
import { Create } from '../create/typings';
13+
import { ArduinoMenus } from '../menu/arduino-menus';
14+
import { WorkspaceInputDialog } from '../theia/workspace/workspace-input-dialog';
15+
import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree';
16+
import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model';
17+
import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-widget';
18+
import { SketchbookCommands } from '../widgets/sketchbook/sketchbook-commands';
19+
import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget';
20+
import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution';
21+
import { Command, CommandRegistry, Contribution, URI } from './contribution';
22+
23+
@injectable()
24+
export class NewCloudSketch extends Contribution {
25+
@inject(CreateApi)
26+
private readonly createApi: CreateApi;
27+
@inject(SketchbookWidgetContribution)
28+
private readonly widgetContribution: SketchbookWidgetContribution;
29+
@inject(AuthenticationClientService)
30+
private readonly authenticationService: AuthenticationClientService;
31+
@inject(MainMenuManager)
32+
private readonly mainMenuManager: MainMenuManager;
33+
34+
private readonly toDispose = new DisposableCollection();
35+
private _session: AuthenticationSession | undefined;
36+
private _enabled: boolean;
37+
38+
override onReady(): void {
39+
this.toDispose.pushAll([
40+
this.authenticationService.onSessionDidChange((session) => {
41+
const oldSession = this._session;
42+
this._session = session;
43+
if (!!oldSession !== !!this._session) {
44+
this.mainMenuManager.update();
45+
}
46+
}),
47+
this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => {
48+
if (preferenceName === 'arduino.cloud.enabled') {
49+
const oldEnabled = this._enabled;
50+
this._enabled = Boolean(newValue);
51+
if (this._enabled !== oldEnabled) {
52+
this.mainMenuManager.update();
53+
}
54+
}
55+
}),
56+
]);
57+
this._enabled = this.preferences['arduino.cloud.enabled'];
58+
this._session = this.authenticationService.session;
59+
if (this._session) {
60+
this.mainMenuManager.update();
61+
}
62+
}
63+
64+
onStop(): void {
65+
this.toDispose.dispose();
66+
}
67+
68+
override registerCommands(registry: CommandRegistry): void {
69+
registry.registerCommand(NewCloudSketch.Commands.NEW_CLOUD_SKETCH, {
70+
execute: () => this.createNewSketch(),
71+
isEnabled: () => !!this._session,
72+
isVisible: () => this._enabled,
73+
});
74+
}
75+
76+
override registerMenus(registry: MenuModelRegistry): void {
77+
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
78+
commandId: NewCloudSketch.Commands.NEW_CLOUD_SKETCH.id,
79+
label: nls.localize('arduino/cloudSketch/new', 'New Remote Sketch'),
80+
order: '1',
81+
});
82+
}
83+
84+
override registerKeybindings(registry: KeybindingRegistry): void {
85+
registry.registerKeybinding({
86+
command: NewCloudSketch.Commands.NEW_CLOUD_SKETCH.id,
87+
keybinding: 'CtrlCmd+Alt+N',
88+
});
89+
}
90+
91+
private async createNewSketch(
92+
initialValue?: string | undefined
93+
): Promise<URI | undefined> {
94+
const widget = await this.widgetContribution.widget;
95+
const treeModel = this.treeModelFrom(widget);
96+
if (!treeModel) {
97+
return undefined;
98+
}
99+
const rootNode = CompositeTreeNode.is(treeModel.root)
100+
? treeModel.root
101+
: undefined;
102+
if (!rootNode) {
103+
return undefined;
104+
}
105+
106+
const newSketchName = await this.newSketchName(rootNode, initialValue);
107+
if (!newSketchName) {
108+
return undefined;
109+
}
110+
let result: Create.Sketch | undefined | 'conflict';
111+
try {
112+
result = await this.createApi.createSketch(newSketchName);
113+
} catch (err) {
114+
if (isConflict(err)) {
115+
result = 'conflict';
116+
} else {
117+
throw err;
118+
}
119+
} finally {
120+
if (result) {
121+
await treeModel.refresh();
122+
}
123+
}
124+
125+
if (result === 'conflict') {
126+
return this.createNewSketch(newSketchName);
127+
}
128+
129+
if (result) {
130+
return this.open(treeModel, result);
131+
}
132+
return undefined;
133+
}
134+
135+
private async open(
136+
treeModel: CloudSketchbookTreeModel,
137+
newSketch: Create.Sketch
138+
): Promise<URI | undefined> {
139+
const id = CreateUri.toUri(newSketch).path.toString();
140+
const node = treeModel.getNode(id);
141+
if (!node) {
142+
throw new Error(
143+
`Could not find remote sketchbook tree node with Tree node ID: ${id}.`
144+
);
145+
}
146+
if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) {
147+
throw new Error(
148+
`Remote sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.`
149+
);
150+
}
151+
try {
152+
await treeModel.sketchbookTree().pull({ node });
153+
} catch (err) {
154+
if (isNotFound(err)) {
155+
await treeModel.refresh();
156+
this.messageService.error(
157+
nls.localize(
158+
'arduino/newCloudSketch/notFound',
159+
"Could not pull the remote sketch '{0}'. It does not exist.",
160+
newSketch.name
161+
)
162+
);
163+
return undefined;
164+
}
165+
throw err;
166+
}
167+
return this.commandService.executeCommand(
168+
SketchbookCommands.OPEN_NEW_WINDOW.id,
169+
{ node }
170+
);
171+
}
172+
173+
private treeModelFrom(
174+
widget: SketchbookWidget
175+
): CloudSketchbookTreeModel | undefined {
176+
const treeWidget = widget.getTreeWidget();
177+
if (treeWidget instanceof CloudSketchbookTreeWidget) {
178+
const model = treeWidget.model;
179+
if (model instanceof CloudSketchbookTreeModel) {
180+
return model;
181+
}
182+
}
183+
return undefined;
184+
}
185+
186+
private async newSketchName(
187+
rootNode: CompositeTreeNode,
188+
initialValue?: string | undefined
189+
): Promise<string | undefined> {
190+
const existingNames = rootNode.children
191+
.filter(CloudSketchbookTree.CloudSketchDirNode.is)
192+
.map(({ fileStat }) => fileStat.name);
193+
return new WorkspaceInputDialog(
194+
{
195+
title: nls.localize(
196+
'arduino/newCloudSketch/newSketchTitle',
197+
'Name of a new Remote Sketch'
198+
),
199+
parentUri: CreateUri.root,
200+
initialValue,
201+
validate: (input) => {
202+
if (existingNames.includes(input)) {
203+
return nls.localize(
204+
'arduino/newCloudSketch/sketchAlreadyExists',
205+
"Remote sketch '{0}' already exists.",
206+
input
207+
);
208+
}
209+
// This is how https://create.arduino.cc/editor/ works when renaming a sketch.
210+
if (/^[0-9a-zA-Z_]{1,36}$/.test(input)) {
211+
return '';
212+
}
213+
return nls.localize(
214+
'arduino/newCloudSketch/invalidSketchName',
215+
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
216+
);
217+
},
218+
},
219+
this.labelProvider
220+
).open();
221+
}
222+
}
223+
export namespace NewCloudSketch {
224+
export namespace Commands {
225+
export const NEW_CLOUD_SKETCH: Command = {
226+
id: 'arduino-new-cloud-sketch',
227+
};
228+
}
229+
}
230+
231+
function isConflict(err: unknown): boolean {
232+
return isErrorWithStatusOf(err, 409);
233+
}
234+
function isNotFound(err: unknown): boolean {
235+
return isErrorWithStatusOf(err, 404);
236+
}
237+
function isErrorWithStatusOf(
238+
err: unknown,
239+
status: number
240+
): err is Error & { status: number } {
241+
if (err instanceof Error) {
242+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
243+
const object = err as any;
244+
return 'status' in object && object.status === status;
245+
}
246+
return false;
247+
}

‎arduino-ide-extension/src/browser/contributions/new-sketch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export class NewSketch extends SketchContribution {
2121
override registerMenus(registry: MenuModelRegistry): void {
2222
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
2323
commandId: NewSketch.Commands.NEW_SKETCH.id,
24-
label: nls.localize('arduino/sketch/new', 'New'),
24+
label: nls.localize('arduino/sketch/new', 'New Sketch'),
2525
order: '0',
2626
});
2727
}

‎arduino-ide-extension/src/browser/contributions/open-sketch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class OpenSketch extends SketchContribution {
5454
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
5555
commandId: OpenSketch.Commands.OPEN_SKETCH.id,
5656
label: nls.localize('vscode/workspaceActions/openFileFolder', 'Open...'),
57-
order: '1',
57+
order: '2',
5858
});
5959
}
6060

‎arduino-ide-extension/src/browser/contributions/save-sketch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class SaveSketch extends SketchContribution {
2424
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
2525
commandId: SaveSketch.Commands.SAVE_SKETCH.id,
2626
label: nls.localize('vscode/fileCommands/save', 'Save'),
27-
order: '6',
27+
order: '7',
2828
});
2929
}
3030

‎arduino-ide-extension/src/browser/create/create-uri.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ export namespace CreateUri {
77
export const scheme = 'arduino-create';
88
export const root = toUri(posix.sep);
99

10-
export function toUri(posixPathOrResource: string | Create.Resource): URI {
10+
export function toUri(
11+
posixPathOrResource: string | Create.Resource | Create.Sketch
12+
): URI {
1113
const posixPath =
1214
typeof posixPathOrResource === 'string'
1315
? posixPathOrResource

‎arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ export class LocalCacheFsProvider
3434
@inject(AuthenticationClientService)
3535
protected readonly authenticationService: AuthenticationClientService;
3636

37-
// TODO: do we need this? Cannot we `await` on the `init` call from `registerFileSystemProviders`?
3837
readonly ready = new Deferred<void>();
3938

4039
private _localCacheRoot: URI;
@@ -153,7 +152,7 @@ export class LocalCacheFsProvider
153152
return uri;
154153
}
155154

156-
private toUri(session: AuthenticationSession): URI {
155+
toUri(session: AuthenticationSession): URI {
157156
// Hack: instead of getting the UUID only, we get `auth0|UUID` after the authentication. `|` cannot be part of filesystem path or filename.
158157
return this._localCacheRoot.resolve(session.id.split('|')[1]);
159158
}

‎arduino-ide-extension/src/browser/style/dialogs.css

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,8 @@
8080
opacity: .4;
8181
}
8282

83-
8483
@media only screen and (max-height: 560px) {
8584
.p-Widget.dialogOverlay .dialogBlock {
8685
max-height: 400px;
8786
}
8887
}
89-

‎arduino-ide-extension/src/browser/style/sketchbook.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@
3333
height: 100%;
3434
}
3535

36+
.sketchbook-trees-container .create-new {
37+
min-height: 58px;
38+
height: 58px;
39+
display: flex;
40+
align-items: center;
41+
justify-content: center;
42+
}
43+
/*
44+
By default, theia-button has a left-margin. IDE2 does not need the left margin
45+
for the _New Remote? Sketch_. Otherwise, the button does not fit the default
46+
widget width.
47+
*/
48+
.sketchbook-trees-container .create-new .theia-button {
49+
margin-left: unset;
50+
}
51+
3652
.sketchbook-tree__opts {
3753
background-color: var(--theia-foreground);
3854
-webkit-mask: url(./sketchbook-opts-icon.svg);
Lines changed: 50 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,78 @@
11
import * as React from '@theia/core/shared/react';
22
import * as ReactDOM from '@theia/core/shared/react-dom';
3-
import { inject, injectable } from '@theia/core/shared/inversify';
4-
import { Widget } from '@theia/core/shared/@phosphor/widgets';
5-
import { Message, MessageLoop } from '@theia/core/shared/@phosphor/messaging';
6-
import { Disposable } from '@theia/core/lib/common/disposable';
7-
import { BaseWidget } from '@theia/core/lib/browser/widgets/widget';
3+
import {
4+
inject,
5+
injectable,
6+
postConstruct,
7+
} from '@theia/core/shared/inversify';
88
import { UserStatus } from './cloud-user-status';
9+
import { nls } from '@theia/core/lib/common/nls';
910
import { CloudSketchbookTreeWidget } from './cloud-sketchbook-tree-widget';
1011
import { AuthenticationClientService } from '../../auth/authentication-client-service';
1112
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
12-
import { nls } from '@theia/core/lib/common';
13+
import { BaseSketchbookCompositeWidget } from '../sketchbook/sketchbook-composite-widget';
14+
import { CreateNew } from '../sketchbook/create-new';
15+
import { AuthenticationSession } from '../../../node/auth/types';
1316

1417
@injectable()
15-
export class CloudSketchbookCompositeWidget extends BaseWidget {
18+
export class CloudSketchbookCompositeWidget extends BaseSketchbookCompositeWidget<CloudSketchbookTreeWidget> {
1619
@inject(AuthenticationClientService)
17-
protected readonly authenticationService: AuthenticationClientService;
18-
20+
private readonly authenticationService: AuthenticationClientService;
1921
@inject(CloudSketchbookTreeWidget)
20-
protected readonly cloudSketchbookTreeWidget: CloudSketchbookTreeWidget;
21-
22-
private compositeNode: HTMLElement;
23-
private cloudUserStatusNode: HTMLElement;
22+
private readonly cloudSketchbookTreeWidget: CloudSketchbookTreeWidget;
23+
private _session: AuthenticationSession | undefined;
2424

2525
constructor() {
2626
super();
27-
this.compositeNode = document.createElement('div');
28-
this.compositeNode.classList.add('composite-node');
29-
this.cloudUserStatusNode = document.createElement('div');
30-
this.cloudUserStatusNode.classList.add('cloud-status-node');
31-
this.compositeNode.appendChild(this.cloudUserStatusNode);
32-
this.node.appendChild(this.compositeNode);
27+
this.id = 'cloud-sketchbook-composite-widget';
3328
this.title.caption = nls.localize(
3429
'arduino/cloud/remoteSketchbook',
3530
'Remote Sketchbook'
3631
);
3732
this.title.iconClass = 'cloud-sketchbook-tree-icon';
38-
this.title.closable = false;
39-
this.id = 'cloud-sketchbook-composite-widget';
40-
}
41-
42-
public getTreeWidget(): CloudSketchbookTreeWidget {
43-
return this.cloudSketchbookTreeWidget;
4433
}
4534

46-
protected override onAfterAttach(message: Message): void {
47-
super.onAfterAttach(message);
48-
Widget.attach(this.cloudSketchbookTreeWidget, this.compositeNode);
49-
ReactDOM.render(
50-
<UserStatus
51-
model={this.cloudSketchbookTreeWidget.model as CloudSketchbookTreeModel}
52-
authenticationService={this.authenticationService}
53-
/>,
54-
this.cloudUserStatusNode
55-
);
56-
this.toDisposeOnDetach.push(
57-
Disposable.create(() => Widget.detach(this.cloudSketchbookTreeWidget))
35+
@postConstruct()
36+
protected init(): void {
37+
this.toDispose.push(
38+
this.authenticationService.onSessionDidChange((session) => {
39+
const oldSession = this._session;
40+
this._session = session;
41+
if (!!oldSession !== !!this._session) {
42+
this.updateFooter();
43+
}
44+
})
5845
);
5946
}
6047

61-
protected override onActivateRequest(msg: Message): void {
62-
super.onActivateRequest(msg);
63-
64-
/*
65-
Sending a resize message is needed because otherwise the cloudSketchbookTreeWidget
66-
would render empty
67-
*/
68-
this.onResize(Widget.ResizeMessage.UnknownSize);
48+
get treeWidget(): CloudSketchbookTreeWidget {
49+
return this.cloudSketchbookTreeWidget;
6950
}
7051

71-
protected override onResize(message: Widget.ResizeMessage): void {
72-
super.onResize(message);
73-
MessageLoop.sendMessage(
74-
this.cloudSketchbookTreeWidget,
75-
Widget.ResizeMessage.UnknownSize
52+
protected renderFooter(footerNode: HTMLElement): void {
53+
ReactDOM.render(
54+
<>
55+
{this._session && (
56+
<CreateNew
57+
label={nls.localize(
58+
'arduino/sketchbook/newRemoteSketch',
59+
'New Remote Sketch'
60+
)}
61+
onClick={this.onDidClickCreateNew}
62+
/>
63+
)}
64+
<UserStatus
65+
model={
66+
this.cloudSketchbookTreeWidget.model as CloudSketchbookTreeModel
67+
}
68+
authenticationService={this.authenticationService}
69+
/>
70+
</>,
71+
footerNode
7672
);
7773
}
74+
75+
private onDidClickCreateNew: () => void = () => {
76+
this.commandService.executeCommand('arduino-new-cloud-sketch');
77+
};
7878
}

‎arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1-
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
2-
import { TreeNode } from '@theia/core/lib/browser/tree';
1+
import {
2+
inject,
3+
injectable,
4+
postConstruct,
5+
} from '@theia/core/shared/inversify';
6+
import { CompositeTreeNode, TreeNode } from '@theia/core/lib/browser/tree';
37
import { posixSegments, splitSketchPath } from '../../create/create-paths';
48
import { CreateApi } from '../../create/create-api';
59
import { CloudSketchbookTree } from './cloud-sketchbook-tree';
610
import { AuthenticationClientService } from '../../auth/authentication-client-service';
711
import { SketchbookTreeModel } from '../sketchbook/sketchbook-tree-model';
8-
import { ArduinoPreferences } from '../../arduino-preferences';
912
import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree';
1013
import { CreateUri } from '../../create/create-uri';
11-
import { FileStat } from '@theia/filesystem/lib/common/files';
12-
import { LocalCacheFsProvider } from '../../local-cache/local-cache-fs-provider';
13-
import { FileService } from '@theia/filesystem/lib/browser/file-service';
14+
import { FileChangesEvent, FileStat } from '@theia/filesystem/lib/common/files';
15+
import {
16+
LocalCacheFsProvider,
17+
LocalCacheUri,
18+
} from '../../local-cache/local-cache-fs-provider';
1419
import URI from '@theia/core/lib/common/uri';
1520
import { SketchCache } from './cloud-sketch-cache';
1621
import { Create } from '../../create/typings';
17-
import { nls } from '@theia/core/lib/common';
22+
import { nls } from '@theia/core/lib/common/nls';
23+
import { Deferred } from '@theia/core/lib/common/promise-util';
1824

1925
export function sketchBaseDir(sketch: Create.Sketch): FileStat {
2026
// extract the sketch path
@@ -52,26 +58,16 @@ export function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] {
5258

5359
@injectable()
5460
export class CloudSketchbookTreeModel extends SketchbookTreeModel {
55-
@inject(FileService)
56-
protected override readonly fileService: FileService;
57-
58-
@inject(AuthenticationClientService)
59-
protected readonly authenticationService: AuthenticationClientService;
60-
6161
@inject(CreateApi)
62-
protected readonly createApi: CreateApi;
63-
64-
@inject(CloudSketchbookTree)
65-
protected readonly cloudSketchbookTree: CloudSketchbookTree;
66-
67-
@inject(ArduinoPreferences)
68-
protected override readonly arduinoPreferences: ArduinoPreferences;
69-
62+
private readonly createApi: CreateApi;
63+
@inject(AuthenticationClientService)
64+
private readonly authenticationService: AuthenticationClientService;
7065
@inject(LocalCacheFsProvider)
71-
protected readonly localCacheFsProvider: LocalCacheFsProvider;
72-
66+
private readonly localCacheFsProvider: LocalCacheFsProvider;
7367
@inject(SketchCache)
74-
protected readonly sketchCache: SketchCache;
68+
private readonly sketchCache: SketchCache;
69+
70+
private _localCacheFsProviderReady: Deferred<void> | undefined;
7571

7672
@postConstruct()
7773
protected override init(): void {
@@ -81,6 +77,50 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
8177
);
8278
}
8379

80+
override *getNodesByUri(uri: URI): IterableIterator<TreeNode> {
81+
if (uri.scheme === LocalCacheUri.scheme) {
82+
const workspace = this.root;
83+
const { session } = this.authenticationService;
84+
if (session && WorkspaceNode.is(workspace)) {
85+
const currentUri = this.localCacheFsProvider.to(uri);
86+
if (currentUri) {
87+
const rootPath = this.localCacheFsProvider
88+
.toUri(session)
89+
.path.toString();
90+
const currentPath = currentUri.path.toString();
91+
if (rootPath === currentPath) {
92+
return workspace;
93+
}
94+
if (currentPath.startsWith(rootPath)) {
95+
const id = currentPath.substring(rootPath.length);
96+
const node = this.getNode(id);
97+
if (node) {
98+
yield node;
99+
}
100+
}
101+
}
102+
}
103+
}
104+
}
105+
106+
protected override isRootAffected(changes: FileChangesEvent): boolean {
107+
return changes.changes
108+
.map(({ resource }) => resource)
109+
.some(
110+
(uri) => uri.parent.toString().startsWith(LocalCacheUri.root.toString()) // all files under the root might affect the tree
111+
);
112+
}
113+
114+
override async refresh(
115+
parent?: Readonly<CompositeTreeNode>
116+
): Promise<CompositeTreeNode | undefined> {
117+
if (parent) {
118+
return super.refresh(parent);
119+
}
120+
await this.updateRoot();
121+
return super.refresh();
122+
}
123+
84124
override async createRoot(): Promise<TreeNode | undefined> {
85125
const { session } = this.authenticationService;
86126
if (!session) {
@@ -89,7 +129,10 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
89129
}
90130
this.createApi.init(this.authenticationService, this.arduinoPreferences);
91131
this.sketchCache.init();
92-
const sketches = await this.createApi.sketches();
132+
const [sketches] = await Promise.all([
133+
this.createApi.sketches(),
134+
this.ensureLocalFsProviderReady(),
135+
]);
93136
const rootFileStats = sketchesToFileStats(sketches);
94137
if (this.workspaceService.opened) {
95138
const workspaceNode = WorkspaceNode.createRoot(
@@ -108,7 +151,9 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
108151
return this.tree as CloudSketchbookTree;
109152
}
110153

111-
protected override recursivelyFindSketchRoot(node: TreeNode): any {
154+
protected override recursivelyFindSketchRoot(
155+
node: TreeNode
156+
): TreeNode | false {
112157
if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) {
113158
return node;
114159
}
@@ -122,13 +167,25 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
122167
}
123168

124169
override async revealFile(uri: URI): Promise<TreeNode | undefined> {
170+
await this.localCacheFsProvider.ready.promise;
125171
// we use remote uris as keys for the tree
126172
// convert local URIs
127-
const remoteuri = this.localCacheFsProvider.from(uri);
128-
if (remoteuri) {
129-
return super.revealFile(remoteuri);
173+
const remoteUri = this.localCacheFsProvider.from(uri);
174+
if (remoteUri) {
175+
return super.revealFile(remoteUri);
130176
} else {
131177
return super.revealFile(uri);
132178
}
133179
}
180+
181+
private async ensureLocalFsProviderReady(): Promise<void> {
182+
if (this._localCacheFsProviderReady) {
183+
return this._localCacheFsProviderReady.promise;
184+
}
185+
this._localCacheFsProviderReady = new Deferred();
186+
this.fileService
187+
.access(LocalCacheUri.root)
188+
.then(() => this._localCacheFsProviderReady?.resolve());
189+
return this._localCacheFsProviderReady.promise;
190+
}
134191
}

‎arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from '@theia/core/shared/react';
2-
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
2+
import { inject, injectable } from '@theia/core/shared/inversify';
33
import { TreeModel } from '@theia/core/lib/browser/tree/tree-model';
44
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
55
import { AuthenticationClientService } from '../../auth/authentication-client-service';
@@ -27,12 +27,6 @@ export class CloudSketchbookTreeWidget extends SketchbookTreeWidget {
2727
@inject(CloudSketchbookTree)
2828
protected readonly cloudSketchbookTree: CloudSketchbookTree;
2929

30-
@postConstruct()
31-
protected override async init(): Promise<void> {
32-
await super.init();
33-
this.addClass('tree-container'); // Adds `height: 100%` to the tree. Otherwise you cannot see it.
34-
}
35-
3630
protected override renderTree(model: TreeModel): React.ReactNode {
3731
if (this.shouldShowWelcomeView()) return this.renderViewWelcome();
3832
if (this.shouldShowEmptyView()) return this.renderEmptyView();

‎arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class CloudSketchbookTree extends SketchbookTree {
136136
return;
137137
}
138138
}
139-
this.runWithState(node, 'pulling', async (node) => {
139+
return this.runWithState(node, 'pulling', async (node) => {
140140
const commandsCopy = node.commands;
141141
node.commands = [];
142142

@@ -196,7 +196,7 @@ export class CloudSketchbookTree extends SketchbookTree {
196196
return;
197197
}
198198
}
199-
this.runWithState(node, 'pushing', async (node) => {
199+
return this.runWithState(node, 'pushing', async (node) => {
200200
if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
201201
throw new Error(
202202
nls.localize(
@@ -269,7 +269,7 @@ export class CloudSketchbookTree extends SketchbookTree {
269269
return prev;
270270
}
271271

272-
// do not map "do_not_sync" files/directoris and their descendants
272+
// do not map "do_not_sync" files/directories and their descendants
273273
const segments = path[1].split(posix.sep) || [];
274274
if (
275275
segments.some((segment) => Create.do_not_sync_files.includes(segment))

‎arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'
22
import { CloudSketchbookCompositeWidget } from './cloud-sketchbook-composite-widget';
33
import { SketchbookWidget } from '../sketchbook/sketchbook-widget';
44
import { ArduinoPreferences } from '../../arduino-preferences';
5+
import { BaseSketchbookCompositeWidget } from '../sketchbook/sketchbook-composite-widget';
56

67
@injectable()
78
export class CloudSketchbookWidget extends SketchbookWidget {
@@ -19,8 +20,8 @@ export class CloudSketchbookWidget extends SketchbookWidget {
1920
override getTreeWidget(): any {
2021
const widget: any = this.sketchbookTreesContainer.selectedWidgets().next();
2122

22-
if (widget && typeof widget.getTreeWidget !== 'undefined') {
23-
return (widget as CloudSketchbookCompositeWidget).getTreeWidget();
23+
if (widget instanceof BaseSketchbookCompositeWidget) {
24+
return widget.treeWidget;
2425
}
2526
return widget;
2627
}
@@ -30,7 +31,7 @@ export class CloudSketchbookWidget extends SketchbookWidget {
3031
this.sketchbookTreesContainer.activateWidget(this.widget);
3132
} else {
3233
this.sketchbookTreesContainer.activateWidget(
33-
this.localSketchbookTreeWidget
34+
this.sketchbookCompositeWidget
3435
);
3536
}
3637
this.setDocumentMode();
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as React from '@theia/core/shared/react';
2+
3+
export class CreateNew extends React.Component<CreateNew.Props> {
4+
override render(): React.ReactNode {
5+
return (
6+
<div className="create-new">
7+
<button className="theia-button secondary" onClick={this.props.onClick}>
8+
{this.props.label}
9+
</button>
10+
</div>
11+
);
12+
}
13+
}
14+
15+
export namespace CreateNew {
16+
export interface Props {
17+
readonly label: string;
18+
readonly onClick: () => void;
19+
}
20+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as React from '@theia/core/shared/react';
2+
import * as ReactDOM from '@theia/core/shared/react-dom';
3+
import { inject, injectable } from '@theia/core/shared/inversify';
4+
import { nls } from '@theia/core/lib/common/nls';
5+
import { Widget } from '@theia/core/shared/@phosphor/widgets';
6+
import { Message, MessageLoop } from '@theia/core/shared/@phosphor/messaging';
7+
import { Disposable } from '@theia/core/lib/common/disposable';
8+
import { BaseWidget } from '@theia/core/lib/browser/widgets/widget';
9+
import { CommandService } from '@theia/core/lib/common/command';
10+
import { SketchbookTreeWidget } from './sketchbook-tree-widget';
11+
import { CreateNew } from '../sketchbook/create-new';
12+
13+
@injectable()
14+
export abstract class BaseSketchbookCompositeWidget<
15+
TW extends SketchbookTreeWidget
16+
> extends BaseWidget {
17+
@inject(CommandService)
18+
protected readonly commandService: CommandService;
19+
20+
private readonly compositeNode: HTMLElement;
21+
private readonly footerNode: HTMLElement;
22+
23+
constructor() {
24+
super();
25+
this.compositeNode = document.createElement('div');
26+
this.compositeNode.classList.add('composite-node');
27+
this.footerNode = document.createElement('div');
28+
this.footerNode.classList.add('footer-node');
29+
this.compositeNode.appendChild(this.footerNode);
30+
this.node.appendChild(this.compositeNode);
31+
this.title.closable = false;
32+
}
33+
34+
abstract get treeWidget(): TW;
35+
protected abstract renderFooter(footerNode: HTMLElement): void;
36+
protected updateFooter(): void {
37+
this.renderFooter(this.footerNode);
38+
}
39+
40+
protected override onAfterAttach(message: Message): void {
41+
super.onAfterAttach(message);
42+
Widget.attach(this.treeWidget, this.compositeNode);
43+
this.renderFooter(this.footerNode);
44+
this.toDisposeOnDetach.push(
45+
Disposable.create(() => Widget.detach(this.treeWidget))
46+
);
47+
}
48+
49+
protected override onActivateRequest(message: Message): void {
50+
super.onActivateRequest(message);
51+
// Sending a resize message is needed because otherwise the tree widget would render empty
52+
this.onResize(Widget.ResizeMessage.UnknownSize);
53+
}
54+
55+
protected override onResize(message: Widget.ResizeMessage): void {
56+
super.onResize(message);
57+
MessageLoop.sendMessage(this.treeWidget, Widget.ResizeMessage.UnknownSize);
58+
}
59+
}
60+
61+
@injectable()
62+
export class SketchbookCompositeWidget extends BaseSketchbookCompositeWidget<SketchbookTreeWidget> {
63+
@inject(SketchbookTreeWidget)
64+
private readonly sketchbookTreeWidget: SketchbookTreeWidget;
65+
66+
constructor() {
67+
super();
68+
this.id = 'sketchbook-composite-widget';
69+
this.title.caption = nls.localize(
70+
'arduino/sketch/titleLocalSketchbook',
71+
'Local Sketchbook'
72+
);
73+
this.title.iconClass = 'sketchbook-tree-icon';
74+
}
75+
76+
get treeWidget(): SketchbookTreeWidget {
77+
return this.sketchbookTreeWidget;
78+
}
79+
80+
protected renderFooter(footerNode: HTMLElement): void {
81+
ReactDOM.render(
82+
<CreateNew
83+
label={nls.localize('arduino/sketchbook/newSketch', 'New Sketch')}
84+
onClick={this.onDidClickCreateNew}
85+
/>,
86+
footerNode
87+
);
88+
}
89+
90+
private onDidClickCreateNew: () => void = () => {
91+
this.commandService.executeCommand('arduino-new-sketch');
92+
};
93+
}

‎arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-widget.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class SketchbookTreeWidget extends FileTreeWidget {
5959
'Local Sketchbook'
6060
);
6161
this.title.closable = false;
62+
this.addClass('tree-container'); // Adds `height: 100%` to the tree. Otherwise you cannot see it.
6263
}
6364

6465
@postConstruct()

‎arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ import { Disposable } from '@theia/core/lib/common/disposable';
1111
import { BaseWidget } from '@theia/core/lib/browser/widgets/widget';
1212
import { SketchbookTreeWidget } from './sketchbook-tree-widget';
1313
import { nls } from '@theia/core/lib/common';
14-
import { CloudSketchbookCompositeWidget } from '../cloud-sketchbook/cloud-sketchbook-composite-widget';
1514
import { URI } from '../../contributions/contribution';
15+
import {
16+
BaseSketchbookCompositeWidget,
17+
SketchbookCompositeWidget,
18+
} from './sketchbook-composite-widget';
1619

1720
@injectable()
1821
export class SketchbookWidget extends BaseWidget {
19-
static LABEL = nls.localize('arduino/sketch/titleSketchbook', 'Sketchbook');
22+
static readonly LABEL = nls.localize(
23+
'arduino/sketch/titleSketchbook',
24+
'Sketchbook'
25+
);
2026

21-
@inject(SketchbookTreeWidget)
22-
protected readonly localSketchbookTreeWidget: SketchbookTreeWidget;
27+
@inject(SketchbookCompositeWidget)
28+
protected readonly sketchbookCompositeWidget: SketchbookCompositeWidget;
2329

2430
protected readonly sketchbookTreesContainer: DockPanel;
2531

@@ -36,7 +42,7 @@ export class SketchbookWidget extends BaseWidget {
3642

3743
@postConstruct()
3844
protected init(): void {
39-
this.sketchbookTreesContainer.addWidget(this.localSketchbookTreeWidget);
45+
this.sketchbookTreesContainer.addWidget(this.sketchbookCompositeWidget);
4046
}
4147

4248
protected override onAfterAttach(message: Message): void {
@@ -48,7 +54,7 @@ export class SketchbookWidget extends BaseWidget {
4854
}
4955

5056
getTreeWidget(): SketchbookTreeWidget {
51-
return this.localSketchbookTreeWidget;
57+
return this.sketchbookCompositeWidget.treeWidget;
5258
}
5359

5460
activeTreeWidgetId(): string | undefined {
@@ -80,8 +86,8 @@ export class SketchbookWidget extends BaseWidget {
8086
if (widget instanceof SketchbookTreeWidget) {
8187
return widget;
8288
}
83-
if (widget instanceof CloudSketchbookCompositeWidget) {
84-
return widget.getTreeWidget();
89+
if (widget instanceof BaseSketchbookCompositeWidget) {
90+
return widget.treeWidget;
8591
}
8692
return undefined;
8793
};

‎arduino-ide-extension/src/electron-browser/theia/core/electron-main-menu-factory.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { inject, injectable } from '@theia/core/shared/inversify';
22
import * as remote from '@theia/core/electron-shared/@electron/remote';
33
import { isOSX } from '@theia/core/lib/common/os';
44
import {
5+
ActionMenuNode,
56
CompositeMenuNode,
67
MAIN_MENU_BAR,
8+
MenuNode,
79
MenuPath,
810
} from '@theia/core/lib/common/menu';
911
import {
@@ -134,7 +136,7 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory {
134136
}
135137

136138
protected override handleElectronDefault(
137-
menuNode: CompositeMenuNode,
139+
menuNode: MenuNode,
138140
args: any[] = [],
139141
options?: ElectronMenuOptions
140142
): Electron.MenuItemConstructorOptions[] {
@@ -149,4 +151,119 @@ export class ElectronMainMenuFactory extends TheiaElectronMainMenuFactory {
149151
}
150152
return [];
151153
}
154+
155+
// Copied from 1.25.0 Theia as is to customize the enablement of the menu items.
156+
// Source: https://github.com/eclipse-theia/theia/blob/ca417a31e402bd35717d3314bf6254049d1dae44/packages/core/src/electron-browser/menu/electron-main-menu-factory.ts#L125-L220
157+
// See https://github.com/arduino/arduino-ide/issues/1533
158+
protected override fillMenuTemplate(
159+
items: Electron.MenuItemConstructorOptions[],
160+
menuModel: CompositeMenuNode,
161+
args: any[] = [],
162+
options?: ElectronMenuOptions
163+
): Electron.MenuItemConstructorOptions[] {
164+
const showDisabled =
165+
options?.showDisabled === undefined ? true : options?.showDisabled;
166+
for (const menu of menuModel.children) {
167+
if (menu instanceof CompositeMenuNode) {
168+
if (menu.children.length > 0) {
169+
// do not render empty nodes
170+
171+
if (menu.isSubmenu) {
172+
// submenu node
173+
174+
const submenu = this.fillMenuTemplate([], menu, args, options);
175+
if (submenu.length === 0) {
176+
continue;
177+
}
178+
179+
items.push({
180+
label: menu.label,
181+
submenu,
182+
});
183+
} else {
184+
// group node
185+
186+
// process children
187+
const submenu = this.fillMenuTemplate([], menu, args, options);
188+
if (submenu.length === 0) {
189+
continue;
190+
}
191+
192+
if (items.length > 0) {
193+
// do not put a separator above the first group
194+
195+
items.push({
196+
type: 'separator',
197+
});
198+
}
199+
200+
// render children
201+
items.push(...submenu);
202+
}
203+
}
204+
} else if (menu instanceof ActionMenuNode) {
205+
const node =
206+
menu.altNode && this.context.altPressed ? menu.altNode : menu;
207+
const commandId = node.action.commandId;
208+
209+
// That is only a sanity check at application startup.
210+
if (!this.commandRegistry.getCommand(commandId)) {
211+
console.debug(
212+
`Skipping menu item with missing command: "${commandId}".`
213+
);
214+
continue;
215+
}
216+
217+
if (
218+
!this.commandRegistry.isVisible(commandId, ...args) ||
219+
(!!node.action.when &&
220+
!this.contextKeyService.match(node.action.when))
221+
) {
222+
continue;
223+
}
224+
225+
// We should omit rendering context-menu items which are disabled.
226+
if (
227+
!showDisabled &&
228+
!this.commandRegistry.isEnabled(commandId, ...args)
229+
) {
230+
continue;
231+
}
232+
233+
const bindings =
234+
this.keybindingRegistry.getKeybindingsForCommand(commandId);
235+
236+
const accelerator = bindings[0] && this.acceleratorFor(bindings[0]);
237+
238+
const menuItem: Electron.MenuItemConstructorOptions = {
239+
id: node.id,
240+
label: node.label,
241+
type: this.commandRegistry.getToggledHandler(commandId, ...args)
242+
? 'checkbox'
243+
: 'normal',
244+
checked: this.commandRegistry.isToggled(commandId, ...args),
245+
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
246+
visible: true,
247+
accelerator,
248+
click: () => this.execute(commandId, args),
249+
};
250+
251+
if (isOSX) {
252+
const role = this.roleFor(node.id);
253+
if (role) {
254+
menuItem.role = role;
255+
delete menuItem.click;
256+
}
257+
}
258+
items.push(menuItem);
259+
260+
if (this.commandRegistry.getToggledHandler(commandId, ...args)) {
261+
this._toggledCommands.add(commandId);
262+
}
263+
} else {
264+
items.push(...this.handleElectronDefault(menu, args, options));
265+
}
266+
}
267+
return items;
268+
}
152269
}

‎i18n/en.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@
119119
"syncEditSketches": "Sync and edit your Arduino Cloud Sketches",
120120
"visitArduinoCloud": "Visit Arduino Cloud to create Cloud Sketches."
121121
},
122+
"cloudSketch": {
123+
"new": "New Remote Sketch"
124+
},
122125
"common": {
123126
"all": "All",
124127
"contributed": "Contributed",
@@ -299,6 +302,12 @@
299302
"unableToCloseWebSocket": "Unable to close websocket",
300303
"unableToConnectToWebSocket": "Unable to connect to websocket"
301304
},
305+
"newCloudSketch": {
306+
"invalidSketchName": "The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.",
307+
"newSketchTitle": "Name of a new Remote Sketch",
308+
"notFound": "Could not pull the remote sketch '{0}'. It does not exist.",
309+
"sketchAlreadyExists": "Remote sketch '{0}' already exists."
310+
},
302311
"portProtocol": {
303312
"network": "Network",
304313
"serial": "Serial"
@@ -388,7 +397,7 @@
388397
"exportBinary": "Export Compiled Binary",
389398
"moving": "Moving",
390399
"movingMsg": "The file \"{0}\" needs to be inside a sketch folder named \"{1}\".\nCreate this folder, move the file, and continue?",
391-
"new": "New",
400+
"new": "New Sketch",
392401
"openFolder": "Open Folder",
393402
"openRecent": "Open Recent",
394403
"openSketchInNewWindow": "Open Sketch in New Window",
@@ -407,6 +416,10 @@
407416
"verify": "Verify",
408417
"verifyOrCompile": "Verify/Compile"
409418
},
419+
"sketchbook": {
420+
"newRemoteSketch": "New Remote Sketch",
421+
"newSketch": "New Sketch"
422+
},
410423
"survey": {
411424
"answerSurvey": "Answer survey",
412425
"dismissSurvey": "Don't show again",

0 commit comments

Comments
 (0)
Please sign in to comment.