Improve remote sketchbook explorer (#459)

* Refactor remote sketchbook explorer
* sketches sorting
This commit is contained in:
Francesco Stasi 2021-07-22 14:34:10 +02:00 committed by GitHub
parent 4da5d573e4
commit d790266cc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 592 additions and 613 deletions

View File

@ -236,6 +236,7 @@ import { CloudSketchbookCompositeWidget } from './widgets/cloud-sketchbook/cloud
import { SketchbookWidget } from './widgets/sketchbook/sketchbook-widget'; import { SketchbookWidget } from './widgets/sketchbook/sketchbook-widget';
import { SketchbookTreeWidget } from './widgets/sketchbook/sketchbook-tree-widget'; import { SketchbookTreeWidget } from './widgets/sketchbook/sketchbook-tree-widget';
import { createSketchbookTreeWidget } from './widgets/sketchbook/sketchbook-tree-container'; import { createSketchbookTreeWidget } from './widgets/sketchbook/sketchbook-tree-container';
import { SketchCache } from './widgets/cloud-sketchbook/cloud-sketch-cache';
const ElementQueries = require('css-element-queries/src/ElementQueries'); const ElementQueries = require('css-element-queries/src/ElementQueries');
@ -686,6 +687,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
createCloudSketchbookTreeWidget(container) createCloudSketchbookTreeWidget(container)
); );
bind(CreateApi).toSelf().inSingletonScope(); bind(CreateApi).toSelf().inSingletonScope();
bind(SketchCache).toSelf().inSingletonScope();
bind(ShareSketchDialog).toSelf().inSingletonScope(); bind(ShareSketchDialog).toSelf().inSingletonScope();
bind(AuthenticationClientService).toSelf().inSingletonScope(); bind(AuthenticationClientService).toSelf().inSingletonScope();
bind(CommandContribution).toService(AuthenticationClientService); bind(CommandContribution).toService(AuthenticationClientService);

View File

