[atl-1217] sketchbook explorer local & remote

This commit is contained in:
Akos Kitta
2021-04-16 16:47:23 +02:00
committed by Francesco Stasi
parent e6cbefb880
commit 4c536ec8fc
75 changed files with 5559 additions and 430 deletions

View File

@@ -0,0 +1,65 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { inject, injectable } from 'inversify';
import { Widget } from '@phosphor/widgets';
import { Message, MessageLoop } from '@phosphor/messaging';
import { Disposable } from '@theia/core/lib/common/disposable';
import { BaseWidget } from '@theia/core/lib/browser/widgets/widget';
import { UserStatus } from './cloud-user-status';
import { CloudSketchbookTreeWidget } from './cloud-sketchbook-tree-widget';
import { AuthenticationClientService } from '../../auth/authentication-client-service';
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
@injectable()
export class CloudSketchbookCompositeWidget extends BaseWidget {
@inject(AuthenticationClientService)
protected readonly authenticationService: AuthenticationClientService;
@inject(CloudSketchbookTreeWidget)
protected readonly cloudSketchbookTreeWidget: CloudSketchbookTreeWidget;
private compositeNode: HTMLElement;
private cloudUserStatusNode: HTMLElement;
constructor() {
super();
this.compositeNode = document.createElement('div');
this.compositeNode.classList.add('composite-node');
this.cloudUserStatusNode = document.createElement('div');
this.cloudUserStatusNode.classList.add('cloud-status-node');
this.compositeNode.appendChild(this.cloudUserStatusNode);
this.node.appendChild(this.compositeNode);
this.title.caption = 'Cloud Sketchbook';
this.title.iconClass = 'cloud-sketchbook-tree-icon';
this.title.closable = false;
this.id = 'cloud-sketchbook-composite-widget';
}
protected onAfterAttach(message: Message): void {
super.onAfterAttach(message);
Widget.attach(this.cloudSketchbookTreeWidget, this.compositeNode);
ReactDOM.render(
<UserStatus
model={
this.cloudSketchbookTreeWidget
.model as CloudSketchbookTreeModel
}
authenticationService={this.authenticationService}
/>,
this.cloudUserStatusNode
);
this.toDisposeOnDetach.push(
Disposable.create(() =>
Widget.detach(this.cloudSketchbookTreeWidget)
)
);
}
protected onResize(message: Widget.ResizeMessage): void {
super.onResize(message);
MessageLoop.sendMessage(
this.cloudSketchbookTreeWidget,
Widget.ResizeMessage.UnknownSize
);
}
}

View File

@@ -0,0 +1,393 @@
import { inject, injectable } from 'inversify';
import { TreeNode } from '@theia/core/lib/browser/tree';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { Command, CommandRegistry } from '@theia/core/lib/common/command';
import {
ContextMenuRenderer,
RenderContextMenuOptions,
} from '@theia/core/lib/browser';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
import { CloudSketchbookTree } from './cloud-sketchbook-tree';
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
import { CloudUserCommands } from '../../auth/cloud-user-commands';
import { ShareSketchDialog } from '../../dialogs.ts/cloud-share-sketch-dialog';
import { CreateApi } from '../../create/create-api';
import {
PreferenceService,
PreferenceScope,
} from '@theia/core/lib/browser/preferences/preference-service';
import { ArduinoMenus, PlaceholderMenuNode } from '../../menu/arduino-menus';
import { SketchbookCommands } from '../sketchbook/sketchbook-commands';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { Contribution } from '../../contributions/contribution';
import { ArduinoPreferences } from '../../arduino-preferences';
import { MainMenuManager } from '../../../common/main-menu-manager';
export const SKETCHBOOKSYNC__CONTEXT = ['arduino-sketchbook-sync--context'];
// `Open Folder`, `Open in New Window`
export const SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP = [
...SKETCHBOOKSYNC__CONTEXT,
'0_main',
];
export const CLOUD_USER__CONTEXT = ['arduino-cloud-user--context'];
export const CLOUD_USER__CONTEXT__USERNAME = [
...CLOUD_USER__CONTEXT,
'0_username',
];
export const CLOUD_USER__CONTEXT__MAIN_GROUP = [
...CLOUD_USER__CONTEXT,
'1_main',
];
export namespace CloudSketchbookCommands {
export interface Arg {
model: CloudSketchbookTreeModel;
node: TreeNode;
event?: MouseEvent;
}
export namespace Arg {
export function is(arg: Partial<Arg> | undefined): arg is Arg {
return (
!!arg &&
!!arg.node &&
arg.model instanceof CloudSketchbookTreeModel
);
}
}
export const TOGGLE_CLOUD_SKETCHBOOK: Command = {
id: 'arduino-cloud-sketchbook--disable',
label: 'Show/Hide Remote Sketchbook',
};
export const PULL_SKETCH: Command = {
id: 'arduino-cloud-sketchbook--pull-sketch',
label: 'Pull Sketch',
iconClass: 'pull-sketch-icon',
};
export const PUSH_SKETCH: Command = {
id: 'arduino-cloud-sketchbook--push-sketch',
label: 'Push Sketch',
iconClass: 'push-sketch-icon',
};
export const OPEN_IN_CLOUD_EDITOR: Command = {
id: 'arduino-cloud-sketchbook--open-in-cloud-editor',
label: 'Open in Cloud Editor',
};
export const OPEN_SKETCHBOOKSYNC_CONTEXT_MENU: Command = {
id: 'arduino-sketchbook-sync--open-sketch-context-menu',
label: 'Options...',
iconClass: 'sketchbook-tree__opts',
};
export const OPEN_SKETCH_SHARE_DIALOG: Command = {
id: 'arduino-cloud-sketchbook--share-modal',
label: 'Share...',
};
export const OPEN_PROFILE_CONTEXT_MENU: Command = {
id: 'arduino-cloud-sketchbook--open-profile-menu',
label: 'Contextual menu',
};
}
@injectable()
export class CloudSketchbookContribution extends Contribution {
@inject(FileService)
protected readonly fileService: FileService;
@inject(ContextMenuRenderer)
protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(MenuModelRegistry)
protected readonly menuRegistry: MenuModelRegistry;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
@inject(WindowService)
protected readonly windowService: WindowService;
@inject(CreateApi)
protected readonly createApi: CreateApi;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(MainMenuManager)
protected readonly mainMenuManager: MainMenuManager;
protected readonly toDisposeBeforeNewContextMenu =
new DisposableCollection();
registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction(ArduinoMenus.FILE__ADVANCED_SUBMENU, {
commandId: CloudSketchbookCommands.TOGGLE_CLOUD_SKETCHBOOK.id,
label: CloudSketchbookCommands.TOGGLE_CLOUD_SKETCHBOOK.label,
order: '2',
});
}
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(
CloudSketchbookCommands.TOGGLE_CLOUD_SKETCHBOOK,
{
execute: () => {
this.preferenceService.set(
'arduino.cloud.enabled',
!this.arduinoPreferences['arduino.cloud.enabled'],
PreferenceScope.User
);
},
isEnabled: () => true,
isVisible: () => true,
}
);
registry.registerCommand(CloudSketchbookCommands.PULL_SKETCH, {
execute: (arg) => arg.model.sketchbookTree().pull(arg),
isEnabled: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
isVisible: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
});
registry.registerCommand(CloudSketchbookCommands.PUSH_SKETCH, {
execute: (arg) => arg.model.sketchbookTree().push(arg.node),
isEnabled: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node) &&
!!arg.node.synced,
isVisible: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node) &&
!!arg.node.synced,
});
registry.registerCommand(CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR, {
execute: (arg) => {
this.windowService.openNewWindow(
`https://create.arduino.cc/editor/${arg.node.sketchId}`,
{ external: true }
);
},
isEnabled: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
isVisible: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
});
registry.registerCommand(
CloudSketchbookCommands.OPEN_SKETCH_SHARE_DIALOG,
{
execute: (arg) => {
new ShareSketchDialog({
node: arg.node,
title: 'Share Sketch',
createApi: this.createApi,
}).open();
},
isEnabled: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
isVisible: (arg) =>
CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
}
);
registry.registerCommand(
CloudSketchbookCommands.OPEN_SKETCHBOOKSYNC_CONTEXT_MENU,
{
isEnabled: (arg) =>
!!arg &&
'node' in arg &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
isVisible: (arg) =>
!!arg &&
'node' in arg &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node),
execute: async (arg) => {
// cleanup previous context menu entries
this.toDisposeBeforeNewContextMenu.dispose();
const container = arg.event.target;
if (!container) {
return;
}
this.menuRegistry.registerMenuAction(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
{
commandId:
CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR.id,
label: CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR
.label,
order: '0',
}
);
this.toDisposeBeforeNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR
)
)
);
this.menuRegistry.registerMenuAction(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
{
commandId:
CloudSketchbookCommands.OPEN_SKETCH_SHARE_DIALOG
.id,
label: CloudSketchbookCommands
.OPEN_SKETCH_SHARE_DIALOG.label,
order: '1',
}
);
this.toDisposeBeforeNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
CloudSketchbookCommands.OPEN_SKETCH_SHARE_DIALOG
)
)
);
const currentSketch =
await this.sketchServiceClient.currentSketch();
const localUri =
await arg.model.cloudSketchbookTree.localUri(arg.node);
let underlying = null;
if (arg.node && localUri) {
underlying =
await this.fileService.toUnderlyingResource(
localUri
);
}
// disable the "open sketch" command for the current sketch and for those not in sync
if (
!underlying ||
(currentSketch &&
currentSketch.uri === underlying.toString())
) {
const placeholder = new PlaceholderMenuNode(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
SketchbookCommands.OPEN_NEW_WINDOW.label!
);
this.menuRegistry.registerMenuNode(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
placeholder
);
this.toDisposeBeforeNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuNode(
placeholder.id
)
)
);
} else {
arg.node.uri = localUri;
this.menuRegistry.registerMenuAction(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
{
commandId:
SketchbookCommands.OPEN_NEW_WINDOW.id,
label: SketchbookCommands.OPEN_NEW_WINDOW.label,
}
);
this.toDisposeBeforeNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
SketchbookCommands.OPEN_NEW_WINDOW
)
)
);
}
const options: RenderContextMenuOptions = {
menuPath: SKETCHBOOKSYNC__CONTEXT,
anchor: {
x: container.getBoundingClientRect().left,
y:
container.getBoundingClientRect().top +
container.offsetHeight,
},
args: arg,
};
this.contextMenuRenderer.render(options);
},
}
);
registry.registerCommand(CloudUserCommands.OPEN_PROFILE_CONTEXT_MENU, {
execute: async (arg) => {
this.toDisposeBeforeNewContextMenu.dispose();
const container = arg.event.target;
if (!container) {
return;
}
this.menuRegistry.registerMenuAction(
CLOUD_USER__CONTEXT__MAIN_GROUP,
{
commandId: CloudUserCommands.LOGOUT.id,
label: CloudUserCommands.LOGOUT.label,
}
);
this.toDisposeBeforeNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
CloudUserCommands.LOGOUT
)
)
);
const placeholder = new PlaceholderMenuNode(
CLOUD_USER__CONTEXT__USERNAME,
arg.username
);
this.menuRegistry.registerMenuNode(
CLOUD_USER__CONTEXT__USERNAME,
placeholder
);
this.toDisposeBeforeNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuNode(placeholder.id)
)
);
const options: RenderContextMenuOptions = {
menuPath: CLOUD_USER__CONTEXT,
anchor: {
x: container.getBoundingClientRect().left,
y:
container.getBoundingClientRect().top -
3.5 * container.offsetHeight,
},
args: arg,
};
this.contextMenuRenderer.render(options);
},
});
this.registerMenus(this.menuRegistry);
}
}

