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