@ -1,8 +1,10 @@
import { injectable } from 'inversify'; import { injectable, inject } from 'inversify';
import * as createPaths from './create-paths'; import * as createPaths from './create-paths';
import { posix, splitSketchPath } from './create-paths'; import { posix } from './create-paths';
import { AuthenticationClientService } from '../auth/authentication-client-service'; import { AuthenticationClientService } from '../auth/authentication-client-service';
import { ArduinoPreferences } from '../arduino-preferences'; import { ArduinoPreferences } from '../arduino-preferences';
import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache';
import { Create, CreateError } from './typings';
export interface ResponseResultProvider { export interface ResponseResultProvider {
(response: Response): Promise<any>; (response: Response): Promise<any>;
@ -15,10 +17,11 @@ export namespace ResponseResultProvider {
type ResourceType = 'f' | 'd'; type ResourceType = 'f' | 'd';
export let sketchCache: Create.Sketch[] = [];
@injectable() @injectable()
export class CreateApi { export class CreateApi {
@inject(SketchCache)
protected sketchCache: SketchCache;
protected authenticationService: AuthenticationClientService; protected authenticationService: AuthenticationClientService;
protected arduinoPreferences: ArduinoPreferences; protected arduinoPreferences: ArduinoPreferences;
@ -32,48 +35,20 @@ export class CreateApi {
return this; return this;
} }
public sketchCompareByPath = (param: string) => {
return (sketch: Create.Sketch) => {
const [, spath] = splitSketchPath(sketch.path);
return param === spath;
};
};
async findSketchInCache(
compareFn: (sketch: Create.Sketch) => boolean,
trustCache = true
): Promise<Create.Sketch | undefined> {
const sketch = sketchCache.find((sketch) => compareFn(sketch));
if (trustCache) {
return Promise.resolve(sketch);
}
return await this.sketch({ id: sketch?.id });
}
getSketchSecretStat(sketch: Create.Sketch): Create.Resource { getSketchSecretStat(sketch: Create.Sketch): Create.Resource {
return { return {
href: `${sketch.href}${posix.sep}${Create.arduino_secrets_file}`, href: `${sketch.href}${posix.sep}${Create.arduino_secrets_file}`,
modified_at: sketch.modified_at, modified_at: sketch.modified_at,
created_at: sketch.created_at,
name: `${Create.arduino_secrets_file}`, name: `${Create.arduino_secrets_file}`,
path: `${sketch.path}${posix.sep}${Create.arduino_secrets_file}`, path: `${sketch.path}${posix.sep}${Create.arduino_secrets_file}`,
mimetype: 'text/x-c++src; charset=utf-8', mimetype: 'text/x-c++src; charset=utf-8',
type: 'file', type: 'file',
sketchId: sketch.id,
}; };
} }
async sketch(opt: { async sketch(id: string): Promise<Create.Sketch> {
id?: string; const url = new URL(`${this.domain()}/sketches/byID/${id}`);
path?: string;
}): Promise<Create.Sketch | undefined> {
let url;
if (opt.id) {
url = new URL(`${this.domain()}/sketches/byID/${opt.id}`);
} else if (opt.path) {
url = new URL(`${this.domain()}/sketches/byPath${opt.path}`);
} else {
return;
}
url.searchParams.set('user_id', 'me'); url.searchParams.set('user_id', 'me');
const headers = await this.headers(); const headers = await this.headers();
@ -92,7 +67,7 @@ export class CreateApi {
method: 'GET', method: 'GET',
headers, headers,
}); });
sketchCache = result.sketches; result.sketches.forEach((sketch) => this.sketchCache.addSketch(sketch));
return result.sketches; return result.sketches;
} }
@ -118,7 +93,7 @@ export class CreateApi {
async readDirectory( async readDirectory(
posixPath: string, posixPath: string,
options: { recursive?: boolean; match?: string; secrets?: boolean } = {} options: { recursive?: boolean; match?: string } = {}
): Promise<Create.Resource[]> { ): Promise<Create.Resource[]> {
const url = new URL( const url = new URL(
`${this.domain()}/files/d/$HOME/sketches_v2${posixPath}` `${this.domain()}/files/d/$HOME/sketches_v2${posixPath}`
@ -131,58 +106,21 @@ export class CreateApi {
} }
const headers = await this.headers(); const headers = await this.headers();
const sketchProm = options.secrets return this.run<Create.RawResource[]>(url, {
? this.sketches() method: 'GET',
: Promise.resolve(sketchCache); headers,
})
.then(async (result) => {
// add arduino_secrets.h to the results, when reading a sketch main folder
if (posixPath.length && posixPath !== posix.sep) {
const sketch = this.sketchCache.getSketch(posixPath);
return Promise.all([ if (sketch && sketch.secrets && sketch.secrets.length > 0) {
this.run<Create.RawResource[]>(url, { result.push(this.getSketchSecretStat(sketch));
method: 'GET',
headers,
}),
sketchProm,
])
.then(async ([result, sketches]) => {
if (options.secrets) {
// for every sketch with secrets, create a fake arduino_secrets.h
result.forEach(async (res) => {
if (res.type !== 'sketch') {
return;
}
const [, spath] = createPaths.splitSketchPath(res.path);
const sketch = await this.findSketchInCache(
this.sketchCompareByPath(spath)
);
if (sketch && sketch.secrets && sketch.secrets.length > 0) {
result.push(this.getSketchSecretStat(sketch));
}
});
if (posixPath !== posix.sep) {
const sketch = await this.findSketchInCache(
this.sketchCompareByPath(posixPath)
);
if (sketch && sketch.secrets && sketch.secrets.length > 0) {
result.push(this.getSketchSecretStat(sketch));
}
} }
} }
const sketchesMap: Record<string, Create.Sketch> = sketches.reduce(
(prev, curr) => {
return { ...prev, [curr.path]: curr };
},
{}
);
// add the sketch id and isPublic to the resource return result;
return result.map((resource) => {
return {
...resource,
sketchId: sketchesMap[resource.path]?.id || '',
isPublic: sketchesMap[resource.path]?.is_public || false,
};
});
}) })
.catch((reason) => { .catch((reason) => {
if (reason?.status === 404) return [] as Create.Resource[]; if (reason?.status === 404) return [] as Create.Resource[];
@ -214,18 +152,16 @@ export class CreateApi {
let resources; let resources;
if (basename === Create.arduino_secrets_file) { if (basename === Create.arduino_secrets_file) {
const sketch = await this.findSketchInCache( const sketch = this.sketchCache.getSketch(parentPosixPath);
this.sketchCompareByPath(parentPosixPath)
);
resources = sketch ? [this.getSketchSecretStat(sketch)] : []; resources = sketch ? [this.getSketchSecretStat(sketch)] : [];
} else { } else {
resources = await this.readDirectory(parentPosixPath, { resources = await this.readDirectory(parentPosixPath, {
match: basename, match: basename,
}); });
} }
const resource = resources.find(
resources.sort((left, right) => left.path.length - right.path.length); ({ path }) => createPaths.splitSketchPath(path)[1] === posixPath
const resource = resources.find(({ name }) => name === basename); );
if (!resource) { if (!resource) {
throw new CreateError(`Not found: ${posixPath}.`, 404); throw new CreateError(`Not found: ${posixPath}.`, 404);
} }
@ -248,10 +184,7 @@ export class CreateApi {
return data; return data;
} }
const sketch = await this.findSketchInCache((sketch) => { const sketch = this.sketchCache.getSketch(createPaths.parentPosix(path));
const [, spath] = splitSketchPath(sketch.path);
return spath === createPaths.parentPosix(path);
}, true);
if ( if (
sketch && sketch &&
@ -273,14 +206,25 @@ export class CreateApi {
if (basename === Create.arduino_secrets_file) { if (basename === Create.arduino_secrets_file) {
const parentPosixPath = createPaths.parentPosix(posixPath); const parentPosixPath = createPaths.parentPosix(posixPath);
const sketch = await this.findSketchInCache(
this.sketchCompareByPath(parentPosixPath), //retrieve the sketch id from the cache
false const cacheSketch = this.sketchCache.getSketch(parentPosixPath);
); if (!cacheSketch) {
throw new Error(`Unable to find sketch ${parentPosixPath} in cache`);
}
// get a fresh copy of the sketch in order to guarantee fresh secrets
const sketch = await this.sketch(cacheSketch.id);
if (!sketch) {
throw new Error(
`Unable to get a fresh copy of the sketch ${cacheSketch.id}`
);
}
this.sketchCache.addSketch(sketch);
let file = ''; let file = '';
if (sketch && sketch.secrets) { if (sketch && sketch.secrets) {
for (const item of sketch?.secrets) { for (const item of sketch.secrets) {
file += `#define ${item.name} "${item.value}"\r\n`; file += `#define ${item.name} "${item.value}"\r\n`;
} }
} }
@ -310,9 +254,9 @@ export class CreateApi {
if (basename === Create.arduino_secrets_file) { if (basename === Create.arduino_secrets_file) {
const parentPosixPath = createPaths.parentPosix(posixPath); const parentPosixPath = createPaths.parentPosix(posixPath);
const sketch = await this.findSketchInCache(
this.sketchCompareByPath(parentPosixPath) const sketch = this.sketchCache.getSketch(parentPosixPath);
);
if (sketch) { if (sketch) {
const url = new URL(`${this.domain()}/sketches/${sketch.id}`); const url = new URL(`${this.domain()}/sketches/${sketch.id}`);
const headers = await this.headers(); const headers = await this.headers();
@ -356,9 +300,10 @@ export class CreateApi {
secrets: { data: secrets }, secrets: { data: secrets },
}; };
// replace the sketch in the cache, so other calls will not overwrite each other // replace the sketch in the cache with the one we are pushing
sketchCache = sketchCache.filter((skt) => skt.id !== sketch.id); // TODO: we should do a get after the POST, in order to be sure the cache
sketchCache.push({ ...sketch, secrets }); // is updated the most recent metadata
this.sketchCache.addSketch(sketch);
const init = { const init = {
method: 'POST', method: 'POST',
@ -370,6 +315,14 @@ export class CreateApi {
return; return;
} }
// do not upload "do_not_sync" files/directoris and their descendants
const segments = posixPath.split(posix.sep) || [];
if (
segments.some((segment) => Create.do_not_sync_files.includes(segment))
) {
return;
}
const url = new URL( const url = new URL(
`${this.domain()}/files/f/$HOME/sketches_v2${posixPath}` `${this.domain()}/files/f/$HOME/sketches_v2${posixPath}`
); );
@ -512,75 +465,3 @@ void loop() {
`; `;
} }
export namespace Create {
export interface Sketch {
readonly name: string;
readonly path: string;
readonly modified_at: string;
readonly created_at: string;
readonly secrets?: { name: string; value: string }[];
readonly id: string;
readonly is_public: boolean;
// readonly board_fqbn: '',
// readonly board_name: '',
// readonly board_type: 'serial' | 'network' | 'cloud' | '',
readonly href?: string;
readonly libraries: string[];
// readonly tutorials: string[] | null;
// readonly types: string[] | null;
// readonly user_id: string;
}
export type ResourceType = 'sketch' | 'folder' | 'file';
export const arduino_secrets_file = 'arduino_secrets.h';
export interface Resource {
readonly name: string;
/**
* Note: this path is **not** the POSIX path we use. It has the leading segments with the `user_id`.
*/
readonly path: string;
readonly type: ResourceType;
readonly sketchId: string;
readonly modified_at: string; // As an ISO-8601 formatted string: `YYYY-MM-DDTHH:mm:ss.sssZ`
readonly children?: number; // For 'sketch' and 'folder' types.
readonly size?: number; // For 'sketch' type only.
readonly isPublic?: boolean; // For 'sketch' type only.
readonly mimetype?: string; // For 'file' type.
readonly href?: string;
}
export namespace Resource {
export function is(arg: any): arg is Resource {
return (
!!arg &&
'name' in arg &&
typeof arg['name'] === 'string' &&
'path' in arg &&
typeof arg['path'] === 'string' &&
'type' in arg &&
typeof arg['type'] === 'string' &&
'modified_at' in arg &&
typeof arg['modified_at'] === 'string' &&
(arg['type'] === 'sketch' ||
arg['type'] === 'folder' ||
arg['type'] === 'file')
);
}
}
export type RawResource = Omit<Resource, 'sketchId' | 'isPublic'>;
}
export class CreateError extends Error {
constructor(
message: string,
readonly status: number,
readonly details?: string
) {
super(message);
Object.setPrototypeOf(this, CreateError.prototype);
}
}

View File

@ -24,10 +24,11 @@ import {
FileServiceContribution, FileServiceContribution,
} from '@theia/filesystem/lib/browser/file-service'; } from '@theia/filesystem/lib/browser/file-service';
import { AuthenticationClientService } from '../auth/authentication-client-service'; import { AuthenticationClientService } from '../auth/authentication-client-service';
import { Create, CreateApi } from './create-api'; import { CreateApi } from './create-api';
import { CreateUri } from './create-uri'; import { CreateUri } from './create-uri';
import { SketchesService } from '../../common/protocol'; import { SketchesService } from '../../common/protocol';
import { ArduinoPreferences } from '../arduino-preferences'; import { ArduinoPreferences } from '../arduino-preferences';
import { Create } from './typings';
export const REMOTE_ONLY_FILES = ['sketch.json']; export const REMOTE_ONLY_FILES = ['sketch.json'];
@ -106,10 +107,7 @@ export class CreateFsProvider
async readdir(uri: URI): Promise<[string, FileType][]> { async readdir(uri: URI): Promise<[string, FileType][]> {
const resources = await this.getCreateApi.readDirectory( const resources = await this.getCreateApi.readDirectory(
uri.path.toString(), uri.path.toString()
{
secrets: true,
}
); );
return resources return resources
.filter((res) => !REMOTE_ONLY_FILES.includes(res.name)) .filter((res) => !REMOTE_ONLY_FILES.includes(res.name))

View File

@ -1,7 +1,7 @@
import { URI as Uri } from 'vscode-uri'; import { URI as Uri } from 'vscode-uri';
import URI from '@theia/core/lib/common/uri'; import URI from '@theia/core/lib/common/uri';
import { Create } from './create-api';
import { toPosixPath, parentPosix, posix } from './create-paths'; import { toPosixPath, parentPosix, posix } from './create-paths';
import { Create } from './typings';
export namespace CreateUri { export namespace CreateUri {
export const scheme = 'arduino-create'; export const scheme = 'arduino-create';

View File

@ -0,0 +1,73 @@
export namespace Create {
export interface Sketch {
readonly name: string;
readonly path: string;
readonly modified_at: string;
readonly created_at: string;
readonly secrets?: { name: string; value: string }[];
readonly id: string;
readonly is_public: boolean;
readonly board_fqbn: '';
readonly board_name: '';
readonly board_type: 'serial' | 'network' | 'cloud' | '';
readonly href?: string;
readonly libraries: string[];
readonly tutorials: string[] | null;
readonly types: string[] | null;
readonly user_id: string;
}
export type ResourceType = 'sketch' | 'folder' | 'file';
export const arduino_secrets_file = 'arduino_secrets.h';
export const do_not_sync_files = ['.theia'];
export interface Resource {
readonly name: string;
/**
* Note: this path is **not** the POSIX path we use. It has the leading segments with the `user_id`.
*/
readonly path: string;
readonly type: ResourceType;
readonly sketchId?: string;
readonly modified_at: string; // As an ISO-8601 formatted string: `YYYY-MM-DDTHH:mm:ss.sssZ`
readonly created_at: string; // As an ISO-8601 formatted string: `YYYY-MM-DDTHH:mm:ss.sssZ`
readonly children?: number; // For 'sketch' and 'folder' types.
readonly size?: number; // For 'sketch' type only.
readonly isPublic?: boolean; // For 'sketch' type only.
readonly mimetype?: string; // For 'file' type.
readonly href?: string;
}
export namespace Resource {
export function is(arg: any): arg is Resource {
return (
!!arg &&
'name' in arg &&
typeof arg['name'] === 'string' &&
'path' in arg &&
typeof arg['path'] === 'string' &&
'type' in arg &&
typeof arg['type'] === 'string' &&
'modified_at' in arg &&
typeof arg['modified_at'] === 'string' &&
(arg['type'] === 'sketch' ||
arg['type'] === 'folder' ||
arg['type'] === 'file')
);
}
}
export type RawResource = Omit<Resource, 'sketchId' | 'isPublic'>;
}
export class CreateError extends Error {
constructor(
message: string,
readonly status: number,
readonly details?: string
) {
super(message);
Object.setPrototypeOf(this, CreateError.prototype);
}
}

View File

@ -103,7 +103,7 @@ export class LocalCacheFsProvider
}); });
} }
private get currentUserUri(): URI { public get currentUserUri(): URI {
const { session } = this.authenticationService; const { session } = this.authenticationService;
if (!session) { if (!session) {
throw new FileSystemProviderError( throw new FileSystemProviderError(

View File

@ -0,0 +1,34 @@
import { FileStat } from '@theia/filesystem/lib/common/files';
import { injectable } from 'inversify';
import { toPosixPath } from '../../create/create-paths';
import { Create } from '../../create/typings';
@injectable()
export class SketchCache {
sketches: Record<string, Create.Sketch> = {};
fileStats: Record<string, FileStat> = {};
init(): void {
// reset the data
this.sketches = {};
this.fileStats = {};
}
addItem(item: FileStat): void {
this.fileStats[item.resource.path.toString()] = item;
}
getItem(path: string): FileStat | null {
return this.fileStats[path] || null;
}
addSketch(sketch: Create.Sketch): void {
const { path } = sketch;
const posixPath = toPosixPath(path);
this.sketches[posixPath] = sketch;
}
getSketch(path: string): Create.Sketch | null {
return this.sketches[path] || null;
}
}

View File

@ -35,6 +35,10 @@ export class CloudSketchbookCompositeWidget extends BaseWidget {
this.id = 'cloud-sketchbook-composite-widget'; this.id = 'cloud-sketchbook-composite-widget';
} }
public getTreeWidget(): CloudSketchbookTreeWidget {
return this.cloudSketchbookTreeWidget;
}
protected onAfterAttach(message: Message): void { protected onAfterAttach(message: Message): void {
super.onAfterAttach(message); super.onAfterAttach(message);
Widget.attach(this.cloudSketchbookTreeWidget, this.compositeNode); Widget.attach(this.cloudSketchbookTreeWidget, this.compositeNode);

View File

@ -166,11 +166,11 @@ export class CloudSketchbookContribution extends Contribution {
isEnabled: (arg) => isEnabled: (arg) =>
CloudSketchbookCommands.Arg.is(arg) && CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node) && CloudSketchbookTree.CloudSketchDirNode.is(arg.node) &&
!!arg.node.synced, CloudSketchbookTree.CloudSketchTreeNode.isSynced(arg.node),
isVisible: (arg) => isVisible: (arg) =>
CloudSketchbookCommands.Arg.is(arg) && CloudSketchbookCommands.Arg.is(arg) &&
CloudSketchbookTree.CloudSketchDirNode.is(arg.node) && CloudSketchbookTree.CloudSketchDirNode.is(arg.node) &&
!!arg.node.synced, CloudSketchbookTree.CloudSketchTreeNode.isSynced(arg.node),
}); });
registry.registerCommand(CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR, { registry.registerCommand(CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR, {
@ -257,18 +257,10 @@ export class CloudSketchbookContribution extends Contribution {
const currentSketch = await this.sketchServiceClient.currentSketch(); 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 // disable the "open sketch" command for the current sketch and for those not in sync
if ( if (
!underlying || !CloudSketchbookTree.CloudSketchTreeNode.isSynced(arg.node) ||
(currentSketch && currentSketch.uri === underlying.toString()) (currentSketch && currentSketch.uri === arg.node.uri.toString())
) { ) {
const placeholder = new PlaceholderMenuNode( const placeholder = new PlaceholderMenuNode(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP, SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
@ -284,7 +276,6 @@ export class CloudSketchbookContribution extends Contribution {
) )
); );
} else { } else {
arg.node.uri = localUri;
this.menuRegistry.registerMenuAction( this.menuRegistry.registerMenuAction(
SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP, SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP,
{ {

View File

@ -1,81 +1,76 @@
import { inject, injectable, postConstruct } from 'inversify'; import { inject, injectable, postConstruct } from 'inversify';
import { TreeNode } from '@theia/core/lib/browser/tree'; import { TreeNode } from '@theia/core/lib/browser/tree';
import { toPosixPath, posixSegments, posix } from '../../create/create-paths'; import { posixSegments, splitSketchPath } from '../../create/create-paths';
import { CreateApi, Create } from '../../create/create-api'; import { CreateApi } from '../../create/create-api';
import { CloudSketchbookTree } from './cloud-sketchbook-tree'; import { CloudSketchbookTree } from './cloud-sketchbook-tree';
import { AuthenticationClientService } from '../../auth/authentication-client-service'; 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 { SketchbookTreeModel } from '../sketchbook/sketchbook-tree-model';
import { ArduinoPreferences } from '../../arduino-preferences'; import { ArduinoPreferences } from '../../arduino-preferences';
import { ConfigService } from '../../../common/protocol'; import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree';
import { CreateUri } from '../../create/create-uri';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { LocalCacheFsProvider } from '../../local-cache/local-cache-fs-provider';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import URI from '@theia/core/lib/common/uri';
import { SketchCache } from './cloud-sketch-cache';
import { Create } from '../../create/typings';
export type CreateCache = Record<string, Create.Resource>; export function sketchBaseDir(sketch: Create.Sketch): FileStat {
export namespace CreateCache { // extract the sketch path
export function build(resources: Create.Resource[]): CreateCache { const [, path] = splitSketchPath(sketch.path);
const treeData: CreateCache = {}; const dirs = posixSegments(path);
treeData[posix.sep] = CloudSketchbookTree.rootResource;
for (const resource of resources) { const mtime = Date.parse(sketch.modified_at);
const { path } = resource; const ctime = Date.parse(sketch.created_at);
const posixPath = toPosixPath(path); const createPath = CreateUri.toUri(dirs[0]);
if (treeData[posixPath] !== undefined) { const baseDir: FileStat = {
throw new Error( name: dirs[0],
`Already visited resource for path: ${posixPath}.\nData: ${JSON.stringify( isDirectory: true,
treeData, isFile: false,
null, isSymbolicLink: false,
2 resource: createPath,
)}` mtime,
); ctime,
} };
treeData[posixPath] = resource; return baseDir;
} }
return treeData;
export function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] {
const sketchesBaseDirs: Record<string, FileStat> = {};
for (const sketch of sketches) {
const sketchBaseDirFileStat = sketchBaseDir(sketch);
sketchesBaseDirs[sketchBaseDirFileStat.resource.toString()] =
sketchBaseDirFileStat;
} }
export function childrenOf( return Object.keys(sketchesBaseDirs).map(
resource: Create.Resource, (dirUri) => sketchesBaseDirs[dirUri]
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() @injectable()
export class CloudSketchbookTreeModel extends SketchbookTreeModel { export class CloudSketchbookTreeModel extends SketchbookTreeModel {
@inject(FileService)
protected readonly fileService: FileService;
@inject(AuthenticationClientService) @inject(AuthenticationClientService)
protected readonly authenticationService: AuthenticationClientService; protected readonly authenticationService: AuthenticationClientService;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(CreateApi) @inject(CreateApi)
protected readonly createApi: CreateApi; protected readonly createApi: CreateApi;
@inject(CloudSketchbookTree) @inject(CloudSketchbookTree)
protected readonly cloudSketchbookTree: CloudSketchbookTree; protected readonly cloudSketchbookTree: CloudSketchbookTree;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(LocalCacheFsProvider) @inject(LocalCacheFsProvider)
protected readonly localCacheFsProvider: LocalCacheFsProvider; protected readonly localCacheFsProvider: LocalCacheFsProvider;
@inject(CommandRegistry) @inject(SketchCache)
public readonly commandRegistry: CommandRegistry; protected readonly sketchCache: SketchCache;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@postConstruct() @postConstruct()
protected init(): void { protected init(): void {
@ -85,56 +80,25 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
); );
} }
async updateRoot(): Promise<void> { async createRoot(): Promise<TreeNode | undefined> {
const { session } = this.authenticationService; const { session } = this.authenticationService;
if (!session) { if (!session) {
this.tree.root = undefined; this.tree.root = undefined;
return; return;
} }
this.createApi.init(this.authenticationService, this.arduinoPreferences); this.createApi.init(this.authenticationService, this.arduinoPreferences);
this.sketchCache.init();
const resources = await this.createApi.readDirectory(posix.sep, { const sketches = await this.createApi.sketches();
recursive: true, const rootFileStats = sketchesToFileStats(sketches);
secrets: true, if (this.workspaceService.opened) {
}); const workspaceNode = WorkspaceNode.createRoot('Remote');
for await (const stat of rootFileStats) {
const cache = CreateCache.build(resources); workspaceNode.children.push(
await this.tree.createWorkspaceRoot(stat, workspaceNode)
// 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;
});
}
} }
return workspaceNode;
} }
const showAllFiles =
this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
this.tree.root = CloudSketchbookTree.CloudRootNode.create(
cache,
showAllFiles
);
} }
sketchbookTree(): CloudSketchbookTree { sketchbookTree(): CloudSketchbookTree {
@ -143,9 +107,6 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
protected recursivelyFindSketchRoot(node: TreeNode): any { protected recursivelyFindSketchRoot(node: TreeNode): any {
if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) { if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) {
if (node.hasOwnProperty('underlying')) {
return { ...node, uri: node.underlying };
}
return node; return node;
} }
@ -156,4 +117,15 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
// can't find a root, return false // can't find a root, return false
return false; return false;
} }
async revealFile(uri: URI): Promise<TreeNode | undefined> {
// we use remote uris as keys for the tree
// convert local URIs
const remoteuri = this.localCacheFsProvider.from(uri);
if (remoteuri) {
return super.revealFile(remoteuri);
} else {
return super.revealFile(uri);
}
}
} }

View File

@ -91,7 +91,7 @@ export class CloudSketchbookTreeWidget extends SketchbookTreeWidget {
CloudSketchbookTree.CloudSketchDirNode.is(node) && CloudSketchbookTree.CloudSketchDirNode.is(node) &&
node.commands && node.commands &&
(node.id === this.hoveredNodeId || (node.id === this.hoveredNodeId ||
this.currentSketchUri === node.underlying?.toString()) this.currentSketchUri === node.uri.toString())
) { ) {
return Array.from(new Set(node.commands)).map((command) => return Array.from(new Set(node.commands)).map((command) =>
this.renderInlineCommand(command.id, node) this.renderInlineCommand(command.id, node)
@ -135,37 +135,17 @@ export class CloudSketchbookTreeWidget extends SketchbookTreeWidget {
); );
} }
protected async handleClickEvent( protected handleDblClickEvent(
node: any, node: TreeNode,
event: React.MouseEvent<HTMLElement> event: React.MouseEvent<HTMLElement>
) { ): void {
event.persist(); event.persist();
let uri = node.uri; if (
// overwrite the uri using the local-cache CloudSketchbookTree.CloudSketchTreeNode.is(node) &&
const localUri = await this.cloudSketchbookTree.localUri(node); CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)
if (node && localUri) { ) {
const underlying = await this.fileService.toUnderlyingResource(localUri); super.handleDblClickEvent(node, event);
uri = underlying;
}
super.handleClickEvent({ ...node, uri }, event);
}
protected async handleDblClickEvent(
node: any,
event: React.MouseEvent<HTMLElement>
) {
event.persist();
let uri = node.uri;
// overwrite the uri using the local-cache
// if the localURI does not exists, ignore the double click, so that the sketch is not opened
const localUri = await this.cloudSketchbookTree.localUri(node);
if (node && localUri) {
const underlying = await this.fileService.toUnderlyingResource(localUri);
uri = underlying;
super.handleDblClickEvent({ ...node, uri }, event);
} }
} }
} }

View File

@ -1,15 +1,15 @@
import { SketchCache } from './cloud-sketch-cache';
import { inject, injectable } from 'inversify'; import { inject, injectable } from 'inversify';
import URI from '@theia/core/lib/common/uri'; import URI from '@theia/core/lib/common/uri';
import { MaybePromise } from '@theia/core/lib/common/types'; 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 { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStatNode } from '@theia/filesystem/lib/browser/file-tree'; import { FileStatNode } from '@theia/filesystem/lib/browser/file-tree';
import { Command } from '@theia/core/lib/common/command'; import { Command } from '@theia/core/lib/common/command';
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration'; import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator'; import { DecoratedTreeNode } from '@theia/core/lib/browser/tree/tree-decorator';
import { import {
FileNode,
DirNode, DirNode,
FileNode,
} from '@theia/filesystem/lib/browser/file-tree/file-tree'; } from '@theia/filesystem/lib/browser/file-tree/file-tree';
import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree'; import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree';
import { import {
@ -18,20 +18,21 @@ import {
} from '@theia/core/lib/browser/preferences/preference-service'; } from '@theia/core/lib/browser/preferences/preference-service';
import { MessageService } from '@theia/core/lib/common/message-service'; import { MessageService } from '@theia/core/lib/common/message-service';
import { REMOTE_ONLY_FILES } from './../../create/create-fs-provider'; import { REMOTE_ONLY_FILES } from './../../create/create-fs-provider';
import { posix } from '../../create/create-paths'; import { CreateApi } from '../../create/create-api';
import { Create, CreateApi } from '../../create/create-api';
import { CreateUri } from '../../create/create-uri'; import { CreateUri } from '../../create/create-uri';
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
import { import {
CloudSketchbookTreeModel, LocalCacheFsProvider,
CreateCache, LocalCacheUri,
} from './cloud-sketchbook-tree-model'; } from '../../local-cache/local-cache-fs-provider';
import { LocalCacheUri } from '../../local-cache/local-cache-fs-provider';
import { CloudSketchbookCommands } from './cloud-sketchbook-contributions'; import { CloudSketchbookCommands } from './cloud-sketchbook-contributions';
import { DoNotAskAgainConfirmDialog } from '../../dialogs.ts/dialogs'; import { DoNotAskAgainConfirmDialog } from '../../dialogs.ts/dialogs';
import { SketchbookTree } from '../sketchbook/sketchbook-tree'; import { SketchbookTree } from '../sketchbook/sketchbook-tree';
import { firstToUpperCase } from '../../../common/utils'; import { firstToUpperCase } from '../../../common/utils';
import { ArduinoPreferences } from '../../arduino-preferences'; import { ArduinoPreferences } from '../../arduino-preferences';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl'; import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree';
const MESSAGE_TIMEOUT = 5 * 1000; const MESSAGE_TIMEOUT = 5 * 1000;
const deepmerge = require('deepmerge').default; const deepmerge = require('deepmerge').default;
@ -41,6 +42,12 @@ export class CloudSketchbookTree extends SketchbookTree {
@inject(FileService) @inject(FileService)
protected readonly fileService: FileService; protected readonly fileService: FileService;
@inject(LocalCacheFsProvider)
protected readonly localCacheFsProvider: LocalCacheFsProvider;
@inject(SketchCache)
protected readonly sketchCache: SketchCache;
@inject(ArduinoPreferences) @inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences; protected readonly arduinoPreferences: ArduinoPreferences;
@ -95,7 +102,8 @@ export class CloudSketchbookTree extends SketchbookTree {
} = arg; } = arg;
const warn = const warn =
node.synced && this.arduinoPreferences['arduino.cloud.pull.warn']; CloudSketchbookTree.CloudSketchTreeNode.isSynced(node) &&
this.arduinoPreferences['arduino.cloud.pull.warn'];
if (warn) { if (warn) {
const ok = await new DoNotAskAgainConfirmDialog({ const ok = await new DoNotAskAgainConfirmDialog({
@ -120,11 +128,9 @@ export class CloudSketchbookTree extends SketchbookTree {
node.commands = []; node.commands = [];
// check if the sketch dir already exist // check if the sketch dir already exist
if (node.synced) { if (CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
const filesToPull = ( const filesToPull = (
await this.createApi.readDirectory(node.uri.path.toString(), { await this.createApi.readDirectory(node.remoteUri.path.toString())
secrets: true,
})
).filter((file: any) => !REMOTE_ONLY_FILES.includes(file.name)); ).filter((file: any) => !REMOTE_ONLY_FILES.includes(file.name));
await Promise.all( await Promise.all(
@ -140,9 +146,9 @@ export class CloudSketchbookTree extends SketchbookTree {
const currentSketch = await this.sketchServiceClient.currentSketch(); const currentSketch = await this.sketchServiceClient.currentSketch();
if ( if (
!CreateUri.is(node.uri) &&
currentSketch && currentSketch &&
node.underlying && currentSketch.uri === node.uri.toString()
currentSketch.uri === node.underlying.toString()
) { ) {
filesToPull.forEach(async (file) => { filesToPull.forEach(async (file) => {
const localUri = LocalCacheUri.root.resolve( const localUri = LocalCacheUri.root.resolve(
@ -157,7 +163,7 @@ export class CloudSketchbookTree extends SketchbookTree {
} }
} else { } else {
await this.fileService.copy( await this.fileService.copy(
node.uri, node.remoteUri,
LocalCacheUri.root.resolve(node.uri.path), LocalCacheUri.root.resolve(node.uri.path),
{ overwrite: true } { overwrite: true }
); );
@ -171,7 +177,7 @@ export class CloudSketchbookTree extends SketchbookTree {
} }
async push(node: CloudSketchbookTree.CloudSketchDirNode): Promise<void> { async push(node: CloudSketchbookTree.CloudSketchDirNode): Promise<void> {
if (!node.synced) { if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
throw new Error('Cannot push to Cloud. It is not yet pulled.'); throw new Error('Cannot push to Cloud. It is not yet pulled.');
} }
@ -201,20 +207,17 @@ export class CloudSketchbookTree extends SketchbookTree {
} }
} }
this.runWithState(node, 'pushing', async (node) => { this.runWithState(node, 'pushing', async (node) => {
if (!node.synced) { if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
throw new Error( throw new Error(
'You have to pull first to be able to push to the Cloud.' 'You have to pull first to be able to push to the Cloud.'
); );
} }
const commandsCopy = node.commands; const commandsCopy = node.commands;
node.commands = []; node.commands = [];
// delete every first level file, then push everything // delete every first level file, then push everything
const result = await this.fileService.copy( const result = await this.fileService.copy(node.uri, node.remoteUri, {
LocalCacheUri.root.resolve(node.uri.path), overwrite: true,
node.uri, });
{ overwrite: true }
);
node.commands = commandsCopy; node.commands = commandsCopy;
this.messageService.info(`Done pushing ${result.name}.`, { this.messageService.info(`Done pushing ${result.name}.`, {
timeout: MESSAGE_TIMEOUT, timeout: MESSAGE_TIMEOUT,
@ -225,23 +228,10 @@ export class CloudSketchbookTree extends SketchbookTree {
async refresh( async refresh(
node?: CompositeTreeNode node?: CompositeTreeNode
): Promise<CompositeTreeNode | undefined> { ): Promise<CompositeTreeNode | undefined> {
if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) { if (node) {
const localUri = await this.localUri(node); const showAllFiles =
if (localUri) { this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
node.synced = true; await this.decorateNode(node, showAllFiles);
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); return super.refresh(node);
} }
@ -276,20 +266,140 @@ export class CloudSketchbookTree extends SketchbookTree {
} }
} }
async resolveChildren(parent: CompositeTreeNode): Promise<TreeNode[]> {
return (await super.resolveChildren(parent)).sort((a, b) => {
if (
WorkspaceNode.is(parent) &&
FileStatNode.is(a) &&
FileStatNode.is(b)
) {
const syncNodeA =
CloudSketchbookTree.CloudSketchTreeNode.is(a) &&
CloudSketchbookTree.CloudSketchTreeNode.isSynced(a);
const syncNodeB =
CloudSketchbookTree.CloudSketchTreeNode.is(b) &&
CloudSketchbookTree.CloudSketchTreeNode.isSynced(b);
const syncComparison = Number(syncNodeB) - Number(syncNodeA);
// same sync status, compare on modified time
if (syncComparison === 0) {
return (a.fileStat.mtime || 0) - (b.fileStat.mtime || 0);
}
return syncComparison;
}
return 0;
});
}
/**
* Retrieve fileStats for the given node, merging the local and remote childrens
* Local children take prevedence over remote ones
* @param node
* @returns
*/
protected async resolveFileStat( protected async resolveFileStat(
node: FileStatNode node: FileStatNode
): Promise<FileStat | undefined> { ): Promise<FileStat | undefined> {
if ( if (
CreateUri.is(node.uri) && CloudSketchbookTree.CloudSketchTreeNode.is(node) &&
CloudSketchbookTree.CloudRootNode.is(this.root) CreateUri.is(node.remoteUri)
) { ) {
const resource = this.root.cache[node.uri.path.toString()]; let remoteFileStat: FileStat;
if (!resource) { const cacheHit = this.sketchCache.getItem(node.remoteUri.path.toString());
return undefined; if (cacheHit) {
remoteFileStat = cacheHit;
} else {
// not found, fetch and add it for future calls
remoteFileStat = await this.fileService.resolve(node.remoteUri);
if (remoteFileStat) {
this.sketchCache.addItem(remoteFileStat);
}
} }
return CloudSketchbookTree.toFileStat(resource, this.root.cache, 1);
const children: FileStat[] = [...(remoteFileStat?.children || [])];
const childrenLocalPaths = children.map((child) => {
return (
this.localCacheFsProvider.currentUserUri.path.toString() +
child.resource.path.toString()
);
});
// if the node is in sync, also get local-only children
if (CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
const localFileStat = await this.fileService.resolve(node.uri);
// merge the two children
for (const child of localFileStat.children || []) {
if (!childrenLocalPaths.includes(child.resource.path.toString())) {
children.push(child);
}
}
}
// add a remote uri for the children. it's used as ID for the nodes
const childrenWithRemoteUri: FileStat[] = await Promise.all(
children.map(async (childFs) => {
let remoteUri: URI = childFs.resource;
if (!CreateUri.is(childFs.resource)) {
let refUri = node.fileStat.resource;
if (node.fileStat.hasOwnProperty('remoteUri')) {
refUri = (node.fileStat as any).remoteUri;
}
remoteUri = refUri.resolve(childFs.name);
}
return { ...childFs, remoteUri };
})
);
const fileStat = { ...remoteFileStat, children: childrenWithRemoteUri };
node.fileStat = fileStat;
return fileStat;
} else {
// it's a local-only file
return super.resolveFileStat(node);
} }
return super.resolveFileStat(node); }
protected toNode(
fileStat: any,
parent: CompositeTreeNode
): FileNode | DirNode {
const uri = fileStat.resource;
let idUri;
if (fileStat.remoteUri) {
idUri = fileStat.remoteUri;
}
const id = this.toNodeId(idUri || uri, parent);
const node = this.getNode(id);
if (fileStat.isDirectory) {
if (DirNode.is(node)) {
node.fileStat = fileStat;
return node;
}
return <DirNode>{
id,
uri,
fileStat,
parent,
expanded: false,
selected: false,
children: [],
};
}
if (FileNode.is(node)) {
node.fileStat = fileStat;
return node;
}
return <FileNode>{
id,
uri,
fileStat,
parent,
selected: false,
};
} }
protected readonly notInSyncDecoration: WidgetDecoration.Data = { protected readonly notInSyncDecoration: WidgetDecoration.Data = {
@ -297,75 +407,90 @@ export class CloudSketchbookTree extends SketchbookTree {
color: 'var(--theia-activityBar-inactiveForeground)', 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); protected readonly inSyncDecoration: WidgetDecoration.Data = {
let underlying = null; fontData: {},
if (localUri) { };
underlying = await this.fileService.toUnderlyingResource(localUri);
Object.assign(child, { underlying });
}
if (CloudSketchbookTree.CloudSketchDirNode.is(child)) { /**
if (child.fileStat.sketchId) { * Add commands available to the given node.
child.sketchId = child.fileStat.sketchId; * In the case the node is a sketch, it also adds sketchId and isPublic flags
child.isPublic = child.fileStat.isPublic; * @param node
} * @returns
const commands = [CloudSketchbookCommands.PULL_SKETCH]; */
protected async augmentSketchNode(node: DirNode): Promise<void> {
const sketch = this.sketchCache.getSketch(
node.fileStat.resource.path.toString()
);
if (underlying) { const commands = [CloudSketchbookCommands.PULL_SKETCH];
child.synced = true;
commands.push(CloudSketchbookCommands.PUSH_SKETCH);
} else {
this.mergeDecoration(child, this.notInSyncDecoration);
}
commands.push(CloudSketchbookCommands.OPEN_SKETCHBOOKSYNC_CONTEXT_MENU); if (
CloudSketchbookTree.CloudSketchTreeNode.is(node) &&
Object.assign(child, { commands }); CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)
if (!this.showAllFiles) { ) {
delete (child as any).expanded; commands.push(CloudSketchbookCommands.PUSH_SKETCH);
}
} 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) { commands.push(CloudSketchbookCommands.OPEN_SKETCHBOOKSYNC_CONTEXT_MENU);
return [];
} Object.assign(node, {
return children; type: 'sketch',
...(sketch && {
isPublic: sketch.is_public,
}),
...(sketch && {
sketchId: sketch.id,
}),
commands,
});
} }
protected toNode( protected async nodeLocalUri(node: TreeNode): Promise<TreeNode> {
fileStat: FileStat, if (FileStatNode.is(node) && CreateUri.is(node.uri)) {
parent: CompositeTreeNode Object.assign(node, { remoteUri: node.uri });
): FileNode | DirNode { const localUri = await this.localUri(node);
const node = super.toNode(fileStat, parent); if (localUri) {
if (CreateFileStat.is(fileStat)) { // if the node has a local uri, use it
Object.assign(node, { const underlying = await this.fileService.toUnderlyingResource(
type: fileStat.type, localUri
isPublic: fileStat.isPublic, );
sketchId: fileStat.sketchId, node.uri = underlying;
}); }
} }
// add style decoration for not-in-sync files
if (
CloudSketchbookTree.CloudSketchTreeNode.is(node) &&
!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)
) {
this.mergeDecoration(node, this.notInSyncDecoration);
} else {
this.removeDecoration(node, this.notInSyncDecoration);
}
return node; return node;
} }
protected async decorateNode(
node: TreeNode,
showAllFiles: boolean
): Promise<TreeNode> {
node = await this.nodeLocalUri(node);
node = await super.decorateNode(node, showAllFiles);
return node;
}
protected async isSketchNode(node: DirNode): Promise<boolean> {
if (DirNode.is(node)) {
const sketch = this.sketchCache.getSketch(
node.fileStat.resource.path.toString()
);
return !!sketch;
}
return false;
}
private mergeDecoration( private mergeDecoration(
node: TreeNode, node: TreeNode,
decorationData: WidgetDecoration.Data decorationData: WidgetDecoration.Data
@ -378,14 +503,16 @@ export class CloudSketchbookTree extends SketchbookTree {
}); });
} }
private setDecoration( private removeDecoration(
node: TreeNode, node: TreeNode,
decorationData: WidgetDecoration.Data | undefined decorationData: WidgetDecoration.Data
): void { ): void {
if (!decorationData) { if (DecoratedTreeNode.is(node)) {
delete (node as any).decorationData; for (const property of Object.keys(decorationData)) {
} else { if (node.decorationData.hasOwnProperty(property)) {
Object.assign(node, { decorationData }); delete (node.decorationData as any)[property];
}
}
} }
} }
@ -397,74 +524,31 @@ export class CloudSketchbookTree extends SketchbookTree {
} }
return undefined; 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 namespace CloudSketchbookTree {
export const rootResource: Create.Resource = Object.freeze({ export interface CloudSketchTreeNode extends FileStatNode {
modified_at: '', remoteUri: URI;
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 namespace CloudSketchTreeNode {
export function create( export function is(node: TreeNode): node is CloudSketchTreeNode {
cache: CreateCache, return !!node && typeof node.hasOwnProperty('remoteUri') !== 'undefined';
showAllFiles: boolean
): CloudRootNode {
return Object.assign(
SketchbookTree.RootNode.create(
toFileStat(rootResource, cache, 1),
showAllFiles
),
{ cache }
);
} }
export function is( export function isSynced(node: CloudSketchTreeNode): boolean {
node: (TreeNode & Partial<CloudRootNode>) | undefined return node.remoteUri !== node.uri;
): node is CloudRootNode {
return !!node && !!node.cache && SketchbookTree.RootNode.is(node);
} }
} }
export interface CloudSketchDirNode extends SketchbookTree.SketchDirNode { export interface CloudSketchDirNode
extends Omit<SketchbookTree.SketchDirNode, 'fileStat'>,
CloudSketchTreeNode {
state?: CloudSketchDirNode.State; state?: CloudSketchDirNode.State;
synced?: true;
sketchId?: string;
isPublic?: boolean; isPublic?: boolean;
sketchId?: string;
commands?: Command[]; commands?: Command[];
underlying?: URI;
} }
export interface CloudSketchTreeNode extends TreeNode {
underlying?: URI;
}
export namespace CloudSketchDirNode { export namespace CloudSketchDirNode {
export function is(node: TreeNode): node is CloudSketchDirNode { export function is(node: TreeNode): node is CloudSketchDirNode {
return SketchbookTree.SketchDirNode.is(node); return SketchbookTree.SketchDirNode.is(node);
@ -472,28 +556,4 @@ export namespace CloudSketchbookTree {
export type State = 'syncing' | 'pulling' | 'pushing'; export type State = 'syncing' | 'pulling' | 'pushing';
} }
export function toFileStat(
resource: Create.Resource,
cache: CreateCache,
depth = 0
): CreateFileStat {
return {
isDirectory: resource.type !== 'file',
isFile: resource.type === 'file',
isPublic: resource.isPublic,
isSymbolicLink: false,
name: resource.name,
resource: CreateUri.toUri(resource),
size: resource.size,
mtime: Date.parse(resource.modified_at),
sketchId: resource.sketchId || undefined,
type: resource.type,
...(!!depth && {
children: CreateCache.childrenOf(resource, cache)?.map(
(childResource) => toFileStat(childResource, cache, depth - 1)
),
}),
};
}
} }

View File

@ -16,6 +16,15 @@ export class CloudSketchbookWidget extends SketchbookWidget {
super.init(); super.init();
} }
getTreeWidget(): any {
const widget: any = this.sketchbookTreesContainer.selectedWidgets().next();
if (widget && typeof widget.getTreeWidget !== 'undefined') {
return (widget as CloudSketchbookCompositeWidget).getTreeWidget();
}
return widget;
}
checkCloudEnabled() { checkCloudEnabled() {
if (this.arduinoPreferences['arduino.cloud.enabled']) { if (this.arduinoPreferences['arduino.cloud.enabled']) {
this.sketchbookTreesContainer.activateWidget(this.widget); this.sketchbookTreesContainer.activateWidget(this.widget);

View File

@ -34,7 +34,7 @@ export class SketchbookTreeModel extends FileTreeModel {
protected readonly arduinoPreferences: ArduinoPreferences; protected readonly arduinoPreferences: ArduinoPreferences;
@inject(CommandRegistry) @inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry; public readonly commandRegistry: CommandRegistry;
@inject(ConfigService) @inject(ConfigService)
protected readonly configService: ConfigService; protected readonly configService: ConfigService;
@ -162,34 +162,24 @@ export class SketchbookTreeModel extends FileTreeModel {
protected async createRoot(): Promise<TreeNode | undefined> { protected async createRoot(): Promise<TreeNode | undefined> {
const config = await this.configService.getConfiguration(); const config = await this.configService.getConfiguration();
const stat = await this.fileService.resolve(new URI(config.sketchDirUri)); const rootFileStats = await this.fileService.resolve(
new URI(config.sketchDirUri)
);
if (this.workspaceService.opened) { if (this.workspaceService.opened && rootFileStats.children) {
const isMulti = stat ? !stat.isDirectory : false; // filter out libraries and hardware
const workspaceNode = isMulti
? this.createMultipleRootNode()
: WorkspaceNode.createRoot();
workspaceNode.children.push(
await this.tree.createWorkspaceRoot(stat, workspaceNode)
);
return workspaceNode; if (this.workspaceService.opened) {
const workspaceNode = WorkspaceNode.createRoot();
workspaceNode.children.push(
await this.tree.createWorkspaceRoot(rootFileStats, workspaceNode)
);
return workspaceNode;
}
} }
} }
/**
* Create multiple root node used to display
* the multiple root workspace name.
*
* @returns `WorkspaceNode`
*/
protected createMultipleRootNode(): WorkspaceNode {
const workspace = this.workspaceService.workspace;
let name = workspace ? workspace.resource.path.name : 'untitled';
name += ' (Workspace)';
return WorkspaceNode.createRoot(name);
}
/** /**
* Move the given source file or directory to the given target directory. * Move the given source file or directory to the given target directory.
*/ */

View File

@ -1,22 +1,17 @@
import { inject, injectable } from 'inversify'; import { inject, injectable } from 'inversify';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { Command } from '@theia/core/lib/common/command'; import { Command } from '@theia/core/lib/common/command';
import { CompositeTreeNode, TreeNode } from '@theia/core/lib/browser/tree'; import { CompositeTreeNode, TreeNode } from '@theia/core/lib/browser/tree';
import { DirNode, FileStatNode } from '@theia/filesystem/lib/browser/file-tree'; import { DirNode, FileStatNode } from '@theia/filesystem/lib/browser/file-tree';
import { SketchesService } from '../../../common/protocol'; import { SketchesService } from '../../../common/protocol';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { SketchbookCommands } from './sketchbook-commands'; import { SketchbookCommands } from './sketchbook-commands';
import { import {
FileNavigatorTree, FileNavigatorTree,
WorkspaceNode, WorkspaceRootNode,
} from '@theia/navigator/lib/browser/navigator-tree'; } from '@theia/navigator/lib/browser/navigator-tree';
import { ArduinoPreferences } from '../../arduino-preferences'; import { ArduinoPreferences } from '../../arduino-preferences';
@injectable() @injectable()
export class SketchbookTree extends FileNavigatorTree { export class SketchbookTree extends FileNavigatorTree {
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(SketchesService) @inject(SketchesService)
protected readonly sketchesService: SketchesService; protected readonly sketchesService: SketchesService;
@ -27,61 +22,71 @@ export class SketchbookTree extends FileNavigatorTree {
const showAllFiles = const showAllFiles =
this.arduinoPreferences['arduino.sketchbook.showAllFiles']; this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
const children = ( const children = (await super.resolveChildren(parent)).filter((child) => {
await Promise.all( // strip libraries and hardware directories
( if (
await super.resolveChildren(parent) DirNode.is(child) &&
).map((node) => this.maybeDecorateNode(node, showAllFiles)) ['libraries', 'hardware'].includes(child.fileStat.name) &&
) WorkspaceRootNode.is(child.parent)
).filter((node) => { ) {
// filter out hidden nodes return false;
if (DirNode.is(node) || FileStatNode.is(node)) {
return node.fileStat.name.indexOf('.') !== 0;
} }
// strip files if only directories are admitted
if (!DirNode.is(child) && !showAllFiles) {
return false;
}
// strip hidden files
if (FileStatNode.is(child) && child.fileStat.name.indexOf('.') === 0) {
return false;
}
return true; return true;
}); });
// filter out hardware and libraries if (children.length === 0) {
if (WorkspaceNode.is(parent.parent)) { delete (parent as any).expanded;
return children
.filter(DirNode.is)
.filter(
(node) =>
['libraries', 'hardware'].indexOf(
this.labelProvider.getName(node)
) === -1
);
} }
// return the Arduino directory containing all user sketches return await Promise.all(
if (WorkspaceNode.is(parent)) { children.map(
return children; async (childNode) => await this.decorateNode(childNode, showAllFiles)
} )
);
return children;
// return this.filter.filter(super.resolveChildren(parent));
} }
protected async maybeDecorateNode( protected async isSketchNode(node: DirNode): Promise<boolean> {
const sketch = await this.sketchesService.maybeLoadSketch(
node.uri.toString()
);
return !!sketch;
}
/**
* Add commands available for the given node
* @param node
* @returns
*/
protected async augmentSketchNode(node: DirNode): Promise<void> {
Object.assign(node, {
type: 'sketch',
commands: [SketchbookCommands.OPEN_SKETCHBOOK_CONTEXT_MENU],
});
}
protected async decorateNode(
node: TreeNode, node: TreeNode,
showAllFiles: boolean showAllFiles: boolean
): Promise<TreeNode> { ): Promise<TreeNode> {
if (DirNode.is(node)) { if (DirNode.is(node) && (await this.isSketchNode(node))) {
const sketch = await this.sketchesService.maybeLoadSketch( await this.augmentSketchNode(node);
node.uri.toString()
); if (!showAllFiles) {
if (sketch) { delete (node as any).expanded;
Object.assign(node, { (node as any).children = [];
type: 'sketch', } else {
commands: [SketchbookCommands.OPEN_SKETCHBOOK_CONTEXT_MENU], (node as any).expanded = false;
});
if (!showAllFiles) {
delete (node as any).expanded;
node.children = [];
} else {
node.expanded = false;
}
return node;
} }
} }
return node; return node;
@ -89,25 +94,6 @@ export class SketchbookTree extends FileNavigatorTree {
} }
export namespace SketchbookTree { 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 { export interface SketchDirNode extends DirNode {
readonly type: 'sketch'; readonly type: 'sketch';
readonly commands?: Command[]; readonly commands?: Command[];

View File

@ -100,10 +100,7 @@ export class SketchbookWidgetContribution
registry.registerCommand(SketchbookCommands.OPEN_NEW_WINDOW, { registry.registerCommand(SketchbookCommands.OPEN_NEW_WINDOW, {
execute: async (arg) => { execute: async (arg) => {
const underlying = await this.fileService.toUnderlyingResource( return this.workspaceService.open(arg.node.uri);
arg.node.uri
);
return this.workspaceService.open(underlying);
}, },
isEnabled: (arg) => isEnabled: (arg) =>
!!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node), !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node),
@ -214,7 +211,8 @@ export class SketchbookWidgetContribution
if (Navigatable.is(widget)) { if (Navigatable.is(widget)) {
const resourceUri = widget.getResourceUri(); const resourceUri = widget.getResourceUri();
if (resourceUri) { if (resourceUri) {
const { model } = (await this.widget).getTreeWidget(); const treeWidget = (await this.widget).getTreeWidget();
const { model } = treeWidget;
const node = await model.revealFile(resourceUri); const node = await model.revealFile(resourceUri);
if (SelectableTreeNode.is(node)) { if (SelectableTreeNode.is(node)) {
model.selectNode(node); model.selectNode(node);