Reimplemented sketchbook watcher.

Moved it to the frontend.

Signed-off-by: Akos Kitta <kittaakos@typefox.io>
This commit is contained in:
Akos Kitta 2021-02-02 11:48:52 +01:00 committed by Akos Kitta
parent 911875665d
commit b1ab6df8b7
17 changed files with 147 additions and 541 deletions

View File

@ -1,136 +0,0 @@
{
"$id": "http://arduino.cc/arduino-cli.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Arduino CLI Configuration",
"properties": {
"board_manager": {
"type": "object",
"description": "Board Manager Configuration",
"properties": {
"additional_urls": {
"type": "array",
"description": "If your board requires 3rd party core packages to work, you can list the URLs to additional package indexes in the Arduino CLI configuration file.",
"items": {
"type": "string",
"description": "URL pointing to the 3rd party core package index JSON.",
"pattern": "^(.*)$"
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"daemon": {
"type": "object",
"description": "CLI Daemon Configuration",
"properties": {
"port": {
"type": [
"string",
"number"
],
"description": "The CLI daemon port where the gRPC clients can connect to.",
"pattern": "^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$",
"additionalProperties": false
}
},
"additionalProperties": false
},
"directories": {
"type": "object",
"description": "Directories Configuration",
"properties": {
"data": {
"type": "string",
"description": "Path to the the data folder where core packages will be stored.",
"pattern": "^(.*)$"
},
"downloads": {
"type": "string",
"description": "Path to the staging folder.",
"pattern": "^(.*)$"
},
"user": {
"type": "string",
"description": "Path to the sketchbooks.",
"pattern": "^(.*)$"
}
},
"additionalProperties": false
},
"logging": {
"type": "object",
"description": "Logging Configuration",
"properties": {
"file": {
"type": "string",
"description": "Path to the file where logs will be written.",
"pattern": "^(.*)$"
},
"format": {
"type": "string",
"description": "The output format for the logs, can be 'text' or 'json'",
"enum": [
"text",
"json"
]
},
"level": {
"type": "string",
"description": "Messages with this level and above will be logged.",
"enum": [
"trace",
"debug",
"info",
"warning",
"error",
"fatal",
"panic"
]
}
},
"additionalProperties": false
},
"sketch": {
"type": "object",
"description": "Sketch Configuration",
"properties": {
"always_export_binaries": {
"type": "boolean",
"description": "Controls whether the compiled binaries will be exported into the sketch's 'build' folder or not. 'false' if the binaries are not exported."
}
},
"additionalProperties": false
},
"metrics": {
"type": "object",
"description": "Metrics Configuration",
"properties": {
"addr": {
"type": "string",
"description": "Address to the metrics endpoint. Must be a full address with host, address, and port. For instance, ':9090' represents 'localhost:9090'",
"pattern": "^(.*)$"
},
"enabled": {
"type": "boolean",
"description": "Whether the metrics is enabled or not."
},
"additionalProperties": false
},
"additionalProperties": false
},
"library": {
"type": "object",
"description": "Library Configuration",
"properties": {
"enable_unsafe_install": {
"type": "boolean",
"description": "Set to 'true' to enable the use of the '--git-url' and '--zip-file' flags with 'arduino-cli lib install' These are considered 'unsafe' installation methods because they allow installing files that have not passed through the Library Manager submission process."
},
"additionalProperties": false
},
"additionalProperties": false
}
}
}

View File

@ -181,6 +181,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Sketch list service
bind(SketchesService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, SketchesServicePath)).inSingletonScope();
bind(SketchesServiceClientImpl).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(SketchesServiceClientImpl);
// Config service
bind(ConfigService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, ConfigServicePath)).inSingletonScope();

View File

@ -28,7 +28,7 @@ export class Sketchbook extends SketchContribution {
this.register(sketches);
this.mainMenuManager.update();
});
this.notificationCenter.onSketchbookChanged(({ created, removed }) => {
this.sketchServiceClient.onSketchbookDidChange(({ created, removed }) => {
this.unregister(removed);
this.register(created);
this.mainMenuManager.update();

View File

@ -21,7 +21,6 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
protected readonly libraryInstalledEmitter = new Emitter<{ item: LibraryPackage }>();
protected readonly libraryUninstalledEmitter = new Emitter<{ item: LibraryPackage }>();
protected readonly attachedBoardsChangedEmitter = new Emitter<AttachedBoardsChangeEvent>();
protected readonly sketchbookChangedEmitter = new Emitter<{ created: Sketch[], removed: Sketch[] }>();
protected readonly recentSketchesChangedEmitter = new Emitter<{ sketches: Sketch[] }>();
protected readonly toDispose = new DisposableCollection(
@ -33,8 +32,7 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
this.platformUninstalledEmitter,
this.libraryInstalledEmitter,
this.libraryUninstalledEmitter,
this.attachedBoardsChangedEmitter,
this.sketchbookChangedEmitter
this.attachedBoardsChangedEmitter
);
readonly onIndexUpdated = this.indexUpdatedEmitter.event;
@ -46,7 +44,6 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
readonly onLibraryInstalled = this.libraryInstalledEmitter.event;
readonly onLibraryUninstalled = this.libraryUninstalledEmitter.event;
readonly onAttachedBoardsChanged = this.attachedBoardsChangedEmitter.event;
readonly onSketchbookChanged = this.sketchbookChangedEmitter.event;
readonly onRecentSketchesChanged = this.recentSketchesChangedEmitter.event;
@postConstruct()
@ -94,10 +91,6 @@ export class NotificationCenter implements NotificationServiceClient, FrontendAp
this.attachedBoardsChangedEmitter.fire(event);
}
notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void {
this.sketchbookChangedEmitter.fire(event);
}
notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void {
this.recentSketchesChangedEmitter.fire(event);
}

View File

@ -4,7 +4,6 @@ export interface ConfigService {
getVersion(): Promise<Readonly<{ version: string, commit: string, status?: string }>>;
getConfiguration(): Promise<Config>;
setConfiguration(config: Config): Promise<void>;
getConfigurationFileSchemaUri(): Promise<string>;
isInDataDir(uri: string): Promise<boolean>;
isInSketchDir(uri: string): Promise<boolean>;
}

View File

@ -12,7 +12,6 @@ export interface NotificationServiceClient {
notifyLibraryInstalled(event: { item: LibraryPackage }): void;
notifyLibraryUninstalled(event: { item: LibraryPackage }): void;
notifyAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void;
notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void;
notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void;
}

View File

@ -5,9 +5,13 @@ import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { MessageService } from '@theia/core/lib/common/message-service';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { Sketch, SketchesService } from '../../common/protocol';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { ConfigService } from './config-service';
import { DisposableCollection, Emitter } from '@theia/core';
import { FileChangeType } from '@theia/filesystem/lib/browser';
@injectable()
export class SketchesServiceClientImpl {
export class SketchesServiceClientImpl implements FrontendApplicationContribution {
@inject(FileService)
protected readonly fileService: FileService;
@ -21,6 +25,58 @@ export class SketchesServiceClientImpl {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(ConfigService)
protected readonly configService: ConfigService;
protected toDispose = new DisposableCollection();
protected sketches = new Map<string, Sketch>();
protected sketchbookDidChangeEmitter = new Emitter<{ created: Sketch[], removed: Sketch[] }>();
readonly onSketchbookDidChange = this.sketchbookDidChangeEmitter.event;
onStart(): void {
this.configService.getConfiguration().then(({ sketchDirUri }) => {
this.sketchService.getSketches(sketchDirUri).then(sketches => {
const sketchbookUri = new URI(sketchDirUri);
for (const sketch of sketches) {
this.sketches.set(sketch.uri, sketch);
}
this.toDispose.push(this.fileService.watch(new URI(sketchDirUri), { recursive: true, excludes: [] }));
this.toDispose.push(this.fileService.onDidFilesChange(async event => {
for (const { type, resource } of event.changes) {
// We track main sketch files changes only.
if (sketchbookUri.isEqualOrParent(resource)) {
const { ext } = resource.path; // TODO: add support for `.pde`.
if (ext === '.ino') {
if (type === FileChangeType.ADDED) {
try {
const toAdd = await this.sketchService.loadSketch(resource.parent.toString());
if (!this.sketches.has(toAdd.uri)) {
console.log(`New sketch '${toAdd.name}' was crated in sketchbook '${sketchDirUri}'.`);
this.sketches.set(toAdd.uri, toAdd);
this.fireSoon(toAdd, 'created');
}
} catch { }
} else if (type === FileChangeType.DELETED) {
const uri = resource.parent.toString();
const toDelete = this.sketches.get(uri);
if (toDelete) {
console.log(`Sketch '${toDelete.name}' was removed from sketchbook '${sketchbookUri}'.`);
this.sketches.delete(uri);
this.fireSoon(toDelete, 'removed');
}
}
}
}
}
}));
});
});
}
onStop(): void {
this.toDispose.dispose();
}
async currentSketch(): Promise<Sketch | undefined> {
const sketches = (await Promise.all(this.workspaceService.tryGetRoots().map(({ resource }) => this.sketchService.getSketchFolder(resource.toString())))).filter(notEmpty);
if (!sketches.length) {
@ -46,4 +102,31 @@ export class SketchesServiceClientImpl {
return undefined;
}
private fireSoonHandle?: number;
private bufferedSketchbookEvents: { type: 'created' | 'removed', sketch: Sketch }[] = [];
private fireSoon(sketch: Sketch, type: 'created' | 'removed'): void {
this.bufferedSketchbookEvents.push({ type, sketch });
if (typeof this.fireSoonHandle === 'number') {
window.clearTimeout(this.fireSoonHandle);
}
this.fireSoonHandle = window.setTimeout(() => {
const event: { created: Sketch[], removed: Sketch[] } = {
created: [],
removed: []
};
for (const { type, sketch } of this.bufferedSketchbookEvents) {
if (type === 'created') {
event.created.push(sketch);
} else {
event.removed.push(sketch);
}
}
this.sketchbookDidChangeEmitter.fire(event);
this.bufferedSketchbookEvents.length = 0;
}, 100);
}
}

View File

@ -23,7 +23,6 @@ import { MonitorClientProvider } from './monitor/monitor-client-provider';
import { ConfigServiceImpl } from './config-service-impl';
import { HostedPluginReader } from './theia/plugin-ext/plugin-reader';
import { HostedPluginReader as TheiaHostedPluginReader } from '@theia/plugin-ext/lib/hosted/node/plugin-reader';
import { ConfigFileValidator } from './config-file-validator';
import { EnvVariablesServer as TheiaEnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { EnvVariablesServer } from './theia/env-variables/env-variables-server';
import { NodeFileSystemExt } from './node-filesystem-ext';
@ -43,7 +42,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
rebind(TheiaBackendApplication).toService(BackendApplication);
// Shared config service
bind(ConfigFileValidator).toSelf().inSingletonScope();
bind(ConfigServiceImpl).toSelf().inSingletonScope();
bind(ConfigService).toService(ConfigServiceImpl);
// Note: The config service must start earlier than the daemon, hence the binding order of the BA contribution does matter.

View File

@ -1,9 +1,6 @@
import { join } from 'path';
import { RecursivePartial } from '@theia/core/lib/common/types';
export const CLI_CONFIG = 'arduino-cli.yaml';
export const CLI_CONFIG_SCHEMA = 'arduino-cli.schema.json';
export const CLI_CONFIG_SCHEMA_PATH = join(__dirname, '..', '..', 'data', 'cli', 'schema', CLI_CONFIG_SCHEMA);
export interface BoardManager {
readonly additional_urls: Array<string>;

View File

@ -1,60 +0,0 @@
import * as Ajv from 'ajv';
import * as fs from './fs-extra';
import { injectable } from 'inversify';
import { CLI_CONFIG_SCHEMA_PATH, DefaultCliConfig } from './cli-config';
@injectable()
export class ConfigFileValidator {
protected readonly function = new Ajv().compile(JSON.parse(fs.readFileSync(CLI_CONFIG_SCHEMA_PATH, 'utf8')));
async validate(pathOrObject: string | object): Promise<boolean> {
return this.doValidate(typeof pathOrObject === 'string' ? fs.readFileSync(pathOrObject) : pathOrObject);
}
protected async doValidate(object: object): Promise<boolean> {
const valid = this.function(object);
if (!valid) {
if (Array.isArray(this.function.errors)) {
for (const error of this.function.errors) {
console.log(JSON.stringify(error));
}
}
return false;
}
if (!DefaultCliConfig.is(object)) {
return false;
}
const { directories: { data, downloads, user } } = object;
for (const path of [data, downloads, user]) {
const validPath = await this.isValidPath(path);
if (!validPath) {
return false;
}
}
const port = typeof object.daemon.port === 'string' ? Number.parseInt(object.daemon.port, 10) : object.daemon.port;
if (Number.isNaN(port) || port <= 0) {
return false;
}
return true;
}
protected async isValidPath(path: string): Promise<boolean> {
try {
if (!path.trim()) {
return false;
}
const exists = await fs.exists(path);
if (!exists) {
await fs.mkdirp(path);
}
return true;
} catch {
return false;
}
}
}

View File

@ -1,6 +1,8 @@
import * as fs from 'fs';
import * as path from 'path';
import * as temp from 'temp';
import * as yaml from 'js-yaml';
import { promisify } from 'util';
import * as grpc from '@grpc/grpc-js';
import * as deepmerge from 'deepmerge';
import { injectable, inject, named } from 'inversify';
@ -10,14 +12,12 @@ import { FileUri } from '@theia/core/lib/node/file-uri';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import { ConfigService, Config, NotificationServiceServer } from '../common/protocol';
import * as fs from './fs-extra';
import { spawnCommand } from './exec-util';
import { RawData, WriteRequest } from './cli-protocol/settings/settings_pb';
import { SettingsClient } from './cli-protocol/settings/settings_grpc_pb';
import * as serviceGrpcPb from './cli-protocol/settings/settings_grpc_pb';
import { ConfigFileValidator } from './config-file-validator';
import { ArduinoDaemonImpl } from './arduino-daemon-impl';
import { DefaultCliConfig, CLI_CONFIG_SCHEMA_PATH, CLI_CONFIG } from './cli-config';
import { DefaultCliConfig, CLI_CONFIG } from './cli-config';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { deepClone } from '@theia/core';
@ -34,9 +34,6 @@ export class ConfigServiceImpl implements BackendApplicationContribution, Config
@inject(EnvVariablesServer)
protected readonly envVariablesServer: EnvVariablesServer;
@inject(ConfigFileValidator)
protected readonly validator: ConfigFileValidator;
@inject(ArduinoDaemonImpl)
protected readonly daemon: ArduinoDaemonImpl;
@ -67,10 +64,6 @@ export class ConfigServiceImpl implements BackendApplicationContribution, Config
return new URI(configDirUri).resolve(CLI_CONFIG).toString();
}
async getConfigurationFileSchemaUri(): Promise<string> {
return FileUri.create(CLI_CONFIG_SCHEMA_PATH).toString();
}
async getConfiguration(): Promise<Config> {
await this.ready.promise;
return this.config;
@ -129,7 +122,7 @@ export class ConfigServiceImpl implements BackendApplicationContribution, Config
const cliConfigFileUri = await this.getCliConfigFileUri();
const cliConfigPath = FileUri.fsPath(cliConfigFileUri);
try {
const content = await fs.readFile(cliConfigPath, { encoding: 'utf8' });
const content = await promisify(fs.readFile)(cliConfigPath, { encoding: 'utf8' });
const model = yaml.safeLoad(content) || {};
// The CLI can run with partial (missing `port`, `directories`), the app cannot, we merge the default with the user's config.
const fallbackModel = await this.getFallbackCliConfig();
@ -152,7 +145,7 @@ export class ConfigServiceImpl implements BackendApplicationContribution, Config
});
});
await spawnCommand(`"${cliPath}"`, ['config', 'init', '--dest-dir', `"${throwawayDirPath}"`]);
const rawYaml = await fs.readFile(path.join(throwawayDirPath, CLI_CONFIG), { encoding: 'utf-8' });
const rawYaml = await promisify(fs.readFile)(path.join(throwawayDirPath, CLI_CONFIG), { encoding: 'utf-8' });
const model = yaml.safeLoad(rawYaml.trim());
return model as DefaultCliConfig;
}
@ -160,10 +153,10 @@ export class ConfigServiceImpl implements BackendApplicationContribution, Config
protected async ensureCliConfigExists(): Promise<void> {
const cliConfigFileUri = await this.getCliConfigFileUri();
const cliConfigPath = FileUri.fsPath(cliConfigFileUri);
let exists = await fs.exists(cliConfigPath);
let exists = await promisify(fs.exists)(cliConfigPath);
if (!exists) {
await this.initCliConfigTo(path.dirname(cliConfigPath));
exists = await fs.exists(cliConfigPath);
exists = await promisify(fs.exists)(cliConfigPath);
if (!exists) {
throw new Error(`Could not initialize the default CLI configuration file at ${cliConfigPath}.`);
}

View File

@ -1,6 +1,7 @@
import { inject, injectable, postConstruct } from 'inversify';
import { join, basename } from 'path';
import * as fs from './fs-extra';
import * as fs from 'fs';
import { promisify } from 'util';
import { FileUri } from '@theia/core/lib/node/file-uri';
import { notEmpty } from '@theia/core/lib/common/objects';
import { Sketch } from '../common/protocol/sketches-service';
@ -33,7 +34,7 @@ export class ExamplesServiceImpl implements ExamplesService {
return this._all;
}
const exampleRootPath = join(__dirname, '..', '..', 'Examples');
const exampleNames = await fs.readdir(exampleRootPath);
const exampleNames = await promisify(fs.readdir)(exampleRootPath);
this._all = await Promise.all(exampleNames.map(name => join(exampleRootPath, name)).map(path => this.load(path)));
return this._all;
}
@ -70,15 +71,15 @@ export class ExamplesServiceImpl implements ExamplesService {
if (installDirUri) {
for (const example of ['example', 'Example', 'EXAMPLE', 'examples', 'Examples', 'EXAMPLES']) {
const examplesPath = join(FileUri.fsPath(installDirUri), example);
const exists = await fs.exists(examplesPath);
const isDir = exists && (await fs.lstat(examplesPath)).isDirectory();
const exists = await promisify(fs.exists)(examplesPath);
const isDir = exists && (await promisify(fs.lstat)(examplesPath)).isDirectory();
if (isDir) {
const fileNames = await fs.readdir(examplesPath);
const fileNames = await promisify(fs.readdir)(examplesPath);
const children: ExampleContainer[] = [];
const sketches: Sketch[] = [];
for (const fileName of fileNames) {
const subPath = join(examplesPath, fileName);
const subIsDir = (await fs.lstat(subPath)).isDirectory();
const subIsDir = (await promisify(fs.lstat)(subPath)).isDirectory();
if (subIsDir) {
const sketch = await this.tryLoadSketch(subPath);
if (!sketch) {
@ -109,18 +110,18 @@ export class ExamplesServiceImpl implements ExamplesService {
// Built-ins are included inside the IDE.
protected async load(path: string): Promise<ExampleContainer> {
if (!await fs.exists(path)) {
if (!await promisify(fs.exists)(path)) {
throw new Error('Examples are not available');
}
const stat = await fs.stat(path);
const stat = await promisify(fs.stat)(path);
if (!stat.isDirectory) {
throw new Error(`${path} is not a directory.`);
}
const names = await fs.readdir(path);
const names = await promisify(fs.readdir)(path);
const sketches: Sketch[] = [];
const children: ExampleContainer[] = [];
for (const p of names.map(name => join(path, name))) {
const stat = await fs.stat(p);
const stat = await promisify(fs.stat)(p);
if (stat.isDirectory()) {
const sketch = await this.tryLoadSketch(p);
if (sketch) {
@ -142,7 +143,7 @@ export class ExamplesServiceImpl implements ExamplesService {
protected async group(paths: string[]): Promise<Map<string, fs.Stats>> {
const map = new Map<string, fs.Stats>();
for (const path of paths) {
const stat = await fs.stat(path);
const stat = await promisify(fs.stat)(path);
map.set(path, stat);
}
return map;

View File

@ -1,48 +0,0 @@
import * as fs from 'fs';
import { promisify } from 'util';
export const constants = fs.constants;
export type Stats = fs.Stats;
export const existsSync = fs.existsSync;
export const lstatSync = fs.lstatSync;
export const readdirSync = fs.readdirSync;
export const statSync = fs.statSync;
export const writeFileSync = fs.writeFileSync;
export const readFileSync = fs.readFileSync;
export const accessSync = fs.accessSync;
export const renameSync = fs.renameSync;
export const exists = promisify(fs.exists);
export const lstat = promisify(fs.lstat);
export const readdir = promisify(fs.readdir);
export const stat = promisify(fs.stat);
export const writeFile = promisify(fs.writeFile);
export const readFile = promisify(fs.readFile);
export const access = promisify(fs.access);
export const rename = promisify(fs.rename);
export const watchFile = fs.watchFile;
export const unwatchFile = fs.unwatchFile;
export function mkdirp(path: string, timeout: number = 3000): Promise<void> {
return new Promise((resolve, reject) => {
let timeoutHandle: NodeJS.Timeout;
if (timeout > 0) {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timeout of ${timeout} ms exceeded while trying to create the directory "${path}"`));
}, timeout);
}
fs.mkdir(path, { recursive: true }, err => {
clearTimeout(timeoutHandle);
if (err)
reject(err);
else
resolve();
});
});
}
export function mkdirpSync(path: string): void {
fs.mkdirSync(path, { recursive: true });
}

View File

@ -42,10 +42,6 @@ export class NotificationServiceServerImpl implements NotificationServiceServer
this.clients.forEach(client => client.notifyConfigChanged(event));
}
notifySketchbookChanged(event: { created: Sketch[], removed: Sketch[] }): void {
this.clients.forEach(client => client.notifySketchbookChanged(event));
}
notifyRecentSketchesChanged(event: { sketches: Sketch[] }): void {
this.clients.forEach(client => client.notifyRecentSketchesChanged(event));
}

View File

@ -1,21 +1,19 @@
import { injectable, inject } from 'inversify';
import * as fs from 'fs';
import * as os from 'os';
import * as temp from 'temp';
import * as path from 'path';
import * as nsfw from 'nsfw';
import { ncp } from 'ncp';
import { Stats } from 'fs';
import * as fs from './fs-extra';
import { promisify } from 'util';
import URI from '@theia/core/lib/common/uri';
import { FileUri } from '@theia/core/lib/node';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { isWindows } from '@theia/core/lib/common/os';
import { ConfigService } from '../common/protocol/config-service';
import { SketchesService, Sketch } from '../common/protocol/sketches-service';
import { firstToLowerCase } from '../common/utils';
import { NotificationServiceServerImpl } from './notification-service-server';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { notEmpty } from '@theia/core';
// As currently implemented on Linux,
// the maximum number of symbolic links that will be followed while resolving a pathname is 40
@ -39,78 +37,31 @@ export class SketchesServiceImpl implements SketchesService {
protected readonly envVariableServer: EnvVariablesServer;
async getSketches(uri?: string): Promise<SketchWithDetails[]> {
let fsPath: undefined | string;
let sketchbookPath: undefined | string;
if (!uri) {
const { sketchDirUri } = await this.configService.getConfiguration();
fsPath = FileUri.fsPath(sketchDirUri);
if (!fs.existsSync(fsPath)) {
await fs.mkdirp(fsPath);
sketchbookPath = FileUri.fsPath(sketchDirUri);
if (!await promisify(fs.exists)(sketchbookPath)) {
await promisify(fs.mkdir)(sketchbookPath, { recursive: true });
}
} else {
fsPath = FileUri.fsPath(uri);
sketchbookPath = FileUri.fsPath(uri);
}
if (!fs.existsSync(fsPath)) {
if (!await promisify(fs.exists)(sketchbookPath)) {
return [];
}
const stat = await fs.stat(fsPath);
const stat = await promisify(fs.stat)(sketchbookPath);
if (!stat.isDirectory()) {
return [];
}
return this.doGetSketches(fsPath);
}
/**
* Dev note: The keys are filesystem paths, not URI strings.
*/
private sketchbooks = new Map<string, SketchWithDetails[] | Deferred<SketchWithDetails[]>>();
private fireSoonHandle?: NodeJS.Timer;
private bufferedSketchbookEvents: { type: 'created' | 'removed', sketch: Sketch }[] = [];
private fireSoon(type: 'created' | 'removed', sketch: Sketch): void {
this.bufferedSketchbookEvents.push({ type, sketch });
if (this.fireSoonHandle) {
clearTimeout(this.fireSoonHandle);
}
this.fireSoonHandle = setTimeout(() => {
const event: { created: Sketch[], removed: Sketch[] } = {
created: [],
removed: []
};
for (const { type, sketch } of this.bufferedSketchbookEvents) {
if (type === 'created') {
event.created.push(sketch);
} else {
event.removed.push(sketch);
}
}
this.notificationService.notifySketchbookChanged(event);
this.bufferedSketchbookEvents.length = 0;
}, 100);
}
/**
* Assumes the `fsPath` points to an existing directory.
*/
private async doGetSketches(sketchbookPath: string): Promise<SketchWithDetails[]> {
const resolvedSketches = this.sketchbooks.get(sketchbookPath);
if (resolvedSketches) {
if (Array.isArray(resolvedSketches)) {
return resolvedSketches;
}
return resolvedSketches.promise;
}
const deferred = new Deferred<SketchWithDetails[]>();
this.sketchbooks.set(sketchbookPath, deferred);
const sketches: Array<SketchWithDetails> = [];
const filenames = await fs.readdir(sketchbookPath);
const filenames = await promisify(fs.readdir)(sketchbookPath);
for (const fileName of filenames) {
const filePath = path.join(sketchbookPath, fileName);
if (await this.isSketchFolder(FileUri.create(filePath).toString())) {
try {
const stat = await fs.stat(filePath);
const stat = await promisify(fs.stat)(filePath);
const sketch = await this.loadSketch(FileUri.create(filePath).toString());
sketches.push({
...sketch,
@ -122,88 +73,6 @@ export class SketchesServiceImpl implements SketchesService {
}
}
sketches.sort((left, right) => right.mtimeMs - left.mtimeMs);
const deleteSketch = (toDelete: Sketch & { mtimeMs: number }) => {
const index = sketches.indexOf(toDelete);
if (index !== -1) {
console.log(`Sketch '${toDelete.name}' was removed from sketchbook '${sketchbookPath}'.`);
sketches.splice(index, 1);
sketches.sort((left, right) => right.mtimeMs - left.mtimeMs);
this.fireSoon('removed', toDelete);
}
};
const createSketch = async (path: string) => {
try {
const [stat, sketch] = await Promise.all([
fs.stat(path),
this.loadSketch(path)
]);
console.log(`New sketch '${sketch.name}' was crated in sketchbook '${sketchbookPath}'.`);
sketches.push({ ...sketch, mtimeMs: stat.mtimeMs });
sketches.sort((left, right) => right.mtimeMs - left.mtimeMs);
this.fireSoon('created', sketch);
} catch { }
};
const watcher = await nsfw(sketchbookPath, async (events: any) => {
// We track `.ino` files changes only.
for (const event of events) {
switch (event.action) {
case nsfw.ActionType.CREATED:
if (event.file.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.file === `${path.basename(event.directory)}.ino`) {
createSketch(event.directory);
}
break;
case nsfw.ActionType.DELETED:
let sketch: Sketch & { mtimeMs: number } | undefined = undefined
// Deleting the `ino` file.
if (event.file.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.file === `${path.basename(event.directory)}.ino`) {
sketch = sketches.find(sketch => FileUri.fsPath(sketch.uri) === event.directory);
} else if (event.directory === sketchbookPath) { // Deleting the sketch (or any folder folder in the sketchbook).
sketch = sketches.find(sketch => FileUri.fsPath(sketch.uri) === path.join(event.directory, event.file));
}
if (sketch) {
deleteSketch(sketch);
}
break;
case nsfw.ActionType.RENAMED:
let sketchToDelete: Sketch & { mtimeMs: number } | undefined = undefined
// When renaming with the Java IDE we got an event where `directory` is the sketchbook and `oldFile` is the sketch.
if (event.directory === sketchbookPath) {
sketchToDelete = sketches.find(sketch => FileUri.fsPath(sketch.uri) === path.join(event.directory, event.oldFile));
}
if (sketchToDelete) {
deleteSketch(sketchToDelete);
} else {
// If it's not a deletion, check for creation. The `directory` is the new sketch and the `newFile` is the new `ino` file.
// tslint:disable-next-line:max-line-length
if (event.newFile.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.newFile === `${path.basename(event.directory)}.ino`) {
createSketch(event.directory);
} else {
// When renaming the `ino` file directly on the filesystem. The `directory` is the sketch and `newFile` and `oldFile` is the `ino` file.
// tslint:disable-next-line:max-line-length
if (event.oldFile.endsWith('.ino') && path.join(event.directory, '..') === sketchbookPath && event.oldFile === `${path.basename(event.directory)}.ino`) {
sketchToDelete = sketches.find(sketch => FileUri.fsPath(sketch.uri) === event.directory, event.oldFile);
}
if (sketchToDelete) {
deleteSketch(sketchToDelete);
} else if (event.directory === sketchbookPath) {
createSketch(path.join(event.directory, event.newFile));
}
}
}
break;
}
}
});
// TODO: no `await` for some reason this blocks the workspace root initialization on Windows inside a bundled electron app.
console.log(`Starting to watch sketchbook at '${sketchbookPath}'.`);
watcher.start()
.then(() => console.log(`Initialized watcher in sketchbook: '${sketchbookPath}. Watching for sketch changes.`))
.catch(err => console.error(`Failed to initialize watcher in sketchbook '${sketchbookPath}'. Cannot track sketch changes.`, err));
deferred.resolve(sketches);
this.sketchbooks.set(sketchbookPath, sketches);
return sketches;
}
@ -214,11 +83,11 @@ export class SketchesServiceImpl implements SketchesService {
*/
async loadSketch(uri: string): Promise<SketchWithDetails> {
const sketchPath = FileUri.fsPath(uri);
const exists = await fs.exists(sketchPath);
const exists = await promisify(fs.exists)(sketchPath);
if (!exists) {
throw new Error(`${uri} does not exist.`);
}
const stat = await fs.lstat(sketchPath);
const stat = await promisify(fs.lstat)(sketchPath);
let sketchFolder: string | undefined;
let mainSketchFile: string | undefined;
@ -228,7 +97,7 @@ export class SketchesServiceImpl implements SketchesService {
// Allowed extensions are .ino and .pde (but not both)
for (const extension of Sketch.Extensions.MAIN) {
const candidateSketchFile = path.join(sketchPath, `${path.basename(sketchPath)}${extension}`);
const candidateExists = await fs.exists(candidateSketchFile);
const candidateExists = await promisify(fs.exists)(candidateSketchFile);
if (candidateExists) {
if (!mainSketchFile) {
mainSketchFile = candidateSketchFile;
@ -245,12 +114,12 @@ export class SketchesServiceImpl implements SketchesService {
// Check main file is readable.
try {
await fs.access(mainSketchFile, fs.constants.R_OK);
await promisify(fs.access)(mainSketchFile, fs.constants.R_OK);
} catch {
throw new Error('Unable to open the main sketch file.');
}
const mainSketchFileStat = await fs.lstat(mainSketchFile);
const mainSketchFileStat = await promisify(fs.lstat)(mainSketchFile);
if (mainSketchFileStat.isDirectory()) {
throw new Error(`Sketch must not be a directory.`);
}
@ -289,7 +158,7 @@ export class SketchesServiceImpl implements SketchesService {
}
try {
await fs.access(fsPath, fs.constants.R_OK);
await promisify(fs.access)(fsPath, fs.constants.R_OK);
files.push(fsPath);
} catch { }
@ -312,7 +181,7 @@ export class SketchesServiceImpl implements SketchesService {
private async loadRecentSketches(fsPath: string): Promise<Record<string, number>> {
let data: Record<string, number> = {};
try {
const raw = await fs.readFile(fsPath, { encoding: 'utf8' });
const raw = await promisify(fs.readFile)(fsPath, { encoding: 'utf8' });
data = JSON.parse(raw);
} catch { }
return data;
@ -349,7 +218,7 @@ export class SketchesServiceImpl implements SketchesService {
delete data[toDeleteUri];
}
await fs.writeFile(fsPath, JSON.stringify(data, null, 2));
await promisify(fs.writeFile)(fsPath, JSON.stringify(data, null, 2));
this.recentlyOpenedSketches().then(sketches => this.notificationService.notifyRecentSketchesChanged({ sketches }));
}
@ -358,23 +227,18 @@ export class SketchesServiceImpl implements SketchesService {
const fsPath = path.join(FileUri.fsPath(configDirUri), 'recent-sketches.json');
let data: Record<string, number> = {};
try {
const raw = await fs.readFile(fsPath, { encoding: 'utf8' });
const raw = await promisify(fs.readFile)(fsPath, { encoding: 'utf8' });
data = JSON.parse(raw);
} catch { }
const loadSketchSafe = (uri: string) => {
const sketches: SketchWithDetails[] = []
for (const uri of Object.keys(data).sort((left, right) => data[right] - data[left])) {
try {
return this.loadSketch(uri);
} catch {
return undefined;
}
const sketch = await this.loadSketch(uri);
sketches.push(sketch);
} catch { }
}
const sketches = await Promise.all(Object.keys(data)
.sort((left, right) => data[right] - data[left])
.map(loadSketchSafe)
.filter(notEmpty));
return sketches;
}
@ -410,7 +274,7 @@ export class SketchesServiceImpl implements SketchesService {
additionalFiles.sort();
otherSketchFiles.sort();
const { mtimeMs } = await fs.lstat(sketchFolderPath);
const { mtimeMs } = await promisify(fs.lstat)(sketchFolderPath);
return {
uri: FileUri.create(sketchFolderPath).toString(),
mainFileUri: FileUri.create(mainFile).toString(),
@ -461,7 +325,7 @@ export class SketchesServiceImpl implements SketchesService {
maxDepth--;
const files: string[] = [];
try {
files.push(...await fs.readdir(root));
files.push(...await promisify(fs.readdir)(root));
} catch { }
for (const file of files) {
err = await this.simpleLocalWalk(path.join(root, file), maxDepth, walk);
@ -475,12 +339,12 @@ export class SketchesServiceImpl implements SketchesService {
}
private async lstat(fsPath: string): Promise<{ info: Stats, err: undefined } | { info: undefined, err: Error }> {
const exists = await fs.exists(fsPath);
const exists = await promisify(fs.exists)(fsPath);
if (!exists) {
return { info: undefined, err: new Error(`${fsPath} does not exist`) };
}
try {
const info = await fs.lstat(fsPath);
const info = await promisify(fs.lstat)(fsPath);
return { info, err: undefined };
} catch (err) {
return { info: undefined, err };
@ -506,7 +370,7 @@ export class SketchesServiceImpl implements SketchesService {
for (let i = 97; i < 97 + 26; i++) {
let sketchNameCandidate = `${sketchBaseName}${String.fromCharCode(i)}`;
// Note: we check the future destination folder (`directories.user`) for name collision and not the temp folder!
if (fs.existsSync(path.join(user, sketchNameCandidate))) {
if (await promisify(fs.exists)(path.join(user, sketchNameCandidate))) {
continue;
}
@ -520,8 +384,8 @@ export class SketchesServiceImpl implements SketchesService {
const sketchDir = path.join(parentPath, sketchName)
const sketchFile = path.join(sketchDir, `${sketchName}.ino`);
await fs.mkdirp(sketchDir);
await fs.writeFile(sketchFile, `void setup() {
await promisify(fs.mkdir)(sketchDir, { recursive: true });
await promisify(fs.writeFile)(sketchFile, `void setup() {
// put your setup code here, to run once:
}
@ -550,9 +414,13 @@ void loop() {
async isSketchFolder(uri: string): Promise<boolean> {
const fsPath = FileUri.fsPath(uri);
if (fs.existsSync(fsPath) && fs.lstatSync(fsPath).isDirectory()) {
let stat: fs.Stats | undefined;
try {
stat = await promisify(fs.lstat)(fsPath);
} catch { }
if (stat && stat.isDirectory()) {
const basename = path.basename(fsPath);
const files = await fs.readdir(fsPath);
const files = await promisify(fs.readdir)(fsPath);
for (let i = 0; i < files.length; i++) {
if (files[i] === basename + '.ino') {
try {
@ -583,7 +451,7 @@ void loop() {
async copy(sketch: Sketch, { destinationUri }: { destinationUri: string }): Promise<string> {
const source = FileUri.fsPath(sketch.uri);
const exists = await fs.exists(source);
const exists = await promisify(fs.exists)(source);
if (!exists) {
throw new Error(`Sketch does not exist: ${sketch}`);
}
@ -604,7 +472,7 @@ void loop() {
const oldPath = path.join(destination, new URI(sketch.mainFileUri).path.base);
const newPath = path.join(destination, `${newName}.ino`);
if (oldPath !== newPath) {
await fs.rename(oldPath, newPath);
await promisify(fs.rename)(oldPath, newPath);
}
await this.loadSketch(destinationUri); // Sanity check.
resolve();

View File

@ -12,7 +12,7 @@ export class HostedPluginReader extends TheiaHostedPluginReader {
protected cliConfigSchemaUri: string;
async onStart(): Promise<void> {
this.cliConfigSchemaUri = await this.configService.getConfigurationFileSchemaUri();
this.cliConfigSchemaUri = ''; // TODO: this was removed in another PR.
}
readContribution(plugin: PluginPackage): PluginContribution | undefined {

View File

@ -1,78 +0,0 @@
import { expect } from 'chai'
import { safeLoad } from 'js-yaml';
import { ArduinoDaemonImpl } from '../../node/arduino-daemon-impl';
import { ConfigFileValidator } from '../../node/config-file-validator';
import { spawnCommand } from '../../node/exec-util';
class MockConfigFileValidator extends ConfigFileValidator {
protected async isValidPath(path: string): Promise<boolean> {
if (path.endsWith('!invalid')) {
return false;
}
return super.isValidPath(path);
}
}
describe('config-file-validator', () => {
const testMe = new MockConfigFileValidator();
it('valid - default', async () => {
const config = await defaultConfig();
const result = await testMe.validate(config);
// tslint:disable-next-line:no-unused-expression
expect(result).to.be.true;
});
it("valid - no 'board_manager'", async () => {
const config = await defaultConfig();
delete config.board_manager;
const result = await testMe.validate(config);
// tslint:disable-next-line:no-unused-expression
expect(result).to.be.true;
});
it("valid - no 'board_manager.additional_urls'", async () => {
const config = await defaultConfig();
delete config.board_manager.additional_urls;
const result = await testMe.validate(config);
// tslint:disable-next-line:no-unused-expression
expect(result).to.be.true;
});
it("invalid - no 'directories.data'", async () => {
const config = await defaultConfig();
delete config.directories.data;
const result = await testMe.validate(config);
// tslint:disable-next-line:no-unused-expression
expect(result).to.be.false;
});
it("invalid - 'directories.data' is a empty string", async () => {
const config = await defaultConfig();
config.directories.data = '';
const result = await testMe.validate(config);
// tslint:disable-next-line:no-unused-expression
expect(result).to.be.false;
});
it("invalid - 'directories.data' is contains invalid chars", async () => {
const config = await defaultConfig();
config.directories.data = '!invalid';
const result = await testMe.validate(config);
// tslint:disable-next-line:no-unused-expression
expect(result).to.be.false;
});
async function defaultConfig(): Promise<any> {
return new Promise<any>(resolve => {
new ArduinoDaemonImpl().getExecPath()
.then(execPath => spawnCommand(execPath, ['config', 'dump']))
.then(content => safeLoad(content))
.then(config => resolve(config));
});
}
})