arduino-ide/arduino-ide-extension/src/browser/sketches-service-client-impl.ts
Akos Kitta d68bc4abdb feat: rename, deletion, and validation support
Closes #1599
Closes #1825
Closes #649
Closes #1847
Closes #1882

Co-authored-by: Akos Kitta <a.kitta@arduino.cc>
Co-authored-by: per1234 <accounts@perglass.com>

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-15 14:09:36 +01:00

294 lines
10 KiB
TypeScript

import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Emitter } from '@theia/core/lib/common/event';
import { notEmpty } from '@theia/core/lib/common/objects';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileChangeType } from '@theia/filesystem/lib/common/files';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { Sketch, SketchesService } from '../common/protocol';
import { ConfigServiceClient } from './config/config-service-client';
import {
SketchContainer,
SketchesError,
SketchRef,
} from '../common/protocol/sketches-service';
import {
ARDUINO_CLOUD_FOLDER,
REMOTE_SKETCHBOOK_FOLDER,
} from './utils/constants';
import * as monaco from '@theia/monaco-editor-core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
const READ_ONLY_FILES = ['sketch.json'];
const READ_ONLY_FILES_REMOTE = ['thingProperties.h', 'thingsProperties.h'];
export type CurrentSketch = Sketch | 'invalid';
export namespace CurrentSketch {
export function isValid(arg: CurrentSketch | undefined): arg is Sketch {
return !!arg && arg !== 'invalid';
}
}
@injectable()
export class SketchesServiceClientImpl
implements FrontendApplicationContribution
{
@inject(FileService)
private readonly fileService: FileService;
@inject(SketchesService)
private readonly sketchesService: SketchesService;
@inject(WorkspaceService)
private readonly workspaceService: WorkspaceService;
@inject(ConfigServiceClient)
private readonly configService: ConfigServiceClient;
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
private sketches = new Map<string, SketchRef>();
private onSketchbookDidChangeEmitter = new Emitter<{
created: SketchRef[];
removed: SketchRef[];
}>();
readonly onSketchbookDidChange = this.onSketchbookDidChangeEmitter.event;
private currentSketchDidChangeEmitter = new Emitter<CurrentSketch>();
readonly onCurrentSketchDidChange = this.currentSketchDidChangeEmitter.event;
private toDisposeBeforeWatchSketchbookDir = new DisposableCollection();
private toDispose = new DisposableCollection(
this.onSketchbookDidChangeEmitter,
this.currentSketchDidChangeEmitter,
this.toDisposeBeforeWatchSketchbookDir
);
private _currentSketch: CurrentSketch | undefined;
private currentSketchLoaded = new Deferred<CurrentSketch>();
onStart(): void {
const sketchDirUri = this.configService.tryGetSketchDirUri();
this.watchSketchbookDir(sketchDirUri);
const refreshCurrentSketch = async () => {
const currentSketch = await this.loadCurrentSketch();
this.useCurrentSketch(currentSketch);
};
this.toDispose.push(
this.configService.onDidChangeSketchDirUri((sketchDirUri) => {
this.watchSketchbookDir(sketchDirUri);
refreshCurrentSketch();
})
);
this.appStateService
.reachedState('started_contributions')
.then(refreshCurrentSketch);
}
private async watchSketchbookDir(
sketchDirUri: URI | undefined
): Promise<void> {
this.toDisposeBeforeWatchSketchbookDir.dispose();
if (!sketchDirUri) {
return;
}
const container = await this.sketchesService.getSketches({
uri: sketchDirUri.toString(),
});
for (const sketch of SketchContainer.toArray(container)) {
this.sketches.set(sketch.uri, sketch);
}
this.toDisposeBeforeWatchSketchbookDir.pushAll([
Disposable.create(() => this.sketches.clear()),
// Watch changes in the sketchbook to update `File` > `Sketchbook` menu items.
this.fileService.watch(sketchDirUri, {
recursive: true,
excludes: [],
}),
this.fileService.onDidFilesChange(async (event) => {
for (const { type, resource } of event.changes) {
// The file change events have higher precedence in the current sketch over the sketchbook.
if (
CurrentSketch.isValid(this._currentSketch) &&
new URI(this._currentSketch.uri).isEqualOrParent(resource)
) {
// https://github.com/arduino/arduino-ide/pull/1351#pullrequestreview-1086666656
// On a sketch file rename, the FS watcher will contain two changes:
// - Deletion of the original file,
// - Update of the new file,
// Hence, `UPDATE` events must be processed but only and if only there is a `DELETED` change in the same event.
// Otherwise, IDE2 would ask CLI to reload the sketch content on every save event in IDE2.
if (type === FileChangeType.UPDATED && event.changes.length === 1) {
// If the event contains only one `UPDATE` change, it cannot be a rename.
return;
}
let reloadedSketch: Sketch | undefined = undefined;
try {
reloadedSketch = await this.sketchesService.loadSketch(
this._currentSketch.uri
);
} catch (err) {
if (!SketchesError.NotFound.is(err)) {
throw err;
}
}
if (!reloadedSketch) {
return;
}
if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) {
this.useCurrentSketch(reloadedSketch, true);
}
return;
}
// We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file.
if (sketchDirUri.isEqualOrParent(resource)) {
if (Sketch.isSketchFile(resource)) {
if (type === FileChangeType.ADDED) {
try {
const toAdd = await this.sketchesService.loadSketch(
resource.parent.toString()
);
if (!this.sketches.has(toAdd.uri)) {
console.log(
`New sketch '${toAdd.name}' was created 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 '${sketchDirUri}'.`
);
this.sketches.delete(uri);
this.fireSoon(toDelete, 'removed');
}
}
}
}
}
}),
]);
}
private useCurrentSketch(
currentSketch: CurrentSketch,
reassignPromise = false
) {
this._currentSketch = currentSketch;
if (reassignPromise) {
this.currentSketchLoaded = new Deferred();
}
this.currentSketchLoaded.resolve(this._currentSketch);
this.currentSketchDidChangeEmitter.fire(this._currentSketch);
}
onStop(): void {
this.toDispose.dispose();
}
private async loadCurrentSketch(): Promise<CurrentSketch> {
const sketches = (
await Promise.all(
this.workspaceService
.tryGetRoots()
.map(({ resource }) =>
this.sketchesService.getSketchFolder(resource.toString())
)
)
).filter(notEmpty);
if (!sketches.length) {
return 'invalid';
}
if (sketches.length > 1) {
console.log(
`Multiple sketch folders were found in the workspace. Falling back to the first one. Sketch folders: ${JSON.stringify(
sketches
)}`
);
}
return sketches[0];
}
async currentSketch(): Promise<CurrentSketch> {
return this.currentSketchLoaded.promise;
}
tryGetCurrentSketch(): CurrentSketch | undefined {
return this._currentSketch;
}
async currentSketchFile(): Promise<string | undefined> {
const currentSketch = await this.currentSketch();
if (CurrentSketch.isValid(currentSketch)) {
return currentSketch.mainFileUri;
}
return undefined;
}
private fireSoonHandle?: number;
private bufferedSketchbookEvents: {
type: 'created' | 'removed';
sketch: SketchRef;
}[] = [];
private fireSoon(sketch: SketchRef, 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: SketchRef[]; removed: SketchRef[] } = {
created: [],
removed: [],
};
for (const { type, sketch } of this.bufferedSketchbookEvents) {
if (type === 'created') {
event.created.push(sketch);
} else {
event.removed.push(sketch);
}
}
this.onSketchbookDidChangeEmitter.fire(event);
this.bufferedSketchbookEvents.length = 0;
}, 100);
}
/**
* `true` if the `uri` is not contained in any of the opened workspaces. Otherwise, `false`.
*/
isReadOnly(uri: URI | monaco.Uri | string): boolean {
const toCheck = uri instanceof URI ? uri : new URI(uri);
if (toCheck.scheme === 'user-storage') {
return false;
}
const isCloudSketch = toCheck
.toString()
.includes(`${REMOTE_SKETCHBOOK_FOLDER}/${ARDUINO_CLOUD_FOLDER}`);
const filesToCheck = [
...READ_ONLY_FILES,
...(isCloudSketch ? READ_ONLY_FILES_REMOTE : []),
];
if (filesToCheck.includes(toCheck?.path?.base)) {
return true;
}
const readOnly = !this.workspaceService
.tryGetRoots()
.some(({ resource }) => resource.isEqualOrParent(toCheck));
return readOnly;
}
}