From a783d5b64d1d4363280aadb16a2a0a11a7ad948e Mon Sep 17 00:00:00 2001 From: Francesco Stasi Date: Tue, 13 Jul 2021 18:25:30 +0200 Subject: [PATCH 1/3] Refactor remote sketchbook explorer --- .../browser/arduino-ide-frontend-module.ts | 3 + .../src/browser/create/create-api.ts | 157 +++---- .../src/browser/create/create-fs-provider.ts | 5 +- .../local-cache/local-cache-fs-provider.ts | 2 +- .../cloud-sketchbook/cloud-sketch-cache.ts | 34 ++ .../cloud-sketchbook-composite-widget.tsx | 4 + .../cloud-sketchbook-contributions.ts | 17 +- .../cloud-sketchbook-tree-model.ts | 165 +++---- .../cloud-sketchbook-tree-widget.tsx | 38 +- .../cloud-sketchbook/cloud-sketchbook-tree.ts | 408 ++++++++++-------- .../cloud-sketchbook-widget.ts | 9 + .../sketchbook/sketchbook-tree-model.ts | 42 +- .../widgets/sketchbook/sketchbook-tree.ts | 122 +++--- .../sketchbook-widget-contribution.ts | 8 +- 14 files changed, 476 insertions(+), 538 deletions(-) create mode 100644 arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 1cb6f0294..c502346a0 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -236,6 +236,7 @@ import { CloudSketchbookCompositeWidget } from './widgets/cloud-sketchbook/cloud import { SketchbookWidget } from './widgets/sketchbook/sketchbook-widget'; import { SketchbookTreeWidget } from './widgets/sketchbook/sketchbook-tree-widget'; 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'); @@ -686,6 +687,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { createCloudSketchbookTreeWidget(container) ); bind(CreateApi).toSelf().inSingletonScope(); + bind(SketchCache).toSelf().inSingletonScope(); + bind(ShareSketchDialog).toSelf().inSingletonScope(); bind(AuthenticationClientService).toSelf().inSingletonScope(); bind(CommandContribution).toService(AuthenticationClientService); diff --git a/arduino-ide-extension/src/browser/create/create-api.ts b/arduino-ide-extension/src/browser/create/create-api.ts index f8ea5a6f1..06e7506cb 100644 --- a/arduino-ide-extension/src/browser/create/create-api.ts +++ b/arduino-ide-extension/src/browser/create/create-api.ts @@ -1,8 +1,9 @@ -import { injectable } from 'inversify'; +import { injectable, inject } from 'inversify'; 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 { ArduinoPreferences } from '../arduino-preferences'; +import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache'; export interface ResponseResultProvider { (response: Response): Promise; @@ -15,10 +16,11 @@ export namespace ResponseResultProvider { type ResourceType = 'f' | 'd'; -export let sketchCache: Create.Sketch[] = []; - @injectable() export class CreateApi { + @inject(SketchCache) + protected readonly sketchCache: SketchCache; + protected authenticationService: AuthenticationClientService; protected arduinoPreferences: ArduinoPreferences; @@ -32,48 +34,24 @@ export class CreateApi { 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 { - const sketch = sketchCache.find((sketch) => compareFn(sketch)); - if (trustCache) { - return Promise.resolve(sketch); - } - return await this.sketch({ id: sketch?.id }); + public wipeCache(): void { + this.sketchCache.init(); } getSketchSecretStat(sketch: Create.Sketch): Create.Resource { return { href: `${sketch.href}${posix.sep}${Create.arduino_secrets_file}`, modified_at: sketch.modified_at, + created_at: sketch.created_at, name: `${Create.arduino_secrets_file}`, path: `${sketch.path}${posix.sep}${Create.arduino_secrets_file}`, mimetype: 'text/x-c++src; charset=utf-8', type: 'file', - sketchId: sketch.id, }; } - async sketch(opt: { - id?: string; - path?: string; - }): Promise { - 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; - } + async sketch(id: string): Promise { + const url = new URL(`${this.domain()}/sketches/byID/${id}`); url.searchParams.set('user_id', 'me'); const headers = await this.headers(); @@ -92,7 +70,7 @@ export class CreateApi { method: 'GET', headers, }); - sketchCache = result.sketches; + result.sketches.forEach((sketch) => this.sketchCache.addSketch(sketch)); return result.sketches; } @@ -118,7 +96,7 @@ export class CreateApi { async readDirectory( posixPath: string, - options: { recursive?: boolean; match?: string; secrets?: boolean } = {} + options: { recursive?: boolean; match?: string } = {} ): Promise { const url = new URL( `${this.domain()}/files/d/$HOME/sketches_v2${posixPath}` @@ -131,58 +109,21 @@ export class CreateApi { } const headers = await this.headers(); - const sketchProm = options.secrets - ? this.sketches() - : Promise.resolve(sketchCache); - - return Promise.all([ - this.run(url, { - 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)); - } + return this.run(url, { + method: 'GET', + 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); + + if (sketch && sketch.secrets && sketch.secrets.length > 0) { + result.push(this.getSketchSecretStat(sketch)); } } - const sketchesMap: Record = sketches.reduce( - (prev, curr) => { - return { ...prev, [curr.path]: curr }; - }, - {} - ); - // add the sketch id and isPublic to the resource - return result.map((resource) => { - return { - ...resource, - sketchId: sketchesMap[resource.path]?.id || '', - isPublic: sketchesMap[resource.path]?.is_public || false, - }; - }); + return result; }) .catch((reason) => { if (reason?.status === 404) return [] as Create.Resource[]; @@ -214,18 +155,16 @@ export class CreateApi { let resources; if (basename === Create.arduino_secrets_file) { - const sketch = await this.findSketchInCache( - this.sketchCompareByPath(parentPosixPath) - ); + const sketch = this.sketchCache.getSketch(parentPosixPath); resources = sketch ? [this.getSketchSecretStat(sketch)] : []; } else { resources = await this.readDirectory(parentPosixPath, { match: basename, }); } - - resources.sort((left, right) => left.path.length - right.path.length); - const resource = resources.find(({ name }) => name === basename); + const resource = resources.find( + ({ path }) => createPaths.splitSketchPath(path)[1] === posixPath + ); if (!resource) { throw new CreateError(`Not found: ${posixPath}.`, 404); } @@ -248,10 +187,7 @@ export class CreateApi { return data; } - const sketch = await this.findSketchInCache((sketch) => { - const [, spath] = splitSketchPath(sketch.path); - return spath === createPaths.parentPosix(path); - }, true); + const sketch = this.sketchCache.getSketch(createPaths.parentPosix(path)); if ( sketch && @@ -273,14 +209,25 @@ export class CreateApi { if (basename === Create.arduino_secrets_file) { const parentPosixPath = createPaths.parentPosix(posixPath); - const sketch = await this.findSketchInCache( - this.sketchCompareByPath(parentPosixPath), - false - ); + + //retrieve the sketch id from the cache + 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 = ''; if (sketch && sketch.secrets) { - for (const item of sketch?.secrets) { + for (const item of sketch.secrets) { file += `#define ${item.name} "${item.value}"\r\n`; } } @@ -310,9 +257,9 @@ export class CreateApi { if (basename === Create.arduino_secrets_file) { const parentPosixPath = createPaths.parentPosix(posixPath); - const sketch = await this.findSketchInCache( - this.sketchCompareByPath(parentPosixPath) - ); + + const sketch = this.sketchCache.getSketch(parentPosixPath); + if (sketch) { const url = new URL(`${this.domain()}/sketches/${sketch.id}`); const headers = await this.headers(); @@ -357,8 +304,7 @@ export class CreateApi { }; // replace the sketch in the cache, so other calls will not overwrite each other - sketchCache = sketchCache.filter((skt) => skt.id !== sketch.id); - sketchCache.push({ ...sketch, secrets }); + this.sketchCache.addSketch(sketch); const init = { method: 'POST', @@ -543,8 +489,9 @@ export namespace Create { */ readonly path: string; readonly type: ResourceType; - readonly sketchId: string; + 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. diff --git a/arduino-ide-extension/src/browser/create/create-fs-provider.ts b/arduino-ide-extension/src/browser/create/create-fs-provider.ts index 79484cc84..96c6cf77f 100644 --- a/arduino-ide-extension/src/browser/create/create-fs-provider.ts +++ b/arduino-ide-extension/src/browser/create/create-fs-provider.ts @@ -106,10 +106,7 @@ export class CreateFsProvider async readdir(uri: URI): Promise<[string, FileType][]> { const resources = await this.getCreateApi.readDirectory( - uri.path.toString(), - { - secrets: true, - } + uri.path.toString() ); return resources .filter((res) => !REMOTE_ONLY_FILES.includes(res.name)) diff --git a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts index a64c686f6..a0aa3733e 100644 --- a/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts +++ b/arduino-ide-extension/src/browser/local-cache/local-cache-fs-provider.ts @@ -103,7 +103,7 @@ export class LocalCacheFsProvider }); } - private get currentUserUri(): URI { + public get currentUserUri(): URI { const { session } = this.authenticationService; if (!session) { throw new FileSystemProviderError( diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts new file mode 100644 index 000000000..c07166825 --- /dev/null +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts @@ -0,0 +1,34 @@ +import { FileStat } from '@theia/filesystem/lib/common/files'; +import { injectable } from 'inversify'; +import { Create } from '../../create/create-api'; +import { toPosixPath } from '../../create/create-paths'; + +@injectable() +export class SketchCache { + sketches: Record = {}; + filestats: Record = {}; + + 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; + } +} diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx index 0652611fb..6b2a97f66 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-composite-widget.tsx @@ -35,6 +35,10 @@ export class CloudSketchbookCompositeWidget extends BaseWidget { this.id = 'cloud-sketchbook-composite-widget'; } + public getTreeWidget(): CloudSketchbookTreeWidget { + return this.cloudSketchbookTreeWidget; + } + protected onAfterAttach(message: Message): void { super.onAfterAttach(message); Widget.attach(this.cloudSketchbookTreeWidget, this.compositeNode); diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts index 1a5319b27..aa1505c58 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-contributions.ts @@ -166,11 +166,11 @@ export class CloudSketchbookContribution extends Contribution { isEnabled: (arg) => CloudSketchbookCommands.Arg.is(arg) && CloudSketchbookTree.CloudSketchDirNode.is(arg.node) && - !!arg.node.synced, + CloudSketchbookTree.CloudSketchTreeNode.isSynced(arg.node), isVisible: (arg) => CloudSketchbookCommands.Arg.is(arg) && CloudSketchbookTree.CloudSketchDirNode.is(arg.node) && - !!arg.node.synced, + CloudSketchbookTree.CloudSketchTreeNode.isSynced(arg.node), }); registry.registerCommand(CloudSketchbookCommands.OPEN_IN_CLOUD_EDITOR, { @@ -257,18 +257,10 @@ export class CloudSketchbookContribution extends Contribution { 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()) + !CloudSketchbookTree.CloudSketchTreeNode.isSynced(arg.node) || + (currentSketch && currentSketch.uri === arg.node.uri.toString()) ) { const placeholder = new PlaceholderMenuNode( SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP, @@ -284,7 +276,6 @@ export class CloudSketchbookContribution extends Contribution { ) ); } else { - arg.node.uri = localUri; this.menuRegistry.registerMenuAction( SKETCHBOOKSYNC__CONTEXT__MAIN_GROUP, { diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts index de6747e6e..b74d18f64 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts @@ -1,82 +1,72 @@ import { inject, injectable, postConstruct } from 'inversify'; 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 { 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; -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; - } +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'; + +export function sketchBaseDir(sketch: Create.Sketch): FileStat { + // extract the sketch path + const [, path] = splitSketchPath(sketch.path); + const dirs = posixSegments(path); + + const mtime = Date.parse(sketch.modified_at); + const ctime = Date.parse(sketch.created_at); + const createPath = CreateUri.toUri(dirs[0]); + const baseDir: FileStat = { + name: dirs[0], + isDirectory: true, + isFile: false, + isSymbolicLink: false, + resource: createPath, + mtime, + ctime, + }; + return baseDir; +} - 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]); +export function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] { + const sketchesBaseDirs: Record = {}; + + for (const sketch of sketches) { + const sketchBaseDirFileStat = sketchBaseDir(sketch); + sketchesBaseDirs[sketchBaseDirFileStat.resource.toString()] = + sketchBaseDirFileStat; } + + return Object.keys(sketchesBaseDirs).map( + (dirUri) => sketchesBaseDirs[dirUri] + ); } @injectable() export class CloudSketchbookTreeModel extends SketchbookTreeModel { + @inject(FileService) + protected readonly fileService: FileService; + @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; + @inject(LocalCacheFsProvider) + protected readonly localCacheFsProvider: LocalCacheFsProvider; + @postConstruct() protected init(): void { super.init(); @@ -85,56 +75,25 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { ); } - async updateRoot(): Promise { + async createRoot(): Promise { 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; - }); - } + this.createApi.wipeCache(); + const sketches = await this.createApi.sketches(); + const rootFileStats = sketchesToFileStats(sketches); + if (this.workspaceService.opened) { + const workspaceNode = WorkspaceNode.createRoot('Remote'); + for await (const stat of rootFileStats) { + workspaceNode.children.push( + await this.tree.createWorkspaceRoot(stat, workspaceNode) + ); } + return workspaceNode; } - - const showAllFiles = - this.arduinoPreferences['arduino.sketchbook.showAllFiles']; - this.tree.root = CloudSketchbookTree.CloudRootNode.create( - cache, - showAllFiles - ); } sketchbookTree(): CloudSketchbookTree { @@ -143,9 +102,6 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { protected recursivelyFindSketchRoot(node: TreeNode): any { if (node && CloudSketchbookTree.CloudSketchDirNode.is(node)) { - if (node.hasOwnProperty('underlying')) { - return { ...node, uri: node.underlying }; - } return node; } @@ -156,4 +112,15 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { // can't find a root, return false return false; } + + async revealFile(uri: URI): Promise { + // 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); + } + } } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx index fe029cce8..fa4c63bf2 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-widget.tsx @@ -91,7 +91,7 @@ export class CloudSketchbookTreeWidget extends SketchbookTreeWidget { CloudSketchbookTree.CloudSketchDirNode.is(node) && node.commands && (node.id === this.hoveredNodeId || - this.currentSketchUri === node.underlying?.toString()) + this.currentSketchUri === node.uri.toString()) ) { return Array.from(new Set(node.commands)).map((command) => this.renderInlineCommand(command.id, node) @@ -135,37 +135,17 @@ export class CloudSketchbookTreeWidget extends SketchbookTreeWidget { ); } - protected async handleClickEvent( - node: any, + protected handleDblClickEvent( + node: TreeNode, event: React.MouseEvent - ) { + ): void { 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 - ) { - 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); + if ( + CloudSketchbookTree.CloudSketchTreeNode.is(node) && + CloudSketchbookTree.CloudSketchTreeNode.isSynced(node) + ) { + super.handleDblClickEvent(node, event); } } } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts index a4e42e8ee..71015f2f5 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts @@ -1,15 +1,15 @@ +import { SketchCache } from './cloud-sketch-cache'; 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, + FileNode, } from '@theia/filesystem/lib/browser/file-tree/file-tree'; import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree'; import { @@ -18,20 +18,20 @@ import { } 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 { CreateApi } from '../../create/create-api'; import { CreateUri } from '../../create/create-uri'; +import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model'; import { - CloudSketchbookTreeModel, - CreateCache, -} from './cloud-sketchbook-tree-model'; -import { LocalCacheUri } from '../../local-cache/local-cache-fs-provider'; + LocalCacheFsProvider, + 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'; +import { FileStat } from '@theia/filesystem/lib/common/files'; const MESSAGE_TIMEOUT = 5 * 1000; const deepmerge = require('deepmerge').default; @@ -41,6 +41,12 @@ export class CloudSketchbookTree extends SketchbookTree { @inject(FileService) protected readonly fileService: FileService; + @inject(LocalCacheFsProvider) + protected readonly localCacheFsProvider: LocalCacheFsProvider; + + @inject(SketchCache) + protected readonly sketchCache: SketchCache; + @inject(ArduinoPreferences) protected readonly arduinoPreferences: ArduinoPreferences; @@ -95,7 +101,8 @@ export class CloudSketchbookTree extends SketchbookTree { } = arg; const warn = - node.synced && this.arduinoPreferences['arduino.cloud.pull.warn']; + CloudSketchbookTree.CloudSketchTreeNode.isSynced(node) && + this.arduinoPreferences['arduino.cloud.pull.warn']; if (warn) { const ok = await new DoNotAskAgainConfirmDialog({ @@ -120,11 +127,9 @@ export class CloudSketchbookTree extends SketchbookTree { node.commands = []; // check if the sketch dir already exist - if (node.synced) { + if (CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) { const filesToPull = ( - await this.createApi.readDirectory(node.uri.path.toString(), { - secrets: true, - }) + await this.createApi.readDirectory(node.remoteUri.path.toString()) ).filter((file: any) => !REMOTE_ONLY_FILES.includes(file.name)); await Promise.all( @@ -140,9 +145,9 @@ export class CloudSketchbookTree extends SketchbookTree { const currentSketch = await this.sketchServiceClient.currentSketch(); if ( + !CreateUri.is(node.uri) && currentSketch && - node.underlying && - currentSketch.uri === node.underlying.toString() + currentSketch.uri === node.uri.toString() ) { filesToPull.forEach(async (file) => { const localUri = LocalCacheUri.root.resolve( @@ -157,7 +162,7 @@ export class CloudSketchbookTree extends SketchbookTree { } } else { await this.fileService.copy( - node.uri, + node.remoteUri, LocalCacheUri.root.resolve(node.uri.path), { overwrite: true } ); @@ -171,7 +176,7 @@ export class CloudSketchbookTree extends SketchbookTree { } async push(node: CloudSketchbookTree.CloudSketchDirNode): Promise { - if (!node.synced) { + if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) { throw new Error('Cannot push to Cloud. It is not yet pulled.'); } @@ -201,20 +206,17 @@ export class CloudSketchbookTree extends SketchbookTree { } } this.runWithState(node, 'pushing', async (node) => { - if (!node.synced) { + if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) { 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 } - ); + const result = await this.fileService.copy(node.uri, node.remoteUri, { + overwrite: true, + }); node.commands = commandsCopy; this.messageService.info(`Done pushing ‘${result.name}’.`, { timeout: MESSAGE_TIMEOUT, @@ -225,23 +227,10 @@ export class CloudSketchbookTree extends SketchbookTree { async refresh( node?: CompositeTreeNode ): Promise { - 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; - } - } + if (node) { + const showAllFiles = + this.arduinoPreferences['arduino.sketchbook.showAllFiles']; + await this.decorateNode(node, showAllFiles); } return super.refresh(node); } @@ -276,20 +265,113 @@ export class CloudSketchbookTree extends SketchbookTree { } } + /** + * 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( node: FileStatNode ): Promise { if ( - CreateUri.is(node.uri) && - CloudSketchbookTree.CloudRootNode.is(this.root) + CloudSketchbookTree.CloudSketchTreeNode.is(node) && + CreateUri.is(node.remoteUri) ) { - const resource = this.root.cache[node.uri.path.toString()]; - if (!resource) { - return undefined; + let remoteFileStat: FileStat; + const cacheHit = this.sketchCache.getItem(node.remoteUri.path.toString()); + 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 { + id, + uri, + fileStat, + parent, + expanded: false, + selected: false, + children: [], + }; + } + if (FileNode.is(node)) { + node.fileStat = fileStat; + return node; + } + return { + id, + uri, + fileStat, + parent, + selected: false, + }; } protected readonly notInSyncDecoration: WidgetDecoration.Data = { @@ -297,75 +379,90 @@ export class CloudSketchbookTree extends SketchbookTree { color: 'var(--theia-activityBar-inactiveForeground)', }, }; - protected async toNodes( - fileStat: FileStat, - parent: CompositeTreeNode - ): Promise { - 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 }); - } + protected readonly inSyncDecoration: WidgetDecoration.Data = { + fontData: {}, + }; - if (CloudSketchbookTree.CloudSketchDirNode.is(child)) { - if (child.fileStat.sketchId) { - child.sketchId = child.fileStat.sketchId; - child.isPublic = child.fileStat.isPublic; - } - const commands = [CloudSketchbookCommands.PULL_SKETCH]; + /** + * Add commands available to the given node. + * In the case the node is a sketch, it also adds sketchId and isPublic flags + * @param node + * @returns + */ + protected async augmentSketchNode(node: DirNode): Promise { + const sketch = this.sketchCache.getSketch( + node.fileStat.resource.path.toString() + ); - if (underlying) { - child.synced = true; - commands.push(CloudSketchbookCommands.PUSH_SKETCH); - } else { - this.mergeDecoration(child, this.notInSyncDecoration); - } + const commands = [CloudSketchbookCommands.PULL_SKETCH]; - commands.push(CloudSketchbookCommands.OPEN_SKETCHBOOKSYNC_CONTEXT_MENU); + if ( + CloudSketchbookTree.CloudSketchTreeNode.is(node) && + CloudSketchbookTree.CloudSketchTreeNode.isSynced(node) + ) { + commands.push(CloudSketchbookCommands.PUSH_SKETCH); + } + 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 - ); - } + Object.assign(node, { + type: 'sketch', + ...(sketch && { + isPublic: sketch.is_public, + }), + ...(sketch && { + sketchId: sketch.id, + }), + commands, + }); + } + + protected async nodeLocalUri(node: TreeNode): Promise { + if (FileStatNode.is(node) && CreateUri.is(node.uri)) { + Object.assign(node, { remoteUri: node.uri }); + const localUri = await this.localUri(node); + if (localUri) { + // if the node has a local uri, use it + const underlying = await this.fileService.toUnderlyingResource( + localUri + ); + node.uri = underlying; } } - if (CloudSketchbookTree.SketchDirNode.is(parent) && !this.showAllFiles) { - return []; + + // 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 children; + + return node; } - 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, - }); - } + protected async decorateNode( + node: TreeNode, + showAllFiles: boolean + ): Promise { + node = await this.nodeLocalUri(node); + + node = await super.decorateNode(node, showAllFiles); return node; } + protected async isSketchNode(node: DirNode): Promise { + if (DirNode.is(node)) { + const sketch = this.sketchCache.getSketch( + node.fileStat.resource.path.toString() + ); + return !!sketch; + } + return false; + } + private mergeDecoration( node: TreeNode, decorationData: WidgetDecoration.Data @@ -378,14 +475,16 @@ export class CloudSketchbookTree extends SketchbookTree { }); } - private setDecoration( + private removeDecoration( node: TreeNode, - decorationData: WidgetDecoration.Data | undefined + decorationData: WidgetDecoration.Data ): void { - if (!decorationData) { - delete (node as any).decorationData; - } else { - Object.assign(node, { decorationData }); + if (DecoratedTreeNode.is(node)) { + for (const property of Object.keys(decorationData)) { + if (node.decorationData.hasOwnProperty(property)) { + delete (node.decorationData as any)[property]; + } + } } } @@ -397,74 +496,31 @@ export class CloudSketchbookTree extends SketchbookTree { } 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 interface CloudSketchTreeNode extends FileStatNode { + remoteUri: URI; } - export namespace CloudRootNode { - export function create( - cache: CreateCache, - showAllFiles: boolean - ): CloudRootNode { - return Object.assign( - SketchbookTree.RootNode.create( - toFileStat(rootResource, cache, 1), - showAllFiles - ), - { cache } - ); + export namespace CloudSketchTreeNode { + export function is(node: TreeNode): node is CloudSketchTreeNode { + return !!node && typeof node.hasOwnProperty('remoteUri') !== 'undefined'; } - export function is( - node: (TreeNode & Partial) | undefined - ): node is CloudRootNode { - return !!node && !!node.cache && SketchbookTree.RootNode.is(node); + export function isSynced(node: CloudSketchTreeNode): boolean { + return node.remoteUri !== node.uri; } } - export interface CloudSketchDirNode extends SketchbookTree.SketchDirNode { + export interface CloudSketchDirNode + extends Omit, + CloudSketchTreeNode { state?: CloudSketchDirNode.State; - synced?: true; - sketchId?: string; isPublic?: boolean; + sketchId?: string; 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); @@ -472,28 +528,4 @@ export namespace CloudSketchbookTree { 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) - ), - }), - }; - } } diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts index 42b696b32..42b3177be 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-widget.ts @@ -16,6 +16,15 @@ export class CloudSketchbookWidget extends SketchbookWidget { 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() { if (this.arduinoPreferences['arduino.cloud.enabled']) { this.sketchbookTreesContainer.activateWidget(this.widget); diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts index 158612ae1..454420356 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree-model.ts @@ -34,7 +34,7 @@ export class SketchbookTreeModel extends FileTreeModel { protected readonly arduinoPreferences: ArduinoPreferences; @inject(CommandRegistry) - protected readonly commandRegistry: CommandRegistry; + public readonly commandRegistry: CommandRegistry; @inject(ConfigService) protected readonly configService: ConfigService; @@ -162,32 +162,22 @@ export class SketchbookTreeModel extends FileTreeModel { protected async createRoot(): Promise { const config = await this.configService.getConfiguration(); - const stat = await this.fileService.resolve(new URI(config.sketchDirUri)); - - if (this.workspaceService.opened) { - const isMulti = stat ? !stat.isDirectory : false; - const workspaceNode = isMulti - ? this.createMultipleRootNode() - : WorkspaceNode.createRoot(); - workspaceNode.children.push( - await this.tree.createWorkspaceRoot(stat, workspaceNode) - ); - - return workspaceNode; - } - } + const rootFileStats = await this.fileService.resolve( + new URI(config.sketchDirUri) + ); - /** - * 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); + if (this.workspaceService.opened && rootFileStats.children) { + // filter out libraries and hardware + + if (this.workspaceService.opened) { + const workspaceNode = WorkspaceNode.createRoot(); + workspaceNode.children.push( + await this.tree.createWorkspaceRoot(rootFileStats, workspaceNode) + ); + + return workspaceNode; + } + } } /** diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree.ts index 764304dd1..ec7676f4f 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-tree.ts @@ -1,22 +1,17 @@ import { inject, injectable } from 'inversify'; -import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { Command } from '@theia/core/lib/common/command'; import { CompositeTreeNode, TreeNode } from '@theia/core/lib/browser/tree'; import { DirNode, FileStatNode } 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'; import { FileNavigatorTree, - WorkspaceNode, + WorkspaceRootNode, } from '@theia/navigator/lib/browser/navigator-tree'; import { ArduinoPreferences } from '../../arduino-preferences'; @injectable() export class SketchbookTree extends FileNavigatorTree { - @inject(LabelProvider) - protected readonly labelProvider: LabelProvider; - @inject(SketchesService) protected readonly sketchesService: SketchesService; @@ -27,61 +22,71 @@ export class SketchbookTree extends FileNavigatorTree { const showAllFiles = this.arduinoPreferences['arduino.sketchbook.showAllFiles']; - const children = ( - await Promise.all( - ( - await super.resolveChildren(parent) - ).map((node) => this.maybeDecorateNode(node, showAllFiles)) - ) - ).filter((node) => { - // filter out hidden nodes - if (DirNode.is(node) || FileStatNode.is(node)) { - return node.fileStat.name.indexOf('.') !== 0; + const children = (await super.resolveChildren(parent)).filter((child) => { + // strip libraries and hardware directories + if ( + DirNode.is(child) && + ['libraries', 'hardware'].includes(child.fileStat.name) && + WorkspaceRootNode.is(child.parent) + ) { + return false; + } + + // 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; }); - // filter out hardware and libraries - if (WorkspaceNode.is(parent.parent)) { - return children - .filter(DirNode.is) - .filter( - (node) => - ['libraries', 'hardware'].indexOf( - this.labelProvider.getName(node) - ) === -1 - ); + if (children.length === 0) { + delete (parent as any).expanded; } - // return the Arduino directory containing all user sketches - if (WorkspaceNode.is(parent)) { - return children; - } + return await Promise.all( + children.map( + async (childNode) => await this.decorateNode(childNode, showAllFiles) + ) + ); + } - return children; - // return this.filter.filter(super.resolveChildren(parent)); + protected async isSketchNode(node: DirNode): Promise { + const sketch = await this.sketchesService.maybeLoadSketch( + node.uri.toString() + ); + return !!sketch; } - protected async maybeDecorateNode( + /** + * Add commands available for the given node + * @param node + * @returns + */ + protected async augmentSketchNode(node: DirNode): Promise { + Object.assign(node, { + type: 'sketch', + commands: [SketchbookCommands.OPEN_SKETCHBOOK_CONTEXT_MENU], + }); + } + + protected async decorateNode( node: TreeNode, showAllFiles: boolean ): Promise { - 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; - node.children = []; - } else { - node.expanded = false; - } - return node; + if (DirNode.is(node) && (await this.isSketchNode(node))) { + await this.augmentSketchNode(node); + + if (!showAllFiles) { + delete (node as any).expanded; + (node as any).children = []; + } else { + (node as any).expanded = false; } } return node; @@ -89,25 +94,6 @@ export class SketchbookTree extends FileNavigatorTree { } export namespace SketchbookTree { - export interface RootNode extends DirNode { - readonly showAllFiles: boolean; - } - export namespace RootNode { - export function is(node: TreeNode & Partial): 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[]; diff --git a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts index 86f6c1aa8..32dcf257c 100644 --- a/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts +++ b/arduino-ide-extension/src/browser/widgets/sketchbook/sketchbook-widget-contribution.ts @@ -100,10 +100,7 @@ export class SketchbookWidgetContribution registry.registerCommand(SketchbookCommands.OPEN_NEW_WINDOW, { execute: async (arg) => { - const underlying = await this.fileService.toUnderlyingResource( - arg.node.uri - ); - return this.workspaceService.open(underlying); + return this.workspaceService.open(arg.node.uri); }, isEnabled: (arg) => !!arg && 'node' in arg && SketchbookTree.SketchDirNode.is(arg.node), @@ -214,7 +211,8 @@ export class SketchbookWidgetContribution if (Navigatable.is(widget)) { const resourceUri = widget.getResourceUri(); if (resourceUri) { - const { model } = (await this.widget).getTreeWidget(); + const treeWidget = (await this.widget).getTreeWidget(); + const { model } = treeWidget; const node = await model.revealFile(resourceUri); if (SelectableTreeNode.is(node)) { model.selectNode(node); From cb71ce728aa2bec17ddf0ae18c484970ae47a9ef Mon Sep 17 00:00:00 2001 From: Francesco Stasi Date: Tue, 20 Jul 2021 18:08:09 +0200 Subject: [PATCH 2/3] sketches sorting --- .../cloud-sketchbook/cloud-sketchbook-tree.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts index 71015f2f5..99eb456a5 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree.ts @@ -32,6 +32,7 @@ import { firstToUpperCase } from '../../../common/utils'; import { ArduinoPreferences } from '../../arduino-preferences'; 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 deepmerge = require('deepmerge').default; @@ -265,6 +266,33 @@ export class CloudSketchbookTree extends SketchbookTree { } } + async resolveChildren(parent: CompositeTreeNode): Promise { + 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 From 65b079b396c1a0b340e7694e2323864bd62ac7fa Mon Sep 17 00:00:00 2001 From: Francesco Stasi Date: Thu, 22 Jul 2021 09:26:23 +0200 Subject: [PATCH 3/3] code review --- .../src/browser/create/create-api.ts | 94 +++---------------- .../src/browser/create/create-fs-provider.ts | 3 +- .../src/browser/create/create-uri.ts | 2 +- .../src/browser/create/typings.ts | 73 ++++++++++++++ .../cloud-sketchbook/cloud-sketch-cache.ts | 10 +- .../cloud-sketchbook-tree-model.ts | 9 +- 6 files changed, 102 insertions(+), 89 deletions(-) create mode 100644 arduino-ide-extension/src/browser/create/typings.ts diff --git a/arduino-ide-extension/src/browser/create/create-api.ts b/arduino-ide-extension/src/browser/create/create-api.ts index 06e7506cb..0e81ecb90 100644 --- a/arduino-ide-extension/src/browser/create/create-api.ts +++ b/arduino-ide-extension/src/browser/create/create-api.ts @@ -4,6 +4,7 @@ import { posix } from './create-paths'; import { AuthenticationClientService } from '../auth/authentication-client-service'; import { ArduinoPreferences } from '../arduino-preferences'; import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache'; +import { Create, CreateError } from './typings'; export interface ResponseResultProvider { (response: Response): Promise; @@ -19,7 +20,7 @@ type ResourceType = 'f' | 'd'; @injectable() export class CreateApi { @inject(SketchCache) - protected readonly sketchCache: SketchCache; + protected sketchCache: SketchCache; protected authenticationService: AuthenticationClientService; protected arduinoPreferences: ArduinoPreferences; @@ -34,10 +35,6 @@ export class CreateApi { return this; } - public wipeCache(): void { - this.sketchCache.init(); - } - getSketchSecretStat(sketch: Create.Sketch): Create.Resource { return { href: `${sketch.href}${posix.sep}${Create.arduino_secrets_file}`, @@ -50,7 +47,7 @@ export class CreateApi { }; } - async sketch(id: string): Promise { + async sketch(id: string): Promise { const url = new URL(`${this.domain()}/sketches/byID/${id}`); url.searchParams.set('user_id', 'me'); @@ -303,7 +300,9 @@ export class CreateApi { 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 + // TODO: we should do a get after the POST, in order to be sure the cache + // is updated the most recent metadata this.sketchCache.addSketch(sketch); const init = { @@ -316,6 +315,14 @@ export class CreateApi { 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( `${this.domain()}/files/f/$HOME/sketches_v2${posixPath}` ); @@ -458,76 +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 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; -} - -export class CreateError extends Error { - constructor( - message: string, - readonly status: number, - readonly details?: string - ) { - super(message); - Object.setPrototypeOf(this, CreateError.prototype); - } -} diff --git a/arduino-ide-extension/src/browser/create/create-fs-provider.ts b/arduino-ide-extension/src/browser/create/create-fs-provider.ts index 96c6cf77f..8199f693d 100644 --- a/arduino-ide-extension/src/browser/create/create-fs-provider.ts +++ b/arduino-ide-extension/src/browser/create/create-fs-provider.ts @@ -24,10 +24,11 @@ import { FileServiceContribution, } from '@theia/filesystem/lib/browser/file-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 { SketchesService } from '../../common/protocol'; import { ArduinoPreferences } from '../arduino-preferences'; +import { Create } from './typings'; export const REMOTE_ONLY_FILES = ['sketch.json']; diff --git a/arduino-ide-extension/src/browser/create/create-uri.ts b/arduino-ide-extension/src/browser/create/create-uri.ts index e5ddb25d6..1d60ffff2 100644 --- a/arduino-ide-extension/src/browser/create/create-uri.ts +++ b/arduino-ide-extension/src/browser/create/create-uri.ts @@ -1,7 +1,7 @@ import { URI as Uri } from 'vscode-uri'; import URI from '@theia/core/lib/common/uri'; -import { Create } from './create-api'; import { toPosixPath, parentPosix, posix } from './create-paths'; +import { Create } from './typings'; export namespace CreateUri { export const scheme = 'arduino-create'; diff --git a/arduino-ide-extension/src/browser/create/typings.ts b/arduino-ide-extension/src/browser/create/typings.ts new file mode 100644 index 000000000..e951ac794 --- /dev/null +++ b/arduino-ide-extension/src/browser/create/typings.ts @@ -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; +} + +export class CreateError extends Error { + constructor( + message: string, + readonly status: number, + readonly details?: string + ) { + super(message); + Object.setPrototypeOf(this, CreateError.prototype); + } +} diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts index c07166825..09a9779f6 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketch-cache.ts @@ -1,25 +1,25 @@ import { FileStat } from '@theia/filesystem/lib/common/files'; import { injectable } from 'inversify'; -import { Create } from '../../create/create-api'; import { toPosixPath } from '../../create/create-paths'; +import { Create } from '../../create/typings'; @injectable() export class SketchCache { sketches: Record = {}; - filestats: Record = {}; + fileStats: Record = {}; init(): void { // reset the data this.sketches = {}; - this.filestats = {}; + this.fileStats = {}; } addItem(item: FileStat): void { - this.filestats[item.resource.path.toString()] = item; + this.fileStats[item.resource.path.toString()] = item; } getItem(path: string): FileStat | null { - return this.filestats[path] || null; + return this.fileStats[path] || null; } addSketch(sketch: Create.Sketch): void { diff --git a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts index b74d18f64..9c6ece2fc 100644 --- a/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts +++ b/arduino-ide-extension/src/browser/widgets/cloud-sketchbook/cloud-sketchbook-tree-model.ts @@ -1,7 +1,7 @@ import { inject, injectable, postConstruct } from 'inversify'; import { TreeNode } from '@theia/core/lib/browser/tree'; 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 { AuthenticationClientService } from '../../auth/authentication-client-service'; import { SketchbookTreeModel } from '../sketchbook/sketchbook-tree-model'; @@ -12,6 +12,8 @@ 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 function sketchBaseDir(sketch: Create.Sketch): FileStat { // extract the sketch path @@ -67,6 +69,9 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { @inject(LocalCacheFsProvider) protected readonly localCacheFsProvider: LocalCacheFsProvider; + @inject(SketchCache) + protected readonly sketchCache: SketchCache; + @postConstruct() protected init(): void { super.init(); @@ -82,7 +87,7 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel { return; } this.createApi.init(this.authenticationService, this.arduinoPreferences); - this.createApi.wipeCache(); + this.sketchCache.init(); const sketches = await this.createApi.sketches(); const rootFileStats = sketchesToFileStats(sketches); if (this.workspaceService.opened) {