Skip to content

Commit 7d6a2d5

Browse files
Akos Kittakittaakos
Akos Kitta
authored andcommitted
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

Diff for: arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts

+8
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);

Diff for: arduino-ide-extension/src/browser/contributions/close.ts

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

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

Diff for: arduino-ide-extension/src/browser/contributions/new-sketch.ts

+1-1
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
}

Diff for: arduino-ide-extension/src/browser/contributions/open-sketch.ts

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

Diff for: arduino-ide-extension/src/browser/contributions/save-sketch.ts

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

Diff for: arduino-ide-extension/src/browser/create/create-uri.ts

+3-1
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

Diff for: arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts

+1-2
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
}

Diff for: arduino-ide-extension/src/browser/style/dialogs.css

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

Diff for: arduino-ide-extension/src/browser/style/sketchbook.css

+16
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);

0 commit comments

Comments
 (0)