mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-10-04 09:08:31 +00:00
671 lines
20 KiB
TypeScript
671 lines
20 KiB
TypeScript
import { SketchCache } from './cloud-sketch-cache';
|
||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||
import URI from '@theia/core/lib/common/uri';
|
||
import { MaybePromise } from '@theia/core/lib/common/types';
|
||
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 {
|
||
DirNode,
|
||
FileNode,
|
||
} from '@theia/filesystem/lib/browser/file-tree/file-tree';
|
||
import { TreeNode, CompositeTreeNode } from '@theia/core/lib/browser/tree';
|
||
import {
|
||
PreferenceService,
|
||
PreferenceScope,
|
||
} from '@theia/core/lib/browser/preferences/preference-service';
|
||
import { MessageService } from '@theia/core/lib/common/message-service';
|
||
import { CreateApi } from '../../create/create-api';
|
||
import { CreateUri } from '../../create/create-uri';
|
||
import { CloudSketchbookTreeModel } from './cloud-sketchbook-tree-model';
|
||
import {
|
||
LocalCacheFsProvider,
|
||
LocalCacheUri,
|
||
} from '../../local-cache/local-cache-fs-provider';
|
||
import { CloudSketchbookCommands } from './cloud-sketchbook-contributions';
|
||
import { DoNotAskAgainConfirmDialog } from '../../dialogs/do-not-ask-again-dialog';
|
||
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';
|
||
import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree';
|
||
import { posix, splitSketchPath } from '../../create/create-paths';
|
||
import { Create } from '../../create/typings';
|
||
import { nls } from '@theia/core/lib/common';
|
||
|
||
const MESSAGE_TIMEOUT = 5 * 1000;
|
||
const deepmerge = require('deepmerge').default;
|
||
|
||
type FilesToWrite = { source: URI; dest: URI };
|
||
type FilesToSync = {
|
||
filesToWrite: FilesToWrite[];
|
||
filesToDelete: URI[];
|
||
};
|
||
@injectable()
|
||
export class CloudSketchbookTree extends SketchbookTree {
|
||
@inject(FileService)
|
||
protected override readonly fileService: FileService;
|
||
|
||
@inject(LocalCacheFsProvider)
|
||
protected readonly localCacheFsProvider: LocalCacheFsProvider;
|
||
|
||
@inject(SketchCache)
|
||
protected readonly sketchCache: SketchCache;
|
||
|
||
@inject(ArduinoPreferences)
|
||
protected override readonly arduinoPreferences: ArduinoPreferences;
|
||
|
||
@inject(PreferenceService)
|
||
protected readonly preferenceService: PreferenceService;
|
||
|
||
@inject(MessageService)
|
||
protected readonly messageService: MessageService;
|
||
|
||
@inject(SketchesServiceClientImpl)
|
||
protected readonly sketchServiceClient: SketchesServiceClientImpl;
|
||
|
||
@inject(CreateApi)
|
||
protected readonly createApi: CreateApi;
|
||
|
||
async pushPublicWarn(
|
||
node: CloudSketchbookTree.CloudSketchDirNode
|
||
): Promise<boolean> {
|
||
const warn =
|
||
node.isPublic && this.arduinoPreferences['arduino.cloud.pushpublic.warn'];
|
||
|
||
if (warn) {
|
||
const ok = await new DoNotAskAgainConfirmDialog({
|
||
ok: nls.localize('arduino/cloud/continue', 'Continue'),
|
||
cancel: nls.localize('vscode/issueMainService/cancel', 'Cancel'),
|
||
title: nls.localize('arduino/cloud/pushSketch', 'Push Sketch'),
|
||
msg: nls.localize(
|
||
'arduino/cloud/pushSketchMsg',
|
||
'This is a Public Sketch. Before pushing, make sure any sensitive information is defined in arduino_secrets.h files. You can make a Sketch private from the Share panel.'
|
||
),
|
||
maxWidth: 400,
|
||
onAccept: () =>
|
||
this.preferenceService.set(
|
||
'arduino.cloud.pushpublic.warn',
|
||
false,
|
||
PreferenceScope.User
|
||
),
|
||
}).open();
|
||
if (!ok) {
|
||
return false;
|
||
}
|
||
return true;
|
||
} else {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
async pull(arg: any): Promise<void> {
|
||
const {
|
||
// model,
|
||
node,
|
||
}: {
|
||
model: CloudSketchbookTreeModel;
|
||
node: CloudSketchbookTree.CloudSketchDirNode;
|
||
} = arg;
|
||
|
||
const warn =
|
||
CloudSketchbookTree.CloudSketchTreeNode.isSynced(node) &&
|
||
this.arduinoPreferences['arduino.cloud.pull.warn'];
|
||
|
||
if (warn) {
|
||
const ok = await new DoNotAskAgainConfirmDialog({
|
||
ok: nls.localize('arduino/cloud/pull', 'Pull'),
|
||
cancel: nls.localize('vscode/issueMainService/cancel', 'Cancel'),
|
||
title: nls.localize('arduino/cloud/pullSketch', 'Pull Sketch'),
|
||
msg: nls.localize(
|
||
'arduino/cloud/pullSketchMsg',
|
||
'Pulling this Sketch from the Cloud will overwrite its local version. Are you sure you want to continue?'
|
||
),
|
||
maxWidth: 400,
|
||
onAccept: () =>
|
||
this.preferenceService.set(
|
||
'arduino.cloud.pull.warn',
|
||
false,
|
||
PreferenceScope.User
|
||
),
|
||
}).open();
|
||
if (!ok) {
|
||
return;
|
||
}
|
||
}
|
||
return this.runWithState(node, 'pulling', async (node) => {
|
||
const commandsCopy = node.commands;
|
||
node.commands = [];
|
||
|
||
const localUri = await this.fileService.toUnderlyingResource(
|
||
LocalCacheUri.root.resolve(node.remoteUri.path)
|
||
);
|
||
await this.sync(node.remoteUri, localUri);
|
||
|
||
this.sketchCache.purgeByPath(node.remoteUri.path.toString());
|
||
|
||
node.commands = commandsCopy;
|
||
this.messageService.info(
|
||
nls.localize(
|
||
'arduino/cloud/donePulling',
|
||
'Done pulling ‘{0}’.',
|
||
node.fileStat.name
|
||
),
|
||
{
|
||
timeout: MESSAGE_TIMEOUT,
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
async push(node: CloudSketchbookTree.CloudSketchDirNode): Promise<void> {
|
||
if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
|
||
throw new Error(
|
||
nls.localize(
|
||
'arduino/cloud/notYetPulled',
|
||
'Cannot push to Cloud. It is not yet pulled.'
|
||
)
|
||
);
|
||
}
|
||
|
||
const pushPublic = await this.pushPublicWarn(node);
|
||
if (!pushPublic) {
|
||
return;
|
||
}
|
||
|
||
const warn = this.arduinoPreferences['arduino.cloud.push.warn'];
|
||
|
||
if (warn) {
|
||
const ok = await new DoNotAskAgainConfirmDialog({
|
||
ok: nls.localize('arduino/cloud/push', 'Push'),
|
||
cancel: nls.localize('vscode/issueMainService/cancel', 'Cancel'),
|
||
title: nls.localize('arduino/cloud/pushSketch', 'Push Sketch'),
|
||
msg: 'Pushing this Sketch will overwrite its Cloud version. Are you sure you want to continue?',
|
||
maxWidth: 400,
|
||
onAccept: () =>
|
||
this.preferenceService.set(
|
||
'arduino.cloud.push.warn',
|
||
false,
|
||
PreferenceScope.User
|
||
),
|
||
}).open();
|
||
if (!ok) {
|
||
return;
|
||
}
|
||
}
|
||
return this.runWithState(node, 'pushing', async (node) => {
|
||
if (!CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)) {
|
||
throw new Error(
|
||
nls.localize(
|
||
'arduino/cloud/pullFirst',
|
||
'You have to pull first to be able to push to the Cloud.'
|
||
)
|
||
);
|
||
}
|
||
const commandsCopy = node.commands;
|
||
node.commands = [];
|
||
|
||
const localUri = await this.fileService.toUnderlyingResource(
|
||
LocalCacheUri.root.resolve(node.remoteUri.path)
|
||
);
|
||
await this.sync(localUri, node.remoteUri);
|
||
|
||
this.sketchCache.purgeByPath(node.remoteUri.path.toString());
|
||
|
||
node.commands = commandsCopy;
|
||
this.messageService.info(
|
||
nls.localize(
|
||
'arduino/cloud/donePushing',
|
||
'Done pushing ‘{0}’.',
|
||
node.fileStat.name
|
||
),
|
||
{
|
||
timeout: MESSAGE_TIMEOUT,
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
async recursiveURIs(uri: URI): Promise<URI[]> {
|
||
// remote resources can be fetched one-shot via api
|
||
if (CreateUri.is(uri)) {
|
||
const resources = await this.createApi.readDirectory(
|
||
uri.path.toString(),
|
||
{ recursive: true, skipSketchCache: true }
|
||
);
|
||
return resources.map((resource) =>
|
||
CreateUri.toUri(splitSketchPath(resource.path)[1])
|
||
);
|
||
}
|
||
|
||
const fileStat = await this.fileService.resolve(uri, {
|
||
resolveMetadata: false,
|
||
});
|
||
|
||
if (!fileStat.children || !fileStat.isDirectory) {
|
||
return [fileStat.resource];
|
||
}
|
||
|
||
let childrenUris: URI[] = [];
|
||
|
||
for await (const child of fileStat.children) {
|
||
childrenUris = [
|
||
...childrenUris,
|
||
...(await this.recursiveURIs(child.resource)),
|
||
];
|
||
}
|
||
|
||
return [fileStat.resource, ...childrenUris];
|
||
}
|
||
|
||
private URIsToMap(uris: URI[], basepath: string): Record<string, URI> {
|
||
return uris.reduce((prev: Record<string, URI>, curr) => {
|
||
const path = curr.toString().split(basepath);
|
||
|
||
if (path.length !== 2 || path[1].length === 0) {
|
||
return prev;
|
||
}
|
||
|
||
// do not map "do_not_sync" files/directories and their descendants
|
||
const segments = path[1].split(posix.sep) || [];
|
||
if (
|
||
segments.some((segment) => Create.do_not_sync_files.includes(segment))
|
||
) {
|
||
return prev;
|
||
}
|
||
|
||
// skip when the filename is a hidden file (starts with `.`)
|
||
if (segments[segments.length - 1].indexOf('.') === 0) {
|
||
return prev;
|
||
}
|
||
|
||
return { ...prev, [path[1]]: curr };
|
||
}, {});
|
||
}
|
||
|
||
async getUrisMap(uri: URI) {
|
||
const basepath = uri.toString();
|
||
const exists = await this.fileService.exists(uri);
|
||
const uris =
|
||
(exists && this.URIsToMap(await this.recursiveURIs(uri), basepath)) || {};
|
||
return uris;
|
||
}
|
||
|
||
async treeDiff(source: URI, dest: URI): Promise<FilesToSync> {
|
||
const [sourceURIs, destURIs] = await Promise.all([
|
||
this.getUrisMap(source),
|
||
this.getUrisMap(dest),
|
||
]);
|
||
|
||
const destBase = dest.toString();
|
||
const filesToWrite: FilesToWrite[] = [];
|
||
|
||
Object.keys(sourceURIs).forEach((path) => {
|
||
const destUri = destURIs[path] || new URI(destBase + path);
|
||
|
||
filesToWrite.push({ source: sourceURIs[path], dest: destUri });
|
||
delete destURIs[path];
|
||
});
|
||
|
||
const filesToDelete = Object.values(destURIs);
|
||
|
||
return { filesToWrite, filesToDelete };
|
||
}
|
||
|
||
override async refresh(
|
||
node?: CompositeTreeNode
|
||
): Promise<CompositeTreeNode | undefined> {
|
||
if (node) {
|
||
const showAllFiles =
|
||
this.arduinoPreferences['arduino.sketchbook.showAllFiles'];
|
||
await this.decorateNode(node, showAllFiles);
|
||
}
|
||
return super.refresh(node);
|
||
}
|
||
|
||
private async runWithState<T>(
|
||
node: CloudSketchbookTree.CloudSketchDirNode & Partial<DecoratedTreeNode>,
|
||
state: CloudSketchbookTree.CloudSketchDirNode.State,
|
||
task: (node: CloudSketchbookTree.CloudSketchDirNode) => MaybePromise<T>
|
||
): Promise<T> {
|
||
const decoration: WidgetDecoration.TailDecoration = {
|
||
data: `${firstToUpperCase(state)}...`,
|
||
fontData: {
|
||
color: 'var(--theia-list-highlightForeground)',
|
||
},
|
||
};
|
||
try {
|
||
node.state = state;
|
||
this.mergeDecoration(node, { tailDecorations: [decoration] });
|
||
await this.refresh(node);
|
||
const result = await task(node);
|
||
return result;
|
||
} finally {
|
||
delete node.state;
|
||
// TODO: find a better way to attach and detach decorators. Do we need a proper `TreeDecorator` instead?
|
||
const index = node.decorationData?.tailDecorations?.findIndex(
|
||
(candidate) => JSON.stringify(decoration) === JSON.stringify(candidate)
|
||
);
|
||
if (typeof index === 'number' && index !== -1) {
|
||
node.decorationData?.tailDecorations?.splice(index, 1);
|
||
}
|
||
await this.refresh(node);
|
||
}
|
||
}
|
||
|
||
async sync(source: URI, dest: URI) {
|
||
const { filesToWrite, filesToDelete } = await this.treeDiff(source, dest);
|
||
await Promise.all(
|
||
filesToWrite.map(async ({ source, dest }) => {
|
||
if ((await this.fileService.resolve(source)).isFile) {
|
||
const content = await this.fileService.read(source);
|
||
return this.fileService.write(dest, content.value);
|
||
}
|
||
return this.fileService.createFolder(dest);
|
||
})
|
||
);
|
||
|
||
await Promise.all(
|
||
filesToDelete.map((file) =>
|
||
this.fileService.delete(file, { recursive: true })
|
||
)
|
||
);
|
||
}
|
||
|
||
override 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 (b.fileStat.mtime || 0) - (a.fileStat.mtime || 0);
|
||
}
|
||
return syncComparison;
|
||
}
|
||
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Retrieve fileStats for the given node, merging the local and remote children
|
||
* Local children take precedence over remote ones
|
||
* @param node
|
||
* @returns
|
||
*/
|
||
protected override async resolveFileStat(
|
||
node: FileStatNode
|
||
): Promise<FileStat | undefined> {
|
||
if (
|
||
CloudSketchbookTree.CloudSketchTreeNode.is(node) &&
|
||
CreateUri.is(node.remoteUri)
|
||
) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
protected override 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.uri = uri;
|
||
node.fileStat = fileStat;
|
||
return node;
|
||
}
|
||
return <DirNode>{
|
||
id,
|
||
uri,
|
||
fileStat,
|
||
parent,
|
||
expanded: false,
|
||
selected: false,
|
||
children: [],
|
||
};
|
||
}
|
||
if (FileNode.is(node)) {
|
||
node.fileStat = fileStat;
|
||
node.uri = uri;
|
||
return node;
|
||
}
|
||
return <FileNode>{
|
||
id,
|
||
uri,
|
||
fileStat,
|
||
parent,
|
||
selected: false,
|
||
};
|
||
}
|
||
|
||
protected readonly notInSyncDecoration: WidgetDecoration.Data = {
|
||
fontData: {
|
||
color: 'var(--theia-activityBar-inactiveForeground)',
|
||
},
|
||
};
|
||
|
||
protected readonly inSyncDecoration: WidgetDecoration.Data = {
|
||
fontData: {},
|
||
};
|
||
|
||
/**
|
||
* 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 override async augmentSketchNode(node: DirNode): Promise<void> {
|
||
const sketch = this.sketchCache.getSketch(
|
||
node.fileStat.resource.path.toString()
|
||
);
|
||
|
||
const commands = [CloudSketchbookCommands.PULL_SKETCH];
|
||
|
||
if (
|
||
CloudSketchbookTree.CloudSketchTreeNode.is(node) &&
|
||
CloudSketchbookTree.CloudSketchTreeNode.isSynced(node)
|
||
) {
|
||
commands.push(CloudSketchbookCommands.PUSH_SKETCH);
|
||
}
|
||
commands.push(CloudSketchbookCommands.OPEN_SKETCHBOOKSYNC_CONTEXT_MENU);
|
||
|
||
Object.assign(node, {
|
||
type: 'sketch',
|
||
...(sketch && {
|
||
isPublic: sketch.is_public,
|
||
}),
|
||
...(sketch && {
|
||
sketchId: sketch.id,
|
||
}),
|
||
commands,
|
||
});
|
||
}
|
||
|
||
protected async nodeLocalUri(node: TreeNode): Promise<TreeNode> {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
protected override async decorateNode(
|
||
node: TreeNode,
|
||
showAllFiles: boolean
|
||
): Promise<TreeNode> {
|
||
node = await this.nodeLocalUri(node);
|
||
|
||
node = await super.decorateNode(node, showAllFiles);
|
||
return node;
|
||
}
|
||
|
||
protected override 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(
|
||
node: TreeNode,
|
||
decorationData: WidgetDecoration.Data
|
||
): void {
|
||
Object.assign(node, {
|
||
decorationData: deepmerge(
|
||
DecoratedTreeNode.is(node) ? node.decorationData : {},
|
||
decorationData
|
||
),
|
||
});
|
||
}
|
||
|
||
private removeDecoration(
|
||
node: TreeNode,
|
||
decorationData: WidgetDecoration.Data
|
||
): void {
|
||
if (DecoratedTreeNode.is(node)) {
|
||
for (const property of Object.keys(decorationData)) {
|
||
if (node.decorationData.hasOwnProperty(property)) {
|
||
delete (node.decorationData as any)[property];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public async localUri(node: FileStatNode): Promise<URI | undefined> {
|
||
const localUri = LocalCacheUri.root.resolve(node.uri.path);
|
||
const exists = await this.fileService.exists(localUri);
|
||
if (exists) {
|
||
return localUri;
|
||
}
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
export namespace CloudSketchbookTree {
|
||
export interface CloudSketchTreeNode extends FileStatNode {
|
||
remoteUri: URI;
|
||
}
|
||
|
||
export namespace CloudSketchTreeNode {
|
||
export function is(node: TreeNode): node is CloudSketchTreeNode {
|
||
return !!node && typeof node.hasOwnProperty('remoteUri') !== 'undefined';
|
||
}
|
||
|
||
export function isSynced(node: CloudSketchTreeNode): boolean {
|
||
return node.remoteUri !== node.uri;
|
||
}
|
||
}
|
||
|
||
export interface CloudSketchDirNode
|
||
extends Omit<SketchbookTree.SketchDirNode, 'fileStat'>,
|
||
CloudSketchTreeNode {
|
||
state?: CloudSketchDirNode.State;
|
||
isPublic?: boolean;
|
||
sketchId?: string;
|
||
commands?: Command[];
|
||
}
|
||
export namespace CloudSketchDirNode {
|
||
export function is(node: TreeNode): node is CloudSketchDirNode {
|
||
return SketchbookTree.SketchDirNode.is(node);
|
||
}
|
||
|
||
export type State = 'syncing' | 'pulling' | 'pushing';
|
||
}
|
||
}
|