View File

@@ -0,0 +1,29 @@
import { interfaces, Container } from 'inversify';
import { CloudSketchbookTreeWidget } from './cloud-sketchbook-tree-widget';
import { CloudSketchbookTree } from './cloud-sketchbook-tree';
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
import { createSketchbookTreeContainer } from '../sketchbook/sketchbook-tree-container';
import { SketchbookTree } from '../sketchbook/sketchbook-tree';
import { SketchbookTreeModel } from '../sketchbook/sketchbook-tree-model';
import { SketchbookTreeWidget } from '../sketchbook/sketchbook-tree-widget';
function createCloudSketchbookTreeContainer(
parent: interfaces.Container
): Container {
const child = createSketchbookTreeContainer(parent);
child.bind(CloudSketchbookTree).toSelf();
child.rebind(SketchbookTree).toService(CloudSketchbookTree);
child.bind(CloudSketchbookTreeModel).toSelf();
child.rebind(SketchbookTreeModel).toService(CloudSketchbookTreeModel);
child.bind(CloudSketchbookTreeWidget).toSelf();
child.rebind(SketchbookTreeWidget).toService(CloudSketchbookTreeWidget);
return child;
}
export function createCloudSketchbookTreeWidget(
parent: interfaces.Container
): CloudSketchbookTreeWidget {
return createCloudSketchbookTreeContainer(parent).get(
CloudSketchbookTreeWidget
);
}

View File

