diff --git a/arduino-ide-extension/data/cli/schema/arduino-cli.schema.json b/arduino-ide-extension/data/cli/schema/arduino-cli.schema.json deleted file mode 100644 index e74c3e80..00000000 --- a/arduino-ide-extension/data/cli/schema/arduino-cli.schema.json +++ /dev/null @@ -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 - } - } -} 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 61f82cff..5e62cf00 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -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(); diff --git a/arduino-ide-extension/src/browser/contributions/sketchbook.ts b/arduino-ide-extension/src/browser/contributions/sketchbook.ts index 1792ffd1..55efb77d 100644 --- a/arduino-ide-extension/src/browser/contributions/sketchbook.ts +++ b/arduino-ide-extension/src/browser/contributions/sketchbook.ts @@ -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(); diff --git a/arduino-ide-extension/src/browser/notification-center.ts b/arduino-ide-extension/src/browser/notification-center.ts index 87ecc23a..4dbd64ba 100644 --- a/arduino-ide-extension/src/browser/notification-center.ts +++ b/arduino-ide-extension/src/browser/notification-center.ts @@ -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(); - 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); } diff --git a/arduino-ide-extension/src/common/protocol/config-service.ts b/arduino-ide-extension/src/common/protocol/config-service.ts index 276599da..cc130109 100644 --- a/arduino-ide-extension/src/common/protocol/config-service.ts +++ b/arduino-ide-extension/src/common/protocol/config-service.ts @@ -4,7 +4,6 @@ export interface ConfigService { getVersion(): Promise>; getConfiguration(): Promise; setConfiguration(config: Config): Promise; - getConfigurationFileSchemaUri(): Promise; isInDataDir(uri: string): Promise; isInSketchDir(uri: string): Promise; } diff --git a/arduino-ide-extension/src/common/protocol/notification-service.ts b/arduino-ide-extension/src/common/protocol/notification-service.ts index 5b2596d8..d373c00d 100644 --- a/arduino-ide-extension/src/common/protocol/notification-service.ts +++ b/arduino-ide-extension/src/common/protocol/notification-service.ts @@ -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; } diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts index 81c84d46..2e892216 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts @@ -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(); + 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 { 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); + } + } diff --git a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts index 84a1b177..98efddb4 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -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. diff --git a/arduino-ide-extension/src/node/cli-config.ts b/arduino-ide-extension/src/node/cli-config.ts index 692871e7..07686130 100644 --- a/arduino-ide-extension/src/node/cli-config.ts +++ b/arduino-ide-extension/src/node/cli-config.ts @@ -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; diff --git a/arduino-ide-extension/src/node/config-file-validator.ts b/arduino-ide-extension/src/node/config-file-validator.ts deleted file mode 100644 index 037b39b8..00000000 --- a/arduino-ide-extension/src/node/config-file-validator.ts +++ /dev/null @@ -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 { - return this.doValidate(typeof pathOrObject === 'string' ? fs.readFileSync(pathOrObject) : pathOrObject); - } - - protected async doValidate(object: object): Promise { - 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 { - try { - if (!path.trim()) { - return false; - } - const exists = await fs.exists(path); - if (!exists) { - await fs.mkdirp(path); - } - return true; - } catch { - return false; - } - } - -} diff --git a/arduino-ide-extension/src/node/config-service-impl.ts b/arduino-ide-extension/src/node/config-service-impl.ts index 1d1bc7c0..2f5035ac 100644 --- a/arduino-ide-extension/src/node/config-service-impl.ts +++ b/arduino-ide-extension/src/node/config-service-impl.ts @@ -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 { - return FileUri.create(CLI_CONFIG_SCHEMA_PATH).toString(); - } - async getConfiguration(): Promise { 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 { 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}.`); } diff --git a/arduino-ide-extension/src/node/examples-service-impl.ts b/arduino-ide-extension/src/node/examples-service-impl.ts index 0b604146..d519d1fe 100644 --- a/arduino-ide-extension/src/node/examples-service-impl.ts +++ b/arduino-ide-extension/src/node/examples-service-impl.ts @@ -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 { - 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> { const map = new Map(); for (const path of paths) { - const stat = await fs.stat(path); + const stat = await promisify(fs.stat)(path); map.set(path, stat); } return map; diff --git a/arduino-ide-extension/src/node/fs-extra.ts b/arduino-ide-extension/src/node/fs-extra.ts deleted file mode 100644 index 2893eace..00000000 --- a/arduino-ide-extension/src/node/fs-extra.ts +++ /dev/null @@ -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 { - 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 }); -} diff --git a/arduino-ide-extension/src/node/notification-service-server.ts b/arduino-ide-extension/src/node/notification-service-server.ts index 7d0be513..6060ab7d 100644 --- a/arduino-ide-extension/src/node/notification-service-server.ts +++ b/arduino-ide-extension/src/node/notification-service-server.ts @@ -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)); } diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 30c510ca..fa39658b 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -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 { - 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>(); - 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 { - const resolvedSketches = this.sketchbooks.get(sketchbookPath); - if (resolvedSketches) { - if (Array.isArray(resolvedSketches)) { - return resolvedSketches; - } - return resolvedSketches.promise; - } - - const deferred = new Deferred(); - this.sketchbooks.set(sketchbookPath, deferred); const sketches: Array = []; - 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 { 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> { let data: Record = {}; 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 = {}; 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 { 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 { 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(); diff --git a/arduino-ide-extension/src/node/theia/plugin-ext/plugin-reader.ts b/arduino-ide-extension/src/node/theia/plugin-ext/plugin-reader.ts index b8669dc8..8f02912c 100644 --- a/arduino-ide-extension/src/node/theia/plugin-ext/plugin-reader.ts +++ b/arduino-ide-extension/src/node/theia/plugin-ext/plugin-reader.ts @@ -12,7 +12,7 @@ export class HostedPluginReader extends TheiaHostedPluginReader { protected cliConfigSchemaUri: string; async onStart(): Promise { - this.cliConfigSchemaUri = await this.configService.getConfigurationFileSchemaUri(); + this.cliConfigSchemaUri = ''; // TODO: this was removed in another PR. } readContribution(plugin: PluginPackage): PluginContribution | undefined { diff --git a/arduino-ide-extension/src/test/node/config-file-validator.test.ts b/arduino-ide-extension/src/test/node/config-file-validator.test.ts deleted file mode 100644 index daf09554..00000000 --- a/arduino-ide-extension/src/test/node/config-file-validator.test.ts +++ /dev/null @@ -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 { - 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 { - return new Promise(resolve => { - new ArduinoDaemonImpl().getExecPath() - .then(execPath => spawnCommand(execPath, ['config', 'dump'])) - .then(content => safeLoad(content)) - .then(config => resolve(config)); - }); - } - -})