mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-11-13 20:29:27 +00:00
[atl-1217] sketchbook explorer local & remote
This commit is contained in:
committed by
Francesco Stasi
parent
e6cbefb880
commit
4c536ec8fc
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user