@@ -0,0 +1,166 @@
import { inject, injectable, postConstruct } from 'inversify';
import { TreeNode } from '@theia/core/lib/browser/tree';
import { toPosixPath, posixSegments, posix } from '../../create/create-paths';
import { CreateApi, Create } from '../../create/create-api';
import { CloudSketchbookTree } from './cloud-sketchbook-tree';
import { AuthenticationClientService } from '../../auth/authentication-client-service';
import {
LocalCacheFsProvider,
LocalCacheUri,
} from '../../local-cache/local-cache-fs-provider';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { SketchbookTreeModel } from '../sketchbook/sketchbook-tree-model';
import { ArduinoPreferences } from '../../arduino-preferences';
import { ConfigService } from '../../../common/protocol';
export type CreateCache = Record<string, Create.Resource>;
export namespace CreateCache {
export function build(resources: Create.Resource[]): CreateCache {
const treeData: CreateCache = {};
treeData[posix.sep] = CloudSketchbookTree.rootResource;
for (const resource of resources) {
const { path } = resource;
const posixPath = toPosixPath(path);
if (treeData[posixPath] !== undefined) {
throw new Error(
`Already visited resource for path: ${posixPath}.\nData: ${JSON.stringify(
treeData,
null,
2
)}`
);
}
treeData[posixPath] = resource;
}
return treeData;
}
export function childrenOf(
resource: Create.Resource,
cache: CreateCache
): Create.Resource[] | undefined {
if (resource.type === 'file') {
return undefined;
}
const posixPath = toPosixPath(resource.path);
const childSegmentCount = posixSegments(posixPath).length + 1;
return Object.keys(cache)
.filter(
(key) =>
key.startsWith(posixPath) &&
posixSegments(key).length === childSegmentCount
)
.map((childPosixPath) => cache[childPosixPath]);
}
}
@injectable()
export class CloudSketchbookTreeModel extends SketchbookTreeModel {
@inject(AuthenticationClientService)
protected readonly authenticationService: AuthenticationClientService;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(CreateApi)
protected readonly createApi: CreateApi;
@inject(CloudSketchbookTree)
protected readonly cloudSketchbookTree: CloudSketchbookTree;
@inject(LocalCacheFsProvider)
protected readonly localCacheFsProvider: LocalCacheFsProvider;
@inject(CommandRegistry)
public readonly commandRegistry: CommandRegistry;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@postConstruct()
protected init(): void {
super.init();
this.toDispose.push(
this.authenticationService.onSessionDidChange(() =>
this.updateRoot()
)
);
}
async updateRoot(): Promise<void> {
const { session } = this.authenticationService;
if (!session) {
this.tree.root = undefined;
return;
}
this.createApi.init(
this.authenticationService,
this.arduinoPreferences
);
const resources = await this.createApi.readDirectory(posix.sep, {
recursive: true,
secrets: true,
});
const cache = CreateCache.build(resources);
// also read local files
for await (const path of Object.keys(cache)) {
if (cache[path].type === 'sketch') {
const localUri = LocalCacheUri.root.resolve(path);
const exists = await this.fileService.exists(localUri);
if (exists) {
const fileStat = await this.fileService.resolve(localUri);
// add every missing file
fileStat.children
?.filter(
(child) =>
!Object.keys(cache).includes(
path + posix.sep + child.name
)
)
.forEach((child) => {
const localChild: Create.Resource = {
modified_at: '',
href: cache[path].href + posix.sep + child.name,
mimetype: '',
name: child.name,
path: cache[path].path + posix.sep + child.name,
sketchId: '',
type: child.isFile ? 'file' : 'folder',
};
cache[path + posix.sep + child.name] = localChild;
});
}
}
}
const showAllFiles =
this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
this.tree.root = CloudSketchbookTree.CloudRootNode.create(
cache,
showAllFiles
);
}
sketchbookTree(): CloudSketchbookTree {
return this.tree as CloudSketchbookTree;
}
protected recursivelyFindSketchRoot(node: TreeNode): any {
if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) {
if (node.hasOwnProperty('underlying')) {
return { ...node, uri: node.underlying };
}
return node;
}
if (node && node.parent) {
return this.recursivelyFindSketchRoot(node.parent);
}
// can't find a root, return false
return false;
}
}

View File

@@ -0,0 +1,184 @@
import * as React from 'react';
import { inject, injectable, postConstruct } from 'inversify';
import { TreeModel } from '@theia/core/lib/browser/tree/tree-model';
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
import { AuthenticationClientService } from '../../auth/authentication-client-service';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { CloudSketchbookTree } from './cloud-sketchbook-tree';
import { CloudUserCommands } from '../../auth/cloud-user-commands';
import { NodeProps } from '@theia/core/lib/browser/tree/tree-widget';
import { TreeNode } from '@theia/core/lib/browser/tree';
import { CompositeTreeNode } from '@theia/core/lib/browser';
import { shell } from 'electron';
import { SketchbookTreeWidget } from '../sketchbook/sketchbook-tree-widget';
const LEARN_MORE_URL =
'https://docs.arduino.cc/software/ide-v2/tutorials/ide-v2-cloud-sketch-sync';
@injectable()
export class CloudSketchbookTreeWidget extends SketchbookTreeWidget {
@inject(AuthenticationClientService)
protected readonly authenticationService: AuthenticationClientService;
@inject(FileService)
protected readonly fileService: FileService;
@inject(CloudSketchbookTree)
protected readonly cloudSketchbookTree: CloudSketchbookTree;
@postConstruct()
protected async init(): Promise<void> {
await super.init();
this.addClass('tree-container'); // Adds `height: 100%` to the tree. Otherwise you cannot see it.
}
protected renderTree(model: TreeModel): React.ReactNode {
if (this.shouldShowWelcomeView()) return this.renderViewWelcome();
if (this.shouldShowEmptyView()) return this.renderEmptyView();
return super.renderTree(model);
}
protected renderEmptyView() {
return (
<div className="cloud-sketchbook-welcome center">
<div className="center item">
<div>
<p>
<b>Your Sketchbook is empty</b>
</p>
<p>Visit Arduino Cloud to create Cloud Sketches.</p>
</div>
</div>
<button
className="theia-button"
onClick={() =>
shell.openExternal('https://create.arduino.cc/editor')
}
>
GO TO CLOUD
</button>
<div className="center item"></div>
</div>
);
}
protected shouldShowWelcomeView(): boolean {
if (!this.model || this.model instanceof CloudSketchbookTreeModel) {
return !this.authenticationService.session;
}
return super.shouldShowWelcomeView();
}
protected shouldShowEmptyView(): boolean {
const node = this.cloudSketchbookTree.root as TreeNode;
return CompositeTreeNode.is(node) && node.children.length === 0;
}
protected createNodeClassNames(node: any, props: NodeProps): string[] {
const classNames = super.createNodeClassNames(node, props);
if (
node &&
node.hasOwnProperty('underlying') &&
this.currentSketchUri === node.underlying.toString()
) {
classNames.push('active-sketch');
}
return classNames;
}
protected renderInlineCommands(
node: any,
props: NodeProps
): React.ReactNode {
if (
CloudSketchbookTree.CloudSketchDirNode.is(node) &&
node.commands &&
(node.id === this.hoveredNodeId ||
this.currentSketchUri === node.underlying?.toString())
) {
return Array.from(new Set(node.commands)).map((command) =>
this.renderInlineCommand(command.id, node)
);
}
return undefined;
}
protected renderViewWelcome(): React.ReactNode {
return (
<div className="cloud-sketchbook-welcome center">
<div className="center item">
<div>
<p className="sign-in-title">
Sign in to Arduino Cloud
</p>
<p className="sign-in-desc">
Sync and edit your Arduino Cloud Sketches
</p>
</div>
</div>
<button
className="theia-button sign-in-cta"
onClick={() =>
this.commandRegistry.executeCommand(
CloudUserCommands.LOGIN.id
)
}
>
SIGN IN
</button>
<div className="center item">
<div
className="link sign-in-learnmore"
onClick={() =>
this.windowService.openNewWindow(LEARN_MORE_URL, {
external: true,
})
}
>
Learn more
</div>
</div>
</div>
);
}
protected async handleClickEvent(
node: any,
event: React.MouseEvent<HTMLElement>
) {
event.persist();
let uri = node.uri;
// overwrite the uri using the local-cache
const localUri = await this.cloudSketchbookTree.localUri(node);
if (node && localUri) {
const underlying = await this.fileService.toUnderlyingResource(
localUri
);
uri = underlying;
}
super.handleClickEvent({ ...node, uri }, event);
}
protected async handleDblClickEvent(
node: any,
event: React.MouseEvent<HTMLElement>
) {
event.persist();
let uri = node.uri;
// overwrite the uri using the local-cache
// if the localURI does not exists, ignore the double click, so that the sketch is not opened
const localUri = await this.cloudSketchbookTree.localUri(node);
if (node && localUri) {
const underlying = await this.fileService.toUnderlyingResource(
localUri
);
uri = underlying;
super.handleDblClickEvent({ ...node, uri }, event);
}
}
}

View File

