Skip to content

Commit 05c554f

Browse files
author
Akos Kitta
committed
feat: Create remote sketch
Closes #1580 Signed-off-by: Akos Kitta <[email protected]>
1 parent 0773c39 commit 05c554f

File tree

7 files changed

+362
-11
lines changed

7 files changed

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

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)