@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
331 lines (287 loc) • 13.2 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2020 Red Hat, Inc. and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { injectable } from 'inversify';
import { isThenable } from '../common/promise-util';
import { CancellationToken, CancellationTokenSource, Disposable, Emitter, Event } from '../common';
import { TernarySearchTree } from '../common/ternary-search-tree';
import URI from '../common/uri';
import debounce = require('lodash.debounce');
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// some code copied and modified from https://github.com/microsoft/vscode/blob/1.52.1/src/vs/workbench/services/decorations/browser/decorationsService.ts#L24-L23
export interface DecorationsProvider {
/**
* Signals that decorations changed for the given resources. Firing `undefined` is a
* flush event: all previously provided decorations may have changed, cached data is
* dropped and re-fetched on demand. The last known decorations keep being served
* until the re-fetch of their resource settles, so that displayed decorations do
* not blink out while the round trip is in flight.
*/
readonly onDidChange: Event<URI[] | undefined>;
provideDecorations(uri: URI, token: CancellationToken): Decoration | Promise<Decoration | undefined> | undefined;
}
export interface Decoration {
readonly weight?: number;
readonly colorId?: string;
readonly letter?: string;
readonly tooltip?: string;
readonly bubble?: boolean;
}
export interface ResourceDecorationChangeEvent {
affectsResource(uri: URI): boolean;
}
export const DecorationsService = Symbol('DecorationsService');
export interface DecorationsService {
/**
* Fired when decorations changed. The payload maps the string representation of the
* affected resource URIs to their new decoration. Removals are never part of the
* payload: resources whose decoration was removed are simply absent, and an empty
* map signals that an unspecified set of decorations changed (e.g. a provider fired
* a flush event). Clients must therefore track the resources they display and
* re-query them on every event rather than react to the payload keys alone. Change
* notifications caused by asynchronously resolving decoration requests are batched:
* many resolutions result in a single event.
*/
readonly onDidChangeDecorations: Event<Map<string, Decoration>>;
registerDecorationsProvider(provider: DecorationsProvider): Disposable;
getDecoration(uri: URI, includeChildren: boolean): Decoration[];
}
class DecorationDataRequest {
constructor(
readonly source: CancellationTokenSource,
readonly thenable: Promise<void>,
/**
* Last settled decoration from before this request started. It is served while
* the request is in flight and used to detect removals when it resolves.
*/
readonly previous: Decoration | undefined,
) { }
}
/**
* A decoration that a flush event marked as possibly outdated. It is served until its
* resource is queried again, which triggers the re-fetch.
*/
class StaleDecoration {
constructor(readonly value: Decoration) { }
}
type DecorationEntry = DecorationDataRequest | StaleDecoration | Decoration | null;
/**
* The decoration an entry last settled on: the resolved decoration itself, the
* pre-request value of an in-flight request, or the flushed value of a stale entry.
*/
function lastKnownDecoration(entry: DecorationEntry | undefined): Decoration | undefined {
if (entry instanceof DecorationDataRequest) {
return entry.previous;
}
if (entry instanceof StaleDecoration) {
return entry.value;
}
return entry ?? undefined;
}
class DecorationProviderWrapper {
/**
* Cached provider results. Four states per resource: no entry means the resource was
* never fetched, a `null` entry means it was fetched and has no decoration, a
* {@link DecorationDataRequest} entry means a fetch is in flight and a
* {@link StaleDecoration} entry means a flush marked the last fetched decoration as
* possibly outdated. Distinguishing "known to be undecorated" from "unknown" is what
* keeps on-demand queries from re-fetching undecorated resources over and over.
*/
readonly data: TernarySearchTree<URI, DecorationEntry>;
private readonly disposable: Disposable;
constructor(
readonly provider: DecorationsProvider,
/** Reports a changed decoration, or `undefined` when the decoration was removed. */
private readonly notifyDecorationChange: (uri: URI, decoration: Decoration | undefined) => void,
/** Reports that all decorations of this provider may have changed. */
private readonly notifyFlush: () => void
) {
this.data = TernarySearchTree.forUris<DecorationEntry>(true);
this.disposable = this.provider.onDidChange(uris => {
if (!uris) {
this.flush();
} else {
// selective changes -> re-fetch the given resources
for (const uri of uris) {
this.fetchData(uri);
}
}
});
}
dispose(): void {
this.disposable.dispose();
this.data.clear();
}
/**
* Handles a flush event: all cached data is dropped to be re-fetched on demand.
* Previously fetched decorations are kept as {@link StaleDecoration stale} values
* that are served until the re-fetch of their resource settles, so that displayed
* decorations do not blink out while the round trip is in flight.
*/
private flush(): void {
const stale: [URI, StaleDecoration][] = [];
this.data.forEach((value, key) => {
if (value instanceof DecorationDataRequest) {
value.source.cancel();
}
const decoration = lastKnownDecoration(value);
if (decoration) {
stale.push([key, new StaleDecoration(decoration)]);
}
});
this.data.clear();
for (const [key, value] of stale) {
this.data.set(key, value);
}
this.notifyFlush();
}
getOrRetrieve(uri: URI, includeChildren: boolean, callback: (data: Decoration, isChild: boolean) => void): void {
let item = this.data.get(uri);
if (item === undefined || item instanceof StaleDecoration) {
// unknown or stale -> trigger request
item = this.fetchData(uri);
}
// the result, or the last known decoration while a request is in flight
const decoration = lastKnownDecoration(item);
if (decoration) {
callback(decoration, false);
}
if (includeChildren) {
// (last known decorations of) children
const iter = this.data.findSuperstr(uri);
if (iter) {
let next = iter.next();
while (!next.done) {
const value = lastKnownDecoration(next.value);
if (value) {
callback(value, true);
}
next = iter.next();
}
}
}
}
private fetchData(uri: URI): DecorationDataRequest | Decoration | null {
// check for pending request and cancel it, keeping the last settled value
const existing = this.data.get(uri);
const previous = lastKnownDecoration(existing);
if (existing instanceof DecorationDataRequest) {
existing.source.cancel();
this.data.delete(uri);
}
const source = new CancellationTokenSource();
const dataOrThenable = this.provider.provideDecorations(uri, source.token);
if (!isThenable<Decoration | Promise<Decoration | undefined> | undefined>(dataOrThenable)) {
// sync -> we have a result now
return this.keepItem(uri, dataOrThenable, previous);
} else {
// async -> we have a result soon
const request = new DecorationDataRequest(source, Promise.resolve(dataOrThenable).then(data => {
if (this.data.get(uri) === request) {
this.keepItem(uri, data, request.previous);
}
}).catch(err => {
if (!(err instanceof Error && err.name === 'Canceled' && err.message === 'Canceled') && this.data.get(uri) === request) {
this.data.delete(uri);
}
}), previous);
this.data.set(uri, request);
return request;
}
}
private keepItem(uri: URI, data: Decoration | undefined, previous: Decoration | undefined): Decoration | null {
// eslint-disable-next-line no-null/no-null
const deco = data ? data : null;
this.data.set(uri, deco);
if (deco || previous) {
// only notify when something actually changed: a decoration appeared or
// changed, or a previously known decoration was removed. Resources resolving
// to "no decoration" from an unknown or pending state stay silent so that
// on-demand queries of undecorated resources cause no render churn.
this.notifyDecorationChange(uri, deco ?? undefined);
}
return deco;
}
}
export class DecorationsServiceImpl implements DecorationsService {
private readonly data: DecorationProviderWrapper[] = [];
private readonly onDidChangeDecorationsEmitter = new Emitter<Map<string, Decoration>>();
/**
* Accumulates decoration changes until the batched {@link onDidChangeDecorations}
* event fires. `undefined` values mark removed decorations.
*/
private readonly pendingChanges = new Map<string, Decoration | undefined>();
private pendingFlush = false;
readonly onDidChangeDecorations = this.onDidChangeDecorationsEmitter.event;
/** Coalesces all changes of the same tick into a single event. */
protected readonly fireSoon = debounce(() => this.fireChangeEvent(), 0);
dispose(): void {
this.fireSoon.cancel();
this.onDidChangeDecorationsEmitter.dispose();
}
registerDecorationsProvider(provider: DecorationsProvider): Disposable {
const wrapper = new DecorationProviderWrapper(
provider,
(uri, decoration) => this.notifyDecorationChange(uri, decoration),
() => this.notifyFlush()
);
this.data.push(wrapper);
return Disposable.create(() => {
// dispose and remove the provider, then signal that
// any of its previously provided decorations may be gone.
this.data.splice(this.data.indexOf(wrapper), 1);
wrapper.dispose();
this.notifyFlush();
});
}
getDecoration(uri: URI, includeChildren: boolean): Decoration[] {
const data: Decoration[] = [];
let containsChildren: boolean = false;
for (const wrapper of this.data) {
wrapper.getOrRetrieve(uri, includeChildren, (deco, isChild) => {
if (!isChild || deco.bubble) {
data.push(deco);
containsChildren = isChild || containsChildren;
}
});
}
return data;
}
protected notifyDecorationChange(uri: URI, decoration: Decoration | undefined): void {
this.pendingChanges.set(uri.toString(), decoration);
this.fireSoon();
}
protected notifyFlush(): void {
this.pendingFlush = true;
this.fireSoon();
}
protected fireChangeEvent(): void {
const event = new Map<string, Decoration>();
if (!this.pendingFlush) {
for (const [uri, decoration] of this.pendingChanges) {
if (decoration) {
event.set(uri, decoration);
}
}
}
this.pendingFlush = false;
this.pendingChanges.clear();
this.onDidChangeDecorationsEmitter.fire(event);
}
}