@@ -0,0 +1,523 @@
import { inject, injectable } from 'inversify';
import URI from '@theia/core/lib/common/uri';
import { MaybePromise } from '@theia/core/lib/common/types';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStatNode } from '@theia/filesystem/lib/browser/file-tree';
import { Command } from '@theia/core/lib/common/command';
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator';
import {
FileNode,
DirNode,
} from '@theia/filesystem/lib/browser/file-tree/file-tree';
import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree';
import {
PreferenceService,
PreferenceScope,
} from '@theia/core/lib/browser/preferences/preference-service';
import { MessageService } from '@theia/core/lib/common/message-service';
import { REMOTE_ONLY_FILES } from './../../create/create-fs-provider';
import { posix } from '../../create/create-paths';
import { Create, CreateApi } from '../../create/create-api';
import { CreateUri } from '../../create/create-uri';
import {
CloudSketchbookTreeModel,
CreateCache,
} from './cloud-sketchbook-tree-model';
import { LocalCacheUri } from '../../local-cache/local-cache-fs-provider';
import { CloudSketchbookCommands } from './cloud-sketchbook-contributions';
import { DoNotAskAgainConfirmDialog } from '../../dialogs.ts/dialogs';
import { SketchbookTree } from '../sketchbook/sketchbook-tree';
import { firstToUpperCase } from '../../../common/utils';
import { ArduinoPreferences } from '../../arduino-preferences';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
const MESSAGE_TIMEOUT = 5 * 1000;
const deepmerge = require('deepmerge').default;
@injectable()
export class CloudSketchbookTree extends SketchbookTree {
@inject(FileService)
protected readonly fileService: FileService;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
@inject(CreateApi)
protected readonly createApi: CreateApi;
async pushPublicWarn(
node: CloudSketchbookTree.CloudSketchDirNode
): Promise<boolean> {
const warn =
node.isPublic &&
this.arduinoPreferences['arduino.cloud.pushpublic.warn'];
if (warn) {
const ok = await new DoNotAskAgainConfirmDialog({
ok: 'Continue',
cancel: 'Cancel',
title: 'Push Sketch',
msg: 'This is a Public Sketch. Before pushing, make sure any sensitive information is defined in arduino_secrets.h files. You can make a Sketch private from the Share panel.',
maxWidth: 400,
onAccept: () =>
this.preferenceService.set(
'arduino.cloud.pushpublic.warn',
false,
PreferenceScope.User
),
}).open();
if (!ok) {
return false;
}
return true;
} else {
return true;
}
}
async pull(arg: any): Promise<void> {
const {
model,
node,
}: {
model: CloudSketchbookTreeModel;
node: CloudSketchbookTree.CloudSketchDirNode;
} = arg;
const warn =
node.synced && this.arduinoPreferences['arduino.cloud.pull.warn'];
if (warn) {
const ok = await new DoNotAskAgainConfirmDialog({
ok: 'Pull',
cancel: 'Cancel',
title: 'Pull Sketch',
msg: 'Pulling this Sketch from the Cloud will overwrite its local version. Are you sure you want to continue?',
maxWidth: 400,
onAccept: () =>
this.preferenceService.set(
'arduino.cloud.pull.warn',
false,
PreferenceScope.User
),
}).open();
if (!ok) {
return;
}
}
this.runWithState(node, 'pulling', async (node) => {
const commandsCopy = node.commands;
node.commands = [];
// check if the sketch dir already exist
if (node.synced) {
const filesToPull = (
await this.createApi.readDirectory(
node.uri.path.toString(),
{
secrets: true,
}
)
).filter((file: any) => !REMOTE_ONLY_FILES.includes(file.name));
await Promise.all(
filesToPull.map((file: any) => {
const uri = CreateUri.toUri(file);
this.fileService.copy(
uri,
LocalCacheUri.root.resolve(uri.path),
{ overwrite: true }
);
})
);
// open the pulled files in the current workspace
const currentSketch =
await this.sketchServiceClient.currentSketch();
if (
currentSketch &&
node.underlying &&
currentSketch.uri === node.underlying.toString()
) {
filesToPull.forEach(async (file) => {
const localUri = LocalCacheUri.root.resolve(
CreateUri.toUri(file).path
);
const underlying =
await this.fileService.toUnderlyingResource(
localUri
);
model.open(underlying);
});
}
} else {
await this.fileService.copy(
node.uri,
LocalCacheUri.root.resolve(node.uri.path),
{ overwrite: true }
);
}
node.commands = commandsCopy;
this.messageService.info(`Done pulling ${node.fileStat.name}.`, {
timeout: MESSAGE_TIMEOUT,
});
});
}
async push(node: CloudSketchbookTree.CloudSketchDirNode): Promise<void> {
if (!node.synced) {
throw new Error('Cannot push to Cloud. It is not yet pulled.');
}
const pushPublic = await this.pushPublicWarn(node);
if (!pushPublic) {
return;
}
const warn = this.arduinoPreferences['arduino.cloud.push.warn'];
if (warn) {
const ok = await new DoNotAskAgainConfirmDialog({
ok: 'Push',
cancel: 'Cancel',
title: 'Push Sketch',
msg: 'Pushing this Sketch will overwrite its Cloud version. Are you sure you want to continue?',
maxWidth: 400,
onAccept: () =>
this.preferenceService.set(
'arduino.cloud.push.warn',
false,
PreferenceScope.User
),
}).open();
if (!ok) {
return;
}
}
this.runWithState(node, 'pushing', async (node) => {
if (!node.synced) {
throw new Error(
'You have to pull first to be able to push to the Cloud.'
);
}
const commandsCopy = node.commands;
node.commands = [];
// delete every first level file, then push everything
const result = await this.fileService.copy(
LocalCacheUri.root.resolve(node.uri.path),
node.uri,
{ overwrite: true }
);
node.commands = commandsCopy;
this.messageService.info(`Done pushing ${result.name}.`, {
timeout: MESSAGE_TIMEOUT,
});
});
}
async refresh(
node?: CompositeTreeNode
): Promise<CompositeTreeNode | undefined> {
if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) {
const localUri = await this.localUri(node);
if (localUri) {
node.synced = true;
if (
node.commands?.indexOf(
CloudSketchbookCommands.PUSH_SKETCH
) === -1
) {
node.commands.splice(
1,
0,
CloudSketchbookCommands.PUSH_SKETCH
);
}
// remove italic from synced nodes
if (
'decorationData' in node &&
'fontData' in (node as any).decorationData
) {
delete (node as any).decorationData.fontData;
}
}
}
return super.refresh(node);
}
private async runWithState<T>(
node: CloudSketchbookTree.CloudSketchDirNode &
Partial<DecoratedTreeNode>,
state: CloudSketchbookTree.CloudSketchDirNode.State,
task: (node: CloudSketchbookTree.CloudSketchDirNode) => MaybePromise<T>
): Promise<T> {
const decoration: WidgetDecoration.TailDecoration = {
data: `${firstToUpperCase(state)}...`,
fontData: {
color: 'var(--theia-list-highlightForeground)',
},
};
try {
node.state = state;
this.mergeDecoration(node, { tailDecorations: [decoration] });
await this.refresh(node);
const result = await task(node);
return result;
} finally {
delete node.state;
// TODO: find a better way to attach and detach decorators. Do we need a proper `TreeDecorator` instead?
const index = node.decorationData?.tailDecorations?.findIndex(
(candidate) =>
JSON.stringify(decoration) === JSON.stringify(candidate)
);
if (typeof index === 'number' && index !== -1) {
node.decorationData?.tailDecorations?.splice(index, 1);
}
await this.refresh(node);
}
}
protected async resolveFileStat(
node: FileStatNode
): Promise<FileStat | undefined> {
if (
CreateUri.is(node.uri) &&
CloudSketchbookTree.CloudRootNode.is(this.root)
) {
const resource = this.root.cache[node.uri.path.toString()];
if (!resource) {
return undefined;
}
return CloudSketchbookTree.toFileStat(resource, this.root.cache, 1);
}
return super.resolveFileStat(node);
}
protected readonly notInSyncDecoration: WidgetDecoration.Data = {
fontData: {
color: 'var(--theia-activityBar-inactiveForeground)',
},
};
protected async toNodes(
fileStat: FileStat,
parent: CompositeTreeNode
): Promise<CloudSketchbookTree.CloudSketchTreeNode[]> {
const children = await super.toNodes(fileStat, parent);
for (const child of children.filter(FileStatNode.is)) {
if (!CreateFileStat.is(child.fileStat)) {
continue;
}
const localUri = await this.localUri(child);
let underlying = null;
if (localUri) {
underlying = await this.fileService.toUnderlyingResource(
localUri
);
Object.assign(child, { underlying });
}
if (CloudSketchbookTree.CloudSketchDirNode.is(child)) {
if (child.fileStat.sketchId) {
child.sketchId = child.fileStat.sketchId;
child.isPublic = child.fileStat.isPublic;
}
const commands = [CloudSketchbookCommands.PULL_SKETCH];
if (underlying) {
child.synced = true;
commands.push(CloudSketchbookCommands.PUSH_SKETCH);
} else {
this.mergeDecoration(child, this.notInSyncDecoration);
}
commands.push(
CloudSketchbookCommands.OPEN_SKETCHBOOKSYNC_CONTEXT_MENU
);
Object.assign(child, { commands });
if (!this.showAllFiles) {
delete (child as any).expanded;
}
} else if (CloudSketchbookTree.CloudSketchDirNode.is(parent)) {
if (!parent.synced) {
this.mergeDecoration(child, this.notInSyncDecoration);
} else {
this.setDecoration(
child,
underlying ? undefined : this.notInSyncDecoration
);
}
}
}
if (
CloudSketchbookTree.SketchDirNode.is(parent) &&
!this.showAllFiles
) {
return [];
}
return children;
}
protected toNode(
fileStat: FileStat,
parent: CompositeTreeNode
): FileNode | DirNode {
const node = super.toNode(fileStat, parent);
if (CreateFileStat.is(fileStat)) {
Object.assign(node, {
type: fileStat.type,
isPublic: fileStat.isPublic,
sketchId: fileStat.sketchId,
});
}
return node;
}
private mergeDecoration(
node: TreeNode,
decorationData: WidgetDecoration.Data
): void {
Object.assign(node, {
decorationData: deepmerge(
DecoratedTreeNode.is(node) ? node.decorationData : {},
decorationData
),
});
}
private setDecoration(
node: TreeNode,
decorationData: WidgetDecoration.Data | undefined
): void {
if (!decorationData) {
delete (node as any).decorationData;
} else {
Object.assign(node, { decorationData });
}
}
public async localUri(node: FileStatNode): Promise<URI | undefined> {
const localUri = LocalCacheUri.root.resolve(node.uri.path);
const exists = await this.fileService.exists(localUri);
if (exists) {
return localUri;
}
return undefined;
}
private get showAllFiles(): boolean {
return this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
}
}
export interface CreateFileStat extends FileStat {
type: Create.ResourceType;
sketchId?: string;
isPublic?: boolean;
}
export namespace CreateFileStat {
export function is(
stat: FileStat & { type?: Create.ResourceType }
): stat is CreateFileStat {
return !!stat.type;
}
}
export namespace CloudSketchbookTree {
export const rootResource: Create.Resource = Object.freeze({
modified_at: '',
name: '',
path: posix.sep,
type: 'folder',
children: Number.MIN_SAFE_INTEGER,
size: Number.MIN_SAFE_INTEGER,
sketchId: '',
});
export interface CloudRootNode extends SketchbookTree.RootNode {
readonly cache: CreateCache;
}
export namespace CloudRootNode {
export function create(
cache: CreateCache,
showAllFiles: boolean
): CloudRootNode {
return Object.assign(
SketchbookTree.RootNode.create(
toFileStat(rootResource, cache, 1),
showAllFiles
),
{ cache }
);
}
export function is(
node: (TreeNode & Partial<CloudRootNode>) | undefined
): node is CloudRootNode {
return !!node && !!node.cache && SketchbookTree.RootNode.is(node);
}
}
export interface CloudSketchDirNode extends SketchbookTree.SketchDirNode {
state?: CloudSketchDirNode.State;
synced?: true;
sketchId?: string;
isPublic?: boolean;
commands?: Command[];
underlying?: URI;
}
export interface CloudSketchTreeNode extends TreeNode {
underlying?: URI;
}
export namespace CloudSketchDirNode {
export function is(node: TreeNode): node is CloudSketchDirNode {
return SketchbookTree.SketchDirNode.is(node);
}
export type State = 'syncing' | 'pulling' | 'pushing';
}
export function toFileStat(
resource: Create.Resource,
cache: CreateCache,
depth = 0
): CreateFileStat {
return {
isDirectory: resource.type !== 'file',
isFile: resource.type === 'file',
isPublic: resource.isPublic,
isSymbolicLink: false,
name: resource.name,
resource: CreateUri.toUri(resource),
size: resource.size,
mtime: Date.parse(resource.modified_at),
sketchId: resource.sketchId || undefined,
type: resource.type,
...(!!depth && {
children: CreateCache.childrenOf(resource, cache)?.map(
(childResource) =>
toFileStat(childResource, cache, depth - 1)
),
}),
};
}
}

