Skip to content

Commit f9b8eb9

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

File tree

7 files changed

+322
-11
lines changed

7 files changed

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

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)