diff --git a/packages/core/src/browser/icons/chevron-right-dark.svg b/packages/core/src/browser/icons/chevron-right-dark.svg new file mode 100644 index 0000000000000..6ebaaf4469fed --- /dev/null +++ b/packages/core/src/browser/icons/chevron-right-dark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/core/src/browser/icons/chevron-right-light.svg b/packages/core/src/browser/icons/chevron-right-light.svg new file mode 100644 index 0000000000000..5382facf6c9f8 --- /dev/null +++ b/packages/core/src/browser/icons/chevron-right-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/core/src/browser/icons/loading-dark.svg b/packages/core/src/browser/icons/loading-dark.svg new file mode 100644 index 0000000000000..d886fd06f55d7 --- /dev/null +++ b/packages/core/src/browser/icons/loading-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/core/src/browser/icons/loading-light.svg b/packages/core/src/browser/icons/loading-light.svg new file mode 100644 index 0000000000000..d46f258809474 --- /dev/null +++ b/packages/core/src/browser/icons/loading-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/core/src/browser/style/icons.css b/packages/core/src/browser/style/icons.css index 1d25925002614..765692a50b3b8 100644 --- a/packages/core/src/browser/style/icons.css +++ b/packages/core/src/browser/style/icons.css @@ -49,3 +49,13 @@ .theia-add-icon { background: var(--theia-icon-add) center center no-repeat; } + +@keyframes theia-spin { + 100% { + transform:rotate(360deg); + } +} + +.theia-animation-spin { + animation: theia-spin 1.5s linear infinite; +} diff --git a/packages/core/src/browser/style/tree.css b/packages/core/src/browser/style/tree.css index 914d43698b86e..cf40dd2f687eb 100644 --- a/packages/core/src/browser/style/tree.css +++ b/packages/core/src/browser/style/tree.css @@ -58,28 +58,27 @@ } .theia-ExpansionToggle { - min-width: 10px; display: flex; justify-content: center; - padding-left: calc(var(--theia-ui-padding)*2/3); - padding-right: calc(var(--theia-ui-padding)*2/3); + padding-left: calc(var(--theia-ui-padding)/2); + padding-right: calc(var(--theia-ui-padding)/2); + min-width: var(--theia-icon-size); + min-height: var(--theia-icon-size); + background-size: var(--theia-icon-size); + background: var(--theia-icon-chevron-right) center center no-repeat; } -.theia-ExpansionToggle:hover { - cursor: pointer; +.theia-ExpansionToggle.theia-mod-busy { + background: var(--theia-icon-loading) center center no-repeat; + animation: theia-spin 1.25s linear infinite; } -.theia-ExpansionToggle.theia-mod-collapsed::before { - font-family: FontAwesome; - font-size: calc(var(--theia-content-font-size) * 0.8); - content: "\f0da"; +.theia-ExpansionToggle:not(.theia-mod-busy):hover { + cursor: pointer; } -.theia-ExpansionToggle:not(.theia-mod-collapsed)::before { - font-family: FontAwesome; - font-size: calc(var(--theia-content-font-size) * 0.8); - content: "\f0da"; - transform: rotate(45deg); +.theia-ExpansionToggle:not(.theia-mod-busy):not(.theia-mod-collapsed) { + transform: rotate(90deg); } .theia-Tree:focus .theia-TreeNode.theia-mod-selected, diff --git a/packages/core/src/browser/style/variables-bright.useable.css b/packages/core/src/browser/style/variables-bright.useable.css index f15dd57473fab..a4d2c7085b972 100644 --- a/packages/core/src/browser/style/variables-bright.useable.css +++ b/packages/core/src/browser/style/variables-bright.useable.css @@ -54,6 +54,8 @@ is not optimized for dense, information rich UIs. --theia-ui-padding: 6px; /* Icons */ + --theia-icon-chevron-right: url(../icons/chevron-right-light.svg); + --theia-icon-loading: url(../icons/loading-light.svg); --theia-icon-close: url(../icons/close-bright.svg); --theia-icon-arrow-up: url(../icons/arrow-up-bright.svg); --theia-icon-arrow-down: url(../icons/arrow-down-bright.svg); diff --git a/packages/core/src/browser/style/variables-dark.useable.css b/packages/core/src/browser/style/variables-dark.useable.css index 31c04a11401f2..ea7d60becc4f7 100644 --- a/packages/core/src/browser/style/variables-dark.useable.css +++ b/packages/core/src/browser/style/variables-dark.useable.css @@ -54,6 +54,8 @@ is not optimized for dense, information rich UIs. --theia-ui-padding: 6px; /* Icons */ + --theia-icon-chevron-right: url(../icons/chevron-right-dark.svg); + --theia-icon-loading: url(../icons/loading-dark.svg); --theia-icon-close: url(../icons/close-dark.svg); --theia-icon-arrow-up: url(../icons/arrow-up-dark.svg); --theia-icon-arrow-down: url(../icons/arrow-down-dark.svg); diff --git a/packages/core/src/browser/tree/tree-model.ts b/packages/core/src/browser/tree/tree-model.ts index 5df2d152ce8cc..deb34055fd4e2 100644 --- a/packages/core/src/browser/tree/tree-model.ts +++ b/packages/core/src/browser/tree/tree-model.ts @@ -15,7 +15,11 @@ ********************************************************************************/ import { inject, injectable, postConstruct } from 'inversify'; -import { DisposableCollection, Event, Emitter, SelectionProvider, ILogger, WaitUntilEvent } from '../../common'; +import { Event, Emitter, WaitUntilEvent } from '../../common/event'; +import { DisposableCollection } from '../../common/disposable'; +import { CancellationToken } from '../../common/cancellation'; +import { ILogger } from '../../common/logger'; +import { SelectionProvider, } from '../../common/selection-service'; import { Tree, TreeNode, CompositeTreeNode } from './tree'; import { TreeSelectionService, SelectableTreeNode, TreeSelection } from './tree-selection'; import { TreeExpansionService, ExpandableTreeNode } from './tree-expansion'; @@ -130,7 +134,6 @@ export interface TreeModel extends Tree, TreeSelectionService, TreeExpansionServ * If no node was selected previously, invoking this method does nothing. */ selectRange(node: Readonly): void; - } @injectable() @@ -431,6 +434,14 @@ export class TreeModelImpl implements TreeModel, SelectionProvider { + return this.tree.onDidChangeBusy; + } + + markAsBusy(node: Readonly, ms: number, token: CancellationToken): Promise { + return this.tree.markAsBusy(node, ms, token); + } + } export namespace TreeModelImpl { export interface State { diff --git a/packages/core/src/browser/tree/tree-widget.tsx b/packages/core/src/browser/tree/tree-widget.tsx index 2550bf269860e..e59d960f412e1 100644 --- a/packages/core/src/browser/tree/tree-widget.tsx +++ b/packages/core/src/browser/tree/tree-widget.tsx @@ -20,7 +20,7 @@ import { Disposable, MenuPath, SelectionService } from '../../common'; import { Key, KeyCode, KeyModifier } from '../keyboard/keys'; import { ContextMenuRenderer } from '../context-menu-renderer'; import { StatefulWidget } from '../shell'; -import { EXPANSION_TOGGLE_CLASS, SELECTED_CLASS, COLLAPSED_CLASS, FOCUS_CLASS, Widget } from '../widgets'; +import { EXPANSION_TOGGLE_CLASS, SELECTED_CLASS, COLLAPSED_CLASS, FOCUS_CLASS, Widget, BUSY_CLASS } from '../widgets'; import { TreeNode, CompositeTreeNode } from './tree'; import { TreeModel } from './tree-model'; import { ExpandableTreeNode } from './tree-expansion'; @@ -218,6 +218,7 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { this.model, this.model.onChanged(() => this.updateRows()), this.model.onSelectionChanged(() => this.updateScrollToRow({ resize: false })), + this.model.onDidChangeBusy(() => this.update()), this.model.onNodeRefreshed(() => this.updateDecorations()), this.model.onExpansionChanged(() => this.updateDecorations()), this.decoratorService, @@ -521,6 +522,9 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { if (!node.expanded) { classes.push(COLLAPSED_CLASS); } + if (node.busy) { + classes.push(BUSY_CLASS); + } const className = classes.join(' '); return
& 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'),