View File

@@ -0,0 +1,48 @@
import { inject, injectable, postConstruct } from 'inversify';
import { CloudSketchbookCompositeWidget } from './cloud-sketchbook-composite-widget';
import { SketchbookWidget } from '../sketchbook/sketchbook-widget';
import { ArduinoPreferences } from '../../arduino-preferences';
@injectable()
export class CloudSketchbookWidget extends SketchbookWidget {
@inject(CloudSketchbookCompositeWidget)
protected readonly widget: CloudSketchbookCompositeWidget;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@postConstruct()
protected init(): void {
super.init();
}
checkCloudEnabled() {
if (this.arduinoPreferences['arduino.cloud.enabled']) {
this.sketchbookTreesContainer.activateWidget(this.widget);
} else {
this.sketchbookTreesContainer.activateWidget(
this.localSketchbookTreeWidget
);
}
this.setDocumentMode();
}
setDocumentMode() {
if (this.arduinoPreferences['arduino.cloud.enabled']) {
this.sketchbookTreesContainer.mode = 'multiple-document';
} else {
this.sketchbookTreesContainer.mode = 'single-document';
}
}
protected onAfterAttach(msg: any) {
this.sketchbookTreesContainer.addWidget(this.widget);
this.setDocumentMode();
this.arduinoPreferences.onPreferenceChanged((event) => {
if (event.preferenceName === 'arduino.cloud.enabled') {
this.checkCloudEnabled();
}
});
super.onAfterAttach(msg);
}
}

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
import { AuthenticationClientService } from '../../auth/authentication-client-service';
import { CloudUserCommands } from '../../auth/cloud-user-commands';
import { firstToUpperCase } from '../../../common/utils';
import { AuthenticationSessionAccountInformation } from '../../../common/protocol/authentication-service';
export class UserStatus extends React.Component<
UserStatus.Props,
UserStatus.State
> {
protected readonly toDispose = new DisposableCollection();
constructor(props: UserStatus.Props) {
super(props);
this.state = {
status: this.status,
accountInfo: props.authenticationService.session?.account,
refreshing: false,
};
}
componentDidMount(): void {
const statusListener = () => this.setState({ status: this.status });
window.addEventListener('online', statusListener);
window.addEventListener('offline', statusListener);
this.toDispose.pushAll([
this.props.authenticationService.onSessionDidChange((session) =>
this.setState({ accountInfo: session?.account })
),
Disposable.create(() =>
window.removeEventListener('online', statusListener)
),
Disposable.create(() =>
window.removeEventListener('offline', statusListener)
),
]);
}
componentWillUnmount(): void {
this.toDispose.dispose();
}
render(): React.ReactNode {
if (!this.props.authenticationService.session) {
return null;
}
return (
<div className="cloud-connection-status flex-line">
<div className="status item flex-line">
<div
className={`${
this.state.status === 'connected'
? 'connected-status-icon'
: 'offline-status-icon'
}`}
/>
{firstToUpperCase(this.state.status)}
</div>
<div className="actions item flex-line">
<div
className={`refresh-icon ${
(this.state.refreshing && 'rotating') || ''
}`}
style={{ cursor: 'pointer' }}
onClick={this.onDidClickRefresh}
/>
</div>
<div className="account item flex-line">
<div
className="account-icon"
style={{ cursor: 'pointer' }}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
this.props.model.commandRegistry.executeCommand(
CloudUserCommands.OPEN_PROFILE_CONTEXT_MENU.id,
{
event: event.nativeEvent,
username: this.state.accountInfo?.label,
}
);
}}
>
{this.state.accountInfo?.picture && (
<img
src={this.state.accountInfo?.picture}
alt="Profile picture"
/>
)}
</div>
</div>
</div>
);
}
private onDidClickRefresh = () => {
this.setState({ refreshing: true });
this.props.model.updateRoot().then(() => {
this.props.model.sketchbookTree().refresh();
this.setState({ refreshing: false });
});
};
private get status(): 'connected' | 'offline' {
return window.navigator.onLine ? 'connected' : 'offline';
}
}
export namespace UserStatus {
export interface Props {
readonly model: CloudSketchbookTreeModel;
readonly authenticationService: AuthenticationClientService;
}
export interface State {
status: 'connected' | 'offline';
accountInfo?: AuthenticationSessionAccountInformation;
refreshing?: boolean;
}
}

