Skip to content

Commit e5336cf

Browse files
author
Akos Kitta
committed
feat: Create remote sketch
Closes #1580
1 parent 8a85b5c commit e5336cf

21 files changed

+699
-82
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/arduino-preferences.ts

+53
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ export namespace ErrorRevealStrategy {
4040
export const Default: ErrorRevealStrategy = 'centerIfOutsideViewport';
4141
}
4242

43+
export const CloudSketchOpenStrategyLiterals = [
44+
'Ask',
45+
'Never',
46+
'Always',
47+
] as const;
48+
export type CloudSketchOpenStrategy =
49+
typeof CloudSketchOpenStrategyLiterals[number];
50+
export namespace CloudSketchOpenStrategy {
51+
export const Default: CloudSketchOpenStrategy = 'Ask';
52+
export function labelOf(strategy: CloudSketchOpenStrategy): string {
53+
return CloudSketchOpenStrategyLabels[strategy];
54+
}
55+
const CloudSketchOpenStrategyLabels: Record<CloudSketchOpenStrategy, string> =
56+
{
57+
Ask: nls.localize(
58+
'arduino/preferences/cloud.sketchOpenStrategy.ask',
59+
'Ask'
60+
),
61+
Never: nls.localize(
62+
'arduino/preferences/cloud.sketchOpenStrategy.never',
63+
'Never'
64+
),
65+
Always: nls.localize(
66+
'arduino/preferences/cloud.sketchOpenStrategy.always',
67+
'Always'
68+
),
69+
};
70+
}
71+
4372
export const ArduinoConfigSchema: PreferenceSchema = {
4473
type: 'object',
4574
properties: {
@@ -161,6 +190,29 @@ export const ArduinoConfigSchema: PreferenceSchema = {
161190
),
162191
default: true,
163192
},
193+
'arduino.cloud.sketchOpenStrategy': {
194+
enum: [...CloudSketchOpenStrategyLiterals],
195+
enumDescriptions: [
196+
nls.localize(
197+
'arduino/preferences/cloud.sketchOpenStrategy.ask.description',
198+
'IDE asks users whether to pull the remote sketch and open it in a new window.'
199+
),
200+
nls.localize(
201+
'arduino/preferences/cloud.sketchOpenStrategy.never.description',
202+
'IDE neither pulls nor opens the remote sketch after creating it. Users can manually pull the remote sketch.'
203+
),
204+
nls.localize(
205+
'arduino/preferences/cloud.sketchOpenStrategy.always.description',
206+
'IDE automatically pulls and opens the remote sketch in a new window.'
207+
),
208+
],
209+
markdownDescription: nls.localize(
210+
'arduino/preferences/cloud.sketchOpenStrategy',
211+
'Configures what IDE does after creating a new remote sketch. The default value is `"{0}"`.',
212+
CloudSketchOpenStrategy.labelOf('Ask')
213+
),
214+
default: CloudSketchOpenStrategy.Default,
215+
},
164216
'arduino.cloud.pull.warn': {
165217
type: 'boolean',
166218
description: nls.localize(
@@ -276,6 +328,7 @@ export interface ArduinoConfiguration {
276328
'arduino.board.certificates': string;
277329
'arduino.sketchbook.showAllFiles': boolean;
278330
'arduino.cloud.enabled': boolean;
331+
'arduino.cloud.sketchOpenStrategy': CloudSketchOpenStrategy;
279332
'arduino.cloud.pull.warn': boolean;
280333
'arduino.cloud.push.warn': boolean;
281334
'arduino.cloud.pushpublic.warn': boolean;

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

0 commit comments

Comments
 (0)