Skip to content

Commit 942d974

Browse files
author
Akos Kitta
committed
feat: Create remote sketch
Closes #1580 Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
1 parent 0773c39 commit 942d974

File tree

7 files changed

+361
-11
lines changed

7 files changed

+361
-11
lines changed

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

+2
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ 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';
338339

339340
const registerArduinoThemes = () => {
340341
const themes: MonacoThemeJson[] = [
@@ -751,6 +752,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
751752
Contribution.configure(bind, DeleteSketch);
752753
Contribution.configure(bind, UpdateIndexes);
753754
Contribution.configure(bind, InterfaceScale);
755+
Contribution.configure(bind, NewCloudSketch);
754756

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

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/style/dialogs.css

+4-2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
min-height: 0;
3030
}
3131

32+
.p-Widget.dialogOverlay .dialogBlock .dialogControl .error {
33+
word-break: normal;
34+
}
35+
3236
.p-Widget.dialogOverlay .dialogBlock .dialogContent {
3337
padding: 0;
3438
overflow: auto;
@@ -80,10 +84,8 @@
8084
opacity: .4;
8185
}
8286

83-
8487
@media only screen and (max-height: 560px) {
8588
.p-Widget.dialogOverlay .dialogBlock {
8689
max-height: 400px;
8790
}
8891
}
89-

Diff for: arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts

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

0 commit comments

Comments
 (0)