View File

@@ -0,0 +1,33 @@
import { Command } from '@theia/core/lib/common/command';
export namespace SketchbookCommands {
export const OPEN_NEW_WINDOW: Command = {
id: 'arduino-sketchbook--open-sketch-new-window',
label: 'Open Sketch in New Window',
};
export const REVEAL_IN_FINDER: Command = {
id: 'arduino-sketchbook--reveal-in-finder',
label: 'Open Folder',
};
export const OPEN_SKETCHBOOK_CONTEXT_MENU: Command = {
id: 'arduino-sketchbook--open-sketch-context-menu',
label: 'Contextual menu',
iconClass: 'sketchbook-tree__opts'
};
export const SKETCHBOOK_HIDE_FILES: Command = {
id: 'arduino-sketchbook--hide-files',
label: 'Contextual menu',
};
export const SKETCHBOOK_SHOW_FILES: Command = {
id: 'arduino-sketchbook--show-files',
label: 'Contextual menu',
};
}

View File

@@ -0,0 +1,26 @@
import { interfaces, Container } from 'inversify';
import { createTreeContainer, Tree, TreeImpl, TreeModel, TreeModelImpl, TreeWidget } from '@theia/core/lib/browser/tree';
import { SketchbookTree } from './sketchbook-tree';
import { SketchbookTreeModel } from './sketchbook-tree-model';
import { SketchbookTreeWidget } from './sketchbook-tree-widget';
export function createSketchbookTreeContainer(parent: interfaces.Container): Container {
const child = createTreeContainer(parent);
child.unbind(TreeImpl);
child.bind(SketchbookTree).toSelf();
child.rebind(Tree).toService(SketchbookTree);
child.unbind(TreeModelImpl);
child.bind(SketchbookTreeModel).toSelf();
child.rebind(TreeModel).toService(SketchbookTreeModel);
child.bind(SketchbookTreeWidget).toSelf();
child.rebind(TreeWidget).toService(SketchbookTreeWidget);
return child;
}
export function createSketchbookTreeWidget(parent: interfaces.Container): SketchbookTreeWidget {
return createSketchbookTreeContainer(parent).get(SketchbookTreeWidget);
}

View File

@@ -0,0 +1,105 @@
import { inject, injectable } from 'inversify';
import URI from '@theia/core/lib/common/uri';
import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ConfigService } from '../../../common/protocol';
import { SketchbookTree } from './sketchbook-tree';
import { ArduinoPreferences } from '../../arduino-preferences';
import { SelectableTreeNode, TreeNode } from '@theia/core/lib/browser/tree';
import { SketchbookCommands } from './sketchbook-commands';
import { OpenerService, open } from '@theia/core/lib/browser';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { CommandRegistry } from '@theia/core/lib/common/command';
@injectable()
export class SketchbookTreeModel extends FileTreeModel {
@inject(FileService)
protected readonly fileService: FileService;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
async updateRoot(): Promise<void> {
const config = await this.configService.getConfiguration();
const fileStat = await this.fileService.resolve(
new URI(config.sketchDirUri)
);
const showAllFiles =
this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
this.tree.root = SketchbookTree.RootNode.create(fileStat, showAllFiles);
}
// selectNode gets called when the user single-clicks on an item
// when this happens, we want to open the file if it belongs to the currently open sketch
async selectNode(node: Readonly<SelectableTreeNode>): Promise<void> {
super.selectNode(node);
if (FileNode.is(node) && (await this.isFileInsideCurrentSketch(node))) {
this.open(node.uri);
}
}
public open(uri: URI): void {
open(this.openerService, uri);
}
protected async doOpenNode(node: TreeNode): Promise<void> {
// if it's a sketch dir, or a file from another sketch, open in new window
if (!(await this.isFileInsideCurrentSketch(node))) {
const sketchRoot = this.recursivelyFindSketchRoot(node);
if (sketchRoot) {
this.commandRegistry.executeCommand(
SketchbookCommands.OPEN_NEW_WINDOW.id,
{ node: sketchRoot }
);
}
return;
}
if (node.visible === false) {
return;
} else if (FileNode.is(node)) {
this.open(node.uri);
} else {
super.doOpenNode(node);
}
}
private async isFileInsideCurrentSketch(node: TreeNode): Promise<boolean> {
// it's a directory, not a file
if (!FileNode.is(node)) {
return false;
}
// check if the node is a file that belongs to another sketch
const sketch = await this.sketchServiceClient.currentSketch();
if (sketch && node.uri.toString().indexOf(sketch.uri) !== 0) {
return false;
}
return true;
}
protected recursivelyFindSketchRoot(node: TreeNode): TreeNode | false {
if (node && SketchbookTree.SketchDirNode.is(node)) {
return node;
}
if (node && node.parent) {
return this.recursivelyFindSketchRoot(node.parent);
}
// can't find a root, return false
return false;
}
}

View File

