& WaitUntilEvent>;
+ /**
+ * Emits when the busy state of the given node is changed.
+ */
+ readonly onDidChangeBusy: Event
;
+ /**
+ * Marks the give node as busy after a specified number of milliseconds.
+ * A token source of the given token should be canceled to unmark.
+ */
+ markAsBusy(node: Readonly, ms: number, token: CancellationToken): Promise;
}
/**
@@ -103,6 +116,10 @@ export interface TreeNode {
* A next sibling of this tree node.
*/
readonly nextSibling?: TreeNode;
+ /**
+ * Whether this node is busy. Greater than 0 then busy; otherwise not.
+ */
+ readonly busy?: number;
}
export namespace TreeNode {
@@ -219,6 +236,9 @@ export class TreeImpl implements Tree {
protected readonly onNodeRefreshedEmitter = new Emitter();
protected readonly toDispose = new DisposableCollection();
+ protected readonly onDidChangeBusyEmitter = new Emitter();
+ readonly onDidChangeBusy = this.onDidChangeBusyEmitter.event;
+
protected nodes: {
[id: string]: Mutable | undefined
} = {};
@@ -226,6 +246,7 @@ export class TreeImpl implements Tree {
constructor() {
this.toDispose.push(this.onChangedEmitter);
this.toDispose.push(this.onNodeRefreshedEmitter);
+ this.toDispose.push(this.onDidChangeBusyEmitter);
}
dispose(): void {
@@ -274,9 +295,15 @@ export class TreeImpl implements Tree {
const parent = !raw ? this._root : this.validateNode(raw);
let result: CompositeTreeNode | undefined;
if (CompositeTreeNode.is(parent)) {
- result = parent;
- const children = await this.resolveChildren(parent);
- result = await this.setChildren(parent, children);
+ const busySource = new CancellationTokenSource();
+ this.doMarkAsBusy(parent, 800, busySource.token);
+ try {
+ result = parent;
+ const children = await this.resolveChildren(parent);
+ result = await this.setChildren(parent, children);
+ } finally {
+ busySource.cancel();
+ }
}
this.fireChanged();
return result;
@@ -329,4 +356,29 @@ export class TreeImpl implements Tree {
}
}
+ async markAsBusy(raw: TreeNode, ms: number, token: CancellationToken): Promise {
+ const node = this.validateNode(raw);
+ if (node) {
+ await this.markAsBusy(node, ms, token);
+ }
+ }
+ protected async doMarkAsBusy(node: Mutable, ms: number, token: CancellationToken): Promise {
+ try {
+ await timeout(ms, token);
+ this.doSetBusy(node, true);
+ token.onCancellationRequested(() => this.doSetBusy(node, false));
+ } catch {
+ /* no-op */
+ }
+ }
+ protected doSetBusy(node: Mutable, busy: boolean): void {
+ const oldBusy = node.busy || 0;
+ const newBusy = oldBusy + (busy ? 1 : oldBusy ? -1 : 0);
+ if (!!oldBusy === !!newBusy) {
+ return;
+ }
+ node.busy = newBusy;
+ this.onDidChangeBusyEmitter.fire(node);
+ }
+
}
diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts
index 6ecd486ec352e..e5763426b9d71 100644
--- a/packages/core/src/browser/widgets/widget.ts
+++ b/packages/core/src/browser/widgets/widget.ts
@@ -33,6 +33,7 @@ export * from '@phosphor/messaging';
export const DISABLED_CLASS = 'theia-mod-disabled';
export const EXPANSION_TOGGLE_CLASS = 'theia-ExpansionToggle';
export const COLLAPSED_CLASS = 'theia-mod-collapsed';
+export const BUSY_CLASS = 'theia-mod-busy';
export const SELECTED_CLASS = 'theia-mod-selected';
export const FOCUS_CLASS = 'theia-mod-focus';
diff --git a/packages/core/src/common/progress-service.ts b/packages/core/src/common/progress-service.ts
index ad6383f440e5c..a7ecfeb67a8a0 100644
--- a/packages/core/src/common/progress-service.ts
+++ b/packages/core/src/common/progress-service.ts
@@ -74,10 +74,7 @@ export class ProgressService {
async withProgress(text: string, locationId: string, task: () => Promise): Promise {
const progress = await this.showProgress({ text, options: { cancelable: true, location: locationId } });
try {
- const result = task();
- return result;
- } catch (error) {
- throw error;
+ return await task();
} finally {
progress.cancel();
}
diff --git a/packages/core/src/common/promise-util.ts b/packages/core/src/common/promise-util.ts
index 5d5d100ee743d..5de09bbb8cc21 100644
--- a/packages/core/src/common/promise-util.ts
+++ b/packages/core/src/common/promise-util.ts
@@ -14,6 +14,8 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
+import { CancellationToken, cancelled } from './cancellation';
+
/**
* Simple implementation of the deferred pattern.
* An object that exposes a promise and functions to resolve and reject it.
@@ -27,3 +29,17 @@ export class Deferred {
this.reject = reject;
});
}
+
+/**
+ * @returns resolves after a specified number of milliseconds
+ * @throws cancelled if a given token is cancelled before a specified number of milliseconds
+ */
+export function timeout(ms: number, token = CancellationToken.None): Promise {
+ const deferred = new Deferred();
+ const handle = setTimeout(() => deferred.resolve(), ms);
+ token.onCancellationRequested(() => {
+ clearTimeout(handle);
+ deferred.reject(cancelled());
+ });
+ return deferred.promise;
+}
diff --git a/packages/navigator/src/browser/navigator-frontend-module.ts b/packages/navigator/src/browser/navigator-frontend-module.ts
index bee418be321f2..b47290469edcd 100644
--- a/packages/navigator/src/browser/navigator-frontend-module.ts
+++ b/packages/navigator/src/browser/navigator-frontend-module.ts
@@ -56,7 +56,10 @@ export default new ContainerModule(bind => {
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: EXPLORER_VIEW_CONTAINER_ID,
createWidget: async () => {
- const viewContainer = container.get(ViewContainer.Factory)({ id: EXPLORER_VIEW_CONTAINER_ID });
+ const viewContainer = container.get(ViewContainer.Factory)({
+ id: EXPLORER_VIEW_CONTAINER_ID,
+ progressLocationId: 'explorer'
+ });
viewContainer.setTitleOptions(EXPLORER_VIEW_CONTAINER_TITLE_OPTIONS);
const widget = await container.get(WidgetManager).getOrCreateWidget(FILE_NAVIGATOR_ID);
viewContainer.addWidget(widget, {
diff --git a/packages/navigator/src/browser/navigator-model.spec.ts b/packages/navigator/src/browser/navigator-model.spec.ts
index 7159132b5a2e6..25c2729675ef5 100644
--- a/packages/navigator/src/browser/navigator-model.spec.ts
+++ b/packages/navigator/src/browser/navigator-model.spec.ts
@@ -18,7 +18,7 @@ import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
let disableJSDOM = enableJSDOM();
import { Container } from 'inversify';
-import { Emitter, ILogger, Logger } from '@theia/core';
+import { Event, Emitter, ILogger, Logger } from '@theia/core';
import {
CompositeTreeNode, DefaultOpenerService, ExpandableTreeNode, LabelProvider, OpenerService,
Tree, TreeNode, TreeSelectionService, TreeExpansionService, TreeExpansionServiceImpl,
@@ -37,6 +37,7 @@ import { expect } from 'chai';
import URI from '@theia/core/lib/common/uri';
import * as sinon from 'sinon';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
+import { ProgressService } from '@theia/core/lib/common/progress-service';
disableJSDOM();
@@ -113,6 +114,7 @@ const setup = () => {
});
};
+// TODO rewrite as integration tests instead of testing mocks
describe('FileNavigatorModel', () => {
let testContainer: Container;
@@ -177,12 +179,16 @@ describe('FileNavigatorModel', () => {
testContainer.bind(TreeSearch).toConstantValue(mockTreeSearch);
testContainer.bind(CorePreferences).toConstantValue(mockPreferences);
testContainer.bind(FrontendApplicationStateService).toConstantValue(mockApplicationStateService);
+ testContainer.bind(ProgressService).toConstantValue({
+ withProgress: (_, __, task) => task()
+ });
sinon.stub(mockWorkspaceService, 'onWorkspaceChanged').value(mockWorkspaceServiceEmitter.event);
sinon.stub(mockWorkspaceService, 'onWorkspaceLocationChanged').value(mockWorkspaceOnLocationChangeEmitter.event);
sinon.stub(mockFileSystemWatcher, 'onFilesChanged').value(mockFileChangeEmitter.event);
sinon.stub(mockFileSystemWatcher, 'onDidMove').value(mockFileMoveEmitter.event);
sinon.stub(mockFileNavigatorTree, 'onChanged').value(mockTreeChangeEmitter.event);
+ sinon.stub(mockFileNavigatorTree, 'onDidChangeBusy').value(Event.None);
sinon.stub(mockTreeExpansionService, 'onExpansionChanged').value(mockExpansionChangeEmitter.event);
setup();
diff --git a/packages/navigator/src/browser/navigator-model.ts b/packages/navigator/src/browser/navigator-model.ts
index d1e0f5b0ecb15..bbe6aa8975a4c 100644
--- a/packages/navigator/src/browser/navigator-model.ts
+++ b/packages/navigator/src/browser/navigator-model.ts
@@ -21,6 +21,9 @@ import { OpenerService, open, TreeNode, ExpandableTreeNode, CompositeTreeNode, S
import { FileNavigatorTree, WorkspaceRootNode, WorkspaceNode } from './navigator-tree';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
+import { ProgressService } from '@theia/core/lib/common/progress-service';
+import { Deferred } from '@theia/core/lib/common/promise-util';
+import { Disposable } from '@theia/core/lib/common/disposable';
@injectable()
export class FileNavigatorModel extends FileTreeModel {
@@ -30,12 +33,41 @@ export class FileNavigatorModel extends FileTreeModel {
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(FrontendApplicationStateService) protected readonly applicationState: FrontendApplicationStateService;
+ @inject(ProgressService)
+ protected readonly progressService: ProgressService;
+
@postConstruct()
protected init(): void {
super.init();
+ this.reportBusyProgress();
this.initializeRoot();
}
+ protected readonly pendingBusyProgress = new Map>();
+ protected reportBusyProgress(): void {
+ this.toDispose.push(this.onDidChangeBusy(node => {
+ const pending = this.pendingBusyProgress.get(node.id);
+ if (pending) {
+ if (!node.busy) {
+ pending.resolve();
+ this.pendingBusyProgress.delete(node.id);
+ }
+ return;
+ }
+ if (node.busy) {
+ const progress = new Deferred();
+ this.pendingBusyProgress.set(node.id, progress);
+ this.progressService.withProgress('', 'explorer', () => progress.promise);
+ }
+ }));
+ this.toDispose.push(Disposable.create(() => {
+ for (const pending of this.pendingBusyProgress.values()) {
+ pending.resolve();
+ }
+ this.pendingBusyProgress.clear();
+ }));
+ }
+
protected async initializeRoot(): Promise {
await Promise.all([
this.applicationState.reachedState('initialized_layout'),