diff --git a/.vscode/settings.json b/.vscode/settings.json index 8b9ca6cd07b..2cb6c48fce4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,5 +48,7 @@ }, { "fileMatch": [ "cglicenses.json" ], "url": "./.vscode/cglicenses.schema.json" - }] + } +], +"git.ignoreLimitWarning": true } diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 481fa107067..6b77d619d40 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -173,6 +173,7 @@ export class ListView implements ISpliceable, IDisposable { private supportDynamicHeights: boolean; private dnd: IListViewDragAndDrop; + private canDrop: boolean = false; private currentDragData: IDragAndDropData | undefined; private currentDragFeedback: number[] | undefined; private currentDragFeedbackDisposable: IDisposable = Disposable.None; @@ -658,9 +659,11 @@ export class ListView implements ISpliceable, IDisposable { } const result = this.dnd.onDragOver(this.currentDragData, event.element, event.index, event.browserEvent); - const canDrop = typeof result === 'boolean' ? result : result.accept; + this.canDrop = typeof result === 'boolean' ? result : result.accept; - if (!canDrop) { + if (!this.canDrop) { + this.currentDragFeedback = undefined; + this.currentDragFeedbackDisposable.dispose(); return false; } @@ -727,6 +730,10 @@ export class ListView implements ISpliceable, IDisposable { } private onDrop(event: IListDragEvent): void { + if (!this.canDrop) { + return; + } + const dragData = this.currentDragData; this.teardownDragAndDropScrollTopAnimation(); this.clearDragOverFeedback(); @@ -743,6 +750,7 @@ export class ListView implements ISpliceable, IDisposable { } private onDragEnd(): void { + this.canDrop = false; this.teardownDragAndDropScrollTopAnimation(); this.clearDragOverFeedback(); this.currentDragData = undefined; diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 31046489dcd..65c8b83ef84 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -104,6 +104,9 @@ class TreeNodeListDragAndDrop implements IListDragAndDrop< } drop(data: IDragAndDropData, targetNode: ITreeNode | undefined, targetIndex: number | undefined, originalEvent: DragEvent): void { + this.autoExpandDisposable.dispose(); + this.autoExpandNode = undefined; + this.dnd.drop(asTreeDragAndDropData(data), targetNode && targetNode.element, targetIndex, originalEvent); } } @@ -193,7 +196,7 @@ class TreeRenderer implements IListRenderer { @@ -31,6 +33,16 @@ interface IAsyncDataTreeNode { state: AsyncDataTreeNodeState; } +function isAncestor(ancestor: IAsyncDataTreeNode, descendant: IAsyncDataTreeNode): boolean { + if (!descendant.parent) { + return false; + } else if (descendant.parent === ancestor) { + return true; + } else { + return isAncestor(ancestor, descendant.parent); + } +} + interface IDataTreeListTemplateData { templateData: T; } @@ -114,16 +126,6 @@ function asTreeContextMenuEvent(e: ITreeContextMenuEvent { - readonly element: T | null; - readonly reason: ChildrenResolutionReason; -} - function asAsyncDataTreeDragAndDropData(data: IDragAndDropData): IDragAndDropData { if (data instanceof ElementsDragAndDropData) { const nodes = (data as ElementsDragAndDropData>).elements; @@ -221,7 +223,7 @@ export class AsyncDataTree implements IDisposable private readonly tree: ObjectTree, TFilterData>; private readonly root: IAsyncDataTreeNode; private readonly nodes = new Map>(); - private readonly refreshPromises = new Map, Promise>(); + private readonly refreshPromises = new Map, CancelablePromise>(); private readonly identityProvider?: IIdentityProvider; private readonly _onDidChangeNodeState = new Emitter>(); @@ -232,9 +234,6 @@ export class AsyncDataTree implements IDisposable get onDidChangeSelection(): Event> { return Event.map(this.tree.onDidChangeSelection, asTreeEvent); } get onDidOpen(): Event> { return Event.map(this.tree.onDidOpen, asTreeEvent); } - private readonly _onDidResolveChildren = new Emitter>(); - readonly onDidResolveChildren: Event> = this._onDidResolveChildren.event; - get onMouseClick(): Event> { return Event.map(this.tree.onMouseClick, asTreeMouseEvent); } get onMouseDblClick(): Event> { return Event.map(this.tree.onMouseDblClick, asTreeMouseEvent); } get onContextMenu(): Event> { return Event.map(this.tree.onContextMenu, asTreeContextMenuEvent); } @@ -327,6 +326,9 @@ export class AsyncDataTree implements IDisposable } setInput(input: TInput): Promise { + this.refreshPromises.forEach(promise => promise.cancel()); + this.refreshPromises.clear(); + this.root.element = input!; return this.refresh(input); } @@ -336,7 +338,7 @@ export class AsyncDataTree implements IDisposable throw new Error('Tree input not set'); } - return this.refreshNode(this.getDataNode(element), recursive, ChildrenResolutionReason.Refresh); + return this.refreshNode(this.getDataNode(element), recursive); } // Tree @@ -348,20 +350,21 @@ export class AsyncDataTree implements IDisposable } collapse(element: T, recursive: boolean = false): boolean { - return this.tree.collapse(this.getDataNode(element), recursive); + const node = this.getDataNode(element); + return this.tree.collapse(node === this.root ? null : node, recursive); } async expand(element: T, recursive: boolean = false): Promise { const node = this.getDataNode(element); - if (!this.tree.isCollapsed(node)) { + if (!this.tree.isCollapsed(node === this.root ? null : node)) { return false; } - this.tree.expand(node, recursive); + this.tree.expand(node === this.root ? null : node, recursive); if (node.state !== AsyncDataTreeNodeState.Loaded) { - await this.refreshNode(node, false, ChildrenResolutionReason.Expand); + await this.refreshNode(node, false); } return true; @@ -485,33 +488,48 @@ export class AsyncDataTree implements IDisposable return node; } - private async refreshNode(node: IAsyncDataTreeNode, recursive: boolean, reason: ChildrenResolutionReason): Promise { - await this._refreshNode(node, recursive, reason); + private async refreshNode(node: IAsyncDataTreeNode, recursive: boolean): Promise { + await this.queueRefresh(node, recursive); if (recursive && node.children) { - await Promise.all(node.children.map(child => this.refreshNode(child, recursive, reason))); + await Promise.all(node.children.map(child => this.refreshNode(child, recursive))); } } - private _refreshNode(node: IAsyncDataTreeNode, recursive: boolean, reason: ChildrenResolutionReason): Promise { - let result = this.refreshPromises.get(node); + private currentRefreshCalls = new Map, Promise>(); + + private async queueRefresh(node: IAsyncDataTreeNode, recursive: boolean): Promise { + if (node.state === AsyncDataTreeNodeState.Disposed) { + console.error('Async data tree node is disposed'); + return; + } + + let result: Promise | undefined; + + this.currentRefreshCalls.forEach((refreshPromise, refreshNode) => { + if (isAncestor(refreshNode, node) || isAncestor(node, refreshNode)) { + result = refreshPromise.then(() => this.queueRefresh(node, recursive)); + } + }); if (result) { return result; } - result = this.doRefresh(node, recursive, reason); - this.refreshPromises.set(node, result); - return always(result, () => this.refreshPromises.delete(node)); + result = this.doRefresh(node, recursive); + + this.currentRefreshCalls.set(node, result); + return always(result, () => this.currentRefreshCalls.delete(node)); } - private doRefresh(node: IAsyncDataTreeNode, recursive: boolean, reason: ChildrenResolutionReason): Promise { + private doRefresh(node: IAsyncDataTreeNode, recursive: boolean): Promise { const hasChildren = !!this.dataSource.hasChildren(node.element!); if (!hasChildren) { this.setChildren(node, [], recursive); return Promise.resolve(); } else if (node !== this.root && (!this.tree.isCollapsible(node) || this.tree.isCollapsed(node))) { + node.state = AsyncDataTreeNodeState.Uninitialized; return Promise.resolve(); } else { node.state = AsyncDataTreeNodeState.Loading; @@ -524,21 +542,24 @@ export class AsyncDataTree implements IDisposable this._onDidChangeNodeState.fire(node); }, _ => null); - return Promise.resolve(this.dataSource.getChildren(node.element!)) + return Promise.resolve(this.doGetChildren(node)) .then(children => { slowTimeout.cancel(); node.state = AsyncDataTreeNodeState.Loaded; this._onDidChangeNodeState.fire(node); this.setChildren(node, children, recursive); - this._onDidResolveChildren.fire({ element: node.element as T, reason }); }, err => { + if (isPromiseCanceledError(err)) { + return Promise.resolve(null); + } + slowTimeout.cancel(); node.state = AsyncDataTreeNodeState.Uninitialized; this._onDidChangeNodeState.fire(node); if (node !== this.root) { - this.tree.collapse(node); + this.tree.collapse(node === this.root ? null : node); } return Promise.reject(err); @@ -546,12 +567,24 @@ export class AsyncDataTree implements IDisposable } } + private doGetChildren(node: IAsyncDataTreeNode): Promise { + let result = this.refreshPromises.get(node); + + if (result) { + return result; + } + + result = createCancelablePromise(_ => Promise.resolve(this.dataSource.getChildren(node.element!))); + this.refreshPromises.set(node, result); + return always(result, () => this.refreshPromises.delete(node)); + } + private _onDidChangeCollapseState({ node, deep }: ICollapseStateChangeEvent, any>): void { if (!node.collapsed && node.element.state === AsyncDataTreeNodeState.Uninitialized) { if (deep) { this.collapse(node.element.element as T); } else { - this.refreshNode(node.element, false, ChildrenResolutionReason.Expand); + this.refreshNode(node.element, false); } } } @@ -600,12 +633,12 @@ export class AsyncDataTree implements IDisposable asyncDataTreeNode.element = element; const collapsible = !!this.dataSource.hasChildren(element); - const collapsed = !collapsible || this.tree.isCollapsed(asyncDataTreeNode); + const collapsed = !collapsible || this.tree.isCollapsed(asyncDataTreeNode === this.root ? null : asyncDataTreeNode); if (recursive) { asyncDataTreeNode.state = AsyncDataTreeNodeState.Uninitialized; - if (this.tree.isCollapsed(asyncDataTreeNode)) { + if (this.tree.isCollapsed(asyncDataTreeNode === this.root ? null : asyncDataTreeNode)) { asyncDataTreeNode.children!.length = 0; return { @@ -642,6 +675,7 @@ export class AsyncDataTree implements IDisposable const onDidDeleteNode = (treeNode: ITreeNode, TFilterData>) => { if (treeNode.element.element) { if (!insertedElements.has(treeNode.element.element as T)) { + treeNode.element.state = AsyncDataTreeNodeState.Disposed; this.nodes.delete(treeNode.element.element as T); } } diff --git a/src/vs/base/browser/ui/tree/media/tree.css b/src/vs/base/browser/ui/tree/media/tree.css index 3874c8d7a11..1c7ef65cf57 100644 --- a/src/vs/base/browser/ui/tree/media/tree.css +++ b/src/vs/base/browser/ui/tree/media/tree.css @@ -19,6 +19,7 @@ text-align: right; margin-right: 6px; flex-shrink: 0; + width: 16px; } .monaco-tl-contents { diff --git a/src/vs/workbench/browser/parts/views/customView.ts b/src/vs/workbench/browser/parts/views/customView.ts index 89b94f45a22..456eb50130b 100644 --- a/src/vs/workbench/browser/parts/views/customView.ts +++ b/src/vs/workbench/browser/parts/views/customView.ts @@ -611,10 +611,12 @@ registerThemingParticipant((theme, collector) => { const findMatchHighlightColor = theme.getColor(editorFindMatchHighlight); if (findMatchHighlightColor) { collector.addRule(`.file-icon-themable-tree .monaco-tree-row .content .monaco-highlighted-label .highlight { color: unset !important; background-color: ${findMatchHighlightColor}; }`); + collector.addRule(`.file-icon-themable-tree .monaco-tl-contents .monaco-highlighted-label .highlight { color: unset !important; background-color: ${findMatchHighlightColor}; }`); } const findMatchHighlightColorBorder = theme.getColor(editorFindMatchHighlightBorder); if (findMatchHighlightColorBorder) { collector.addRule(`.file-icon-themable-tree .monaco-tree-row .content .monaco-highlighted-label .highlight { color: unset !important; border: 1px dotted ${findMatchHighlightColorBorder}; box-sizing: border-box; }`); + collector.addRule(`.file-icon-themable-tree .monaco-tl-contents .monaco-highlighted-label .highlight { color: unset !important; border: 1px dotted ${findMatchHighlightColorBorder}; box-sizing: border-box; }`); } const link = theme.getColor(textLinkForeground); if (link) { diff --git a/src/vs/workbench/browser/parts/views/media/views.css b/src/vs/workbench/browser/parts/views/media/views.css index b4f83027944..ec2880220b8 100644 --- a/src/vs/workbench/browser/parts/views/media/views.css +++ b/src/vs/workbench/browser/parts/views/media/views.css @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* File icon themeable tree style */ +/* File icon themeable OLD tree style */ .file-icon-themable-tree .monaco-tree-row .content { display: flex; } @@ -50,6 +50,17 @@ display: none; } +/* File icons in trees */ + +.file-icon-themable-tree.align-icons-and-twisties .monaco-tl-twistie:not(.collapsible), +.file-icon-themable-tree.hide-arrows .monaco-tl-twistie { + background-image: none !important; + width: 0 !important; + margin-right: 0 !important; +} + +/* Misc */ + .monaco-workbench .tree-explorer-viewlet-tree-view { height: 100%; } diff --git a/src/vs/workbench/browser/parts/views/views.ts b/src/vs/workbench/browser/parts/views/views.ts index 44ff6623fd2..b0e7153d2f1 100644 --- a/src/vs/workbench/browser/parts/views/views.ts +++ b/src/vs/workbench/browser/parts/views/views.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import 'vs/css!./media/views'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { IViewsService, ViewsRegistry, IViewsViewlet, ViewContainer, IViewDescriptor, IViewContainersRegistry, Extensions as ViewContainerExtensions, IView, IViewDescriptorCollection } from 'vs/workbench/common/views'; import { Registry } from 'vs/platform/registry/common/platform'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; @@ -19,6 +19,8 @@ import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { localize } from 'vs/nls'; import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { values } from 'vs/base/common/map'; +import { IFileIconTheme, IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import { toggleClass, addClass } from 'vs/base/browser/dom'; function filterViewEvent(container: ViewContainer, event: Event): Event { return Event.chain(event) @@ -612,3 +614,16 @@ export class ViewsService extends Disposable implements IViewsService { return contextKey; } } + +export function createFileIconThemableTreeContainerScope(container: HTMLElement, themeService: IWorkbenchThemeService): IDisposable { + addClass(container, 'file-icon-themable-tree'); + addClass(container, 'show-file-icons'); + + const onDidChangeFileIconTheme = (theme: IFileIconTheme) => { + toggleClass(container, 'align-icons-and-twisties', theme.hasFileIcons && !theme.hasFolderIcons); + toggleClass(container, 'hide-arrows', theme.hidesExplorerArrows === true); + }; + + onDidChangeFileIconTheme(themeService.getFileIconTheme()); + return themeService.onDidFileIconThemeChange(onDidChangeFileIconTheme); +} \ No newline at end of file diff --git a/src/vs/workbench/browser/parts/views/viewsViewlet.ts b/src/vs/workbench/browser/parts/views/viewsViewlet.ts index 9c2acd531ea..767e16d82df 100644 --- a/src/vs/workbench/browser/parts/views/viewsViewlet.ts +++ b/src/vs/workbench/browser/parts/views/viewsViewlet.ts @@ -30,78 +30,6 @@ import { localize } from 'vs/nls'; import { IAddedViewDescriptorRef, IViewDescriptorRef, PersistentContributableViewsModel } from 'vs/workbench/browser/parts/views/views'; import { Registry } from 'vs/platform/registry/common/platform'; -export abstract class TreeViewsViewletPanel extends ViewletPanel { - - protected tree: WorkbenchTree; - - setExpanded(expanded: boolean): boolean { - const changed = super.setExpanded(expanded); - if (changed) { - this.updateTreeVisibility(this.tree, expanded); - } - - return changed; - } - - setVisible(visible: boolean): void { - if (this.isVisible() !== visible) { - super.setVisible(visible); - this.updateTreeVisibility(this.tree, visible && this.isExpanded()); - } - } - - focus(): void { - super.focus(); - this.focusTree(); - } - - layoutBody(size: number): void { - if (this.tree) { - this.tree.layout(size); - } - } - - protected updateTreeVisibility(tree: WorkbenchTree, isVisible: boolean): void { - if (!tree) { - return; - } - - if (isVisible) { - DOM.show(tree.getHTMLElement()); - } else { - DOM.hide(tree.getHTMLElement()); // make sure the tree goes out of the tabindex world by hiding it - } - - if (isVisible) { - tree.onVisible(); - } else { - tree.onHidden(); - } - } - - private focusTree(): void { - if (!this.tree) { - return; // return early if viewlet has not yet been created - } - - // Make sure the current selected element is revealed - const selectedElement = this.tree.getSelection()[0]; - if (selectedElement) { - this.tree.reveal(selectedElement); - } - - // Pass Focus to Viewer - this.tree.domFocus(); - } - - dispose(): void { - if (this.tree) { - this.tree.dispose(); - } - super.dispose(); - } -} - export interface IViewletViewOptions extends IViewletPanelOptions { viewletState: object; } diff --git a/src/vs/workbench/parts/files/browser/editors/textFileEditor.ts b/src/vs/workbench/parts/files/browser/editors/textFileEditor.ts index 6685a076486..ac1fad843a4 100644 --- a/src/vs/workbench/parts/files/browser/editors/textFileEditor.ts +++ b/src/vs/workbench/parts/files/browser/editors/textFileEditor.ts @@ -8,7 +8,7 @@ import { toErrorMessage } from 'vs/base/common/errorMessage'; import * as types from 'vs/base/common/types'; import * as paths from 'vs/base/common/paths'; import { Action } from 'vs/base/common/actions'; -import { VIEWLET_ID, IExplorerViewlet, TEXT_FILE_EDITOR_ID } from 'vs/workbench/parts/files/common/files'; +import { VIEWLET_ID, TEXT_FILE_EDITOR_ID, IExplorerService } from 'vs/workbench/parts/files/common/files'; import { ITextFileEditorModel, ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { BaseTextEditor, IEditorConfiguration } from 'vs/workbench/browser/parts/editor/textEditor'; import { EditorOptions, TextEditorOptions } from 'vs/workbench/common/editor'; @@ -54,7 +54,8 @@ export class TextFileEditor extends BaseTextEditor { @ITextFileService textFileService: ITextFileService, @IWindowsService private readonly windowsService: IWindowsService, @IPreferencesService private readonly preferencesService: IPreferencesService, - @IWindowService windowService: IWindowService + @IWindowService windowService: IWindowService, + @IExplorerService private readonly explorerService: IExplorerService ) { super(TextFileEditor.ID, telemetryService, instantiationService, storageService, configurationService, themeService, textFileService, editorService, editorGroupService, windowService); @@ -218,8 +219,8 @@ export class TextFileEditor extends BaseTextEditor { // Best we can do is to reveal the folder in the explorer if (this.contextService.isInsideWorkspace(input.getResource())) { - this.viewletService.openViewlet(VIEWLET_ID, true).then(viewlet => { - return (viewlet as IExplorerViewlet).getExplorerView().select(input.getResource(), true); + this.viewletService.openViewlet(VIEWLET_ID, true).then(() => { + this.explorerService.select(input.getResource(), true); }); } }); diff --git a/src/vs/workbench/parts/files/browser/files.ts b/src/vs/workbench/parts/files/browser/files.ts index 00f7804d9df..f745fc4a024 100644 --- a/src/vs/workbench/parts/files/browser/files.ts +++ b/src/vs/workbench/parts/files/browser/files.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from 'vs/base/common/uri'; -import { IListService } from 'vs/platform/list/browser/listService'; -import { ExplorerItem, OpenEditor } from 'vs/workbench/parts/files/common/explorerModel'; +import { IListService, WorkbenchAsyncDataTree } from 'vs/platform/list/browser/listService'; +import { OpenEditor } from 'vs/workbench/parts/files/common/files'; import { toResource } from 'vs/workbench/common/editor'; -import { Tree } from 'vs/base/parts/tree/browser/treeImpl'; import { List } from 'vs/base/browser/ui/list/listWidget'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { ExplorerItem } from 'vs/workbench/parts/files/common/explorerModel'; // Commands can get exeucted from a command pallete, from a context menu or from some list using a keybinding // To cover all these cases we need to properly compute the resource on which the command is being executed @@ -44,7 +44,7 @@ export function getMultiSelectedResources(resource: URI | object, listService: I const list = listService.lastFocusedList; if (list && list.getHTMLElement() === document.activeElement) { // Explorer - if (list instanceof Tree) { + if (list instanceof WorkbenchAsyncDataTree) { const selection = list.getSelection().map((fs: ExplorerItem) => fs.resource); const focus = list.getFocus(); const mainUriStr = URI.isUri(resource) ? resource.toString() : focus instanceof ExplorerItem ? focus.resource.toString() : undefined; diff --git a/src/vs/workbench/parts/files/common/explorerModel.ts b/src/vs/workbench/parts/files/common/explorerModel.ts index 8bddd00c946..085c63f8ef4 100644 --- a/src/vs/workbench/parts/files/common/explorerModel.ts +++ b/src/vs/workbench/parts/files/common/explorerModel.ts @@ -8,46 +8,54 @@ import * as paths from 'vs/base/common/paths'; import * as resources from 'vs/base/common/resources'; import { ResourceMap } from 'vs/base/common/map'; import { isLinux } from 'vs/base/common/platform'; -import { IFileStat } from 'vs/platform/files/common/files'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { toResource, IEditorIdentifier, IEditorInput } from 'vs/workbench/common/editor'; -import { IDisposable, dispose } from 'vs/base/common/lifecycle'; -import { Schemas } from 'vs/base/common/network'; +import { IFileStat, IFileService } from 'vs/platform/files/common/files'; import { rtrim, startsWithIgnoreCase, startsWith, equalsIgnoreCase } from 'vs/base/common/strings'; -import { IEditorGroup } from 'vs/workbench/services/group/common/editorGroupsService'; import { coalesce } from 'vs/base/common/arrays'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { memoize } from 'vs/base/common/decorators'; +import { Emitter, Event } from 'vs/base/common/event'; -export class Model { +export class ExplorerModel implements IDisposable { private _roots: ExplorerItem[]; private _listener: IDisposable; + private _onDidChangeRoots = new Emitter(); - constructor(@IWorkspaceContextService private readonly contextService: IWorkspaceContextService) { + constructor(private readonly contextService: IWorkspaceContextService) { const setRoots = () => this._roots = this.contextService.getWorkspace().folders - .map(folder => new ExplorerItem(folder.uri, undefined, false, false, true, folder.name)); - this._listener = this.contextService.onDidChangeWorkspaceFolders(() => setRoots()); + .map(folder => new ExplorerItem(folder.uri, undefined, true, false, false, folder.name)); setRoots(); + + this._listener = this.contextService.onDidChangeWorkspaceFolders(() => { + setRoots(); + this._onDidChangeRoots.fire(); + }); } - public get roots(): ExplorerItem[] { + get roots(): ExplorerItem[] { return this._roots; } + get onDidChangeRoots(): Event { + return this._onDidChangeRoots.event; + } + /** * Returns an array of child stat from this stat that matches with the provided path. * Starts matching from the first root. * Will return empty array in case the FileStat does not exist. */ - public findAll(resource: URI): ExplorerItem[] { + findAll(resource: URI): ExplorerItem[] { return coalesce(this.roots.map(root => root.find(resource))); } /** * Returns a FileStat that matches the passed resource. * In case multiple FileStat are matching the resource (same folder opened multiple times) returns the FileStat that has the closest root. - * Will return null in case the FileStat does not exist. + * Will return undefined in case the FileStat does not exist. */ - public findClosest(resource: URI): ExplorerItem | null { + findClosest(resource: URI): ExplorerItem | null { const folder = this.contextService.getWorkspaceFolder(resource); if (folder) { const root = this.roots.filter(r => r.resource.toString() === folder.uri.toString()).pop(); @@ -59,95 +67,89 @@ export class Model { return null; } - public dispose(): void { + dispose(): void { this._listener = dispose(this._listener); } } export class ExplorerItem { - public resource: URI; - private _name: string; - public mtime?: number; - public etag?: string; - private _isDirectory: boolean; - private _isSymbolicLink: boolean; - private _isReadonly: boolean; - private children?: Map; - private _isError: boolean; - public parent: ExplorerItem; - public isDirectoryResolved: boolean; + public isError: boolean; - constructor(resource: URI, public root: ExplorerItem | undefined, isSymbolicLink?: boolean, isReadonly?: boolean, isDirectory?: boolean, name: string = resources.basenameOrAuthority(resource), mtime?: number, etag?: string, isError?: boolean) { - this.resource = resource; - this._name = name; - this.isDirectory = !!isDirectory; - this._isSymbolicLink = !!isSymbolicLink; - this._isReadonly = !!isReadonly; - this.etag = etag; - this.mtime = mtime; - this._isError = !!isError; - - if (!this.root) { - this.root = this; - } - + constructor( + public resource: URI, + private _parent: ExplorerItem | undefined, + private _isDirectory?: boolean, + private _isSymbolicLink?: boolean, + private _isReadonly?: boolean, + private _name: string = resources.basenameOrAuthority(resource), + private _mtime?: number, + private _etag?: string, + ) { this.isDirectoryResolved = false; } - public get isSymbolicLink(): boolean { - return this._isSymbolicLink; + get isSymbolicLink(): boolean { + return !!this._isSymbolicLink; } - public get isDirectory(): boolean { - return this._isDirectory; + get isDirectory(): boolean { + return !!this._isDirectory; } - public get isReadonly(): boolean { - return this._isReadonly; + get isReadonly(): boolean { + return !!this._isReadonly; } - public get isError(): boolean { - return this._isError; + get etag(): string | undefined { + return this._etag; } - public set isDirectory(value: boolean) { - if (value !== this._isDirectory) { - this._isDirectory = value; - if (this._isDirectory) { - this.children = new Map(); - } else { - this.children = undefined; - } + get mtime(): number | undefined { + return this._mtime; + } + + get name(): string { + return this._name; + } + + get parent(): ExplorerItem | undefined { + return this._parent; + } + + get root(): ExplorerItem { + if (!this._parent) { + return this; } + return this._parent.root; } - public get name(): string { - return this._name; + @memoize get children(): Map { + return new Map(); } private updateName(value: string): void { // Re-add to parent since the parent has a name map to children and the name might have changed - if (this.parent) { - this.parent.removeChild(this); + if (this._parent) { + this._parent.removeChild(this); } this._name = value; - if (this.parent) { - this.parent.addChild(this); + if (this._parent) { + this._parent.addChild(this); } } - public getId(): string { + getId(): string { return this.resource.toString(); } - public get isRoot(): boolean { + get isRoot(): boolean { return this === this.root; } - public static create(raw: IFileStat, root: ExplorerItem, resolveTo?: URI[], isError = false): ExplorerItem { - const stat = new ExplorerItem(raw.resource, root, raw.isSymbolicLink, raw.isReadonly, raw.isDirectory, raw.name, raw.mtime, raw.etag, isError); + static create(raw: IFileStat, parent: ExplorerItem, resolveTo?: URI[]): ExplorerItem { + const stat = new ExplorerItem(raw.resource, parent, raw.isDirectory, raw.isSymbolicLink, raw.isReadonly, raw.name, raw.mtime, raw.etag); // Recursively add children if present if (stat.isDirectory) { @@ -162,8 +164,7 @@ export class ExplorerItem { // Recurse into children if (raw.children) { for (let i = 0, len = raw.children.length; i < len; i++) { - const child = ExplorerItem.create(raw.children[i], root, resolveTo); - child.parent = stat; + const child = ExplorerItem.create(raw.children[i], stat, resolveTo); stat.addChild(child); } } @@ -177,7 +178,7 @@ export class ExplorerItem { * and children. The merge will only consider resolved stat elements to avoid overwriting data which * exists locally. */ - public static mergeLocalWithDisk(disk: ExplorerItem, local: ExplorerItem): void { + static mergeLocalWithDisk(disk: ExplorerItem, local: ExplorerItem): void { if (disk.resource.toString() !== local.resource.toString()) { return; // Merging only supported for stats with the same resource } @@ -191,104 +192,81 @@ export class ExplorerItem { // Properties local.resource = disk.resource; local.updateName(disk.name); - local.isDirectory = disk.isDirectory; - local.mtime = disk.mtime; + local._isDirectory = disk.isDirectory; + local._mtime = disk.mtime; local.isDirectoryResolved = disk.isDirectoryResolved; local._isSymbolicLink = disk.isSymbolicLink; local._isReadonly = disk.isReadonly; - local._isError = disk.isError; + local.isError = disk.isError; // Merge Children if resolved if (mergingDirectories && disk.isDirectoryResolved) { // Map resource => stat const oldLocalChildren = new ResourceMap(); - if (local.children) { - local.children.forEach(child => { - oldLocalChildren.set(child.resource, child); - }); - } + local.children.forEach(child => { + oldLocalChildren.set(child.resource, child); + }); // Clear current children - local.children = new Map(); + local.children.clear(); // Merge received children - if (disk.children) { - disk.children.forEach(diskChild => { - const formerLocalChild = oldLocalChildren.get(diskChild.resource); - // Existing child: merge - if (formerLocalChild) { - ExplorerItem.mergeLocalWithDisk(diskChild, formerLocalChild); - formerLocalChild.parent = local; - local.addChild(formerLocalChild); - } + disk.children.forEach(diskChild => { + const formerLocalChild = oldLocalChildren.get(diskChild.resource); + // Existing child: merge + if (formerLocalChild) { + ExplorerItem.mergeLocalWithDisk(diskChild, formerLocalChild); + local.addChild(formerLocalChild); + } - // New child: add - else { - diskChild.parent = local; - local.addChild(diskChild); - } - }); - } + // New child: add + else { + local.addChild(diskChild); + } + }); } } /** * Adds a child element to this folder. */ - public addChild(child: ExplorerItem): void { - if (!this.children) { - this.isDirectory = true; - } - + addChild(child: ExplorerItem): void { // Inherit some parent properties to child - child.parent = this; + child._parent = this; child.updateResource(false); - - if (this.children) { - this.children.set(this.getPlatformAwareName(child.name), child); - } + this.children.set(this.getPlatformAwareName(child.name), child); } - public getChild(name: string): ExplorerItem | undefined { - if (!this.children) { - return undefined; - } - + getChild(name: string): ExplorerItem | undefined { return this.children.get(this.getPlatformAwareName(name)); } - /** - * Only use this method if you need all the children since it converts a map to an array - */ - public getChildrenArray(): ExplorerItem[] | undefined { - if (!this.children) { - return undefined; + fetchChildren(fileService: IFileService): Promise { + let promise: Promise = Promise.resolve(undefined); + if (!this.isDirectoryResolved) { + promise = fileService.resolveFile(this.resource, { resolveSingleChildDescendants: true }).then(stat => { + const resolved = ExplorerItem.create(stat, this); + ExplorerItem.mergeLocalWithDisk(resolved, this); + this.isDirectoryResolved = true; + }); } - const items: ExplorerItem[] = []; - this.children.forEach(child => { - items.push(child); + return promise.then(() => { + const items: ExplorerItem[] = []; + this.children.forEach(child => { + items.push(child); + }); + + return items; }); - - return items; - } - - public getChildrenCount(): number { - if (!this.children) { - return 0; - } - - return this.children.size; } /** * Removes a child element from this folder. */ - public removeChild(child: ExplorerItem): void { - if (this.children) { - this.children.delete(this.getPlatformAwareName(child.name)); - } + removeChild(child: ExplorerItem): void { + this.children.delete(this.getPlatformAwareName(child.name)); } private getPlatformAwareName(name: string): string { @@ -298,28 +276,22 @@ export class ExplorerItem { /** * Moves this element under a new parent element. */ - public move(newParent: ExplorerItem, fnBetweenStates?: (callback: () => void) => void, fnDone?: () => void): void { - if (!fnBetweenStates) { - fnBetweenStates = (cb: () => void) => { cb(); }; + move(newParent: ExplorerItem): void { + if (this._parent) { + this._parent.removeChild(this); } - - this.parent.removeChild(this); - - fnBetweenStates(() => { - newParent.removeChild(this); // make sure to remove any previous version of the file if any - newParent.addChild(this); - this.updateResource(true); - if (fnDone) { - fnDone(); - } - }); + newParent.removeChild(this); // make sure to remove any previous version of the file if any + newParent.addChild(this); + this.updateResource(true); } private updateResource(recursive: boolean): void { - this.resource = resources.joinPath(this.parent.resource, this.name); + if (this._parent) { + this.resource = resources.joinPath(this._parent.resource, this.name); + } if (recursive) { - if (this.isDirectory && this.children) { + if (this.isDirectory) { this.children.forEach(child => { child.updateResource(true); }); @@ -331,11 +303,11 @@ export class ExplorerItem { * Tells this stat that it was renamed. This requires changes to all children of this stat (if any) * so that the path property can be updated properly. */ - public rename(renamedStat: { name: string, mtime?: number }): void { + rename(renamedStat: { name: string, mtime?: number }): void { // Merge a subset of Properties that can change on rename this.updateName(renamedStat.name); - this.mtime = renamedStat.mtime; + this._mtime = renamedStat.mtime; // Update Paths including children this.updateResource(true); @@ -345,7 +317,7 @@ export class ExplorerItem { * Returns a child stat from this stat that matches with the provided path. * Will return "null" in case the child does not exist. */ - public find(resource: URI): ExplorerItem | null { + find(resource: URI): ExplorerItem | null { // Return if path found // For performance reasons try to do the comparison as fast as possible if (resource && this.resource.scheme === resource.scheme && equalsIgnoreCase(this.resource.authority, resource.authority) && @@ -361,7 +333,7 @@ export class ExplorerItem { return this; } - if (this.children) { + if (this.isDirectory) { // Ignore separtor to more easily deduct the next name to search while (index < path.length && path[index] === paths.sep) { index++; @@ -386,110 +358,3 @@ export class ExplorerItem { return null; } } - -/* A helper that can be used to show a placeholder when creating a new stat */ -export class NewStatPlaceholder extends ExplorerItem { - - public static NAME = ''; - private static ID = 0; - - private id: number; - private directoryPlaceholder: boolean; - - constructor(isDirectory: boolean, root: ExplorerItem | undefined) { - super(URI.file(''), root, false, false, false, NewStatPlaceholder.NAME); - - this.id = NewStatPlaceholder.ID++; - this.isDirectoryResolved = isDirectory; - this.directoryPlaceholder = isDirectory; - } - - public destroy(): void { - this.parent.removeChild(this); - - this.isDirectoryResolved = false; - this.isDirectory = false; - this.mtime = undefined; - } - - public getId(): string { - return `new-stat-placeholder:${this.id}:${this.parent.resource.toString()}`; - } - - public isDirectoryPlaceholder(): boolean { - return this.directoryPlaceholder; - } - - public addChild() { - throw new Error('Can\'t perform operations in NewStatPlaceholder.'); - } - - public removeChild() { - throw new Error('Can\'t perform operations in NewStatPlaceholder.'); - } - - public move() { - throw new Error('Can\'t perform operations in NewStatPlaceholder.'); - } - - public rename() { - throw new Error('Can\'t perform operations in NewStatPlaceholder.'); - } - - public find(resource: URI): ExplorerItem | null { - return null; - } - - public static addNewStatPlaceholder(parent: ExplorerItem, isDirectory: boolean): NewStatPlaceholder { - const child = new NewStatPlaceholder(isDirectory, parent.root); - - // Inherit some parent properties to child - child.parent = parent; - parent.addChild(child); - - return child; - } -} - -export class OpenEditor implements IEditorIdentifier { - - constructor(private _editor: IEditorInput, private _group: IEditorGroup) { - // noop - } - - public get editor() { - return this._editor; - } - - public get editorIndex() { - return this._group.getIndexOfEditor(this.editor); - } - - public get group() { - return this._group; - } - - public get groupId() { - return this._group.id; - } - - public getId(): string { - return `openeditor:${this.groupId}:${this.editorIndex}:${this.editor.getName()}:${this.editor.getDescription()}`; - } - - public isPreview(): boolean { - return this._group.previewEditor === this.editor; - } - - public isUntitled(): boolean { - return !!toResource(this.editor, { supportSideBySide: true, filter: Schemas.untitled }); - } - - public isDirty(): boolean { - return this.editor.isDirty(); - } - - public getResource(): URI | null { - return toResource(this.editor, { supportSideBySide: true }); - } -} diff --git a/src/vs/workbench/parts/files/common/files.ts b/src/vs/workbench/parts/files/common/files.ts index f82f13bf802..f7c25973e45 100644 --- a/src/vs/workbench/parts/files/common/files.ts +++ b/src/vs/workbench/parts/files/common/files.ts @@ -5,21 +5,23 @@ import { URI } from 'vs/base/common/uri'; import { IEditorOptions } from 'vs/editor/common/config/editorOptions'; -import { IWorkbenchEditorConfiguration } from 'vs/workbench/common/editor'; +import { IWorkbenchEditorConfiguration, IEditorIdentifier, IEditorInput, toResource } from 'vs/workbench/common/editor'; import { IFilesConfiguration, FileChangeType, IFileService } from 'vs/platform/files/common/files'; -import { ExplorerItem, OpenEditor } from 'vs/workbench/parts/files/common/explorerModel'; import { ContextKeyExpr, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { ITextModelContentProvider } from 'vs/editor/common/services/resolverService'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { ITextModel } from 'vs/editor/common/model'; +import { Event } from 'vs/base/common/event'; import { IModelService } from 'vs/editor/common/services/modelService'; import { IModeService, ILanguageSelection } from 'vs/editor/common/services/modeService'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import { IViewlet } from 'vs/workbench/common/viewlet'; import { InputFocusedContextKey } from 'vs/platform/workbench/common/contextkeys'; import { Registry } from 'vs/platform/registry/common/platform'; import { IViewContainersRegistry, Extensions as ViewContainerExtensions, ViewContainer } from 'vs/workbench/common/views'; import { Schemas } from 'vs/base/common/network'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IEditorGroup } from 'vs/workbench/services/group/common/editorGroupsService'; +import { ExplorerItem } from 'vs/workbench/parts/files/common/explorerModel'; /** * Explorer viewlet id. @@ -30,13 +32,32 @@ export const VIEWLET_ID = 'workbench.view.explorer'; */ export const VIEW_CONTAINER: ViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer(VIEWLET_ID); -export interface IExplorerViewlet extends IViewlet { - getExplorerView(): IExplorerView; +export interface IEditableData { + validationMessage: (value: string) => string; + onFinish: (value: string, success: boolean) => void; } -export interface IExplorerView { +export interface IExplorerService { + _serviceBrand: any; + readonly roots: ExplorerItem[]; + readonly sortOrder: SortOrder; + readonly onDidChangeRoots: Event; + readonly onDidChangeItem: Event; + readonly onDidChangeEditable: Event; + readonly onDidSelectItem: Event<{ item: ExplorerItem, reveal: boolean }>; + + setEditable(stat: ExplorerItem, data: IEditableData): void; + getEditableData(stat: ExplorerItem): IEditableData | undefined; + findClosest(resource: URI): ExplorerItem | null; + refresh(): void; + + /** + * Selects and reveal the file element provided by the given resource if its found in the explorer. Will try to + * resolve the path from the disk in case the explorer is not yet expanded to the file yet. + */ select(resource: URI, reveal?: boolean): Promise; } +export const IExplorerService = createDecorator('explorerService'); /** * Context Keys to use with keybindings for the Explorer and Open Editors view @@ -107,32 +128,6 @@ export interface IFileResource { isDirectory?: boolean; } -/** - * Helper to get an explorer item from an object. - */ -export function explorerItemToFileResource(obj: ExplorerItem | OpenEditor): IFileResource | null { - if (obj instanceof ExplorerItem) { - const stat = obj as ExplorerItem; - - return { - resource: stat.resource, - isDirectory: stat.isDirectory - }; - } - - if (obj instanceof OpenEditor) { - const editor = obj as OpenEditor; - const resource = editor.getResource(); - if (resource) { - return { - resource - }; - } - } - - return null; -} - export const SortOrderConfiguration = { DEFAULT: 'default', MIXED: 'mixed', @@ -210,3 +205,46 @@ export class FileOnDiskContentProvider implements ITextModelContentProvider { this.fileWatcher = dispose(this.fileWatcher); } } + +export class OpenEditor implements IEditorIdentifier { + + constructor(private _editor: IEditorInput, private _group: IEditorGroup) { + // noop + } + + public get editor() { + return this._editor; + } + + public get editorIndex() { + return this._group.getIndexOfEditor(this.editor); + } + + public get group() { + return this._group; + } + + public get groupId() { + return this._group.id; + } + + public getId(): string { + return `openeditor:${this.groupId}:${this.editorIndex}:${this.editor.getName()}:${this.editor.getDescription()}`; + } + + public isPreview(): boolean { + return this._group.previewEditor === this.editor; + } + + public isUntitled(): boolean { + return !!toResource(this.editor, { supportSideBySide: true, filter: Schemas.untitled }); + } + + public isDirty(): boolean { + return this.editor.isDirty(); + } + + public getResource(): URI | null { + return toResource(this.editor, { supportSideBySide: true }); + } +} diff --git a/src/vs/workbench/parts/files/electron-browser/explorerService.ts b/src/vs/workbench/parts/files/electron-browser/explorerService.ts new file mode 100644 index 00000000000..3e6c9da424c --- /dev/null +++ b/src/vs/workbench/parts/files/electron-browser/explorerService.ts @@ -0,0 +1,329 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event, Emitter } from 'vs/base/common/event'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { IExplorerService, IEditableData, IFilesConfiguration, SortOrder, SortOrderConfiguration } from 'vs/workbench/parts/files/common/files'; +import { ExplorerItem, ExplorerModel } from 'vs/workbench/parts/files/common/explorerModel'; +import { URI } from 'vs/base/common/uri'; +import { FileOperationEvent, FileOperation, IFileStat, IFileService, FileChangesEvent, FILES_EXCLUDE_CONFIG, FileChangeType, IResolveFileOptions } from 'vs/platform/files/common/files'; +import { dirname } from 'vs/base/common/resources'; +import { memoize } from 'vs/base/common/decorators'; +import { ResourceGlobMatcher } from 'vs/workbench/electron-browser/resources'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; +import { IExpression } from 'vs/base/common/glob'; + +function getFileEventsExcludes(configurationService: IConfigurationService, root?: URI): IExpression { + const scope = root ? { resource: root } : void 0; + const configuration = configurationService.getValue(scope); + + return (configuration && configuration.files && configuration.files.exclude) || Object.create(null); +} + +export class ExplorerService implements IExplorerService { + _serviceBrand: any; + + private static readonly EXPLORER_FILE_CHANGES_REACT_DELAY = 500; // delay in ms to react to file changes to give our internal events a chance to react first + + private _onDidChangeRoots = new Emitter(); + private _onDidChangeItem = new Emitter(); + private _onDidChangeEditable = new Emitter(); + private _onDidSelectItem = new Emitter<{ item: ExplorerItem, reveal: boolean }>(); + private disposables: IDisposable[] = []; + private editableStats = new Map(); + private _sortOrder: SortOrder; + + constructor( + @IFileService private fileService: IFileService, + @IInstantiationService private instantiationService: IInstantiationService, + @IConfigurationService private configurationService: IConfigurationService, + @IWorkspaceContextService private contextService: IWorkspaceContextService + ) { } + + get roots(): ExplorerItem[] { + return this.model.roots; + } + + get onDidChangeRoots(): Event { + return this._onDidChangeRoots.event; + } + + get onDidChangeItem(): Event { + return this._onDidChangeItem.event; + } + + get onDidChangeEditable(): Event { + return this._onDidChangeEditable.event; + } + + get onDidSelectItem(): Event<{ item: ExplorerItem, reveal: boolean }> { + return this._onDidSelectItem.event; + } + + get sortOrder(): SortOrder { + return this._sortOrder; + } + + // Memoized locals + @memoize private get fileEventsFilter(): ResourceGlobMatcher { + const fileEventsFilter = this.instantiationService.createInstance( + ResourceGlobMatcher, + (root: URI) => getFileEventsExcludes(this.configurationService, root), + (event: IConfigurationChangeEvent) => event.affectsConfiguration(FILES_EXCLUDE_CONFIG) + ); + this.disposables.push(fileEventsFilter); + + return fileEventsFilter; + } + + @memoize get model(): ExplorerModel { + const model = new ExplorerModel(this.contextService); + this.disposables.push(model); + this.disposables.push(this.fileService.onAfterOperation(e => this.onFileOperation(e))); + this.disposables.push(this.fileService.onFileChanges(e => this.onFileChanges(e))); + this.disposables.push(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue()))); + this.disposables.push(this.fileService.onDidChangeFileSystemProviderRegistrations(() => this._onDidChangeItem.fire(null))); + this.disposables.push(model.onDidChangeRoots(() => this._onDidChangeRoots.fire())); + + return model; + } + + // IExplorerService methods + + findClosest(resource: URI): ExplorerItem { + return this.model.findClosest(resource); + } + + setEditable(stat: ExplorerItem, data: IEditableData): void { + this.editableStats.set(stat, data); + this._onDidChangeEditable.fire(stat); + } + + getEditableData(stat: ExplorerItem): IEditableData | undefined { + return this.editableStats.get(stat); + } + + select(resource: URI, reveal?: boolean): Promise { + const fileStat = this.findClosest(resource); + if (fileStat) { + this._onDidSelectItem.fire({ item: fileStat, reveal }); + return Promise.resolve(null); + } + + // Stat needs to be resolved first and then revealed + const options: IResolveFileOptions = { resolveTo: [resource] }; + const workspaceFolder = this.contextService.getWorkspaceFolder(resource); + const rootUri = workspaceFolder ? workspaceFolder.uri : this.roots[0].resource; + const root = this.roots.filter(r => r.resource.toString() === rootUri.toString()).pop(); + return this.fileService.resolveFile(rootUri, options).then(stat => { + + // Convert to model + const modelStat = ExplorerItem.create(stat, null, options.resolveTo); + // Update Input with disk Stat + ExplorerItem.mergeLocalWithDisk(modelStat, root); + + // Select and Reveal + this._onDidSelectItem.fire({ item: root.find(resource), reveal }); + }, () => { + root.isError = true; + this._onDidChangeItem.fire(root); + }); + } + + refresh(): void { + this.model.roots.forEach(r => r.isDirectoryResolved = false); + this._onDidChangeItem.fire(null); + } + + // File events + + private onFileOperation(e: FileOperationEvent): void { + // Add + if (e.operation === FileOperation.CREATE || e.operation === FileOperation.COPY) { + const addedElement = e.target; + const parentResource = dirname(addedElement.resource); + const parents = this.model.findAll(parentResource); + + if (parents.length) { + + // Add the new file to its parent (Model) + parents.forEach(p => { + // We have to check if the parent is resolved #29177 + const thenable: Promise = p.isDirectoryResolved ? Promise.resolve(undefined) : this.fileService.resolveFile(p.resource); + thenable.then(stat => { + if (stat) { + const modelStat = ExplorerItem.create(stat, p.parent); + ExplorerItem.mergeLocalWithDisk(modelStat, p); + } + + const childElement = ExplorerItem.create(addedElement, p.parent); + // Make sure to remove any previous version of the file if any + p.removeChild(childElement); + p.addChild(childElement); + // Refresh the Parent (View) + this._onDidChangeItem.fire(p); + }); + }); + } + } + + // Move (including Rename) + else if (e.operation === FileOperation.MOVE) { + const oldResource = e.resource; + const newElement = e.target; + const oldParentResource = dirname(oldResource); + const newParentResource = dirname(newElement.resource); + + // Handle Rename + if (oldParentResource && newParentResource && oldParentResource.toString() === newParentResource.toString()) { + const modelElements = this.model.findAll(oldResource); + modelElements.forEach(modelElement => { + // Rename File (Model) + modelElement.rename(newElement); + this._onDidChangeItem.fire(modelElement.parent); + }); + } + + // Handle Move + else if (oldParentResource && newParentResource) { + const newParents = this.model.findAll(newParentResource); + const modelElements = this.model.findAll(oldResource); + + if (newParents.length && modelElements.length) { + // Move in Model + modelElements.forEach((modelElement, index) => { + const oldParent = modelElement.parent; + modelElement.move(newParents[index]); + this._onDidChangeItem.fire(oldParent); + this._onDidChangeItem.fire(newParents[index]); + }); + } + } + } + + // Delete + else if (e.operation === FileOperation.DELETE) { + const modelElements = this.model.findAll(e.resource); + modelElements.forEach(element => { + if (element.parent) { + const parent = element.parent; + // Remove Element from Parent (Model) + parent.removeChild(element); + // Refresh Parent (View) + this._onDidChangeItem.fire(parent); + } + }); + } + } + + private onFileChanges(e: FileChangesEvent): void { + // Check if an explorer refresh is necessary (delayed to give internal events a chance to react first) + // Note: there is no guarantee when the internal events are fired vs real ones. Code has to deal with the fact that one might + // be fired first over the other or not at all. + setTimeout(() => { + // Filter to the ones we care + e = this.filterToViewRelevantEvents(e); + const explorerItemChanged = (item: ExplorerItem) => { + item.isDirectoryResolved = false; + this._onDidChangeItem.fire(item); + }; + + // Handle added files/folders + const added = e.getAdded(); + if (added.length) { + + // Check added: Refresh if added file/folder is not part of resolved root and parent is part of it + const ignoredPaths: { [resource: string]: boolean } = <{ [resource: string]: boolean }>{}; + for (let i = 0; i < added.length; i++) { + const change = added[i]; + + // Find parent + const parent = dirname(change.resource); + + // Continue if parent was already determined as to be ignored + if (ignoredPaths[parent.toString()]) { + continue; + } + + // Compute if parent is visible and added file not yet part of it + const parentStat = this.model.findClosest(parent); + if (parentStat && parentStat.isDirectoryResolved && !this.model.findClosest(change.resource)) { + explorerItemChanged(parentStat); + } + + // Keep track of path that can be ignored for faster lookup + if (!parentStat || !parentStat.isDirectoryResolved) { + ignoredPaths[parent.toString()] = true; + } + } + } + + // Handle deleted files/folders + const deleted = e.getDeleted(); + if (deleted.length) { + + // Check deleted: Refresh if deleted file/folder part of resolved root + for (let j = 0; j < deleted.length; j++) { + const del = deleted[j]; + const item = this.model.findClosest(del.resource); + if (item) { + explorerItemChanged(item.parent); + } + } + } + + // Handle updated files/folders if we sort by modified + if (this._sortOrder === SortOrderConfiguration.MODIFIED) { + const updated = e.getUpdated(); + + // Check updated: Refresh if updated file/folder part of resolved root + for (let j = 0; j < updated.length; j++) { + const upd = updated[j]; + const item = this.model.findClosest(upd.resource); + + if (item) { + explorerItemChanged(item.parent); + } + } + } + + }, ExplorerService.EXPLORER_FILE_CHANGES_REACT_DELAY); + } + + private filterToViewRelevantEvents(e: FileChangesEvent): FileChangesEvent { + return new FileChangesEvent(e.changes.filter(change => { + if (change.type === FileChangeType.UPDATED && this._sortOrder !== SortOrderConfiguration.MODIFIED) { + return false; // we only are about updated if we sort by modified time + } + + if (!this.contextService.isInsideWorkspace(change.resource)) { + return false; // exclude changes for resources outside of workspace + } + + if (this.fileEventsFilter.matches(change.resource)) { + return false; // excluded via files.exclude setting + } + + return true; + })); + } + + private onConfigurationUpdated(configuration: IFilesConfiguration, event?: IConfigurationChangeEvent): void { + const configSortOrder = configuration && configuration.explorer && configuration.explorer.sortOrder || 'default'; + if (this._sortOrder !== configSortOrder) { + const shouldFire = this._sortOrder !== undefined; + this._sortOrder = configSortOrder; + if (shouldFire) { + this._onDidChangeRoots.fire(); + } + } + } + + dispose(): void { + dispose(this.disposables); + } +} diff --git a/src/vs/workbench/parts/files/electron-browser/explorerViewlet.ts b/src/vs/workbench/parts/files/electron-browser/explorerViewlet.ts index c35edcbf9ff..7320d44ec90 100644 --- a/src/vs/workbench/parts/files/electron-browser/explorerViewlet.ts +++ b/src/vs/workbench/parts/files/electron-browser/explorerViewlet.ts @@ -5,13 +5,11 @@ import 'vs/css!./media/explorerviewlet'; import { localize } from 'vs/nls'; -import { IActionRunner } from 'vs/base/common/actions'; import * as DOM from 'vs/base/browser/dom'; -import { VIEWLET_ID, ExplorerViewletVisibleContext, IFilesConfiguration, OpenEditorsVisibleContext, OpenEditorsVisibleCondition, IExplorerViewlet, VIEW_CONTAINER } from 'vs/workbench/parts/files/common/files'; +import { VIEWLET_ID, ExplorerViewletVisibleContext, IFilesConfiguration, OpenEditorsVisibleContext, OpenEditorsVisibleCondition, VIEW_CONTAINER } from 'vs/workbench/parts/files/common/files'; import { ViewContainerViewlet, IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; -import { ActionRunner, FileViewletState } from 'vs/workbench/parts/files/electron-browser/views/explorerViewer'; -import { ExplorerView, IExplorerViewOptions } from 'vs/workbench/parts/files/electron-browser/views/explorerView'; +import { ExplorerView } from 'vs/workbench/parts/files/electron-browser/views/explorerView'; import { EmptyView } from 'vs/workbench/parts/files/electron-browser/views/emptyView'; import { OpenEditorsView } from 'vs/workbench/parts/files/electron-browser/views/openEditorsView'; import { IStorageService } from 'vs/platform/storage/common/storage'; @@ -146,11 +144,10 @@ export class ExplorerViewletViewsContribution extends Disposable implements IWor } } -export class ExplorerViewlet extends ViewContainerViewlet implements IExplorerViewlet { +export class ExplorerViewlet extends ViewContainerViewlet { private static readonly EXPLORER_VIEWS_STATE = 'workbench.explorer.views.state'; - private fileViewletState: FileViewletState; private viewletVisibleContextKey: IContextKey; constructor( @@ -169,7 +166,6 @@ export class ExplorerViewlet extends ViewContainerViewlet implements IExplorerVi ) { super(VIEWLET_ID, ExplorerViewlet.EXPLORER_VIEWS_STATE, true, configurationService, partService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService); - this.fileViewletState = new FileViewletState(); this.viewletVisibleContextKey = ExplorerViewletVisibleContext.bindTo(contextKeyService); this._register(this.contextService.onDidChangeWorkspaceName(e => this.updateTitleArea())); @@ -217,7 +213,7 @@ export class ExplorerViewlet extends ViewContainerViewlet implements IExplorerVi }); const explorerInstantiator = this.instantiationService.createChild(new ServiceCollection([IEditorService, delegatingEditorService])); - return explorerInstantiator.createInstance(ExplorerView, { ...options, fileViewletState: this.fileViewletState }); + return explorerInstantiator.createInstance(ExplorerView, options); } return super.createView(viewDescriptor, options); } @@ -239,19 +235,8 @@ export class ExplorerViewlet extends ViewContainerViewlet implements IExplorerVi super.setVisible(visible); } - public getActionRunner(): IActionRunner { - if (!this.actionRunner) { - this.actionRunner = new ActionRunner(this.fileViewletState); - } - return this.actionRunner; - } - - public getViewletState(): FileViewletState { - return this.fileViewletState; - } - focus(): void { - const explorerView = this.getExplorerView(); + const explorerView = this.getView(ExplorerView.ID); if (explorerView && explorerView.isExpanded()) { explorerView.focus(); } else { diff --git a/src/vs/workbench/parts/files/electron-browser/fileActions.ts b/src/vs/workbench/parts/files/electron-browser/fileActions.ts index d0cab444558..918c4f35be5 100644 --- a/src/vs/workbench/parts/files/electron-browser/fileActions.ts +++ b/src/vs/workbench/parts/files/electron-browser/fileActions.ts @@ -7,28 +7,23 @@ import 'vs/css!./media/fileactions'; import * as nls from 'vs/nls'; import * as types from 'vs/base/common/types'; import { isWindows, isLinux } from 'vs/base/common/platform'; -import { sequence, ITask, always } from 'vs/base/common/async'; +import { always } from 'vs/base/common/async'; import * as paths from 'vs/base/common/paths'; import * as resources from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import * as strings from 'vs/base/common/strings'; -import { Action, IAction } from 'vs/base/common/actions'; -import { MessageType, IInputValidator } from 'vs/base/browser/ui/inputbox/inputBox'; -import { ITree, IHighlightEvent } from 'vs/base/parts/tree/browser/tree'; +import { Action } from 'vs/base/common/actions'; import { dispose, IDisposable } from 'vs/base/common/lifecycle'; -import { VIEWLET_ID } from 'vs/workbench/parts/files/common/files'; -import { ITextFileService, ITextFileOperationResult } from 'vs/workbench/services/textfile/common/textfiles'; -import { IFileService, IFileStat, AutoSaveConfiguration } from 'vs/platform/files/common/files'; +import { VIEWLET_ID, IExplorerService } from 'vs/workbench/parts/files/common/files'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { IFileService, AutoSaveConfiguration } from 'vs/platform/files/common/files'; import { toResource, IUntitledResourceInput } from 'vs/workbench/common/editor'; -import { ExplorerItem, Model, NewStatPlaceholder } from 'vs/workbench/parts/files/common/explorerModel'; -import { ExplorerView } from 'vs/workbench/parts/files/electron-browser/views/explorerView'; import { ExplorerViewlet } from 'vs/workbench/parts/files/electron-browser/explorerViewlet'; import { IUntitledEditorService } from 'vs/workbench/services/untitled/common/untitledEditorService'; -import { CollapseAction } from 'vs/workbench/browser/viewlet'; import { IQuickOpenService } from 'vs/platform/quickOpen/common/quickOpen'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; -import { IInstantiationService, ServicesAccessor, IConstructorSignature2 } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService, ServicesAccessor, IConstructorSignature1 } from 'vs/platform/instantiation/common/instantiation'; import { ITextModel } from 'vs/editor/common/model'; import { IWindowService } from 'vs/platform/windows/common/windows'; import { REVEAL_IN_EXPLORER_COMMAND_ID, SAVE_ALL_COMMAND_ID, SAVE_ALL_LABEL, SAVE_ALL_IN_GROUP_COMMAND_ID } from 'vs/workbench/parts/files/electron-browser/fileCommands'; @@ -39,26 +34,18 @@ import { IModeService } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { ICommandService, CommandsRegistry } from 'vs/platform/commands/common/commands'; import { IListService, ListWidget } from 'vs/platform/list/browser/listService'; -import { RawContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { Schemas } from 'vs/base/common/network'; -import { IDialogService, IConfirmationResult, IConfirmation, getConfirmMessage } from 'vs/platform/dialogs/common/dialogs'; +import { IDialogService, IConfirmationResult, getConfirmMessage } from 'vs/platform/dialogs/common/dialogs'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { Constants } from 'vs/editor/common/core/uint'; import { CLOSE_EDITORS_AND_GROUP_COMMAND_ID } from 'vs/workbench/browser/parts/editor/editorCommands'; import { IViewlet } from 'vs/workbench/common/viewlet'; import { coalesce } from 'vs/base/common/arrays'; - -export interface IEditableData { - action: IAction; - validator: IInputValidator; -} - -export interface IFileViewletState { - getEditableData(stat: ExplorerItem): IEditableData; - setEditable(stat: ExplorerItem, editableData: IEditableData): void; - clearEditable(stat: ExplorerItem): void; -} +import { AsyncDataTree } from 'vs/base/browser/ui/tree/asyncDataTree'; +import { ExplorerItem } from 'vs/workbench/parts/files/common/explorerModel'; +import { onUnexpectedError } from 'vs/base/common/errors'; export const NEW_FILE_COMMAND_ID = 'explorer.newFile'; export const NEW_FILE_LABEL = nls.localize('newFile', "New File"); @@ -108,338 +95,114 @@ export class BaseErrorReportingAction extends Action { } } -export class BaseFileAction extends BaseErrorReportingAction { - public element: ExplorerItem; +const PLACEHOLDER_URI = URI.file(''); + +/* New File */ +export class NewFileAction extends BaseErrorReportingAction { + static readonly ID = 'workbench.files.action.createFileFromExplorer'; + static readonly LABEL = nls.localize('createNewFile', "New File"); constructor( - id: string, - label: string, - @IFileService protected fileService: IFileService, + private getElement: () => ExplorerItem, @INotificationService notificationService: INotificationService, - @ITextFileService protected textFileService: ITextFileService + @IExplorerService private explorerService: IExplorerService, + @IFileService private fileService: IFileService, + @IEditorService private editorService: IEditorService ) { - super(id, label, notificationService); - - this.enabled = false; + super('explorer.newFile', NEW_FILE_LABEL, notificationService); + this.class = 'explorer-action new-file'; } - _isEnabled(): boolean { - return true; - } - - _updateEnablement(): void { - this.enabled = !!(this.fileService && this._isEnabled()); - } -} - -class TriggerRenameFileAction extends BaseFileAction { - - public static readonly ID = 'renameFile'; - - private tree: ITree; - private renameAction: BaseRenameAction; - - constructor( - tree: ITree, - element: ExplorerItem, - @IFileService fileService: IFileService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService, - @IInstantiationService instantiationService: IInstantiationService - ) { - super(TriggerRenameFileAction.ID, TRIGGER_RENAME_LABEL, fileService, notificationService, textFileService); - - this.tree = tree; - this.element = element; - this.renameAction = instantiationService.createInstance(RenameFileAction, element); - this._updateEnablement(); - } - - public validateFileName(name: string): string { - const names: string[] = coalesce(name.split(/[\\/]/)); - if (names.length > 1) { // error only occurs on multi-path - const comparer = isLinux ? strings.compare : strings.compareIgnoreCase; - if (comparer(names[0], this.element.name) === 0) { - return nls.localize('renameWhenSourcePathIsParentOfTargetError', "Please use the 'New Folder' or 'New File' command to add children to an existing folder"); - } - } - - return this.renameAction.validateFileName(this.element.parent, name); - } - - public run(context?: any): Promise { - if (!context) { - return Promise.reject(new Error('No context provided to BaseEnableFileRenameAction.')); - } - - const viewletState = context.viewletState; - if (!viewletState) { - return Promise.reject(new Error('Invalid viewlet state provided to BaseEnableFileRenameAction.')); - } - - const stat = context.stat; - if (!stat) { - return Promise.reject(new Error('Invalid stat provided to BaseEnableFileRenameAction.')); - } - - viewletState.setEditable(stat, { - action: this.renameAction, - validator: (value) => { - const message = this.validateFileName(value); - - if (!message) { - return null; - } - - return { - content: message, - formatContent: true, - type: MessageType.ERROR - }; - } - }); - - this.tree.refresh(stat, false).then(() => { - this.tree.setHighlight(stat); - - const unbind = this.tree.onDidChangeHighlight((e: IHighlightEvent) => { - if (!e.highlight) { - viewletState.clearEditable(stat); - this.tree.refresh(stat); - unbind.dispose(); - } - }); - }); - - return undefined; - } -} - -export abstract class BaseRenameAction extends BaseFileAction { - - constructor( - id: string, - label: string, - element: ExplorerItem, - @IFileService fileService: IFileService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super(id, label, fileService, notificationService, textFileService); - - this.element = element; - } - - _isEnabled(): boolean { - return super._isEnabled() && this.element && !this.element.isReadonly; - } - - public run(context?: any): Promise { - if (!context) { - return Promise.reject(new Error('No context provided to BaseRenameFileAction.')); - } - - let name = context.value; - if (!name) { - return Promise.reject(new Error('No new name provided to BaseRenameFileAction.')); - } - - // Automatically trim whitespaces and trailing dots to produce nice file names - name = getWellFormedFileName(name); - const existingName = getWellFormedFileName(this.element.name); - - // Return early if name is invalid or didn't change - if (name === existingName || this.validateFileName(this.element.parent, name)) { - return Promise.resolve(null); - } - - // Call function and Emit Event through viewer - const promise = this.runAction(name).then(undefined, (error: any) => { - this.onError(error); - }); - - return promise; - } - - public validateFileName(parent: ExplorerItem, name: string): string { - let source = this.element.name; - let target = name; - - if (!isLinux) { // allow rename of same file also when case differs (e.g. Game.js => game.js) - source = source.toLowerCase(); - target = target.toLowerCase(); - } - - if (getWellFormedFileName(source) === getWellFormedFileName(target)) { - return null; - } - - return validateFileName(parent, name); - } - - public abstract runAction(newName: string): Promise; -} - -class RenameFileAction extends BaseRenameAction { - - public static readonly ID = 'workbench.files.action.renameFile'; - - constructor( - element: ExplorerItem, - @IFileService fileService: IFileService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super(RenameFileAction.ID, nls.localize('rename', "Rename"), element, fileService, notificationService, textFileService); - - this._updateEnablement(); - } - - public runAction(newName: string): Promise { - const parentResource = this.element.parent.resource; - const targetResource = resources.joinPath(parentResource, newName); - - return this.textFileService.move(this.element.resource, targetResource); - } -} - -/* Base New File/Folder Action */ -export class BaseNewAction extends BaseFileAction { - private presetFolder: ExplorerItem; - private tree: ITree; - private isFile: boolean; - private renameAction: BaseRenameAction; - - constructor( - id: string, - label: string, - tree: ITree, - isFile: boolean, - editableAction: BaseRenameAction, - element: ExplorerItem, - @IFileService fileService: IFileService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super(id, label, fileService, notificationService, textFileService); - + run(): Promise { + let folder: ExplorerItem; + const element = this.getElement(); if (element) { - this.presetFolder = element.isDirectory ? element : element.parent; + folder = element.isDirectory ? element : element.parent; + } else { + folder = this.explorerService.roots[0]; } - this.tree = tree; - this.isFile = isFile; - this.renameAction = editableAction; - } - - public run(context?: any): Promise { - if (!context) { - return Promise.reject(new Error('No context provided to BaseNewAction.')); - } - - const viewletState = context.viewletState; - if (!viewletState) { - return Promise.reject(new Error('Invalid viewlet state provided to BaseNewAction.')); - } - - let folder = this.presetFolder; - if (!folder) { - const focus = this.tree.getFocus(); - if (focus) { - folder = focus.isDirectory ? focus : focus.parent; - } else { - const input: ExplorerItem | Model = this.tree.getInput(); - folder = input instanceof Model ? input.roots[0] : input; - } - } - - if (!folder) { - return Promise.reject(new Error('Invalid parent folder to create.')); - } if (folder.isReadonly) { return Promise.reject(new Error('Parent folder is readonly.')); } - if (!!folder.getChild(NewStatPlaceholder.NAME)) { - // Do not allow to creatae a new file/folder while in the process of creating a new file/folder #47606 - return Promise.resolve(new Error('Parent folder is already in the process of creating a file')); - } - return this.tree.reveal(folder, 0.5).then(() => { - return this.tree.expand(folder).then(() => { - const stat = NewStatPlaceholder.addNewStatPlaceholder(folder, !this.isFile); + const stat = new ExplorerItem(PLACEHOLDER_URI, folder, false); + folder.addChild(stat); - this.renameAction.element = stat; - - viewletState.setEditable(stat, { - action: this.renameAction, - validator: (value) => { - const message = this.renameAction.validateFileName(folder, value); - - if (!message) { - return null; - } - - return { - content: message, - formatContent: true, - type: MessageType.ERROR - }; - } - }); - - return this.tree.refresh(folder).then(() => { - return this.tree.expand(folder).then(() => { - return this.tree.reveal(stat, 0.5).then(() => { - this.tree.setHighlight(stat); - - const unbind = this.tree.onDidChangeHighlight((e: IHighlightEvent) => { - if (!e.highlight) { - stat.destroy(); - this.tree.refresh(folder); - unbind.dispose(); - } - }); - }); - }); - }); + const onSuccess = value => { + return this.fileService.createFile(resources.joinPath(folder.resource, value)).then(stat => { + return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); + }, (error) => { + this.onErrorWithRetry(error, () => onSuccess(value)); }); + }; + + this.explorerService.setEditable(stat, { + validationMessage: value => validateFileName(stat, value), + onFinish: (value, success) => { + folder.removeChild(stat); + this.explorerService.setEditable(stat, null); + if (success) { + onSuccess(value); + } + } }); - } -} -/* New File */ -export class NewFileAction extends BaseNewAction { - - constructor( - tree: ITree, - element: ExplorerItem, - @IFileService fileService: IFileService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService, - @IInstantiationService instantiationService: IInstantiationService - ) { - super('explorer.newFile', NEW_FILE_LABEL, tree, true, instantiationService.createInstance(CreateFileAction, element), null, fileService, notificationService, textFileService); - - this.class = 'explorer-action new-file'; - this._updateEnablement(); + return Promise.resolve(null); } } /* New Folder */ -export class NewFolderAction extends BaseNewAction { +export class NewFolderAction extends BaseErrorReportingAction { + static readonly ID = 'workbench.files.action.createFolderFromExplorer'; + static readonly LABEL = nls.localize('createNewFolder', "New Folder"); constructor( - tree: ITree, - element: ExplorerItem, - @IFileService fileService: IFileService, + private getElement: () => ExplorerItem, @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService, - @IInstantiationService instantiationService: IInstantiationService + @IFileService private fileService: IFileService, + @IExplorerService private explorerService: IExplorerService ) { - super('explorer.newFolder', NEW_FOLDER_LABEL, tree, false, instantiationService.createInstance(CreateFolderAction, element), null, fileService, notificationService, textFileService); - + super('explorer.newFolder', NEW_FOLDER_LABEL, notificationService); this.class = 'explorer-action new-folder'; - this._updateEnablement(); + } + + run(): Promise { + let folder: ExplorerItem; + const element = this.getElement(); + if (element) { + folder = element.isDirectory ? element : element.parent; + } else { + folder = this.explorerService.roots[0]; + } + + if (folder.isReadonly) { + return Promise.reject(new Error('Parent folder is readonly.')); + } + + const stat = new ExplorerItem(PLACEHOLDER_URI, folder, true); + folder.addChild(stat); + + const onSuccess = value => { + return this.fileService.createFolder(resources.joinPath(folder.resource, value)).then(stat => { + return this.explorerService.select(stat.resource, true); + }, (error) => { + this.onErrorWithRetry(error, () => onSuccess(value)); + }); + }; + + this.explorerService.setEditable(stat, { + validationMessage: value => validateFileName(stat, value), + onFinish: (value, success) => { + folder.removeChild(stat); + this.explorerService.setEditable(stat, null); + if (success) { + onSuccess(value); + } + } + }); + + return Promise.resolve(null); } } @@ -461,106 +224,29 @@ export class GlobalNewUntitledFileAction extends Action { } } -/* Create New File/Folder (only used internally by explorerViewer) */ -export abstract class BaseCreateAction extends BaseRenameAction { - - public validateFileName(parent: ExplorerItem, name: string): string { - if (this.element instanceof NewStatPlaceholder) { - return validateFileName(parent, name); - } - - return super.validateFileName(parent, name); - } -} - -/* Create New File (only used internally by explorerViewer) */ -class CreateFileAction extends BaseCreateAction { - - public static readonly ID = 'workbench.files.action.createFileFromExplorer'; - public static readonly LABEL = nls.localize('createNewFile', "New File"); - - constructor( - element: ExplorerItem, - @IFileService fileService: IFileService, - @IEditorService private readonly editorService: IEditorService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super(CreateFileAction.ID, CreateFileAction.LABEL, element, fileService, notificationService, textFileService); - - this._updateEnablement(); - } - - public runAction(fileName: string): Promise { - const resource = this.element.parent.resource; - return this.fileService.createFile(resources.joinPath(resource, fileName)).then(stat => { - return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); - }, (error) => { - this.onErrorWithRetry(error, () => this.runAction(fileName)); - }); - } -} - -/* Create New Folder (only used internally by explorerViewer) */ -class CreateFolderAction extends BaseCreateAction { - - public static readonly ID = 'workbench.files.action.createFolderFromExplorer'; - public static readonly LABEL = nls.localize('createNewFolder', "New Folder"); - - constructor( - element: ExplorerItem, - @IFileService fileService: IFileService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super(CreateFolderAction.ID, CreateFolderAction.LABEL, null, fileService, notificationService, textFileService); - - this._updateEnablement(); - } - - public runAction(fileName: string): Promise { - const resource = this.element.parent.resource; - return this.fileService.createFolder(resources.joinPath(resource, fileName)).then(undefined, (error) => { - this.onErrorWithRetry(error, () => this.runAction(fileName)); - }); - } -} - -class BaseDeleteFileAction extends BaseFileAction { +class BaseDeleteFileAction extends BaseErrorReportingAction { private static readonly CONFIRM_DELETE_SETTING_KEY = 'explorer.confirmDelete'; private skipConfirm: boolean; constructor( - private tree: ITree, private elements: ExplorerItem[], private useTrash: boolean, - @IFileService fileService: IFileService, + @IFileService private readonly fileService: IFileService, @INotificationService notificationService: INotificationService, @IDialogService private readonly dialogService: IDialogService, - @ITextFileService textFileService: ITextFileService, + @ITextFileService private readonly textFileService: ITextFileService, @IConfigurationService private readonly configurationService: IConfigurationService ) { - super('moveFileToTrash', MOVE_FILE_TO_TRASH_LABEL, fileService, notificationService, textFileService); + super('moveFileToTrash', MOVE_FILE_TO_TRASH_LABEL, notificationService); - this.tree = tree; this.useTrash = useTrash && elements.every(e => !paths.isUNC(e.resource.fsPath)); // on UNC shares there is no trash - - this._updateEnablement(); - } - - _isEnabled(): boolean { - return super._isEnabled() && this.elements && this.elements.every(e => !e.isReadonly); + this.enabled = this.elements && this.elements.every(e => !e.isReadonly); } public run(): Promise { - // Remove highlight - if (this.tree) { - this.tree.clearHighlight(); - } - let primaryButton: string; if (this.useTrash) { primaryButton = isWindows ? nls.localize('deleteButtonLabelRecycleBin', "&&Move to Recycle Bin") : nls.localize({ key: 'deleteButtonLabelTrash', comment: ['&& denotes a mnemonic'] }, "&&Move to Trash"); @@ -657,48 +343,41 @@ class BaseDeleteFileAction extends BaseFileAction { } // Call function - const servicePromise = Promise.all(distinctElements.map(e => this.fileService.del(e.resource, { useTrash: this.useTrash, recursive: true }))).then(() => { - if (distinctElements[0].parent) { - this.tree.setFocus(distinctElements[0].parent); // move focus to parent - } - }, (error: any) => { - - // Handle error to delete file(s) from a modal confirmation dialog - let errorMessage: string; - let detailMessage: string; - let primaryButton: string; - if (this.useTrash) { - errorMessage = isWindows ? nls.localize('binFailed', "Failed to delete using the Recycle Bin. Do you want to permanently delete instead?") : nls.localize('trashFailed', "Failed to delete using the Trash. Do you want to permanently delete instead?"); - detailMessage = nls.localize('irreversible', "This action is irreversible!"); - primaryButton = nls.localize({ key: 'deletePermanentlyButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Delete Permanently"); - } else { - errorMessage = toErrorMessage(error, false); - primaryButton = nls.localize({ key: 'retryButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Retry"); - } - - return this.dialogService.confirm({ - message: errorMessage, - detail: detailMessage, - type: 'warning', - primaryButton - }).then(res => { - - // Focus back to tree - this.tree.domFocus(); - - if (res.confirmed) { - if (this.useTrash) { - this.useTrash = false; // Delete Permanently - } - - this.skipConfirm = true; - - return this.run(); + const servicePromise = Promise.all(distinctElements.map(e => this.fileService.del(e.resource, { useTrash: this.useTrash, recursive: true }))) + .then(undefined, (error: any) => { + // Handle error to delete file(s) from a modal confirmation dialog + let errorMessage: string; + let detailMessage: string; + let primaryButton: string; + if (this.useTrash) { + errorMessage = isWindows ? nls.localize('binFailed', "Failed to delete using the Recycle Bin. Do you want to permanently delete instead?") : nls.localize('trashFailed', "Failed to delete using the Trash. Do you want to permanently delete instead?"); + detailMessage = nls.localize('irreversible', "This action is irreversible!"); + primaryButton = nls.localize({ key: 'deletePermanentlyButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Delete Permanently"); + } else { + errorMessage = toErrorMessage(error, false); + primaryButton = nls.localize({ key: 'retryButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Retry"); } - return Promise.resolve(undefined); + return this.dialogService.confirm({ + message: errorMessage, + detail: detailMessage, + type: 'warning', + primaryButton + }).then(res => { + + if (res.confirmed) { + if (this.useTrash) { + this.useTrash = false; // Delete Permanently + } + + this.skipConfirm = true; + + return this.run(); + } + + return Promise.resolve(undefined); + }); }); - }); return servicePromise; }); @@ -754,140 +433,15 @@ class BaseDeleteFileAction extends BaseFileAction { } } -/* Add File */ -export class AddFilesAction extends BaseFileAction { - - private tree: ITree; - - constructor( - tree: ITree, - element: ExplorerItem, - clazz: string, - @IFileService fileService: IFileService, - @IEditorService private readonly editorService: IEditorService, - @IDialogService private readonly dialogService: IDialogService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super('workbench.files.action.addFile', nls.localize('addFiles', "Add Files"), fileService, notificationService, textFileService); - - this.tree = tree; - this.element = element; - - if (clazz) { - this.class = clazz; - } - - this._updateEnablement(); - } - - public run(resourcesToAdd: URI[]): Promise { - const addPromise = Promise.resolve(null).then(() => { - if (resourcesToAdd && resourcesToAdd.length > 0) { - - // Find parent to add to - let targetElement: ExplorerItem; - if (this.element) { - targetElement = this.element; - } else { - const input: ExplorerItem | Model = this.tree.getInput(); - targetElement = this.tree.getFocus() || (input instanceof Model ? input.roots[0] : input); - } - - if (!targetElement.isDirectory) { - targetElement = targetElement.parent; - } - - // Resolve target to check for name collisions and ask user - return this.fileService.resolveFile(targetElement.resource).then((targetStat: IFileStat) => { - - // Check for name collisions - const targetNames = new Set(); - targetStat.children.forEach((child) => { - targetNames.add(isLinux ? child.name : child.name.toLowerCase()); - }); - - let overwritePromise: Promise = Promise.resolve({ confirmed: true }); - if (resourcesToAdd.some(resource => { - return targetNames.has(!resources.hasToIgnoreCase(resource) ? resources.basename(resource) : resources.basename(resource).toLowerCase()); - })) { - const confirm: IConfirmation = { - message: nls.localize('confirmOverwrite', "A file or folder with the same name already exists in the destination folder. Do you want to replace it?"), - detail: nls.localize('irreversible', "This action is irreversible!"), - primaryButton: nls.localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), - type: 'warning' - }; - - overwritePromise = this.dialogService.confirm(confirm); - } - - return overwritePromise.then(res => { - if (!res.confirmed) { - return undefined; - } - - // Run add in sequence - const addPromisesFactory: ITask>[] = []; - resourcesToAdd.forEach(resource => { - addPromisesFactory.push(() => { - const sourceFile = resource; - const targetFile = resources.joinPath(targetElement.resource, resources.basename(sourceFile)); - - // if the target exists and is dirty, make sure to revert it. otherwise the dirty contents - // of the target file would replace the contents of the added file. since we already - // confirmed the overwrite before, this is OK. - let revertPromise: Promise = Promise.resolve(null); - if (this.textFileService.isDirty(targetFile)) { - revertPromise = this.textFileService.revertAll([targetFile], { soft: true }); - } - - return revertPromise.then(() => { - const target = resources.joinPath(targetElement.resource, resources.basename(sourceFile)); - return this.fileService.copyFile(sourceFile, target, true).then(stat => { - - // if we only add one file, just open it directly - if (resourcesToAdd.length === 1) { - this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); - } - }, error => this.onError(error)); - }); - }); - }); - - return sequence(addPromisesFactory); - }); - }); - } - - return undefined; - }); - - return addPromise.then(() => { - this.tree.clearHighlight(); - }, (error: any) => { - this.onError(error); - this.tree.clearHighlight(); - }); - } -} - // Copy File/Folder -class CopyFileAction extends BaseFileAction { +class CopyFileAction extends BaseErrorReportingAction { - private tree: ITree; constructor( - tree: ITree, private elements: ExplorerItem[], - @IFileService fileService: IFileService, @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService, - @IContextKeyService contextKeyService: IContextKeyService, @IClipboardService private readonly clipboardService: IClipboardService ) { - super('filesExplorer.copy', COPY_FILE_LABEL, fileService, notificationService, textFileService); - - this.tree = tree; - this._updateEnablement(); + super('filesExplorer.copy', COPY_FILE_LABEL, notificationService); } public run(): Promise { @@ -895,41 +449,27 @@ class CopyFileAction extends BaseFileAction { // Write to clipboard as file/folder to copy this.clipboardService.writeResources(this.elements.map(e => e.resource)); - // Remove highlight - if (this.tree) { - this.tree.clearHighlight(); - } - - this.tree.domFocus(); - return Promise.resolve(null); } } // Paste File/Folder -class PasteFileAction extends BaseFileAction { +class PasteFileAction extends BaseErrorReportingAction { public static readonly ID = 'filesExplorer.paste'; - private tree: ITree; - constructor( - tree: ITree, - element: ExplorerItem, - @IFileService fileService: IFileService, + private element: ExplorerItem, + @IFileService private fileService: IFileService, @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService, - @IEditorService private readonly editorService: IEditorService + @IEditorService private readonly editorService: IEditorService, + @IExplorerService private readonly explorerService: IExplorerService ) { - super(PasteFileAction.ID, PASTE_FILE_LABEL, fileService, notificationService, textFileService); + super(PasteFileAction.ID, PASTE_FILE_LABEL, notificationService); - this.tree = tree; - this.element = element; if (!this.element) { - const input: ExplorerItem | Model = this.tree.getInput(); - this.element = input instanceof Model ? input.roots[0] : input; + this.element = this.explorerService.roots[0]; } - this._updateEnablement(); } public run(fileToPaste: URI): Promise { @@ -941,11 +481,6 @@ class PasteFileAction extends BaseFileAction { return this.fileService.resolveFile(fileToPaste).then(fileToPasteStat => { - // Remove highlight - if (this.tree) { - this.tree.clearHighlight(); - } - // Find target let target: ExplorerItem; if (this.element.resource.toString() === fileToPaste.toString()) { @@ -959,12 +494,10 @@ class PasteFileAction extends BaseFileAction { // Copy File return this.fileService.copyFile(fileToPaste, targetFile).then(stat => { if (!stat.isDirectory) { - return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); + return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true, preserveFocus: true } }); } return undefined; - }, error => this.onError(error)).then(() => { - this.tree.domFocus(); }); }, error => { this.onError(new Error(nls.localize('fileDeleted', "File to paste was deleted or moved meanwhile"))); @@ -972,49 +505,7 @@ class PasteFileAction extends BaseFileAction { } } -// Duplicate File/Folder -export class DuplicateFileAction extends BaseFileAction { - private tree: ITree; - private target: ExplorerItem; - - constructor( - tree: ITree, - fileToDuplicate: ExplorerItem, - target: ExplorerItem, - @IFileService fileService: IFileService, - @IEditorService private readonly editorService: IEditorService, - @INotificationService notificationService: INotificationService, - @ITextFileService textFileService: ITextFileService - ) { - super('workbench.files.action.duplicateFile', nls.localize('duplicateFile', "Duplicate"), fileService, notificationService, textFileService); - - this.tree = tree; - this.element = fileToDuplicate; - this.target = (target && target.isDirectory) ? target : fileToDuplicate.parent; - this._updateEnablement(); - } - - public run(): Promise { - - // Remove highlight - if (this.tree) { - this.tree.clearHighlight(); - } - - // Copy File - const result = this.fileService.copyFile(this.element.resource, findValidPasteFileTarget(this.target, { resource: this.element.resource, isDirectory: this.element.isDirectory })).then(stat => { - if (!stat.isDirectory) { - return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); - } - - return undefined; - }, error => this.onError(error)); - - return result; - } -} - -function findValidPasteFileTarget(targetFolder: ExplorerItem, fileToPaste: { resource: URI, isDirectory?: boolean }): URI { +export function findValidPasteFileTarget(targetFolder: ExplorerItem, fileToPaste: { resource: URI, isDirectory?: boolean }): URI { let name = resources.basenameOrAuthority(fileToPaste.resource); let candidate = resources.joinPath(targetFolder.resource, name); @@ -1150,14 +641,6 @@ export class GlobalCompareResourcesAction extends Action { } } -// Refresh Explorer Viewer -export class RefreshViewExplorerAction extends Action { - - constructor(explorerView: ExplorerView, clazz: string) { - super('workbench.files.action.refreshFilesExplorer', nls.localize('refresh', "Refresh"), clazz, true, (context: any) => explorerView.refresh()); - } -} - export class ToggleAutoSaveAction extends Action { public static readonly ID = 'workbench.action.toggleAutoSave'; public static readonly LABEL = nls.localize('toggleAutoSave', "Toggle Auto Save"); @@ -1310,13 +793,7 @@ export class FocusFilesExplorer extends Action { } public run(): Promise { - return this.viewletService.openViewlet(VIEWLET_ID, true).then((viewlet: ExplorerViewlet) => { - const view = viewlet.getExplorerView(); - if (view) { - view.setExpanded(true); - view.getViewer().domFocus(); - } - }); + return this.viewletService.openViewlet(VIEWLET_ID, true); } } @@ -1364,12 +841,7 @@ export class CollapseExplorerView extends Action { return this.viewletService.openViewlet(VIEWLET_ID, true).then((viewlet: ExplorerViewlet) => { const explorerView = viewlet.getExplorerView(); if (explorerView) { - const viewer = explorerView.getViewer(); - if (viewer) { - const action = new CollapseAction(viewer, true, null); - action.run(); - action.dispose(); - } + explorerView.collapseAll(); } }); } @@ -1383,18 +855,16 @@ export class RefreshExplorerView extends Action { constructor( id: string, label: string, - @IViewletService private readonly viewletService: IViewletService + @IViewletService private readonly viewletService: IViewletService, + @IExplorerService private readonly explorerService: IExplorerService ) { - super(id, label); + super(id, label, 'explorer-action refresh-explorer'); } public run(): Promise { - return this.viewletService.openViewlet(VIEWLET_ID, true).then((viewlet: ExplorerViewlet) => { - const explorerView = viewlet.getExplorerView(); - if (explorerView) { - explorerView.refresh(); - } - }); + return this.viewletService.openViewlet(VIEWLET_ID, true).then(() => + this.explorerService.refresh() + ); } } @@ -1425,8 +895,7 @@ export class ShowOpenedFileInNewWindow extends Action { } } -export function validateFileName(parent: ExplorerItem, name: string): string { - +export function validateFileName(item: ExplorerItem, name: string): string { // Produce a well formed file name name = getWellFormedFileName(name); @@ -1441,11 +910,14 @@ export function validateFileName(parent: ExplorerItem, name: string): string { } const names = coalesce(name.split(/[\\/]/)); + const parent = item.parent; - // Do not allow to overwrite existing file - const childExists = !!parent.getChild(name); - if (childExists) { - return nls.localize('fileNameExistsError', "A file or folder **{0}** already exists at this location. Please choose a different name.", name); + if (name !== item.name) { + // Do not allow to overwrite existing file + const childExists = parent && !!parent.getChild(name); + if (childExists) { + return nls.localize('fileNameExistsError', "A file or folder **{0}** already exists at this location. Please choose a different name.", name); + } } // Invalid File name @@ -1455,7 +927,7 @@ export function validateFileName(parent: ExplorerItem, name: string): string { // Max length restriction (on Windows) if (isWindows) { - const fullPathLength = name.length + parent.resource.fsPath.length + 1 /* path segment */; + const fullPathLength = item.resource.fsPath.length + 1 /* path segment */; if (fullPathLength > 255) { return nls.localize('filePathTooLongError', "The name **{0}** results in a path that is too long. Please choose a shorter name.", trimLongName(name)); } @@ -1553,24 +1025,24 @@ class ClipboardContentProvider implements ITextModelContentProvider { } interface IExplorerContext { - viewletState: IFileViewletState; stat: ExplorerItem; selection: ExplorerItem[]; } -function getContext(listWidget: ListWidget, viewletService: IViewletService): IExplorerContext { +function getContext(listWidget: ListWidget): IExplorerContext { // These commands can only be triggered when explorer viewlet is visible so get it using the active viewlet - const tree = listWidget; - const stat = tree.getFocus(); + const tree = >listWidget; + const focus = tree.getFocus(); + const stat = focus.length ? focus[0] : undefined; const selection = tree.getSelection(); // Only respect the selection if user clicked inside it (focus belongs to it) - return { stat, selection: selection && selection.indexOf(stat) >= 0 ? selection : [], viewletState: (viewletService.getActiveViewlet()).getViewletState() }; + return { stat, selection: selection && selection.indexOf(stat) >= 0 ? selection : [] }; } // TODO@isidor these commands are calling into actions due to the complex inheritance action structure. // It should be the other way around, that actions call into commands. -function openExplorerAndRunAction(accessor: ServicesAccessor, constructor: IConstructorSignature2): Promise { +function openExplorerAndRunAction(accessor: ServicesAccessor, constructor: IConstructorSignature1<() => ExplorerItem, Action>): Promise { const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); const viewletService = accessor.get(IViewletService); @@ -1584,10 +1056,10 @@ function openExplorerAndRunAction(accessor: ServicesAccessor, constructor: ICons const explorerView = explorer.getExplorerView(); if (explorerView && explorerView.isBodyVisible()) { explorerView.focus(); - const explorerContext = getContext(listService.lastFocusedList, viewletService); - const action = instantationService.createInstance(constructor, listService.lastFocusedList, explorerContext.stat); + const { stat } = getContext(listService.lastFocusedList); + const action = instantationService.createInstance(constructor, () => stat); - return action.run(explorerContext); + return action.run(); } return undefined; @@ -1609,41 +1081,51 @@ CommandsRegistry.registerCommand({ }); export const renameHandler = (accessor: ServicesAccessor) => { - const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); - const explorerContext = getContext(listService.lastFocusedList, accessor.get(IViewletService)); + const explorerService = accessor.get(IExplorerService); + const textFileService = accessor.get(ITextFileService); + const { stat } = getContext(listService.lastFocusedList); - const renameAction = instantationService.createInstance(TriggerRenameFileAction, listService.lastFocusedList, explorerContext.stat); - return renameAction.run(explorerContext); + explorerService.setEditable(stat, { + validationMessage: value => validateFileName(stat, value), + onFinish: (value, success) => { + if (success) { + const parentResource = stat.parent.resource; + const targetResource = resources.joinPath(parentResource, value); + textFileService.move(stat.resource, targetResource).then(undefined, onUnexpectedError); + } + explorerService.setEditable(stat, null); + } + }); }; export const moveFileToTrashHandler = (accessor: ServicesAccessor) => { const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); - const explorerContext = getContext(listService.lastFocusedList, accessor.get(IViewletService)); + const explorerContext = getContext(listService.lastFocusedList); const stats = explorerContext.selection.length > 1 ? explorerContext.selection : [explorerContext.stat]; - const moveFileToTrashAction = instantationService.createInstance(BaseDeleteFileAction, listService.lastFocusedList, stats, true); + const moveFileToTrashAction = instantationService.createInstance(BaseDeleteFileAction, stats, true); return moveFileToTrashAction.run(); }; export const deleteFileHandler = (accessor: ServicesAccessor) => { const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); - const explorerContext = getContext(listService.lastFocusedList, accessor.get(IViewletService)); + const explorerContext = getContext(listService.lastFocusedList); const stats = explorerContext.selection.length > 1 ? explorerContext.selection : [explorerContext.stat]; - const deleteFileAction = instantationService.createInstance(BaseDeleteFileAction, listService.lastFocusedList, stats, false); + const deleteFileAction = instantationService.createInstance(BaseDeleteFileAction, stats, false); return deleteFileAction.run(); }; export const copyFileHandler = (accessor: ServicesAccessor) => { const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); - const explorerContext = getContext(listService.lastFocusedList, accessor.get(IViewletService)); + const explorerContext = getContext(listService.lastFocusedList); const stats = explorerContext.selection.length > 1 ? explorerContext.selection : [explorerContext.stat]; - const copyFileAction = instantationService.createInstance(CopyFileAction, listService.lastFocusedList, stats); + const copyFileAction = instantationService.createInstance(CopyFileAction, stats); return copyFileAction.run(); }; @@ -1651,10 +1133,10 @@ export const pasteFileHandler = (accessor: ServicesAccessor) => { const instantationService = accessor.get(IInstantiationService); const listService = accessor.get(IListService); const clipboardService = accessor.get(IClipboardService); - const explorerContext = getContext(listService.lastFocusedList, accessor.get(IViewletService)); + const explorerContext = getContext(listService.lastFocusedList); return Promise.all(resources.distinctParents(clipboardService.readResources(), r => r).map(toCopy => { - const pasteFileAction = instantationService.createInstance(PasteFileAction, listService.lastFocusedList, explorerContext.stat); + const pasteFileAction = instantationService.createInstance(PasteFileAction, explorerContext.stat); return pasteFileAction.run(toCopy); })); }; diff --git a/src/vs/workbench/parts/files/electron-browser/fileCommands.ts b/src/vs/workbench/parts/files/electron-browser/fileCommands.ts index c03f7b4a3be..41aea5e0b17 100644 --- a/src/vs/workbench/parts/files/electron-browser/fileCommands.ts +++ b/src/vs/workbench/parts/files/electron-browser/fileCommands.ts @@ -11,7 +11,7 @@ import { IWindowsService, IWindowService } from 'vs/platform/windows/common/wind import { ServicesAccessor, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { ExplorerFocusCondition, FileOnDiskContentProvider, VIEWLET_ID } from 'vs/workbench/parts/files/common/files'; +import { ExplorerFocusCondition, FileOnDiskContentProvider, VIEWLET_ID, IExplorerService } from 'vs/workbench/parts/files/common/files'; import { ExplorerViewlet } from 'vs/workbench/parts/files/electron-browser/explorerViewlet'; import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; import { ITextFileService, ISaveOptions } from 'vs/workbench/services/textfile/common/textfiles'; @@ -39,6 +39,7 @@ import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService'; import { IEditorGroupsService } from 'vs/workbench/services/group/common/editorGroupsService'; import { ILabelService } from 'vs/platform/label/common/label'; +import { onUnexpectedError } from 'vs/base/common/errors'; // Commands @@ -463,6 +464,7 @@ CommandsRegistry.registerCommand({ handler: (accessor, resource: URI | object) => { const viewletService = accessor.get(IViewletService); const contextService = accessor.get(IWorkspaceContextService); + const explorerService = accessor.get(IExplorerService); const uri = getResourceForCommand(resource, accessor.get(IListService), accessor.get(IEditorService)); viewletService.openViewlet(VIEWLET_ID, false).then((viewlet: ExplorerViewlet) => { @@ -471,7 +473,7 @@ CommandsRegistry.registerCommand({ const explorerView = viewlet.getExplorerView(); if (explorerView) { explorerView.setExpanded(true); - explorerView.select(uri, true); + explorerService.select(uri, true).then(undefined, onUnexpectedError); } } else { const openEditorsView = viewlet.getOpenEditorsView(); diff --git a/src/vs/workbench/parts/files/electron-browser/files.contribution.ts b/src/vs/workbench/parts/files/electron-browser/files.contribution.ts index f857f474d90..b325009c33e 100644 --- a/src/vs/workbench/parts/files/electron-browser/files.contribution.ts +++ b/src/vs/workbench/parts/files/electron-browser/files.contribution.ts @@ -13,7 +13,7 @@ import { IWorkbenchActionRegistry, Extensions as ActionExtensions } from 'vs/wor import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { IEditorInputFactory, EditorInput, IFileEditorInput, IEditorInputFactoryRegistry, Extensions as EditorInputExtensions } from 'vs/workbench/common/editor'; import { AutoSaveConfiguration, HotExitConfiguration, SUPPORTED_ENCODINGS } from 'vs/platform/files/common/files'; -import { VIEWLET_ID, SortOrderConfiguration, FILE_EDITOR_INPUT_ID } from 'vs/workbench/parts/files/common/files'; +import { VIEWLET_ID, SortOrderConfiguration, FILE_EDITOR_INPUT_ID, IExplorerService } from 'vs/workbench/parts/files/common/files'; import { FileEditorTracker } from 'vs/workbench/parts/files/browser/editors/fileEditorTracker'; import { SaveErrorHandler } from 'vs/workbench/parts/files/electron-browser/saveErrorHandler'; import { FileEditorInput } from 'vs/workbench/parts/files/common/editors/fileEditorInput'; @@ -35,6 +35,8 @@ import { IEditorGroupsService } from 'vs/workbench/services/group/common/editorG import { ILabelService } from 'vs/platform/label/common/label'; import { nativeSep } from 'vs/base/common/paths'; import { IPartService } from 'vs/workbench/services/part/common/partService'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { ExplorerService } from 'vs/workbench/parts/files/electron-browser/explorerService'; // Viewlet Action export class OpenExplorerViewletAction extends ShowViewletAction { @@ -79,6 +81,8 @@ Registry.as(ViewletExtensions.Viewlets).registerViewlet(new Vie 0 )); +registerSingleton(IExplorerService, ExplorerService, true); + Registry.as(ViewletExtensions.Viewlets).setDefaultViewletId(VIEWLET_ID); const openViewletKb: IKeybindings = { diff --git a/src/vs/workbench/parts/files/electron-browser/media/explorerviewlet.css b/src/vs/workbench/parts/files/electron-browser/media/explorerviewlet.css index b5569536e3e..a38a2026ee5 100644 --- a/src/vs/workbench/parts/files/electron-browser/media/explorerviewlet.css +++ b/src/vs/workbench/parts/files/electron-browser/media/explorerviewlet.css @@ -9,10 +9,16 @@ } /* --- Explorer viewlet --- */ -.explorer-viewlet { +.explorer-viewlet, +.explorer-folders-view { height: 100%; } +.explorer-viewlet .monaco-list.highlight .explorer-item:not(.explorer-item-edited), +.explorer-viewlet .monaco-list.highlight .monaco-tl-twistie { + opacity: 0.3; +} + .explorer-viewlet .explorer-item, .explorer-viewlet .open-editor, .explorer-viewlet .editor-group { diff --git a/src/vs/workbench/parts/files/electron-browser/views/explorerDecorationsProvider.ts b/src/vs/workbench/parts/files/electron-browser/views/explorerDecorationsProvider.ts index 2fcb0fa5e2a..46cc8f53195 100644 --- a/src/vs/workbench/parts/files/electron-browser/views/explorerDecorationsProvider.ts +++ b/src/vs/workbench/parts/files/electron-browser/views/explorerDecorationsProvider.ts @@ -6,12 +6,12 @@ import { URI } from 'vs/base/common/uri'; import { Event, Emitter } from 'vs/base/common/event'; import { localize } from 'vs/nls'; -import { Model } from 'vs/workbench/parts/files/common/explorerModel'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IDecorationsProvider, IDecorationData } from 'vs/workbench/services/decorations/browser/decorations'; import { listInvalidItemForeground } from 'vs/platform/theme/common/colorRegistry'; import { IDisposable } from 'vscode-xterm'; import { dispose } from 'vs/base/common/lifecycle'; +import { IExplorerService } from 'vs/workbench/parts/files/common/files'; export class ExplorerDecorationsProvider implements IDecorationsProvider { readonly label: string = localize('label', "Explorer"); @@ -19,13 +19,18 @@ export class ExplorerDecorationsProvider implements IDecorationsProvider { private toDispose: IDisposable[]; constructor( - private model: Model, + @IExplorerService private explorerService: IExplorerService, @IWorkspaceContextService contextService: IWorkspaceContextService ) { this.toDispose = []; this.toDispose.push(contextService.onDidChangeWorkspaceFolders(e => { this._onDidChange.fire(e.changed.concat(e.added).map(wf => wf.uri)); })); + this.toDispose.push(explorerService.onDidChangeItem(item => { + if (item) { + this._onDidChange.fire([item.resource]); + } + })); } get onDidChange(): Event { @@ -37,7 +42,7 @@ export class ExplorerDecorationsProvider implements IDecorationsProvider { } provideDecorations(resource: URI): IDecorationData | undefined { - const fileStat = this.model.findClosest(resource); + const fileStat = this.explorerService.findClosest(resource); if (fileStat && fileStat.isRoot && fileStat.isError) { return { tooltip: localize('canNotResolve', "Can not resolve workspace folder"), diff --git a/src/vs/workbench/parts/files/electron-browser/views/explorerView.ts b/src/vs/workbench/parts/files/electron-browser/views/explorerView.ts index aa5a6f305a4..c710b6b01fb 100644 --- a/src/vs/workbench/parts/files/electron-browser/views/explorerView.ts +++ b/src/vs/workbench/parts/files/electron-browser/views/explorerView.ts @@ -6,25 +6,18 @@ import * as nls from 'vs/nls'; import { URI } from 'vs/base/common/uri'; import * as perf from 'vs/base/common/performance'; -import { ThrottledDelayer, Delayer } from 'vs/base/common/async'; -import * as paths from 'vs/base/common/paths'; -import * as resources from 'vs/base/common/resources'; -import * as glob from 'vs/base/common/glob'; +import { sequence } from 'vs/base/common/async'; import { Action, IAction } from 'vs/base/common/actions'; import { memoize } from 'vs/base/common/decorators'; -import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocusedContext, ExplorerFocusedContext, SortOrderConfiguration, SortOrder, IExplorerView, ExplorerRootContext, ExplorerResourceReadonlyContext } from 'vs/workbench/parts/files/common/files'; -import { FileOperation, FileOperationEvent, IResolveFileOptions, FileChangeType, FileChangesEvent, IFileService, FILES_EXCLUDE_CONFIG, IFileStat } from 'vs/platform/files/common/files'; -import { RefreshViewExplorerAction, NewFolderAction, NewFileAction } from 'vs/workbench/parts/files/electron-browser/fileActions'; -import { FileDragAndDrop, FileFilter, FileSorter, FileController, FileRenderer, FileDataSource, FileViewletState, FileAccessibilityProvider } from 'vs/workbench/parts/files/electron-browser/views/explorerViewer'; +import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocusedContext, ExplorerFocusedContext, ExplorerRootContext, ExplorerResourceReadonlyContext, IExplorerService } from 'vs/workbench/parts/files/common/files'; +import { NewFolderAction, NewFileAction, FileCopiedContext, RefreshExplorerView } from 'vs/workbench/parts/files/electron-browser/fileActions'; import { toResource } from 'vs/workbench/common/editor'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; import * as DOM from 'vs/base/browser/dom'; -import { CollapseAction } from 'vs/workbench/browser/viewlet'; -import { TreeViewsViewletPanel, FileIconThemableWorkbenchTree, IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; -import { ExplorerItem, Model } from 'vs/workbench/parts/files/common/explorerModel'; +import { CollapseAction2 } from 'vs/workbench/browser/viewlet'; import { IPartService } from 'vs/workbench/services/part/common/partService'; import { ExplorerDecorationsProvider } from 'vs/workbench/parts/files/electron-browser/views/explorerDecorationsProvider'; -import { IWorkspaceContextService, WorkbenchState, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; +import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -32,79 +25,64 @@ import { IProgressService } from 'vs/platform/progress/common/progress'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { ResourceContextKey } from 'vs/workbench/common/resources'; -import { ResourceGlobMatcher } from 'vs/workbench/electron-browser/resources'; -import { isLinux } from 'vs/base/common/platform'; import { IDecorationsService } from 'vs/workbench/services/decorations/browser/decorations'; -import { WorkbenchTree } from 'vs/platform/list/browser/listService'; +import { WorkbenchAsyncDataTree, IListService } from 'vs/platform/list/browser/listService'; import { DelayedDragHandler } from 'vs/base/browser/dnd'; -import { Schemas } from 'vs/base/common/network'; -import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -import { IViewletPanelOptions } from 'vs/workbench/browser/parts/views/panelViewlet'; +import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from 'vs/workbench/services/editor/common/editorService'; +import { IViewletPanelOptions, ViewletPanel } from 'vs/workbench/browser/parts/views/panelViewlet'; import { ILabelService } from 'vs/platform/label/common/label'; +import { ExplorerDelegate, ExplorerAccessibilityProvider, ExplorerDataSource, FilesRenderer, FilesFilter, FileSorter, FileDragAndDrop } from 'vs/workbench/parts/files/electron-browser/views/explorerViewer'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IWorkbenchThemeService } from 'vs/workbench/services/themes/common/workbenchThemeService'; +import { ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree'; +import { IMenuService, MenuId, IMenu } from 'vs/platform/actions/common/actions'; +import { fillInContextMenuActions } from 'vs/platform/actions/browser/menuItemActionItem'; +import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { ExplorerItem } from 'vs/workbench/parts/files/common/explorerModel'; +import { onUnexpectedError } from 'vs/base/common/errors'; import { ResourceLabels, IResourceLabelsContainer } from 'vs/workbench/browser/labels'; +import { createFileIconThemableTreeContainerScope } from 'vs/workbench/browser/parts/views/views'; -export interface IExplorerViewOptions extends IViewletViewOptions { - fileViewletState: FileViewletState; -} +export class ExplorerView extends ViewletPanel { + static readonly ID: string = 'workbench.explorer.fileView'; -export class ExplorerView extends TreeViewsViewletPanel implements IExplorerView { - - public static readonly ID: string = 'workbench.explorer.fileView'; - private static readonly EXPLORER_FILE_CHANGES_REACT_DELAY = 500; // delay in ms to react to file changes to give our internal events a chance to react first - private static readonly EXPLORER_FILE_CHANGES_REFRESH_DELAY = 100; // delay in ms to refresh the explorer from disk file changes - - private static readonly MEMENTO_LAST_ACTIVE_FILE_RESOURCE = 'explorer.memento.lastActiveFileResource'; - private static readonly MEMENTO_EXPANDED_FOLDER_RESOURCES = 'explorer.memento.expandedFolderResources'; - - public readonly id: string = ExplorerView.ID; - - private explorerViewer: WorkbenchTree; - private explorerLabels: ResourceLabels; - private filter: FileFilter; - private fileViewletState: FileViewletState; - - private explorerRefreshDelayer: ThrottledDelayer; + private tree: WorkbenchAsyncDataTree; + private filter: FilesFilter; private resourceContext: ResourceContextKey; private folderContext: IContextKey; private readonlyContext: IContextKey; private rootContext: IContextKey; - private fileEventsFilter: ResourceGlobMatcher; - - private shouldRefresh: boolean; - private autoReveal: boolean; - private sortOrder: SortOrder; - private viewState: object; - private treeContainer: HTMLElement; + // Refresh is needed on the initial explorer open + private shouldRefresh = true; private dragHandler: DelayedDragHandler; private decorationProvider: ExplorerDecorationsProvider; - private isDisposed: boolean; + private autoReveal = false; + private ignoreActiveEditorChange; constructor( - options: IExplorerViewOptions, - @INotificationService private readonly notificationService: INotificationService, + options: IViewletPanelOptions, @IContextMenuService contextMenuService: IContextMenuService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IProgressService private readonly progressService: IProgressService, @IEditorService private readonly editorService: IEditorService, - @IFileService private readonly fileService: IFileService, @IPartService private readonly partService: IPartService, @IKeybindingService keybindingService: IKeybindingService, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IConfigurationService configurationService: IConfigurationService, @IDecorationsService decorationService: IDecorationsService, - @ILabelService private readonly labelService: ILabelService + @ILabelService private readonly labelService: ILabelService, + @IThemeService private readonly themeService: IWorkbenchThemeService, + @IListService private readonly listService: IListService, + @IMenuService private readonly menuService: IMenuService, + @IClipboardService private readonly clipboardService: IClipboardService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IExplorerService private readonly explorerService: IExplorerService ) { - super({ ...(options as IViewletPanelOptions), ariaHeaderLabel: nls.localize('explorerSection', "Files Explorer Section") }, keybindingService, contextMenuService, configurationService); - - this.viewState = options.viewletState; - this.fileViewletState = options.fileViewletState; - this.autoReveal = true; - - this.explorerRefreshDelayer = new ThrottledDelayer(ExplorerView.EXPLORER_FILE_CHANGES_REFRESH_DELAY); + super({ ...(options as IViewletPanelOptions), id: ExplorerView.ID, ariaHeaderLabel: nls.localize('explorerSection', "Files Explorer Section") }, keybindingService, contextMenuService, configurationService); this.resourceContext = instantiationService.createInstance(ResourceContextKey); this.disposables.push(this.resourceContext); @@ -112,25 +90,37 @@ export class ExplorerView extends TreeViewsViewletPanel implements IExplorerView this.readonlyContext = ExplorerResourceReadonlyContext.bindTo(contextKeyService); this.rootContext = ExplorerRootContext.bindTo(contextKeyService); - this.fileEventsFilter = instantiationService.createInstance( - ResourceGlobMatcher, - (root: URI) => this.getFileEventsExcludes(root), - (event: IConfigurationChangeEvent) => event.affectsConfiguration(FILES_EXCLUDE_CONFIG) - ); - - this.decorationProvider = new ExplorerDecorationsProvider(this.model, contextService); + this.decorationProvider = new ExplorerDecorationsProvider(this.explorerService, contextService); decorationService.registerDecorationsProvider(this.decorationProvider); this.disposables.push(this.decorationProvider); this.disposables.push(this.resourceContext); } - private getFileEventsExcludes(root?: URI): glob.IExpression { - const scope = root ? { resource: root } : undefined; - const configuration = this.configurationService.getValue(scope); - - return (configuration && configuration.files && configuration.files.exclude) || Object.create(null); + get name(): string { + return this.labelService.getWorkspaceLabel(this.contextService.getWorkspace()); } + get title(): string { + return this.name; + } + + set title(value: string) { + // noop + } + + // Memoized locals + @memoize private get contributedContextMenu(): IMenu { + const contributedContextMenu = this.menuService.createMenu(MenuId.ExplorerContext, this.tree.contextKeyService); + this.disposables.push(contributedContextMenu); + return contributedContextMenu; + } + + @memoize private get fileCopiedContextKey(): IContextKey { + return FileCopiedContext.bindTo(this.contextKeyService); + } + + // Split view methods + protected renderHeader(container: HTMLElement): void { super.renderHeader(container); @@ -150,130 +140,179 @@ export class ExplorerView extends TreeViewsViewletPanel implements IExplorerView setHeader(); } - public get name(): string { - return this.labelService.getWorkspaceLabel(this.contextService.getWorkspace()); + protected layoutBody(size: number): void { + this.tree.layout(size); } - public get title(): string { - return this.name; - } - - public set title(value: string) { - // noop - } - - public set name(value) { - // noop - } - - public render(): void { - - super.render(); - - // Update configuration - const configuration = this.configurationService.getValue(); - this.onConfigurationUpdated(configuration); - - // Load and Fill Viewer - let targetsToExpand: URI[] = []; - if (this.viewState[ExplorerView.MEMENTO_EXPANDED_FOLDER_RESOURCES]) { - targetsToExpand = this.viewState[ExplorerView.MEMENTO_EXPANDED_FOLDER_RESOURCES].map((e: string) => URI.parse(e)); - } - this.doRefresh(targetsToExpand).then(() => { - - // When the explorer viewer is loaded, listen to changes to the editor input - this.disposables.push(this.editorService.onDidActiveEditorChange(() => this.revealActiveFile())); - - // Also handle configuration updates - this.disposables.push(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue(), e))); - - this.revealActiveFile(); - }); - } - - public renderBody(container: HTMLElement): void { - this.treeContainer = DOM.append(container, DOM.$('.explorer-folders-view')); - this.tree = this.createViewer(this.treeContainer); + renderBody(container: HTMLElement): void { + const treeContainer = DOM.append(container, DOM.$('.explorer-folders-view')); + this.createTree(treeContainer); if (this.toolbar) { this.toolbar.setActions(this.getActions(), this.getSecondaryActions())(); } - this.disposables.push(this.contextService.onDidChangeWorkspaceFolders(e => this.refreshFromEvent(e.added))); - this.disposables.push(this.contextService.onDidChangeWorkbenchState(e => this.refreshFromEvent())); - this.disposables.push(this.fileService.onDidChangeFileSystemProviderRegistrations(() => this.refreshFromEvent())); + this.disposables.push(this.contextService.onDidChangeWorkbenchState(() => this.setTreeInput())); this.disposables.push(this.labelService.onDidRegisterFormatter(() => { this._onDidChangeTitleArea.fire(); - this.refreshFromEvent(); + this.refresh(); + })); + + this.disposables.push(this.explorerService.onDidChangeRoots(() => this.setTreeInput())); + this.disposables.push(this.explorerService.onDidChangeItem(e => this.refresh(e))); + this.disposables.push(this.explorerService.onDidChangeEditable(e => { + let expandPromise = Promise.resolve(null); + const isEditing = !!this.explorerService.getEditableData(e); + if (isEditing) { + this.tree.setFocus([]); + expandPromise = this.tree.expand(e.parent); + } + DOM.toggleClass(this.tree.getHTMLElement(), 'highlight', isEditing); + expandPromise.then(() => this.refresh(e.parent)).then(() => { + if (isEditing) { + this.tree.reveal(e); + } + }); + })); + this.disposables.push(this.explorerService.onDidSelectItem(e => this.onSelectItem(e.item, e.reveal))); + + // Update configuration + const configuration = this.configurationService.getValue(); + this.onConfigurationUpdated(configuration); + + // When the explorer viewer is loaded, listen to changes to the editor input + this.disposables.push(this.editorService.onDidActiveEditorChange(() => { + if (this.autoReveal && !this.ignoreActiveEditorChange) { + const activeFile = this.getActiveFile(); + if (activeFile) { + this.explorerService.select(this.getActiveFile()); + } else { + this.tree.setSelection([]); + } + } + })); + + // Also handle configuration updates + this.disposables.push(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue(), e))); + + this.disposables.push(this.onDidChangeBodyVisibility(visible => { + if (visible) { + // If a refresh was requested and we are now visible, run it + if (this.shouldRefresh) { + this.shouldRefresh = false; + this.setTreeInput().then(undefined, onUnexpectedError); + } + } })); } - layoutBody(size: number): void { - if (this.treeContainer) { - this.treeContainer.style.height = size + 'px'; - } - super.layoutBody(size); - } - - public getActions(): IAction[] { + getActions(): IAction[] { const actions: Action[] = []; - actions.push(this.instantiationService.createInstance(NewFileAction, this.getViewer(), null)); - actions.push(this.instantiationService.createInstance(NewFolderAction, this.getViewer(), null)); - actions.push(this.instantiationService.createInstance(RefreshViewExplorerAction, this, 'explorer-action refresh-explorer')); - actions.push(this.instantiationService.createInstance(CollapseAction, this.getViewer(), true, 'explorer-action collapse-explorer')); + const getFocus = () => { + const focus = this.tree.getFocus(); + return focus.length > 0 ? focus[0] : undefined; + }; + actions.push(this.instantiationService.createInstance(NewFileAction, getFocus)); + actions.push(this.instantiationService.createInstance(NewFolderAction, getFocus)); + actions.push(this.instantiationService.createInstance(RefreshExplorerView, RefreshExplorerView.ID, RefreshExplorerView.LABEL)); + actions.push(this.instantiationService.createInstance(CollapseAction2, this.tree, true, 'explorer-action collapse-explorer')); return actions; } - private revealActiveFile(): void { - if (!this.autoReveal) { - return; // do not touch selection or focus if autoReveal === false - } - - let clearSelection = true; - let clearFocus = false; - - // Handle files - const activeFile = this.getActiveFile(); - if (activeFile) { - - // Always remember last opened file - this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE] = activeFile.toString(); - - // Select file if input is inside workspace - if (this.isBodyVisible() && !this.isDisposed && this.contextService.isInsideWorkspace(activeFile)) { - const selection = this.hasSingleSelection(activeFile); - if (!selection) { - this.select(activeFile); - } - - clearSelection = false; - } - } - - // Handle closed or untitled file (convince explorer to not reopen any file when getting visible) - const activeInput = this.editorService.activeEditor; - if (!activeInput || toResource(activeInput, { supportSideBySide: true, filter: Schemas.untitled })) { - this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE] = undefined; - clearFocus = true; - } - - // Otherwise clear - if (clearSelection) { - this.explorerViewer.clearSelection(); - } - - if (clearFocus) { - this.explorerViewer.clearFocus(); - } + focus(): void { + this.tree.domFocus(); } - private onConfigurationUpdated(configuration: IFilesConfiguration, event?: IConfigurationChangeEvent): void { - if (this.isDisposed) { - return; // guard against possible race condition when config change causes recreate of views - } + private createTree(container: HTMLElement): void { + this.filter = this.instantiationService.createInstance(FilesFilter); + this.disposables.push(this.filter); + const explorerLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility } as IResourceLabelsContainer); + this.disposables.push(explorerLabels); + const filesRenderer = this.instantiationService.createInstance(FilesRenderer, explorerLabels); + this.disposables.push(filesRenderer); + + this.disposables.push(createFileIconThemableTreeContainerScope(container, this.themeService)); + + this.tree = new WorkbenchAsyncDataTree(container, new ExplorerDelegate(), [filesRenderer], + this.instantiationService.createInstance(ExplorerDataSource), { + accessibilityProvider: new ExplorerAccessibilityProvider(), + ariaLabel: nls.localize('treeAriaLabel', "Files Explorer"), + identityProvider: { + getId: stat => stat.resource + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: stat => stat.name + }, + multipleSelectionSupport: true, + filter: this.filter, + sorter: this.instantiationService.createInstance(FileSorter), + dnd: this.instantiationService.createInstance(FileDragAndDrop) + }, this.contextKeyService, this.listService, this.themeService, this.configurationService, this.keybindingService); + + this.disposables.push(this.tree); + // Bind context keys + FilesExplorerFocusedContext.bindTo(this.tree.contextKeyService); + ExplorerFocusedContext.bindTo(this.tree.contextKeyService); + + // Update resource context based on focused element + this.disposables.push(this.tree.onDidChangeFocus(e => { + const stat = e.elements && e.elements.length ? e.elements[0] : undefined; + const isSingleFolder = this.contextService.getWorkbenchState() === WorkbenchState.FOLDER; + const resource = stat ? stat.resource : isSingleFolder ? this.contextService.getWorkspace().folders[0].uri : undefined; + this.resourceContext.set(resource); + this.folderContext.set((isSingleFolder && !stat) || stat && stat.isDirectory); + this.readonlyContext.set(stat && stat.isReadonly); + this.rootContext.set(!stat || (stat && stat.isRoot)); + })); + + // Open when selecting via keyboard + this.disposables.push(this.tree.onDidChangeSelection(e => { + if (!e.browserEvent) { + // Only react on selection change events caused by user interaction (ignore those which are caused by us doing tree.setSelection). + return; + } + const selection = e.elements; + // Do not react if the user is expanding selection + if (selection && selection.length === 1) { + if (selection[0].isDirectory || !selection[0].name) { + // Do not react if user is clicking on directories or explorer items which are input placeholders + return; + } + let isDoubleClick = false; + let sideBySide = false; + let isMiddleClick = false; + + if (e.browserEvent instanceof MouseEvent) { + isDoubleClick = e.browserEvent.detail === 2; + isMiddleClick = e.browserEvent.button === 1; + sideBySide = this.tree.useAltAsMultipleSelectionModifier ? (e.browserEvent.ctrlKey || e.browserEvent.metaKey) : e.browserEvent.altKey; + } + + // Pass focus for keyboard events and for double click + /* __GDPR__ + "workbenchActionExecuted" : { + "id" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "from": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + }*/ + this.telemetryService.publicLog('workbenchActionExecuted', { id: 'workbench.files.openFile', from: 'explorer' }); + this.ignoreActiveEditorChange = true; + this.editorService.openEditor({ resource: selection[0].resource, options: { preserveFocus: (e.browserEvent instanceof MouseEvent) && !isDoubleClick, pinned: isDoubleClick || isMiddleClick } }, sideBySide ? SIDE_GROUP : ACTIVE_GROUP) + .then(() => this.ignoreActiveEditorChange = false).catch(e => { + this.ignoreActiveEditorChange = false; + onUnexpectedError(e); + }); + } + })); + + this.disposables.push(this.tree.onContextMenu(e => this.onContextMenu(e))); + } + + // React on events + + private onConfigurationUpdated(configuration: IFilesConfiguration, event?: IConfigurationChangeEvent): void { this.autoReveal = configuration && configuration.explorer && configuration.explorer.autoReveal; // Push down config updates to components of viewer @@ -282,12 +321,6 @@ export class ExplorerView extends TreeViewsViewletPanel implements IExplorerView needsRefresh = this.filter.updateConfiguration(); } - const configSortOrder = configuration && configuration.explorer && configuration.explorer.sortOrder || 'default'; - if (this.sortOrder !== configSortOrder) { - this.sortOrder = configSortOrder; - needsRefresh = true; - } - if (event && !needsRefresh) { needsRefresh = event.affectsConfiguration('explorer.decorations.colors') || event.affectsConfiguration('explorer.decorations.badges'); @@ -295,91 +328,94 @@ export class ExplorerView extends TreeViewsViewletPanel implements IExplorerView // Refresh viewer as needed if this originates from a config event if (event && needsRefresh) { - this.doRefresh(); + this.refresh(); } } - public focus(): void { - super.focus(); + private onContextMenu(e: ITreeContextMenuEvent): void { + const stat = e.element; - let keepFocus = false; + // update dynamic contexts + this.fileCopiedContextKey.set(this.clipboardService.hasResources()); - // Make sure the current selected element is revealed - if (this.explorerViewer) { - if (this.autoReveal) { - const selection = this.explorerViewer.getSelection(); - if (selection.length > 0) { - this.reveal(selection[0], 0.5); + const selection = this.tree.getSelection(); + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => { + const actions: IAction[] = []; + fillInContextMenuActions(this.contributedContextMenu, { arg: stat instanceof ExplorerItem ? stat.resource : {}, shouldForwardArgs: true }, actions, this.contextMenuService); + return actions; + }, + onHide: (wasCancelled?: boolean) => { + if (wasCancelled) { + this.tree.domFocus(); + } + }, + getActionsContext: () => selection && selection.indexOf(stat) >= 0 + ? selection.map((fs: ExplorerItem) => fs.resource) + : stat instanceof ExplorerItem ? [stat.resource] : [] + }); + } + + // General methods + + /** + * Refresh the contents of the explorer to get up to date data from the disk about the file structure. + * If the item is passed we refresh only that level of the tree, otherwise we do a full refresh. + */ + private refresh(item?: ExplorerItem): Promise { + if (!this.tree || !this.isBodyVisible()) { + this.shouldRefresh = true; + return Promise.resolve(undefined); + } + const recursive = !item; + const toRefresh = item || this.tree.getInput(); + + return this.tree.refresh(toRefresh, recursive); + } + + getOptimalWidth(): number { + const parentNode = this.tree.getHTMLElement(); + const childNodes = ([] as HTMLElement[]).slice.call(parentNode.querySelectorAll('.explorer-item .label-name')); // select all file labels + + return DOM.getLargestChildWidth(parentNode, childNodes); + } + + private setTreeInput(): Promise { + if (!this.isBodyVisible()) { + this.shouldRefresh = true; + return Promise.resolve(undefined); + } + + const initialInputSetup = !this.tree.getInput(); + if (initialInputSetup) { + perf.mark('willResolveExplorer'); + } + const roots = this.explorerService.roots; + let input: ExplorerItem | ExplorerItem[] = roots[0]; + if (this.contextService.getWorkbenchState() !== WorkbenchState.FOLDER || roots[0].isError) { + // Display roots only when multi folder workspace + input = roots; + } + + const promise = this.tree.setInput(input).then(() => { + // Find resource to focus from active editor input if set + if (this.autoReveal && initialInputSetup) { + const resourceToFocus = this.getActiveFile(); + if (resourceToFocus) { + return this.explorerService.select(resourceToFocus, true); } } - // Pass Focus to Viewer - this.explorerViewer.domFocus(); - keepFocus = true; - } - - // Open the focused element in the editor if there is currently no file opened - const activeFile = this.getActiveFile(); - if (!activeFile) { - this.openFocusedElement(keepFocus); - } - } - - public setVisible(visible: boolean): void { - super.setVisible(visible); - - // Show - if (visible) { - - // If a refresh was requested and we are now visible, run it - let refreshPromise: Promise = Promise.resolve(null); - if (this.shouldRefresh) { - refreshPromise = this.doRefresh(); - this.shouldRefresh = false; // Reset flag + return undefined; + }).then(() => { + if (initialInputSetup) { + perf.mark('didResolveExplorer'); } + }); - if (!this.autoReveal) { - return; // do not react to setVisible call if autoReveal === false - } - - // Always select the current navigated file in explorer if input is file editor input - // unless autoReveal is set to false - const activeFile = this.getActiveFile(); - if (activeFile) { - refreshPromise.then(() => { - this.select(activeFile); - }); - return; - } - - // Return now if the workbench has not yet been restored - in this case the workbench takes care of restoring last used editors - if (!this.partService.isRestored()) { - return; - } - - // Otherwise restore last used file: By lastActiveFileResource - let lastActiveFileResource: URI; - if (this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE]) { - lastActiveFileResource = URI.parse(this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE]); - } - - if (lastActiveFileResource && this.isCreated && this.model.findClosest(lastActiveFileResource)) { - this.editorService.openEditor({ resource: lastActiveFileResource, options: { revealIfVisible: true } }); - return; - } - - // Otherwise restore last used file: By Explorer selection - refreshPromise.then(() => { - this.openFocusedElement(); - }); - } - } - - private openFocusedElement(preserveFocus?: boolean): void { - const stat: ExplorerItem = this.explorerViewer.getFocus(); - if (stat && !stat.isDirectory) { - this.editorService.openEditor({ resource: stat.resource, options: { preserveFocus, revealIfVisible: true } }); - } + this.progressService.showWhile(promise, this.partService.isRestored() ? 800 : 1200 /* less ugly initial startup */); + return promise; } private getActiveFile(): URI { @@ -387,647 +423,44 @@ export class ExplorerView extends TreeViewsViewletPanel implements IExplorerView // ignore diff editor inputs (helps to get out of diffing when returning to explorer) if (input instanceof DiffEditorInput) { - return null; + return undefined; } // check for files return toResource(input, { supportSideBySide: true }); } - private get isCreated(): boolean { - return !!(this.explorerViewer && this.explorerViewer.getInput()); - } - - @memoize - private get model(): Model { - const model = this.instantiationService.createInstance(Model); - this.disposables.push(model); - - return model; - } - - private createViewer(container: HTMLElement): WorkbenchTree { - const dataSource = this.instantiationService.createInstance(FileDataSource); - this.explorerLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility } as IResourceLabelsContainer); - this.disposables.push(this.explorerLabels); - const renderer = this.instantiationService.createInstance(FileRenderer, this.fileViewletState, this.explorerLabels); - const controller = this.instantiationService.createInstance(FileController); - this.disposables.push(controller); - const sorter = this.instantiationService.createInstance(FileSorter); - this.disposables.push(sorter); - this.filter = this.instantiationService.createInstance(FileFilter); - this.disposables.push(this.filter); - const dnd = this.instantiationService.createInstance(FileDragAndDrop); - const accessibilityProvider = this.instantiationService.createInstance(FileAccessibilityProvider); - - this.explorerViewer = this.instantiationService.createInstance(FileIconThemableWorkbenchTree, container, { - dataSource, - renderer, - controller, - sorter, - filter: this.filter, - dnd, - accessibilityProvider - }, { - autoExpandSingleChildren: true, - ariaLabel: nls.localize('treeAriaLabel', "Files Explorer") - }); - - // Bind context keys - FilesExplorerFocusedContext.bindTo(this.explorerViewer.contextKeyService); - ExplorerFocusedContext.bindTo(this.explorerViewer.contextKeyService); - - // Update Viewer based on File Change Events - this.disposables.push(this.fileService.onAfterOperation(e => this.onFileOperation(e))); - this.disposables.push(this.fileService.onFileChanges(e => this.onFileChanges(e))); - - // Update resource context based on focused element - this.disposables.push(this.explorerViewer.onDidChangeFocus((e: { focus: ExplorerItem }) => { - const isSingleFolder = this.contextService.getWorkbenchState() === WorkbenchState.FOLDER; - const resource = e.focus ? e.focus.resource : isSingleFolder ? this.contextService.getWorkspace().folders[0].uri : undefined; - this.resourceContext.set(resource); - this.folderContext.set((isSingleFolder && !e.focus) || e.focus && e.focus.isDirectory); - this.readonlyContext.set(e.focus && e.focus.isReadonly); - this.rootContext.set(!e.focus || (e.focus && e.focus.isRoot)); - })); - - // Open when selecting via keyboard - this.disposables.push(this.explorerViewer.onDidChangeSelection(event => { - if (event && event.payload && event.payload.origin === 'keyboard') { - const element = this.tree.getSelection(); - - if (Array.isArray(element) && element[0] instanceof ExplorerItem) { - if (element[0].isDirectory) { - this.explorerViewer.toggleExpansion(element[0]); - } - - controller.openEditor(element[0], { pinned: false, sideBySide: false, preserveFocus: false }); - } - } - })); - - return this.explorerViewer; - } - - getViewer(): WorkbenchTree { - return this.tree; - } - - public getOptimalWidth(): number { - const parentNode = this.explorerViewer.getHTMLElement(); - const childNodes = ([] as HTMLElement[]).slice.call(parentNode.querySelectorAll('.explorer-item .label-name')); // select all file labels - - return DOM.getLargestChildWidth(parentNode, childNodes); - } - - private onFileOperation(e: FileOperationEvent): void { - if (!this.isCreated) { - return; // ignore if not yet created - } - - // Add - if (e.operation === FileOperation.CREATE || e.operation === FileOperation.COPY) { - const addedElement = e.target; - const parentResource = resources.dirname(addedElement.resource); - const parents = this.model.findAll(parentResource); - - if (parents.length) { - - // Add the new file to its parent (Model) - parents.forEach(p => { - // We have to check if the parent is resolved #29177 - const thenable: Promise = p.isDirectoryResolved ? Promise.resolve(null) : this.fileService.resolveFile(p.resource); - thenable.then(stat => { - if (stat) { - const modelStat = ExplorerItem.create(stat, p.root); - ExplorerItem.mergeLocalWithDisk(modelStat, p); - } - - const childElement = ExplorerItem.create(addedElement, p.root); - p.removeChild(childElement); // make sure to remove any previous version of the file if any - p.addChild(childElement); - // Refresh the Parent (View) - this.explorerViewer.refresh(p).then(() => { - return this.reveal(childElement, 0.5).then(() => { - - // Focus new element - this.explorerViewer.setFocus(childElement); - }); - }); - }); - }); - } - } - - // Move (including Rename) - else if (e.operation === FileOperation.MOVE) { - const oldResource = e.resource; - const newElement = e.target; - - const oldParentResource = resources.dirname(oldResource); - const newParentResource = resources.dirname(newElement.resource); - - // Only update focus if renamed/moved element is selected - let restoreFocus = false; - const focus: ExplorerItem = this.explorerViewer.getFocus(); - if (focus && focus.resource && focus.resource.toString() === oldResource.toString()) { - restoreFocus = true; - } - - let isExpanded = false; - // Handle Rename - if (oldParentResource && newParentResource && oldParentResource.toString() === newParentResource.toString()) { - const modelElements = this.model.findAll(oldResource); - modelElements.forEach(modelElement => { - //Check if element is expanded - isExpanded = this.explorerViewer.isExpanded(modelElement); - // Rename File (Model) - modelElement.rename(newElement); - - // Update Parent (View) - this.explorerViewer.refresh(modelElement.parent).then(() => { - - // Select in Viewer if set - if (restoreFocus) { - this.explorerViewer.setFocus(modelElement); - } - //Expand the element again - if (isExpanded) { - this.explorerViewer.expand(modelElement); - } - }); - }); - } - - // Handle Move - else if (oldParentResource && newParentResource) { - const newParents = this.model.findAll(newParentResource); - const modelElements = this.model.findAll(oldResource); - - if (newParents.length && modelElements.length) { - - // Move in Model - modelElements.forEach((modelElement, index) => { - const oldParent = modelElement.parent; - modelElement.move(newParents[index], (callback: () => void) => { - // Update old parent - this.explorerViewer.refresh(oldParent).then(callback); - }, () => { - // Update new parent - this.explorerViewer.refresh(newParents[index], true).then(() => this.explorerViewer.expand(newParents[index])); - }); - }); - } - } - } - - // Delete - else if (e.operation === FileOperation.DELETE) { - const modelElements = this.model.findAll(e.resource); - modelElements.forEach(element => { - if (element.parent) { - const parent = element.parent; - // Remove Element from Parent (Model) - parent.removeChild(element); - - // Refresh Parent (View) - const restoreFocus = this.explorerViewer.isDOMFocused(); - this.explorerViewer.refresh(parent).then(() => { - - // Ensure viewer has keyboard focus if event originates from viewer - if (restoreFocus) { - this.explorerViewer.domFocus(); - } - }); - } - }); - } - } - - private onFileChanges(e: FileChangesEvent): void { - - // Ensure memento state does not capture a deleted file (we run this from a timeout because - // delete events can result in UI activity that will fill the memento again when multiple - // editors are closing) - setTimeout(() => { - const lastActiveResource: string = this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE]; - if (lastActiveResource && e.contains(URI.parse(lastActiveResource), FileChangeType.DELETED)) { - this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE] = null; - } - }); - - // Check if an explorer refresh is necessary (delayed to give internal events a chance to react first) - // Note: there is no guarantee when the internal events are fired vs real ones. Code has to deal with the fact that one might - // be fired first over the other or not at all. - setTimeout(() => { - if (!this.shouldRefresh && this.shouldRefreshFromEvent(e)) { - this.refreshFromEvent(); - } - }, ExplorerView.EXPLORER_FILE_CHANGES_REACT_DELAY); - } - - private shouldRefreshFromEvent(e: FileChangesEvent): boolean { - if (!this.isCreated) { - return false; - } - - // Filter to the ones we care - e = this.filterToViewRelevantEvents(e); - - // Handle added files/folders - const added = e.getAdded(); - if (added.length) { - - // Check added: Refresh if added file/folder is not part of resolved root and parent is part of it - const ignoredPaths: { [resource: string]: boolean } = <{ [resource: string]: boolean }>{}; - for (const change of added) { - // Find parent - const parent = resources.dirname(change.resource); - - // Continue if parent was already determined as to be ignored - if (ignoredPaths[parent.toString()]) { - continue; - } - - // Compute if parent is visible and added file not yet part of it - const parentStat = this.model.findClosest(parent); - if (parentStat && parentStat.isDirectoryResolved && !this.model.findClosest(change.resource)) { - return true; - } - - // Keep track of path that can be ignored for faster lookup - if (!parentStat || !parentStat.isDirectoryResolved) { - ignoredPaths[parent.toString()] = true; - } - } - } - - // Handle deleted files/folders - const deleted = e.getDeleted(); - if (deleted.length) { - - // Check deleted: Refresh if deleted file/folder part of resolved root - for (const del of deleted) { - if (this.model.findClosest(del.resource)) { - return true; - } - } - } - - // Handle updated files/folders if we sort by modified - if (this.sortOrder === SortOrderConfiguration.MODIFIED) { - const updated = e.getUpdated(); - - // Check updated: Refresh if updated file/folder part of resolved root - for (const upd of updated) { - if (this.model.findClosest(upd.resource)) { - return true; - } - } - } - - return false; - } - - private filterToViewRelevantEvents(e: FileChangesEvent): FileChangesEvent { - return new FileChangesEvent(e.changes.filter(change => { - if (change.type === FileChangeType.UPDATED && this.sortOrder !== SortOrderConfiguration.MODIFIED) { - return false; // we only are about updated if we sort by modified time - } - - if (!this.contextService.isInsideWorkspace(change.resource)) { - return false; // exclude changes for resources outside of workspace - } - - if (this.fileEventsFilter.matches(change.resource)) { - return false; // excluded via files.exclude setting - } - - return true; - })); - } - - private refreshFromEvent(newRoots: IWorkspaceFolder[] = []): void { - if (this.isBodyVisible() && !this.isDisposed) { - this.explorerRefreshDelayer.trigger(() => { - if (!this.explorerViewer.getHighlight()) { - return this.doRefresh(newRoots.map(r => r.uri)).then(() => { - if (newRoots.length === 1) { - return this.reveal(this.model.findClosest(newRoots[0].uri), 0.5); - } - - return undefined; - }); - } - - return Promise.resolve(null); - }); - } else { - this.shouldRefresh = true; - } - } - - /** - * Refresh the contents of the explorer to get up to date data from the disk about the file structure. - */ - public refresh(): Promise { - if (!this.explorerViewer || this.explorerViewer.getHighlight()) { + private onSelectItem(fileStat: ExplorerItem, reveal = this.autoReveal): Promise { + if (!fileStat || !this.isBodyVisible()) { return Promise.resolve(undefined); } - // Focus - this.explorerViewer.domFocus(); - - // Find resource to focus from active editor input if set - let resourceToFocus: URI; - if (this.autoReveal) { - resourceToFocus = this.getActiveFile(); - if (!resourceToFocus) { - const selection = this.explorerViewer.getSelection(); - if (selection && selection.length === 1) { - resourceToFocus = (selection[0]).resource; - } - } + // Expand all stats in the parent chain + const toExpand: ExplorerItem[] = []; + let parent = fileStat.parent; + while (parent) { + toExpand.push(parent); + parent = parent.parent; } - return this.doRefresh().then(() => { - if (resourceToFocus) { - return this.select(resourceToFocus, true); + return sequence(toExpand.reverse().map(s => () => this.tree.expand(s))).then(() => { + if (reveal) { + this.tree.reveal(fileStat, 0.5); } - return Promise.resolve(undefined); - }); - } - - private doRefresh(targetsToExpand: URI[] = []): Promise { - const targetsToResolve = this.model.roots.map(root => ({ root, resource: root.resource, options: { resolveTo: [] } })); - - // First time refresh: Receive target through active editor input or selection and also include settings from previous session - if (!this.isCreated) { - const activeFile = this.getActiveFile(); - if (activeFile) { - const workspaceFolder = this.contextService.getWorkspaceFolder(activeFile); - if (workspaceFolder) { - const found = targetsToResolve.filter(t => t.root.resource.toString() === workspaceFolder.uri.toString()).pop(); - found.options.resolveTo.push(activeFile); - } - } - - targetsToExpand.forEach(toExpand => { - const workspaceFolder = this.contextService.getWorkspaceFolder(toExpand); - if (workspaceFolder) { - const found = targetsToResolve.filter(ttr => ttr.resource.toString() === workspaceFolder.uri.toString()).pop(); - found.options.resolveTo.push(toExpand); - } - }); - } - - // Subsequent refresh: Receive targets through expanded folders in tree - else { - targetsToResolve.forEach(t => { - this.getResolvedDirectories(t.root, t.options.resolveTo); - }); - } - - const promise = this.resolveRoots(targetsToResolve, targetsToExpand).then(result => { - this.decorationProvider.changed(targetsToResolve.map(t => t.root.resource)); - return result; - }); - this.progressService.showWhile(promise, this.partService.isRestored() ? 800 : 1200 /* less ugly initial startup */); - - return promise; - } - - private resolveRoots(targetsToResolve: { root: ExplorerItem, resource: URI, options: { resolveTo: any[] } }[], targetsToExpand: URI[]): Promise { - - // Display roots only when multi folder workspace - let input = this.contextService.getWorkbenchState() === WorkbenchState.FOLDER ? this.model.roots[0] : this.model; - if (input !== this.explorerViewer.getInput()) { - perf.mark('willResolveExplorer'); - } - - const errorRoot = (resource: URI, root: ExplorerItem) => { - if (input === this.model.roots[0]) { - input = this.model; - } - - return ExplorerItem.create({ - resource: resource, - name: paths.basename(resource.fsPath), - mtime: 0, - etag: undefined, - isDirectory: true - }, root, undefined, true); - }; - - const setInputAndExpand = (input: ExplorerItem | Model, statsToExpand: ExplorerItem[]) => { - // Make sure to expand all folders that where expanded in the previous session - // Special case: we are switching to multi workspace view, thus expand all the roots (they might just be added) - if (input === this.model && statsToExpand.every(fs => fs && !fs.isRoot)) { - statsToExpand = this.model.roots.concat(statsToExpand); - } - - return this.explorerViewer.setInput(input).then(() => this.explorerViewer.expandAll(statsToExpand)) - .then(() => perf.mark('didResolveExplorer')); - }; - - if (targetsToResolve.every(t => t.root.resource.scheme === 'file')) { - // All the roots are local, resolve them in parallel - return this.fileService.resolveFiles(targetsToResolve).then(results => { - // Convert to model - const modelStats = results.map((result, index) => { - if (result.success && result.stat.isDirectory) { - return ExplorerItem.create(result.stat, targetsToResolve[index].root, targetsToResolve[index].options.resolveTo); - } - - return errorRoot(targetsToResolve[index].resource, targetsToResolve[index].root); - }); - // Subsequent refresh: Merge stat into our local model and refresh tree - modelStats.forEach((modelStat, index) => { - if (index < this.model.roots.length) { - ExplorerItem.mergeLocalWithDisk(modelStat, this.model.roots[index]); - } - }); - - const statsToExpand: ExplorerItem[] = this.explorerViewer.getExpandedElements().concat(targetsToExpand.map(expand => this.model.findClosest(expand))); - if (input === this.explorerViewer.getInput()) { - return this.explorerViewer.refresh().then(() => this.explorerViewer.expandAll(statsToExpand)); - } - - return setInputAndExpand(input, statsToExpand); - }); - } - - // There is a remote root, resolve the roots sequantally - let statsToExpand: ExplorerItem[] = []; - let delayer = new Delayer(100); - let delayerPromise: Promise; - return Promise.all(targetsToResolve.map((target, index) => this.fileService.resolveFile(target.resource, target.options) - .then(result => result.isDirectory ? ExplorerItem.create(result, target.root, target.options.resolveTo) : errorRoot(target.resource, target.root), () => errorRoot(target.resource, target.root)) - .then(modelStat => { - // Subsequent refresh: Merge stat into our local model and refresh tree - if (index < this.model.roots.length) { - ExplorerItem.mergeLocalWithDisk(modelStat, this.model.roots[index]); - } - - let toExpand: ExplorerItem[] = this.explorerViewer.getExpandedElements().concat(targetsToExpand.map(target => this.model.findClosest(target))); - if (input === this.explorerViewer.getInput()) { - statsToExpand = statsToExpand.concat(toExpand); - if (!delayer.isTriggered()) { - delayerPromise = delayer.trigger(() => this.explorerViewer.refresh() - .then(() => this.explorerViewer.expandAll(statsToExpand)) - .then(() => statsToExpand = []) - ); - } - - return delayerPromise; - } - - return setInputAndExpand(input, statsToExpand); - }))); - } - - /** - * Given a stat, fills an array of path that make all folders below the stat that are resolved directories. - */ - private getResolvedDirectories(stat: ExplorerItem, resolvedDirectories: URI[]): void { - if (stat.isDirectoryResolved) { - if (!stat.isRoot) { - - // Drop those path which are parents of the current one - for (let i = resolvedDirectories.length - 1; i >= 0; i--) { - const resource = resolvedDirectories[i]; - if (resources.isEqualOrParent(stat.resource, resource, !isLinux /* ignorecase */)) { - resolvedDirectories.splice(i); - } - } - - // Add to the list of path to resolve - resolvedDirectories.push(stat.resource); - } - - // Recurse into children - stat.getChildrenArray().forEach(child => { - this.getResolvedDirectories(child, resolvedDirectories); - }); - } - } - - /** - * Selects and reveal the file element provided by the given resource if its found in the explorer. Will try to - * resolve the path from the disk in case the explorer is not yet expanded to the file yet. - */ - public select(resource: URI, reveal: boolean = this.autoReveal): Promise { - - // Require valid path - if (!resource) { - return Promise.resolve(undefined); - } - - // If path already selected, just reveal and return - const selection = this.hasSingleSelection(resource); - if (selection) { - return reveal ? this.reveal(selection, 0.5) : Promise.resolve(undefined); - } - - // First try to get the stat object from the input to avoid a roundtrip - if (!this.isCreated) { - return Promise.resolve(undefined); - } - - const fileStat = this.model.findClosest(resource); - if (fileStat) { - return this.doSelect(fileStat, reveal); - } - - // Stat needs to be resolved first and then revealed - const options: IResolveFileOptions = { resolveTo: [resource] }; - const workspaceFolder = this.contextService.getWorkspaceFolder(resource); - const rootUri = workspaceFolder ? workspaceFolder.uri : this.model.roots[0].resource; - return this.fileService.resolveFile(rootUri, options).then(stat => { - - // Convert to model - const root = this.model.roots.filter(r => r.resource.toString() === rootUri.toString()).pop(); - const modelStat = ExplorerItem.create(stat, root, options.resolveTo); - // Update Input with disk Stat - ExplorerItem.mergeLocalWithDisk(modelStat, root); - - // Select and Reveal - return this.explorerViewer.refresh(root).then(() => this.doSelect(root.find(resource), reveal)); - - }, e => { this.notificationService.error(e); }); - } - - private hasSingleSelection(resource: URI): ExplorerItem { - const currentSelection: ExplorerItem[] = this.explorerViewer.getSelection(); - return currentSelection.length === 1 && currentSelection[0].resource.toString() === resource.toString() - ? currentSelection[0] - : undefined; - } - - private doSelect(fileStat: ExplorerItem, reveal: boolean): Promise { - if (!fileStat) { - return Promise.resolve(undefined); - } - - // Special case: we are asked to reveal and select an element that is not visible - // In this case we take the parent element so that we are at least close to it. - if (!this.filter.isVisible(this.tree, fileStat)) { - fileStat = fileStat.parent; - if (!fileStat) { - return Promise.resolve(undefined); - } - } - - // Reveal depending on flag - let revealPromise: Promise; - if (reveal) { - revealPromise = this.reveal(fileStat, 0.5); - } else { - revealPromise = Promise.resolve(undefined); - } - - return revealPromise.then(() => { if (!fileStat.isDirectory) { - this.explorerViewer.setSelection([fileStat]); // Since folders can not be opened, only select files + this.tree.setSelection([fileStat]); // Since folders can not be opened, only select files } - this.explorerViewer.setFocus(fileStat); + this.tree.setFocus([fileStat]); }); } - private reveal(element: any, relativeTop?: number): Promise { - if (!this.tree) { - return Promise.resolve(undefined); // return early if viewlet has not yet been created - } - return this.tree.reveal(element, relativeTop); - } - - saveState(): void { - - // Keep list of expanded folders to restore on next load - if (this.isCreated) { - const expanded = this.explorerViewer.getExpandedElements() - .filter(e => e instanceof ExplorerItem) - .map((e: ExplorerItem) => e.resource.toString()); - - if (expanded.length) { - this.viewState[ExplorerView.MEMENTO_EXPANDED_FOLDER_RESOURCES] = expanded; - } else { - delete this.viewState[ExplorerView.MEMENTO_EXPANDED_FOLDER_RESOURCES]; - } - } - - // Clean up last focused if not set - if (!this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE]) { - delete this.viewState[ExplorerView.MEMENTO_LAST_ACTIVE_FILE_RESOURCE]; - } - - super.saveState(); + collapseAll(): void { + this.tree.collapseAll(); } dispose(): void { - this.isDisposed = true; if (this.dragHandler) { this.dragHandler.dispose(); } diff --git a/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts b/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts index 27f648f793a..d377ac121c8 100644 --- a/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts +++ b/src/vs/workbench/parts/files/electron-browser/views/explorerViewer.ts @@ -3,178 +3,92 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as nls from 'vs/nls'; -import * as objects from 'vs/base/common/objects'; +import { IAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import * as DOM from 'vs/base/browser/dom'; -import * as path from 'path'; -import { URI } from 'vs/base/common/uri'; -import { once } from 'vs/base/common/functional'; -import * as paths from 'vs/base/common/paths'; -import * as resources from 'vs/base/common/resources'; -import * as errors from 'vs/base/common/errors'; -import { IAction, ActionRunner as BaseActionRunner, IActionRunner } from 'vs/base/common/actions'; -import * as comparers from 'vs/base/common/comparers'; -import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; -import { isMacintosh, isLinux } from 'vs/base/common/platform'; import * as glob from 'vs/base/common/glob'; -import { ResourceLabels, IFileLabelOptions, IResourceLabel } from 'vs/workbench/browser/labels'; -import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; -import { IFilesConfiguration, SortOrder } from 'vs/workbench/parts/files/common/files'; -import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import { FileOperationError, FileOperationResult, IFileService, FileKind } from 'vs/platform/files/common/files'; -import { DuplicateFileAction, AddFilesAction, IEditableData, IFileViewletState, FileCopiedContext } from 'vs/workbench/parts/files/electron-browser/fileActions'; -import { IDataSource, ITree, IAccessibilityProvider, IRenderer, ContextMenuEvent, ISorter, IFilter, IDragOverReaction, DRAG_OVER_ACCEPT_BUBBLE_DOWN, DRAG_OVER_ACCEPT_BUBBLE_DOWN_COPY, DRAG_OVER_ACCEPT_BUBBLE_UP, DRAG_OVER_ACCEPT_BUBBLE_UP_COPY, DRAG_OVER_REJECT } from 'vs/base/parts/tree/browser/tree'; -import { DesktopDragAndDropData, ExternalElementsDragAndDropData } from 'vs/base/parts/tree/browser/treeDnd'; -import { ClickBehavior } from 'vs/base/parts/tree/browser/treeDefaults'; -import { ExplorerItem, NewStatPlaceholder, Model } from 'vs/workbench/parts/files/common/explorerModel'; -import { DragMouseEvent, IMouseEvent } from 'vs/base/browser/mouseEvent'; +import { IListVirtualDelegate, ListDragOverEffect } from 'vs/base/browser/ui/list/list'; +import { IProgressService } from 'vs/platform/progress/common/progress'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IFileService, FileKind, IFileStat, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; import { IPartService } from 'vs/workbench/services/part/common/partService'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; -import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; -import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { IContextViewService, IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { IProgressService } from 'vs/platform/progress/common/progress'; -import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IDisposable, Disposable, dispose } from 'vs/base/common/lifecycle'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { IMenuService, IMenu, MenuId } from 'vs/platform/actions/common/actions'; -import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; +import { IFileLabelOptions, IResourceLabel, ResourceLabels } from 'vs/workbench/browser/labels'; +import { ITreeRenderer, ITreeNode, ITreeFilter, TreeVisibility, TreeFilterResult, IAsyncDataSource, ITreeSorter, ITreeDragAndDrop, ITreeDragOverReaction, TreeDragOverBubble } from 'vs/base/browser/ui/tree/tree'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; +import { IFilesConfiguration, IExplorerService, IEditableData } from 'vs/workbench/parts/files/common/files'; +import { dirname, joinPath, isEqualOrParent, basename, hasToIgnoreCase, distinctParents } from 'vs/base/common/resources'; +import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; +import { localize } from 'vs/nls'; +import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; +import { once } from 'vs/base/common/functional'; +import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { normalize } from 'vs/base/common/paths'; +import { equals, deepClone } from 'vs/base/common/objects'; +import * as path from 'path'; +import { ExplorerItem } from 'vs/workbench/parts/files/common/explorerModel'; +import { compareFileExtensions, compareFileNames } from 'vs/base/common/comparers'; +import { fillResourceDataTransfers, CodeDataTransfers, extractResources } from 'vs/workbench/browser/dnd'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IDragAndDropData, DataTransfers } from 'vs/base/browser/dnd'; +import { Schemas } from 'vs/base/common/network'; +import { DesktopDragAndDropData, ExternalElementsDragAndDropData, ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; +import { isMacintosh, isLinux } from 'vs/base/common/platform'; +import { IDialogService, IConfirmationResult, IConfirmation, getConfirmMessage } from 'vs/platform/dialogs/common/dialogs'; +import { ITextFileService, ITextFileOperationResult } from 'vs/workbench/services/textfile/common/textfiles'; import { IWindowService } from 'vs/platform/windows/common/windows'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspace/common/workspaceEditing'; -import { extractResources, SimpleFileResourceDragAndDrop, CodeDataTransfers, fillResourceDataTransfers } from 'vs/workbench/browser/dnd'; -import { WorkbenchTree, WorkbenchTreeController } from 'vs/platform/list/browser/listService'; -import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService'; -import { DataTransfers, IDragAndDropData } from 'vs/base/browser/dnd'; -import { Schemas } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; +import { ITask, sequence } from 'vs/base/common/async'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IWorkspaceFolderCreationData } from 'vs/platform/workspaces/common/workspaces'; -import { rtrim } from 'vs/base/common/strings'; -import { IDialogService, IConfirmationResult, IConfirmation, getConfirmMessage } from 'vs/platform/dialogs/common/dialogs'; -import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from 'vs/workbench/services/editor/common/editorService'; -import { fillInContextMenuActions } from 'vs/platform/actions/browser/menuItemActionItem'; +import { findValidPasteFileTarget } from 'vs/workbench/parts/files/electron-browser/fileActions'; + +export class ExplorerDelegate implements IListVirtualDelegate { + + private static readonly ITEM_HEIGHT = 22; + + getHeight(element: ExplorerItem): number { + return ExplorerDelegate.ITEM_HEIGHT; + } + + getTemplateId(element: ExplorerItem): string { + return FilesRenderer.ID; + } +} + +export class ExplorerDataSource implements IAsyncDataSource { -export class FileDataSource implements IDataSource { constructor( - @IProgressService private readonly progressService: IProgressService, - @INotificationService private readonly notificationService: INotificationService, - @IFileService private readonly fileService: IFileService, - @IPartService private readonly partService: IPartService + @IProgressService private progressService: IProgressService, + @INotificationService private notificationService: INotificationService, + @IPartService private partService: IPartService, + @IFileService private fileService: IFileService ) { } - public getId(tree: ITree, stat: ExplorerItem | Model): string { - if (stat instanceof Model) { - return 'model'; + hasChildren(element: ExplorerItem | ExplorerItem[]): boolean { + return Array.isArray(element) || element.isDirectory; + } + + getChildren(element: ExplorerItem | ExplorerItem[]): Promise { + if (Array.isArray(element)) { + return Promise.resolve(element); } - return `${stat.root.resource.toString()}:${stat.getId()}`; - } + const promise = element.fetchChildren(this.fileService).then(undefined, e => { + // Do not show error for roots since we already use an explorer decoration to notify user + if (!(element instanceof ExplorerItem && element.isRoot)) { + this.notificationService.error(e); + } - public hasChildren(tree: ITree, stat: ExplorerItem | Model): boolean { - return stat instanceof Model || (stat instanceof ExplorerItem && (stat.isDirectory || stat.isRoot)); - } + return []; // we could not resolve any children because of an error + }); - public getChildren(tree: ITree, stat: ExplorerItem | Model): Promise { - if (stat instanceof Model) { - return Promise.resolve(stat.roots); - } - - // Return early if stat is already resolved - if (stat.isDirectoryResolved) { - return Promise.resolve(stat.getChildrenArray()); - } - - // Resolve children and add to fileStat for future lookup - else { - - // Resolve - const promise = this.fileService.resolveFile(stat.resource, { resolveSingleChildDescendants: true }).then(dirStat => { - - // Convert to view model - const modelDirStat = ExplorerItem.create(dirStat, stat.root); - - // Add children to folder - const children = modelDirStat.getChildrenArray(); - if (children) { - children.forEach(child => { - stat.addChild(child); - }); - } - - stat.isDirectoryResolved = true; - - return stat.getChildrenArray(); - }, (e: any) => { - // Do not show error for roots since we already use an explorer decoration to notify user - if (!(stat instanceof ExplorerItem && stat.isRoot)) { - this.notificationService.error(e); - } - - return []; // we could not resolve any children because of an error - }); - - this.progressService.showWhile(promise, this.partService.isRestored() ? 800 : 3200 /* less ugly initial startup */); - - return promise; - } - } - - public getParent(tree: ITree, stat: ExplorerItem | Model): Promise { - if (!stat) { - return Promise.resolve(null); // can be null if nothing selected in the tree - } - - // Return if root reached - if (tree.getInput() === stat) { - return Promise.resolve(null); - } - - // Return if parent already resolved - if (stat instanceof ExplorerItem && stat.parent) { - return Promise.resolve(stat.parent); - } - - // We never actually resolve the parent from the disk for performance reasons. It wouldnt make - // any sense to resolve parent by parent with requests to walk up the chain. Instead, the explorer - // makes sure to properly resolve a deep path to a specific file and merges the result with the model. - return Promise.resolve(null); - } -} - -export class FileViewletState implements IFileViewletState { - private editableStats: Map; - - constructor() { - this.editableStats = new Map(); - } - - public getEditableData(stat: ExplorerItem): IEditableData { - return this.editableStats.get(stat); - } - - public setEditable(stat: ExplorerItem, editableData: IEditableData): void { - if (editableData) { - this.editableStats.set(stat, editableData); - } - } - - public clearEditable(stat: ExplorerItem): void { - this.editableStats.delete(stat); - } -} - -export class ActionRunner extends BaseActionRunner implements IActionRunner { - private viewletState: FileViewletState; - - constructor(state: FileViewletState) { - super(); - - this.viewletState = state; - } - - public run(action: IAction, context?: any): Promise { - return super.run(action, { viewletState: this.viewletState }); + this.progressService.showWhile(promise, this.partService.isRestored() ? 800 : 3200 /* less ugly initial startup */); + return promise; } } @@ -184,22 +98,18 @@ export interface IFileTemplateData { container: HTMLElement; } -// Explorer Renderer -export class FileRenderer implements IRenderer { - - private static readonly ITEM_HEIGHT = 22; - private static readonly FILE_TEMPLATE_ID = 'file'; +export class FilesRenderer implements ITreeRenderer, IDisposable { + static readonly ID = 'file'; private config: IFilesConfiguration; private configListener: IDisposable; constructor( - private state: FileViewletState, private labels: ResourceLabels, @IContextViewService private readonly contextViewService: IContextViewService, @IThemeService private readonly themeService: IThemeService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService + @IExplorerService private readonly explorerService: IExplorerService ) { this.config = this.configurationService.getValue(); this.configListener = this.configurationService.onDidChangeConfiguration(e => { @@ -209,34 +119,21 @@ export class FileRenderer implements IRenderer { }); } - dispose(): void { - this.configListener.dispose(); + get templateId(): string { + return FilesRenderer.ID; } - public getHeight(tree: ITree, element: any): number { - return FileRenderer.ITEM_HEIGHT; - } - - public getTemplateId(tree: ITree, element: any): string { - return FileRenderer.FILE_TEMPLATE_ID; - } - - public disposeTemplate(tree: ITree, templateId: string, templateData: IFileTemplateData): void { - templateData.elementDisposable.dispose(); - templateData.label.dispose(); - } - - public renderTemplate(tree: ITree, templateId: string, container: HTMLElement): IFileTemplateData { + renderTemplate(container: HTMLElement): IFileTemplateData { const elementDisposable = Disposable.None; const label = this.labels.create(container); return { elementDisposable, label, container }; } - public renderElement(tree: ITree, stat: ExplorerItem, templateId: string, templateData: IFileTemplateData): void { + renderElement(element: ITreeNode, index: number, templateData: IFileTemplateData): void { templateData.elementDisposable.dispose(); - - const editableData: IEditableData = this.state.getEditableData(stat); + const stat = element.element; + const editableData = this.explorerService.getEditableData(stat); // File Label if (!editableData) { @@ -250,42 +147,54 @@ export class FileRenderer implements IRenderer { }); templateData.elementDisposable = templateData.label.onDidRender(() => { - tree.updateWidth(stat); + // todo@isidor horizontal scrolling + // this.tree.updateWidth(stat); }); } // Input Box else { templateData.label.element.style.display = 'none'; - this.renderInputBox(templateData.container, tree, stat, editableData); + this.renderInputBox(templateData.container, stat, editableData); templateData.elementDisposable = Disposable.None; } } - private renderInputBox(container: HTMLElement, tree: ITree, stat: ExplorerItem, editableData: IEditableData): void { + private renderInputBox(container: HTMLElement, stat: ExplorerItem, editableData: IEditableData): void { // Use a file label only for the icon next to the input box const label = this.labels.create(container); const extraClasses = ['explorer-item', 'explorer-item-edited']; - const fileKind = stat.isRoot ? FileKind.ROOT_FOLDER : (stat.isDirectory || (stat instanceof NewStatPlaceholder && stat.isDirectoryPlaceholder())) ? FileKind.FOLDER : FileKind.FILE; + const fileKind = stat.isRoot ? FileKind.ROOT_FOLDER : stat.isDirectory ? FileKind.FOLDER : FileKind.FILE; const labelOptions: IFileLabelOptions = { hidePath: true, hideLabel: true, fileKind, extraClasses }; - const parent = stat.name ? resources.dirname(stat.resource) : stat.resource; + const parent = stat.name ? dirname(stat.resource) : stat.resource; const value = stat.name || ''; - label.setFile(resources.joinPath(parent, value || ' '), labelOptions); // Use icon for ' ' if name is empty. + label.setFile(joinPath(parent, value || ' '), labelOptions); // Use icon for ' ' if name is empty. // Input field for name const inputBox = new InputBox(label.element, this.contextViewService, { validationOptions: { - validation: editableData.validator + validation: (value) => { + const content = editableData.validationMessage(value); + if (!content) { + return null; + } + + return { + content, + formatContent: true, + type: MessageType.ERROR + }; + } }, - ariaLabel: nls.localize('fileInputAriaLabel', "Type file name. Press Enter to confirm or Escape to cancel.") + ariaLabel: localize('fileInputAriaLabel', "Type file name. Press Enter to confirm or Escape to cancel.") }); const styler = attachInputBoxStyler(inputBox, this.themeService); inputBox.onDidChange(value => { - label.setFile(resources.joinPath(parent, value || ' '), labelOptions); // update label icon while typing! + label.setFile(joinPath(parent, value || ' '), labelOptions); // update label icon while typing! }); const lastDot = value.lastIndexOf('.'); @@ -294,21 +203,12 @@ export class FileRenderer implements IRenderer { inputBox.select({ start: 0, end: lastDot > 0 && !stat.isDirectory ? lastDot : value.length }); inputBox.focus(); - const done = once((commit: boolean, blur: boolean) => { - tree.clearHighlight(); + const done = once(async (success: boolean, blur: boolean) => { label.element.style.display = 'none'; - - if (commit && inputBox.value) { - editableData.action.run({ value: inputBox.value }); - } - - setTimeout(() => { - if (!blur) { // https://github.com/Microsoft/vscode/issues/20269 - tree.domFocus(); - } - dispose(toDispose); - container.removeChild(label.element); - }, 0); + const value = inputBox.value; + dispose(toDispose); + container.removeChild(label.element); + editableData.onFinish(value, success); }); const toDispose = [ @@ -322,14 +222,6 @@ export class FileRenderer implements IRenderer { done(false, false); } }), - DOM.addStandardDisposableListener(inputBox.inputElement, DOM.EventType.KEY_UP, (e: IKeyboardEvent) => { - const initialRelPath: string = path.relative(stat.root.resource.path, stat.parent.resource.path); - let projectFolderName: string = ''; - if (this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE) { - projectFolderName = paths.basename(stat.root.resource.path); // show root folder name in multi-folder project - } - this.showInputMessage(inputBox, initialRelPath, projectFolderName, editableData.action.id); - }), DOM.addDisposableListener(inputBox.inputElement, DOM.EventType.BLUR, () => { done(inputBox.isInputValid(), true); }), @@ -338,267 +230,94 @@ export class FileRenderer implements IRenderer { ]; } - private showInputMessage(inputBox: InputBox, initialRelPath: string, projectFolderName: string = '', actionID: string) { - if (inputBox.validate()) { - const value = inputBox.value; - if (value && /.[\\/]./.test(value)) { // only show if there's at least one slash enclosed in the string - let displayPath = path.normalize(path.join(projectFolderName, initialRelPath, value)); - displayPath = rtrim(displayPath, paths.nativeSep); + disposeElement?(element: ITreeNode, index: number, templateData: IFileTemplateData): void { + // noop + } - const indexLastSlash: number = displayPath.lastIndexOf(paths.nativeSep); - const name: string = displayPath.substring(indexLastSlash + 1); - const leadingPathPart: string = displayPath.substring(0, indexLastSlash); + disposeTemplate(templateData: IFileTemplateData): void { + templateData.elementDisposable.dispose(); + templateData.label.dispose(); + } - let msg: string; - switch (actionID) { - case 'workbench.files.action.createFileFromExplorer': - msg = nls.localize('createFileFromExplorerInfoMessage', "Create file **{0}** in **{1}**", name, leadingPathPart); - break; - case 'workbench.files.action.renameFile': - msg = nls.localize('renameFileFromExplorerInfoMessage', "Move and rename to **{0}**", displayPath); - break; - case 'workbench.files.action.createFolderFromExplorer': // fallthrough - default: - msg = nls.localize('createFolderFromExplorerInfoMessage', "Create folder **{0}** in **{1}**", name, leadingPathPart); - } - - inputBox.showMessage({ - type: MessageType.INFO, - content: msg, - formatContent: true - }); - } else if (value && /^\s|\s$/.test(value)) { - inputBox.showMessage({ - content: nls.localize('whitespace', "Leading or trailing whitespace detected"), - formatContent: true, - type: MessageType.WARNING - }); - } else { // fixes #46744: inputbox hides again if all slashes are removed - inputBox.hideMessage(); - } - } + dispose(): void { + this.configListener.dispose(); } } -// Explorer Accessibility Provider -export class FileAccessibilityProvider implements IAccessibilityProvider { - - public getAriaLabel(tree: ITree, stat: ExplorerItem): string { - return stat.name; +export class ExplorerAccessibilityProvider implements IAccessibilityProvider { + getAriaLabel(element: ExplorerItem): string { + return element.name; } } -// Explorer Controller -export class FileController extends WorkbenchTreeController implements IDisposable { - private fileCopiedContextKey: IContextKey; - private contributedContextMenu: IMenu; - private toDispose: IDisposable[]; - private previousSelectionRangeStop: ExplorerItem; +interface CachedParsedExpression { + original: glob.IExpression; + parsed: glob.ParsedExpression; +} + +export class FilesFilter implements ITreeFilter { + private hiddenExpressionPerRoot: Map; + private workspaceFolderChangeListener: IDisposable; constructor( - @IEditorService private readonly editorService: IEditorService, - @IContextMenuService private readonly contextMenuService: IContextMenuService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @IMenuService private readonly menuService: IMenuService, - @IContextKeyService contextKeyService: IContextKeyService, - @IClipboardService private readonly clipboardService: IClipboardService, - @IConfigurationService configurationService: IConfigurationService + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IExplorerService private readonly explorerService: IExplorerService ) { - super({ clickBehavior: ClickBehavior.ON_MOUSE_UP /* do not change to not break DND */ }, configurationService); - - this.fileCopiedContextKey = FileCopiedContext.bindTo(contextKeyService); - this.toDispose = []; + this.hiddenExpressionPerRoot = new Map(); + this.workspaceFolderChangeListener = this.contextService.onDidChangeWorkspaceFolders(() => this.updateConfiguration()); } - public onLeftClick(tree: WorkbenchTree, stat: ExplorerItem | Model, event: IMouseEvent, origin: string = 'mouse'): boolean { - const payload = { origin: origin }; - const isDoubleClick = (origin === 'mouse' && event.detail === 2); + updateConfiguration(): boolean { + let needsRefresh = false; + this.contextService.getWorkspace().folders.forEach(folder => { + const configuration = this.configurationService.getValue({ resource: folder.uri }); + const excludesConfig: glob.IExpression = (configuration && configuration.files && configuration.files.exclude) || Object.create(null); - // Handle Highlight Mode - if (tree.getHighlight()) { - - // Cancel Event - event.preventDefault(); - event.stopPropagation(); - - tree.clearHighlight(payload); - - return false; - } - - // Handle root - if (stat instanceof Model) { - tree.clearFocus(payload); - tree.clearSelection(payload); - - return false; - } - - // Cancel Event - const isMouseDown = event && event.browserEvent && event.browserEvent.type === 'mousedown'; - if (!isMouseDown) { - event.preventDefault(); // we cannot preventDefault onMouseDown because this would break DND otherwise - } - event.stopPropagation(); - - // Set DOM focus - tree.domFocus(); - if (stat instanceof NewStatPlaceholder) { - return true; - } - - // Allow to multiselect - if ((tree.useAltAsMultipleSelectionModifier && event.altKey) || !tree.useAltAsMultipleSelectionModifier && (event.ctrlKey || event.metaKey)) { - const selection = tree.getSelection(); - this.previousSelectionRangeStop = undefined; - if (selection.indexOf(stat) >= 0) { - tree.setSelection(selection.filter(s => s !== stat)); - } else { - tree.setSelection(selection.concat(stat)); - tree.setFocus(stat, payload); - } - } - - // Allow to unselect - else if (event.shiftKey) { - const focus = tree.getFocus(); - if (focus) { - if (this.previousSelectionRangeStop) { - tree.deselectRange(stat, this.previousSelectionRangeStop); - } - tree.selectRange(focus, stat, payload); - this.previousSelectionRangeStop = stat; - } - } - - // Select, Focus and open files - else { - - // Expand / Collapse - if (isDoubleClick || this.openOnSingleClick || this.isClickOnTwistie(event)) { - tree.toggleExpansion(stat, event.altKey); - this.previousSelectionRangeStop = undefined; + if (!needsRefresh) { + const cached = this.hiddenExpressionPerRoot.get(folder.uri.toString()); + needsRefresh = !cached || !equals(cached.original, excludesConfig); } - const preserveFocus = !isDoubleClick; - tree.setFocus(stat, payload); + const excludesConfigCopy = deepClone(excludesConfig); // do not keep the config, as it gets mutated under our hoods - if (isDoubleClick) { - event.preventDefault(); // focus moves to editor, we need to prevent default - } - - tree.setSelection([stat], payload); - - if (!stat.isDirectory && (isDoubleClick || this.openOnSingleClick)) { - let sideBySide = false; - if (event) { - sideBySide = tree.useAltAsMultipleSelectionModifier ? (event.ctrlKey || event.metaKey) : event.altKey; - } - - this.openEditor(stat, { preserveFocus, sideBySide, pinned: isDoubleClick }); - } - } - - return true; - } - - public onMouseMiddleClick(tree: WorkbenchTree, element: ExplorerItem | Model, event: IMouseEvent): boolean { - let sideBySide = false; - if (event) { - sideBySide = tree.useAltAsMultipleSelectionModifier ? (event.ctrlKey || event.metaKey) : event.altKey; - } - if (element instanceof ExplorerItem && !element.isDirectory) { - this.openEditor(element, { preserveFocus: true, sideBySide, pinned: true }); - } - - return true; - } - - public onContextMenu(tree: WorkbenchTree, stat: ExplorerItem | Model, event: ContextMenuEvent): boolean { - if (event.target && event.target.tagName && event.target.tagName.toLowerCase() === 'input') { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - tree.setFocus(stat); - - // update dynamic contexts - this.fileCopiedContextKey.set(this.clipboardService.hasResources()); - - if (!this.contributedContextMenu) { - this.contributedContextMenu = this.menuService.createMenu(MenuId.ExplorerContext, tree.contextKeyService); - this.toDispose.push(this.contributedContextMenu); - } - - const anchor = { x: event.posx, y: event.posy }; - const selection = tree.getSelection(); - this.contextMenuService.showContextMenu({ - getAnchor: () => anchor, - getActions: () => { - const actions: IAction[] = []; - fillInContextMenuActions(this.contributedContextMenu, { arg: stat instanceof ExplorerItem ? stat.resource : {}, shouldForwardArgs: true }, actions, this.contextMenuService); - return actions; - }, - onHide: (wasCancelled?: boolean) => { - if (wasCancelled) { - tree.domFocus(); - } - }, - getActionsContext: () => selection && selection.indexOf(stat) >= 0 - ? selection.map((fs: ExplorerItem) => fs.resource) - : stat instanceof ExplorerItem ? [stat.resource] : [] + this.hiddenExpressionPerRoot.set(folder.uri.toString(), { original: excludesConfigCopy, parsed: glob.parse(excludesConfigCopy) } as CachedParsedExpression); }); - return true; + return needsRefresh; } - public openEditor(stat: ExplorerItem, options: { preserveFocus: boolean; sideBySide: boolean; pinned: boolean; }): void { - if (stat && !stat.isDirectory) { - /* __GDPR__ - "workbenchActionExecuted" : { - "id" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "from": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetryService.publicLog('workbenchActionExecuted', { id: 'workbench.files.openFile', from: 'explorer' }); - - this.editorService.openEditor({ resource: stat.resource, options }, options.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + filter(stat: ExplorerItem, parentVisibility: TreeVisibility): TreeFilterResult { + if (parentVisibility === TreeVisibility.Hidden) { + return false; } + if (this.explorerService.getEditableData(stat) || stat.isRoot) { + return true; // always visible + } + + // Hide those that match Hidden Patterns + const cached = this.hiddenExpressionPerRoot.get(stat.root.resource.toString()); + if (cached && cached.parsed(normalize(path.relative(stat.root.resource.path, stat.resource.path), true), stat.name, name => !!stat.parent.getChild(name))) { + return false; // hidden through pattern + } + + return true; } public dispose(): void { - this.toDispose = dispose(this.toDispose); + this.workspaceFolderChangeListener = dispose(this.workspaceFolderChangeListener); } } -// Explorer Sorter -export class FileSorter implements ISorter { - private toDispose: IDisposable[]; - private sortOrder: SortOrder; +// // Explorer Sorter +export class FileSorter implements ITreeSorter { constructor( - @IConfigurationService private readonly configurationService: IConfigurationService, + @IExplorerService private readonly explorerService: IExplorerService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService - ) { - this.toDispose = []; - - this.updateSortOrder(); - - this.registerListeners(); - } - - private registerListeners(): void { - this.toDispose.push(this.configurationService.onDidChangeConfiguration(e => this.updateSortOrder())); - } - - private updateSortOrder(): void { - this.sortOrder = this.configurationService.getValue('explorer.sortOrder') || 'default'; - } - - public compare(tree: ITree, statA: ExplorerItem, statB: ExplorerItem): number { + ) { } + public compare(statA: ExplorerItem, statB: ExplorerItem): number { // Do not sort roots if (statA.isRoot) { if (statB.isRoot) { @@ -612,8 +331,10 @@ export class FileSorter implements ISorter { return 1; } + const sortOrder = this.explorerService.sortOrder; + // Sort Directories - switch (this.sortOrder) { + switch (sortOrder) { case 'type': if (statA.isDirectory && !statB.isDirectory) { return -1; @@ -624,7 +345,7 @@ export class FileSorter implements ISorter { } if (statA.isDirectory && statB.isDirectory) { - return comparers.compareFileNames(statA.name, statB.name); + return compareFileNames(statA.name, statB.name); } break; @@ -655,173 +376,55 @@ export class FileSorter implements ISorter { break; } - // Sort "New File/Folder" placeholders - if (statA instanceof NewStatPlaceholder) { - return -1; - } - - if (statB instanceof NewStatPlaceholder) { - return 1; - } - // Sort Files - switch (this.sortOrder) { + switch (sortOrder) { case 'type': - return comparers.compareFileExtensions(statA.name, statB.name); + return compareFileExtensions(statA.name, statB.name); case 'modified': if (statA.mtime !== statB.mtime) { return statA.mtime < statB.mtime ? 1 : -1; } - return comparers.compareFileNames(statA.name, statB.name); + return compareFileNames(statA.name, statB.name); default: /* 'default', 'mixed', 'filesFirst' */ - return comparers.compareFileNames(statA.name, statB.name); + return compareFileNames(statA.name, statB.name); } } - - public dispose(): void { - this.toDispose = dispose(this.toDispose); - } } -// Explorer Filter -interface CachedParsedExpression { - original: glob.IExpression; - parsed: glob.ParsedExpression; -} - -export class FileFilter implements IFilter { - - private hiddenExpressionPerRoot: Map; - private workspaceFolderChangeListener: IDisposable; - - constructor( - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, - @IConfigurationService private readonly configurationService: IConfigurationService - ) { - this.hiddenExpressionPerRoot = new Map(); - - this.registerListeners(); - } - - public registerListeners(): void { - this.workspaceFolderChangeListener = this.contextService.onDidChangeWorkspaceFolders(() => this.updateConfiguration()); - } - - public updateConfiguration(): boolean { - let needsRefresh = false; - this.contextService.getWorkspace().folders.forEach(folder => { - const configuration = this.configurationService.getValue({ resource: folder.uri }); - const excludesConfig: glob.IExpression = (configuration && configuration.files && configuration.files.exclude) || Object.create(null); - - if (!needsRefresh) { - const cached = this.hiddenExpressionPerRoot.get(folder.uri.toString()); - needsRefresh = !cached || !objects.equals(cached.original, excludesConfig); - } - - const excludesConfigCopy = objects.deepClone(excludesConfig); // do not keep the config, as it gets mutated under our hoods - - this.hiddenExpressionPerRoot.set(folder.uri.toString(), { original: excludesConfigCopy, parsed: glob.parse(excludesConfigCopy) } as CachedParsedExpression); - }); - - return needsRefresh; - } - - public isVisible(tree: ITree, stat: ExplorerItem): boolean { - return this.doIsVisible(stat); - } - - private doIsVisible(stat: ExplorerItem): boolean { - if (stat instanceof NewStatPlaceholder || stat.isRoot) { - return true; // always visible - } - - // Hide those that match Hidden Patterns - const cached = this.hiddenExpressionPerRoot.get(stat.root.resource.toString()); - if (cached && cached.parsed(paths.normalize(path.relative(stat.root.resource.path, stat.resource.path), true), stat.name, name => !!stat.parent.getChild(name))) { - return false; // hidden through pattern - } - - return true; - } - - public dispose(): void { - this.workspaceFolderChangeListener = dispose(this.workspaceFolderChangeListener); - } -} - -// Explorer Drag And Drop Controller -export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { - +export class FileDragAndDrop implements ITreeDragAndDrop { private static readonly CONFIRM_DND_SETTING_KEY = 'explorer.confirmDragAndDrop'; private toDispose: IDisposable[]; private dropEnabled: boolean; constructor( - @INotificationService private readonly notificationService: INotificationService, - @IDialogService private readonly dialogService: IDialogService, - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, - @IFileService private readonly fileService: IFileService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IInstantiationService instantiationService: IInstantiationService, - @ITextFileService private readonly textFileService: ITextFileService, - @IWindowService private readonly windowService: IWindowService, - @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService + @INotificationService private notificationService: INotificationService, + @IExplorerService private explorerService: IExplorerService, + @IEditorService private editorService: IEditorService, + @IDialogService private dialogService: IDialogService, + @IWorkspaceContextService private contextService: IWorkspaceContextService, + @IFileService private fileService: IFileService, + @IConfigurationService private configurationService: IConfigurationService, + @IInstantiationService private instantiationService: IInstantiationService, + @ITextFileService private textFileService: ITextFileService, + @IWindowService private windowService: IWindowService, + @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService ) { - super(stat => this.statToResource(stat), instantiationService); - this.toDispose = []; - this.updateDropEnablement(); - - this.registerListeners(); + const updateDropEnablement = () => { + this.dropEnabled = this.configurationService.getValue('explorer.enableDragAndDrop'); + }; + updateDropEnablement(); + this.toDispose.push(this.configurationService.onDidChangeConfiguration((e) => updateDropEnablement())); } - private statToResource(stat: ExplorerItem): URI { - if (stat.isDirectory) { - return URI.from({ scheme: 'folder', path: stat.resource.path }); // indicates that we are dragging a folder - } - - return stat.resource; - } - - private registerListeners(): void { - this.toDispose.push(this.configurationService.onDidChangeConfiguration(e => this.updateDropEnablement())); - } - - private updateDropEnablement(): void { - this.dropEnabled = this.configurationService.getValue('explorer.enableDragAndDrop'); - } - - public onDragStart(tree: ITree, data: IDragAndDropData, originalEvent: DragMouseEvent): void { - const sources: ExplorerItem[] = data.getData(); - if (sources && sources.length) { - - // When dragging folders, make sure to collapse them to free up some space - sources.forEach(s => { - if (s.isDirectory && tree.isExpanded(s)) { - tree.collapse(s, false); - } - }); - - // Apply some datatransfer types to allow for dragging the element outside of the application - this.instantiationService.invokeFunction(fillResourceDataTransfers, sources, originalEvent); - - // The only custom data transfer we set from the explorer is a file transfer - // to be able to DND between multiple code file explorers across windows - const fileResources = sources.filter(s => !s.isDirectory && s.resource.scheme === Schemas.file).map(r => r.resource.fsPath); - if (fileResources.length) { - originalEvent.dataTransfer.setData(CodeDataTransfers.FILES, JSON.stringify(fileResources)); - } - } - } - - public onDragOver(tree: ITree, data: IDragAndDropData, target: ExplorerItem | Model, originalEvent: DragMouseEvent): IDragOverReaction { + onDragOver(data: IDragAndDropData, target: ExplorerItem, targetIndex: number, originalEvent: DragEvent): boolean | ITreeDragOverReaction { if (!this.dropEnabled) { - return DRAG_OVER_REJECT; + return false; } const isCopy = originalEvent && ((originalEvent.ctrlKey && !isMacintosh) || (originalEvent.altKey && isMacintosh)); @@ -829,42 +432,40 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { // Desktop DND if (fromDesktop) { - const types: string[] = originalEvent.dataTransfer.types; + const types = originalEvent.dataTransfer.types; const typesArray: string[] = []; - for (const t of types) { - typesArray.push(t.toLowerCase()); // somehow the types are lowercase + for (let i = 0; i < types.length; i++) { + typesArray.push(types[i].toLowerCase()); // somehow the types are lowercase } if (typesArray.indexOf(DataTransfers.FILES.toLowerCase()) === -1 && typesArray.indexOf(CodeDataTransfers.FILES.toLowerCase()) === -1) { - return DRAG_OVER_REJECT; + return false; } } // Other-Tree DND else if (data instanceof ExternalElementsDragAndDropData) { - return DRAG_OVER_REJECT; + return false; } // In-Explorer DND else { - const sources: ExplorerItem[] = data.getData(); - if (target instanceof Model) { - if (sources[0].isRoot) { - return DRAG_OVER_ACCEPT_BUBBLE_DOWN(false); + const items = (data as ElementsDragAndDropData).elements; + + if (!target) { + // Droping onto the empty area. Do not accept if items dragged are already children of the root + if (items.every(i => i.parent.isRoot)) { + return false; } - return DRAG_OVER_REJECT; + return { accept: true, bubble: TreeDragOverBubble.Down, autoExpand: false }; } - if (!Array.isArray(sources)) { - return DRAG_OVER_REJECT; + if (!Array.isArray(items)) { + return false; } - if (sources.some((source) => { - if (source instanceof NewStatPlaceholder) { - return true; // NewStatPlaceholders can not be moved - } - + if (items.some((source) => { if (source.isRoot && target instanceof ExplorerItem && !target.isRoot) { return true; // Root folder can not be moved to a non root file stat. } @@ -878,57 +479,97 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { return false; } - if (!isCopy && resources.dirname(source.resource).toString() === target.resource.toString()) { + if (!isCopy && dirname(source.resource).toString() === target.resource.toString()) { return true; // Can not move a file to the same parent unless we copy } - if (resources.isEqualOrParent(target.resource, source.resource, !isLinux /* ignorecase */)) { + if (isEqualOrParent(target.resource, source.resource, !isLinux /* ignorecase */)) { return true; // Can not move a parent folder into one of its children } return false; })) { - return DRAG_OVER_REJECT; + return false; } } // All (target = model) - if (target instanceof Model) { - return this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE ? DRAG_OVER_ACCEPT_BUBBLE_DOWN_COPY(false) : DRAG_OVER_REJECT; // can only drop folders to workspace + if (!target) { + return this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE ? { accept: true, bubble: TreeDragOverBubble.Down } : false; // can only drop folders to workspace } // All (target = file/folder) else { + const effect = fromDesktop || isCopy ? ListDragOverEffect.Copy : ListDragOverEffect.Move; + if (target.isDirectory) { if (target.isReadonly) { - return DRAG_OVER_REJECT; + return false; } - return fromDesktop || isCopy ? DRAG_OVER_ACCEPT_BUBBLE_DOWN_COPY(true) : DRAG_OVER_ACCEPT_BUBBLE_DOWN(true); + + return { accept: true, bubble: TreeDragOverBubble.Down, effect, autoExpand: true }; } if (this.contextService.getWorkspace().folders.every(folder => folder.uri.toString() !== target.resource.toString())) { - return fromDesktop || isCopy ? DRAG_OVER_ACCEPT_BUBBLE_UP_COPY : DRAG_OVER_ACCEPT_BUBBLE_UP; + return { accept: true, bubble: TreeDragOverBubble.Up, effect }; } } - return DRAG_OVER_REJECT; + return false; } - public drop(tree: ITree, data: IDragAndDropData, target: ExplorerItem | Model, originalEvent: DragMouseEvent): void { + getDragURI(element: ExplorerItem): string { + return element.resource.toString(); + } + + getDragLabel(elements: ExplorerItem[]): string { + if (elements.length > 1) { + return String(elements.length); + } + + return elements[0].name; + } + + onDragStart(data: IDragAndDropData, originalEvent: DragEvent): void { + const items = (data as ElementsDragAndDropData).elements; + if (items && items.length) { + // Apply some datatransfer types to allow for dragging the element outside of the application + this.instantiationService.invokeFunction(fillResourceDataTransfers, items, originalEvent); + + // The only custom data transfer we set from the explorer is a file transfer + // to be able to DND between multiple code file explorers across windows + const fileResources = items.filter(s => !s.isDirectory && s.resource.scheme === Schemas.file).map(r => r.resource.fsPath); + if (fileResources.length) { + originalEvent.dataTransfer.setData(CodeDataTransfers.FILES, JSON.stringify(fileResources)); + } + } + } + + drop(data: IDragAndDropData, target: ExplorerItem, targetIndex: number, originalEvent: DragEvent): void { + // Find parent to add to + if (!target) { + target = this.explorerService.roots[this.explorerService.roots.length - 1]; + } + if (!target.isDirectory) { + target = target.parent; + } + if (target.isReadonly) { + return; + } // Desktop DND (Import file) if (data instanceof DesktopDragAndDropData) { - this.handleExternalDrop(tree, data, target, originalEvent); + this.handleExternalDrop(data, target, originalEvent); } - // In-Explorer DND (Move/Copy file) else { - this.handleExplorerDrop(tree, data, target, originalEvent); + this.handleExplorerDrop(data, target, originalEvent); } } - private handleExternalDrop(tree: ITree, data: DesktopDragAndDropData, target: ExplorerItem | Model, originalEvent: DragMouseEvent): Promise { - const droppedResources = extractResources(originalEvent.browserEvent as DragEvent, true); + + private handleExternalDrop(data: DesktopDragAndDropData, target: ExplorerItem, originalEvent: DragEvent): Promise { + const droppedResources = extractResources(originalEvent, true); // Check for dropped external files to be folders return this.fileService.resolveFiles(droppedResources).then(result => { @@ -944,9 +585,9 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { let confirmedPromise: Promise = Promise.resolve({ confirmed: true }); if (this.contextService.getWorkbenchState() !== WorkbenchState.WORKSPACE) { confirmedPromise = this.dialogService.confirm({ - message: folders.length > 1 ? nls.localize('dropFolders', "Do you want to add the folders to the workspace?") : nls.localize('dropFolder', "Do you want to add the folder to the workspace?"), + message: folders.length > 1 ? localize('dropFolders', "Do you want to add the folders to the workspace?") : localize('dropFolder', "Do you want to add the folder to the workspace?"), type: 'question', - primaryButton: folders.length > 1 ? nls.localize('addFolders', "&&Add Folders") : nls.localize('addFolder', "&&Add Folder") + primaryButton: folders.length > 1 ? localize('addFolders', "&&Add Folders") : localize('addFolder', "&&Add Folder") }); } @@ -960,18 +601,84 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { } // Handle dropped files (only support FileStat as target) - else if (target instanceof ExplorerItem && !target.isReadonly) { - const addFilesAction = this.instantiationService.createInstance(AddFilesAction, tree, target, null); - - return addFilesAction.run(droppedResources.map(res => res.resource)); + else if (target instanceof ExplorerItem) { + return this.addResources(target, droppedResources.map(res => res.resource)); } return undefined; }); } - private handleExplorerDrop(tree: ITree, data: IDragAndDropData, target: ExplorerItem | Model, originalEvent: DragMouseEvent): Promise { - const sources: ExplorerItem[] = resources.distinctParents(data.getData(), s => s.resource); + private addResources(target: ExplorerItem, resources: URI[]): Promise { + if (resources && resources.length > 0) { + + // Resolve target to check for name collisions and ask user + return this.fileService.resolveFile(target.resource).then((targetStat: IFileStat) => { + + // Check for name collisions + const targetNames = new Set(); + targetStat.children.forEach((child) => { + targetNames.add(isLinux ? child.name : child.name.toLowerCase()); + }); + + let overwritePromise: Promise = Promise.resolve({ confirmed: true }); + if (resources.some(resource => { + return targetNames.has(!hasToIgnoreCase(resource) ? basename(resource) : basename(resource).toLowerCase()); + })) { + const confirm: IConfirmation = { + message: localize('confirmOverwrite', "A file or folder with the same name already exists in the destination folder. Do you want to replace it?"), + detail: localize('irreversible', "This action is irreversible!"), + primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), + type: 'warning' + }; + + overwritePromise = this.dialogService.confirm(confirm); + } + + return overwritePromise.then(res => { + if (!res.confirmed) { + return undefined; + } + + // Run add in sequence + const addPromisesFactory: ITask>[] = []; + resources.forEach(resource => { + addPromisesFactory.push(() => { + const sourceFile = resource; + const targetFile = joinPath(target.resource, basename(sourceFile)); + + // if the target exists and is dirty, make sure to revert it. otherwise the dirty contents + // of the target file would replace the contents of the added file. since we already + // confirmed the overwrite before, this is OK. + let revertPromise: Promise = Promise.resolve(null); + if (this.textFileService.isDirty(targetFile)) { + revertPromise = this.textFileService.revertAll([targetFile], { soft: true }); + } + + return revertPromise.then(() => { + const copyTarget = joinPath(target.resource, basename(sourceFile)); + return this.fileService.copyFile(sourceFile, copyTarget, true).then(stat => { + + // if we only add one file, just open it directly + if (resources.length === 1) { + this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); + } + }); + }); + }); + }); + + return sequence(addPromisesFactory); + }); + }); + } + + return Promise.resolve(undefined); + } + + private handleExplorerDrop(data: IDragAndDropData, target: ExplorerItem, originalEvent: DragEvent): Promise { + const elementsData = (data as ElementsDragAndDropData).elements; + const items = distinctParents(elementsData, s => s.resource); const isCopy = (originalEvent.ctrlKey && !isMacintosh) || (originalEvent.altKey && isMacintosh); let confirmPromise: Promise; @@ -980,15 +687,15 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { const confirmDragAndDrop = !isCopy && this.configurationService.getValue(FileDragAndDrop.CONFIRM_DND_SETTING_KEY); if (confirmDragAndDrop) { confirmPromise = this.dialogService.confirm({ - message: sources.length > 1 && sources.every(s => s.isRoot) ? nls.localize('confirmRootsMove', "Are you sure you want to change the order of multiple root folders in your workspace?") - : sources.length > 1 ? getConfirmMessage(nls.localize('confirmMultiMove', "Are you sure you want to move the following {0} files?", sources.length), sources.map(s => s.resource)) - : sources[0].isRoot ? nls.localize('confirmRootMove', "Are you sure you want to change the order of root folder '{0}' in your workspace?", sources[0].name) - : nls.localize('confirmMove', "Are you sure you want to move '{0}'?", sources[0].name), + message: items.length > 1 && items.every(s => s.isRoot) ? localize('confirmRootsMove', "Are you sure you want to change the order of multiple root folders in your workspace?") + : items.length > 1 ? getConfirmMessage(localize('confirmMultiMove', "Are you sure you want to move the following {0} files?", items.length), items.map(s => s.resource)) + : items[0].isRoot ? localize('confirmRootMove', "Are you sure you want to change the order of root folder '{0}' in your workspace?", items[0].name) + : localize('confirmMove', "Are you sure you want to move '{0}'?", items[0].name), checkbox: { - label: nls.localize('doNotAskAgain', "Do not ask me again") + label: localize('doNotAskAgain', "Do not ask me again") }, type: 'question', - primaryButton: nls.localize({ key: 'moveButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Move") + primaryButton: localize({ key: 'moveButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Move") }); } else { confirmPromise = Promise.resolve({ confirmed: true } as IConfirmationResult); @@ -1004,8 +711,8 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { return updateConfirmSettingsPromise.then(() => { if (res.confirmed) { - const rootDropPromise = this.doHandleRootDrop(sources.filter(s => s.isRoot), target); - return Promise.all(sources.filter(s => !s.isRoot).map(source => this.doHandleExplorerDrop(tree, source, target, isCopy)).concat(rootDropPromise)).then(() => undefined); + const rootDropPromise = this.doHandleRootDrop(items.filter(s => s.isRoot), target); + return Promise.all(items.filter(s => !s.isRoot).map(source => this.doHandleExplorerDrop(source, target, isCopy)).concat(rootDropPromise)).then(() => undefined); } return Promise.resolve(undefined); @@ -1013,7 +720,7 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { }); } - private doHandleRootDrop(roots: ExplorerItem[], target: ExplorerItem | Model): Promise { + private doHandleRootDrop(roots: ExplorerItem[], target: ExplorerItem): Promise { if (roots.length === 0) { return Promise.resolve(undefined); } @@ -1023,75 +730,68 @@ export class FileDragAndDrop extends SimpleFileResourceDragAndDrop { const workspaceCreationData: IWorkspaceFolderCreationData[] = []; const rootsToMove: IWorkspaceFolderCreationData[] = []; - for (const folder of folders) { + for (let index = 0; index < folders.length; index++) { const data = { - uri: folder.uri + uri: folders[index].uri }; - if (target instanceof ExplorerItem && folder.uri.toString() === target.resource.toString()) { + if (target instanceof ExplorerItem && folders[index].uri.toString() === target.resource.toString()) { targetIndex = workspaceCreationData.length; } - if (roots.every(r => r.resource.toString() !== folder.uri.toString())) { + if (roots.every(r => r.resource.toString() !== folders[index].uri.toString())) { workspaceCreationData.push(data); } else { rootsToMove.push(data); } } - if (target instanceof Model) { - targetIndex = workspaceCreationData.length; - } workspaceCreationData.splice(targetIndex, 0, ...rootsToMove); return this.workspaceEditingService.updateFolders(0, workspaceCreationData.length, workspaceCreationData); } - private doHandleExplorerDrop(tree: ITree, source: ExplorerItem, target: ExplorerItem | Model, isCopy: boolean): Promise { - if (!(target instanceof ExplorerItem)) { - return Promise.resolve(undefined); - } + private doHandleExplorerDrop(source: ExplorerItem, target: ExplorerItem, isCopy: boolean): Promise { + // Reuse duplicate action if user copies + if (isCopy) { - return tree.expand(target).then(() => { - - if (target.isReadonly) { - return undefined; - } - - // Reuse duplicate action if user copies - if (isCopy) { - return this.instantiationService.createInstance(DuplicateFileAction, tree, source, target).run(); - } - - // Otherwise move - const targetResource = resources.joinPath(target.resource, source.name); - - return this.textFileService.move(source.resource, targetResource).then(undefined, error => { - - // Conflict - if ((error).fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) { - const confirm: IConfirmation = { - message: nls.localize('confirmOverwriteMessage', "'{0}' already exists in the destination folder. Do you want to replace it?", source.name), - detail: nls.localize('irreversible', "This action is irreversible!"), - primaryButton: nls.localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), - type: 'warning' - }; - - // Move with overwrite if the user confirms - return this.dialogService.confirm(confirm).then(res => { - if (res.confirmed) { - return this.textFileService.move(source.resource, targetResource, true /* overwrite */).then(undefined, error => this.notificationService.error(error)); - } - - return undefined; - }); - } - - // Any other error - else { - this.notificationService.error(error); + return this.fileService.copyFile(source.resource, findValidPasteFileTarget(target, { resource: source.resource, isDirectory: source.isDirectory })).then(stat => { + if (!stat.isDirectory) { + return this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }).then(() => undefined); } return undefined; }); - }, errors.onUnexpectedError); + } + + // Otherwise move + const targetResource = joinPath(target.resource, source.name); + + return this.textFileService.move(source.resource, targetResource).then(undefined, error => { + + // Conflict + if ((error).fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) { + const confirm: IConfirmation = { + message: localize('confirmOverwriteMessage', "'{0}' already exists in the destination folder. Do you want to replace it?", source.name), + detail: localize('irreversible', "This action is irreversible!"), + primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), + type: 'warning' + }; + + // Move with overwrite if the user confirms + return this.dialogService.confirm(confirm).then(res => { + if (res.confirmed) { + return this.textFileService.move(source.resource, targetResource, true /* overwrite */).then(undefined, error => this.notificationService.error(error)); + } + + return undefined; + }); + } + + // Any other error + else { + this.notificationService.error(error); + } + + return undefined; + }); } } diff --git a/src/vs/workbench/parts/files/electron-browser/views/openEditorsView.ts b/src/vs/workbench/parts/files/electron-browser/views/openEditorsView.ts index a74ef62d051..19c4da353c6 100644 --- a/src/vs/workbench/parts/files/electron-browser/views/openEditorsView.ts +++ b/src/vs/workbench/parts/files/electron-browser/views/openEditorsView.ts @@ -14,9 +14,8 @@ import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/co import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IEditorInput } from 'vs/workbench/common/editor'; import { SaveAllAction, SaveAllInGroupAction, CloseGroupAction } from 'vs/workbench/parts/files/electron-browser/fileActions'; -import { OpenEditorsFocusedContext, ExplorerFocusedContext, IFilesConfiguration } from 'vs/workbench/parts/files/common/files'; +import { OpenEditorsFocusedContext, ExplorerFocusedContext, IFilesConfiguration, OpenEditor } from 'vs/workbench/parts/files/common/files'; import { ITextFileService, AutoSaveMode } from 'vs/workbench/services/textfile/common/textfiles'; -import { OpenEditor } from 'vs/workbench/parts/files/common/explorerModel'; import { IUntitledEditorService } from 'vs/workbench/services/untitled/common/untitledEditorService'; import { CloseAllEditorsAction, CloseEditorAction } from 'vs/workbench/browser/parts/editor/editorActions'; import { ToggleEditorLayoutAction } from 'vs/workbench/browser/actions/toggleEditorLayout'; diff --git a/src/vs/workbench/parts/files/test/electron-browser/explorerModel.test.ts b/src/vs/workbench/parts/files/test/electron-browser/explorerModel.test.ts index e3e14e7d4d6..a5f20068a38 100644 --- a/src/vs/workbench/parts/files/test/electron-browser/explorerModel.test.ts +++ b/src/vs/workbench/parts/files/test/electron-browser/explorerModel.test.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { isUndefinedOrNull } from 'vs/base/common/types'; import { isLinux, isWindows } from 'vs/base/common/platform'; import { URI } from 'vs/base/common/uri'; import { join } from 'vs/base/common/paths'; @@ -12,7 +11,7 @@ import { validateFileName } from 'vs/workbench/parts/files/electron-browser/file import { ExplorerItem } from 'vs/workbench/parts/files/common/explorerModel'; function createStat(path: string, name: string, isFolder: boolean, hasChildren: boolean, size: number, mtime: number): ExplorerItem { - return new ExplorerItem(toResource(path), undefined, false, false, isFolder, name, mtime); + return new ExplorerItem(toResource(path), null, isFolder, false, false, name, mtime); } function toResource(path) { @@ -21,7 +20,6 @@ function toResource(path) { } else { return URI.file(join('/home/john', path)); } - } suite('Files - View Model', () => { @@ -35,10 +33,8 @@ suite('Files - View Model', () => { assert.strictEqual(s.name, 'sName'); assert.strictEqual(s.isDirectory, true); assert.strictEqual(s.mtime, new Date(d).getTime()); - assert.strictEqual(s.getChildrenArray()!.length, 0); s = createStat('/path/to/stat', 'sName', false, false, 8096, d); - assert(isUndefinedOrNull(s.getChildrenArray())); }); test('Add and Remove Child, check for hasChild', function () { @@ -50,14 +46,14 @@ suite('Files - View Model', () => { s.addChild(child1); - assert(s.getChildrenArray()!.length === 1); + assert(!!s.getChild(child1.name)); s.removeChild(child1); s.addChild(child1); - assert(s.getChildrenArray()!.length === 1); + assert(!!s.getChild(child1.name)); s.removeChild(child1); - assert(s.getChildrenArray()!.length === 0); + assert(!s.getChild(child1.name)); // Assert that adding a child updates its path properly s.addChild(child4); @@ -78,10 +74,6 @@ suite('Files - View Model', () => { s4.move(s1); - assert.strictEqual(s3.getChildrenArray()!.length, 0); - - assert.strictEqual(s1.getChildrenArray()!.length, 2); - // Assert the new path of the moved element assert.strictEqual(s4.resource.fsPath, toResource('/' + s4.name).fsPath); @@ -210,8 +202,7 @@ suite('Files - View Model', () => { assert(validateFileName(s, 'foo>bar') !== null); assert(validateFileName(s, 'foo|bar') !== null); } - assert(validateFileName(s, 'alles.klar') !== null); - + assert(validateFileName(s, 'alles.klar') === null); assert(validateFileName(s, '.foo') === null); assert(validateFileName(s, 'foo.bar') === null); assert(validateFileName(s, 'foo') === null); @@ -223,15 +214,10 @@ suite('Files - View Model', () => { const sChild = createStat('/path/to/stat/alles.klar', 'alles.klar', true, true, 8096, d); s.addChild(sChild); - assert(validateFileName(s, 'alles.klar') !== null); + assert(validateFileName(s, 'alles.klar') === null); - if (isLinux) { - assert(validateFileName(s, 'Alles.klar') === null); - assert(validateFileName(s, 'Alles.Klar') === null); - } else { - assert(validateFileName(s, 'Alles.klar') !== null); - assert(validateFileName(s, 'Alles.Klar') !== null); - } + assert(validateFileName(s, 'Alles.klar') === null); + assert(validateFileName(s, 'Alles.Klar') === null); assert(validateFileName(s, '.foo') === null); assert(validateFileName(s, 'foo.bar') === null); @@ -267,27 +253,25 @@ suite('Files - View Model', () => { test('Merge Local with Disk', function () { const d = new Date().toUTCString(); - const merge1 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), undefined, false, false, true, 'to', Date.now(), d); - const merge2 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), undefined, false, false, true, 'to', Date.now(), new Date(0).toUTCString()); + const merge1 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), undefined, true, false, false, 'to', Date.now(), d); + const merge2 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), undefined, true, false, false, 'to', Date.now(), new Date(0).toUTCString()); // Merge Properties ExplorerItem.mergeLocalWithDisk(merge2, merge1); assert.strictEqual(merge1.mtime, merge2.mtime); // Merge Child when isDirectoryResolved=false is a no-op - merge2.addChild(new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), undefined, false, false, true, 'foo.html', Date.now(), d)); + merge2.addChild(new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), undefined, true, false, false, 'foo.html', Date.now(), d)); ExplorerItem.mergeLocalWithDisk(merge2, merge1); - assert.strictEqual(merge1.getChildrenArray()!.length, 0); // Merge Child with isDirectoryResolved=true - const child = new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), undefined, false, false, true, 'foo.html', Date.now(), d); + const child = new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), undefined, true, false, false, 'foo.html', Date.now(), d); merge2.removeChild(child); merge2.addChild(child); merge2.isDirectoryResolved = true; ExplorerItem.mergeLocalWithDisk(merge2, merge1); - assert.strictEqual(merge1.getChildrenArray()!.length, 1); - assert.strictEqual(merge1.getChild('foo.html')!.name, 'foo.html'); - assert.deepEqual(merge1.getChild('foo.html')!.parent, merge1, 'Check parent'); + assert.strictEqual(merge1.getChild('foo.html').name, 'foo.html'); + assert.deepEqual(merge1.getChild('foo.html').parent, merge1, 'Check parent'); // Verify that merge does not replace existing children, but updates properties in that case const existingChild = merge1.getChild('foo.html'); diff --git a/src/vs/workbench/services/themes/electron-browser/fileIconThemeData.ts b/src/vs/workbench/services/themes/electron-browser/fileIconThemeData.ts index d1f0a073413..57f7a7a8669 100644 --- a/src/vs/workbench/services/themes/electron-browser/fileIconThemeData.ts +++ b/src/vs/workbench/services/themes/electron-browser/fileIconThemeData.ts @@ -196,7 +196,8 @@ function _processIconThemeDocument(id: string, iconThemeDocumentLocation: URI, i qualifier = baseThemeClassName + ' ' + qualifier; } - let expanded = '.monaco-tree-row.expanded'; // workaround for #11453 + const expanded = '.monaco-tree-row.expanded'; // workaround for #11453 + const expanded2 = '.monaco-tl-twistie.collapsible:not(.collapsed) + .monaco-tl-contents'; // new tree if (associations.folder) { addSelector(`${qualifier} .folder-icon::before`, associations.folder); @@ -205,6 +206,7 @@ function _processIconThemeDocument(id: string, iconThemeDocumentLocation: URI, i if (associations.folderExpanded) { addSelector(`${qualifier} ${expanded} .folder-icon::before`, associations.folderExpanded); + addSelector(`${qualifier} ${expanded2} .folder-icon::before`, associations.folderExpanded); result.hasFolderIcons = true; } @@ -218,6 +220,7 @@ function _processIconThemeDocument(id: string, iconThemeDocumentLocation: URI, i if (rootFolderExpanded) { addSelector(`${qualifier} ${expanded} .rootfolder-icon::before`, rootFolderExpanded); + addSelector(`${qualifier} ${expanded2} .rootfolder-icon::before`, rootFolderExpanded); result.hasFolderIcons = true; } @@ -237,6 +240,7 @@ function _processIconThemeDocument(id: string, iconThemeDocumentLocation: URI, i if (folderNamesExpanded) { for (let folderName in folderNamesExpanded) { addSelector(`${qualifier} ${expanded} .${escapeCSS(folderName.toLowerCase())}-name-folder-icon.folder-icon::before`, folderNamesExpanded[folderName]); + addSelector(`${qualifier} ${expanded2} .${escapeCSS(folderName.toLowerCase())}-name-folder-icon.folder-icon::before`, folderNamesExpanded[folderName]); result.hasFolderIcons = true; } }