@@ -0,0 +1,168 @@
import * as React from 'react';
import { inject, injectable, postConstruct } from 'inversify';
import { TreeNode } from '@theia/core/lib/browser/tree/tree';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { NodeProps, TreeProps, TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS } from '@theia/core/lib/browser/tree/tree-widget';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { FileTreeWidget } from '@theia/filesystem/lib/browser';
import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer';
import { SketchbookTree } from './sketchbook-tree';
import { SketchbookTreeModel } from './sketchbook-tree-model';
import { ArduinoPreferences } from '../../arduino-preferences';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { SelectableTreeNode } from '@theia/core/lib/browser/tree/tree-selection';
import { Sketch } from '../../contributions/contribution';
@injectable()
export class SketchbookTreeWidget extends FileTreeWidget {
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
protected currentSketchUri = '';
constructor(
@inject(TreeProps) readonly props: TreeProps,
@inject(SketchbookTreeModel) readonly model: SketchbookTreeModel,
@inject(ContextMenuRenderer) readonly contextMenuRenderer: ContextMenuRenderer,
@inject(EditorManager) readonly editorManager: EditorManager
) {
super(props, model, contextMenuRenderer);
this.id = 'arduino-sketchbook-tree-widget';
this.title.iconClass = 'sketchbook-tree-icon';
this.title.caption = 'Local Sketchbook';
this.title.closable = false;
}
@postConstruct()
protected async init(): Promise<void> {
super.init();
this.toDispose.push(this.arduinoPreferences.onPreferenceChanged(({ preferenceName }) => {
if (preferenceName === 'arduino.sketchbook.showAllFiles') {
this.updateModel();
}
}));
this.updateModel();
// cache the current open sketch uri
const currentSketch = await this.sketchServiceClient.currentSketch();
this.currentSketchUri = currentSketch && currentSketch.uri || '';
}
async updateModel(): Promise<void> {
return this.model.updateRoot();
}
protected createNodeClassNames(node: TreeNode, props: NodeProps): string[] {
const classNames = super.createNodeClassNames(node, props);
if (SketchbookTree.SketchDirNode.is(node) && this.currentSketchUri === node?.uri.toString()) {
classNames.push('active-sketch');
}
return classNames;
}
protected renderIcon(node: TreeNode, props: NodeProps): React.ReactNode {
if (SketchbookTree.SketchDirNode.is(node) || Sketch.isSketchFile(node.id)) {
return <div className='sketch-folder-icon file-icon'></div>;
}
const icon = this.toNodeIcon(node);
if (icon) {
return <div className={icon + ' file-icon'}></div>;
}
return undefined;
}
protected renderTailDecorations(node: TreeNode, props: NodeProps): React.ReactNode {
return <React.Fragment>
{super.renderTailDecorations(node, props)}
{this.renderInlineCommands(node, props)}
</React.Fragment>
}
protected hoveredNodeId: string | undefined;
protected setHoverNodeId(id: string | undefined): void {
this.hoveredNodeId = id;
this.update();
}
protected createNodeAttributes(node: TreeNode, props: NodeProps): React.Attributes & React.HTMLAttributes<HTMLElement> {
return {
...super.createNodeAttributes(node, props),
draggable: false,
onMouseOver: () => this.setHoverNodeId(node.id),
onMouseOut: () => this.setHoverNodeId(undefined)
};
}
protected renderInlineCommands(node: TreeNode, props: NodeProps): React.ReactNode {
if (SketchbookTree.SketchDirNode.is(node) && (node.commands && node.id === this.hoveredNodeId || this.currentSketchUri === node?.uri.toString())) {
return Array.from(new Set(node.commands)).map(command => this.renderInlineCommand(command.id, node));
}
return undefined;
}
protected renderInlineCommand(commandId: string, node: SketchbookTree.SketchDirNode): React.ReactNode {
const command = this.commandRegistry.getCommand(commandId);
const icon = command?.iconClass;
const args = { model: this.model, node: node };
if (command && icon && this.commandRegistry.isEnabled(commandId, args) && this.commandRegistry.isVisible(commandId, args)) {
const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, icon, 'theia-tree-view-inline-action'].join(' ');
return <div
key={`${commandId}--${node.id}`}
className={className}
title={command?.label || command.id}
onClick={event => {
event.preventDefault();
event.stopPropagation();
this.commandRegistry.executeCommand(commandId, Object.assign(args, { event: event.nativeEvent }));
}}
/>;
}
return undefined;
}
protected handleClickEvent(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
if (node) {
if (!!this.props.multiSelect) {
const shiftMask = this.hasShiftMask(event);
const ctrlCmdMask = this.hasCtrlCmdMask(event);
if (SelectableTreeNode.is(node)) {
if (shiftMask) {
this.model.selectRange(node);
} else if (ctrlCmdMask) {
this.model.toggleNode(node);
} else {
this.model.selectNode(node);
}
}
} else {
if (SelectableTreeNode.is(node)) {
this.model.selectNode(node);
}
}
event.stopPropagation();
}
}
protected doToggle(event: React.MouseEvent<HTMLElement>): void {
const nodeId = event.currentTarget.getAttribute('data-node-id');
if (nodeId) {
const node = this.model.getNode(nodeId);
if (node && this.isExpandable(node)) {
this.model.toggleNodeExpansion(node);
}
}
event.stopPropagation();
}
}

View File

@@ -0,0 +1,92 @@
import { inject, injectable } from 'inversify';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { Command } from '@theia/core/lib/common/command';
import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree';
import { DirNode, FileStatNode, FileTree } from '@theia/filesystem/lib/browser/file-tree';
import { SketchesService } from '../../../common/protocol';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { SketchbookCommands } from './sketchbook-commands';
@injectable()
export class SketchbookTree extends FileTree {
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(SketchesService)
protected readonly sketchesService: SketchesService;
async resolveChildren(parent: CompositeTreeNode): Promise<TreeNode[]> {
if (!FileStatNode.is(parent)) {
return super.resolveChildren(parent);
}
const { root } = this;
if (!root) {
return [];
}
if (!SketchbookTree.RootNode.is(root)) {
return [];
}
const children = (await Promise.all((await super.resolveChildren(parent)).map(node => this.maybeDecorateNode(node, root.showAllFiles)))).filter(node => {
// filter out hidden nodes
if (DirNode.is(node) || FileStatNode.is(node)) {
return node.fileStat.name.indexOf('.') !== 0
}
return true;
});
if (SketchbookTree.RootNode.is(parent)) {
return children.filter(DirNode.is).filter(node => ['libraries', 'hardware'].indexOf(this.labelProvider.getName(node)) === -1);
}
if (SketchbookTree.SketchDirNode.is(parent)) {
return children.filter(FileStatNode.is);
}
return children;
}
protected async maybeDecorateNode(node: TreeNode, showAllFiles: boolean): Promise<TreeNode> {
if (DirNode.is(node)) {
const sketch = await this.sketchesService.maybeLoadSketch(node.uri.toString());
if (sketch) {
Object.assign(node, { type: 'sketch', commands: [SketchbookCommands.OPEN_SKETCHBOOK_CONTEXT_MENU] });
if (!showAllFiles) {
delete (node as any).expanded;
}
return node;
}
}
return node;
}
}
export namespace SketchbookTree {
export interface RootNode extends DirNode {
readonly showAllFiles: boolean;
}
export namespace RootNode {
export function is(node: TreeNode & Partial<RootNode>): node is RootNode {
return typeof node.showAllFiles === 'boolean';
}
export function create(fileStat: FileStat, showAllFiles: boolean): RootNode {
return Object.assign(DirNode.createRoot(fileStat), { showAllFiles, visible: false });
}
}
export interface SketchDirNode extends DirNode {
readonly type: 'sketch';
readonly commands?: Command[];
}
export namespace SketchDirNode {
export function is(node: TreeNode & Partial<SketchDirNode> | undefined): node is SketchDirNode {
return !!node && node.type === 'sketch' && DirNode.is(node);
}
}
}

View File

@@ -0,0 +1,156 @@
import { shell } from 'electron';
import { inject, injectable } from 'inversify';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
import { PreferenceService } from '@theia/core/lib/browser/preferences/preference-service';
import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { MainMenuManager } from '../../../common/main-menu-manager';
import { ArduinoPreferences } from '../../arduino-preferences';
import { SketchbookWidget } from './sketchbook-widget';
import { PlaceholderMenuNode } from '../../menu/arduino-menus';
import { SketchbookTree } from './sketchbook-tree';
import { SketchbookCommands } from './sketchbook-commands';
import { WorkspaceService } from '../../theia/workspace/workspace-service';
import { ContextMenuRenderer, RenderContextMenuOptions } from '@theia/core/lib/browser';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
export const SKETCHBOOK__CONTEXT = ['arduino-sketchbook--context'];
// `Open Folder`, `Open in New Window`
export const SKETCHBOOK__CONTEXT__MAIN_GROUP = [...SKETCHBOOK__CONTEXT, '0_main'];
@injectable()
export class SketchbookWidgetContribution extends AbstractViewContribution<SketchbookWidget> implements FrontendApplicationContribution {
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(MainMenuManager)
protected readonly mainMenuManager: MainMenuManager;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(MenuModelRegistry)
protected readonly menuRegistry: MenuModelRegistry;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
@inject(ContextMenuRenderer)
protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(FileService)
protected readonly fileService: FileService;
protected readonly toDisposeBeforeNewContextMenu = new DisposableCollection();
constructor() {
super({
widgetId: 'arduino-sketchbook-widget',
widgetName: 'Sketchbook',
defaultWidgetOptions: {
area: 'left',
rank: 1
},
toggleCommandId: 'arduino-sketchbook-widget:toggle',
toggleKeybinding: 'CtrlCmd+Shift+B'
});
}
onStart(): void {
this.arduinoPreferences.onPreferenceChanged(({ preferenceName }) => {
if (preferenceName === 'arduino.sketchbook.showAllFiles') {
this.mainMenuManager.update();
}
});
}
async initializeLayout(): Promise<void> {
return this.openView() as Promise<any>;
}
registerCommands(registry: CommandRegistry): void {
super.registerCommands(registry);
registry.registerCommand(SketchbookCommands.OPEN_NEW_WINDOW, {
execute: async arg => {
const underlying = await this.fileService.toUnderlyingResource(arg.node.uri);
return this.workspaceService.open(underlying)
},
isEnabled: arg => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node),
isVisible: arg => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node)
});
registry.registerCommand(SketchbookCommands.REVEAL_IN_FINDER, {
execute: (arg) => {
shell.openPath(arg.node.id);
},
isEnabled: (arg) => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node),
isVisible: (arg) => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node),
});
registry.registerCommand(SketchbookCommands.OPEN_SKETCHBOOK_CONTEXT_MENU, {
isEnabled: (arg) => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node),
isVisible: (arg) => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node),
execute: async (arg) => {
// cleanup previous context menu entries
this.toDisposeBeforeNewContextMenu.dispose();
const container = arg.event.target;
if (!container) {
return;
}
// disable the "open sketch" command for the current sketch.
// otherwise make the command clickable
const currentSketch = await this.sketchServiceClient.currentSketch();
if (currentSketch && currentSketch.uri === arg.node.uri.toString()) {
const placeholder = new PlaceholderMenuNode(SKETCHBOOK__CONTEXT__MAIN_GROUP, SketchbookCommands.OPEN_NEW_WINDOW.label!);
this.menuRegistry.registerMenuNode(SKETCHBOOK__CONTEXT__MAIN_GROUP, placeholder);
this.toDisposeBeforeNewContextMenu.push(Disposable.create(() => this.menuRegistry.unregisterMenuNode(placeholder.id)));
} else {
this.menuRegistry.registerMenuAction(SKETCHBOOK__CONTEXT__MAIN_GROUP, {
commandId: SketchbookCommands.OPEN_NEW_WINDOW.id,
label: SketchbookCommands.OPEN_NEW_WINDOW.label,
});
this.toDisposeBeforeNewContextMenu.push(Disposable.create(() => this.menuRegistry.unregisterMenuAction(SketchbookCommands.OPEN_NEW_WINDOW)));
}
const options: RenderContextMenuOptions = {
menuPath: SKETCHBOOK__CONTEXT,
anchor: {
x: container.getBoundingClientRect().left,
y: container.getBoundingClientRect().top + container.offsetHeight
},
args: arg
}
this.contextMenuRenderer.render(options);
}
});
}
registerMenus(registry: MenuModelRegistry): void {
super.registerMenus(registry);
// unregister main menu action
registry.unregisterMenuAction({
commandId: 'arduino-sketchbook-widget:toggle',
});
registry.registerMenuAction(SKETCHBOOK__CONTEXT__MAIN_GROUP, {
commandId: SketchbookCommands.REVEAL_IN_FINDER.id,
label: SketchbookCommands.REVEAL_IN_FINDER.label,
order: '0'
});
}
}

View File

@@ -0,0 +1,85 @@
import { inject, injectable, postConstruct } from 'inversify';
import { toArray } from '@phosphor/algorithm';
import { IDragEvent } from '@phosphor/dragdrop';
import { DockPanel, Widget } from '@phosphor/widgets';
import { Message, MessageLoop } from '@phosphor/messaging';
import { Disposable } from '@theia/core/lib/common/disposable';
import { BaseWidget } from '@theia/core/lib/browser/widgets/widget';
import { SketchbookTreeWidget } from './sketchbook-tree-widget';
@injectable()
export class SketchbookWidget extends BaseWidget {
@inject(SketchbookTreeWidget)
protected readonly localSketchbookTreeWidget: SketchbookTreeWidget;
protected readonly sketchbookTreesContainer: DockPanel;
constructor() {
super();
this.id = 'arduino-sketchbook-widget';
this.title.caption = 'Sketchbook';
this.title.label = 'Sketchbook';
this.title.iconClass = 'sketchbook-tab-icon';
this.title.closable = true;
this.node.tabIndex = 0;
this.sketchbookTreesContainer = this.createTreesContainer();
}
@postConstruct()
protected init(): void {
this.sketchbookTreesContainer.addWidget(this.localSketchbookTreeWidget);
}
protected onAfterAttach(message: Message): void {
super.onAfterAttach(message);
Widget.attach(this.sketchbookTreesContainer, this.node);
this.toDisposeOnDetach.push(Disposable.create(() => Widget.detach(this.sketchbookTreesContainer)));
}
protected onActivateRequest(message: Message): void {
super.onActivateRequest(message);
// TODO: focus the active sketchbook
// if (this.editor) {
// this.editor.focus();
// } else {
// }
this.node.focus();
}
protected onResize(message: Widget.ResizeMessage): void {
super.onResize(message);
MessageLoop.sendMessage(this.sketchbookTreesContainer, Widget.ResizeMessage.UnknownSize);
for (const widget of toArray(this.sketchbookTreesContainer.widgets())) {
MessageLoop.sendMessage(widget, Widget.ResizeMessage.UnknownSize);
}
}
protected onAfterShow(msg: Message): void {
super.onAfterShow(msg);
this.onResize(Widget.ResizeMessage.UnknownSize);
}
protected createTreesContainer(): DockPanel {
const panel = new NoopDragOverDockPanel({ spacing: 0, mode: 'single-document' });
panel.addClass('sketchbook-trees-container');
panel.node.tabIndex = -1;
return panel;
}
}
export class NoopDragOverDockPanel extends DockPanel {
constructor(options?: DockPanel.IOptions) {
super(options);
NoopDragOverDockPanel.prototype['_evtDragOver'] = (event: IDragEvent) => {
event.preventDefault();
event.stopPropagation();
event.dropAction = 'none';
};
}
}