diff --git a/extensions/git/package.json b/extensions/git/package.json index 66b8731c6d9..efa8d35b724 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -850,6 +850,53 @@ "group": "inline" } ], + "scm/resourceFolder/context": [ + { + "command": "git.stage", + "when": "scmProvider == git && scmResourceGroup == merge", + "group": "1_modification" + }, + { + "command": "git.stage", + "when": "scmProvider == git && scmResourceGroup == merge", + "group": "inline" + }, + { + "command": "git.unstage", + "when": "scmProvider == git && scmResourceGroup == index", + "group": "1_modification" + }, + { + "command": "git.unstage", + "when": "scmProvider == git && scmResourceGroup == index", + "group": "inline" + }, + { + "command": "git.stage", + "when": "scmProvider == git && scmResourceGroup == workingTree", + "group": "1_modification" + }, + { + "command": "git.clean", + "when": "scmProvider == git && scmResourceGroup == workingTree && !gitFreshRepository", + "group": "1_modification" + }, + { + "command": "git.clean", + "when": "scmProvider == git && scmResourceGroup == workingTree && !gitFreshRepository", + "group": "inline" + }, + { + "command": "git.stage", + "when": "scmProvider == git && scmResourceGroup == workingTree", + "group": "inline" + }, + { + "command": "git.ignore", + "when": "scmProvider == git && scmResourceGroup == workingTree", + "group": "1_modification@3" + } + ], "scm/resourceState/context": [ { "command": "git.stage", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index b4cbdad0986..6587b65421d 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -55,7 +55,7 @@ "command.syncRebase": "Sync (Rebase)", "command.publish": "Publish Branch", "command.showOutput": "Show Git Output", - "command.ignore": "Add File to .gitignore", + "command.ignore": "Add to .gitignore", "command.stashIncludeUntracked": "Stash (Include Untracked)", "command.stash": "Stash", "command.stashPop": "Pop Stash...", diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index ad6f5eee08c..bb60cd87193 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -79,7 +79,10 @@ export interface IKeyboardNavigationLabelProvider { * element always match. */ getKeyboardNavigationLabel(element: T): { toString(): string | undefined; } | undefined; - mightProducePrintableCharacter?(event: IKeyboardEvent): boolean; +} + +export interface IKeyboardNavigationDelegate { + mightProducePrintableCharacter(event: IKeyboardEvent): boolean; } export const enum ListDragOverEffect { diff --git a/src/vs/base/browser/ui/list/listPaging.ts b/src/vs/base/browser/ui/list/listPaging.ts index 1aba4dc1f2c..6f0ea59ff10 100644 --- a/src/vs/base/browser/ui/list/listPaging.ts +++ b/src/vs/base/browser/ui/list/listPaging.ts @@ -127,7 +127,7 @@ export class PagedList implements IDisposable { } get onPin(): Event> { - return Event.map(this.list.onPin, ({ elements, indexes }) => ({ elements: elements.map(e => this._model.get(e)), indexes })); + return Event.map(this.list.onDidPin, ({ elements, indexes }) => ({ elements: elements.map(e => this._model.get(e)), indexes })); } get onContextMenu(): Event> { diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index ab64dd8bdd4..a969eab5f45 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -16,7 +16,7 @@ import { KeyCode } from 'vs/base/common/keyCodes'; import { StandardKeyboardEvent, IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { Event, Emitter, EventBufferer } from 'vs/base/common/event'; import { domEvent } from 'vs/base/browser/event'; -import { IListVirtualDelegate, IListRenderer, IListEvent, IListContextMenuEvent, IListMouseEvent, IListTouchEvent, IListGestureEvent, IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, ListAriaRootRole, ListError } from './list'; +import { IListVirtualDelegate, IListRenderer, IListEvent, IListContextMenuEvent, IListMouseEvent, IListTouchEvent, IListGestureEvent, IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, ListAriaRootRole, ListError, IKeyboardNavigationDelegate } from './list'; import { ListView, IListViewOptions, IListViewDragAndDrop, IAriaProvider } from './listView'; import { Color } from 'vs/base/common/color'; import { mixin } from 'vs/base/common/objects'; @@ -322,16 +322,18 @@ enum TypeLabelControllerState { Typing } -export function mightProducePrintableCharacter(event: IKeyboardEvent): boolean { - if (event.ctrlKey || event.metaKey || event.altKey) { - return false; - } +export const DefaultKeyboardNavigationDelegate = new class implements IKeyboardNavigationDelegate { + mightProducePrintableCharacter(event: IKeyboardEvent): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) { + return false; + } - return (event.keyCode >= KeyCode.KEY_A && event.keyCode <= KeyCode.KEY_Z) - || (event.keyCode >= KeyCode.KEY_0 && event.keyCode <= KeyCode.KEY_9) - || (event.keyCode >= KeyCode.NUMPAD_0 && event.keyCode <= KeyCode.NUMPAD_9) - || (event.keyCode >= KeyCode.US_SEMICOLON && event.keyCode <= KeyCode.US_QUOTE); -} + return (event.keyCode >= KeyCode.KEY_A && event.keyCode <= KeyCode.KEY_Z) + || (event.keyCode >= KeyCode.KEY_0 && event.keyCode <= KeyCode.KEY_9) + || (event.keyCode >= KeyCode.NUMPAD_0 && event.keyCode <= KeyCode.NUMPAD_9) + || (event.keyCode >= KeyCode.US_SEMICOLON && event.keyCode <= KeyCode.US_QUOTE); + } +}; class TypeLabelController implements IDisposable { @@ -347,7 +349,8 @@ class TypeLabelController implements IDisposable { constructor( private list: List, private view: ListView, - private keyboardNavigationLabelProvider: IKeyboardNavigationLabelProvider + private keyboardNavigationLabelProvider: IKeyboardNavigationLabelProvider, + private delegate: IKeyboardNavigationDelegate ) { this.updateOptions(list.options); } @@ -379,7 +382,7 @@ class TypeLabelController implements IDisposable { .filter(e => !isInputElement(e.target as HTMLElement)) .filter(() => this.automaticKeyboardNavigation || this.triggered) .map(event => new StandardKeyboardEvent(event)) - .filter(this.keyboardNavigationLabelProvider.mightProducePrintableCharacter ? e => this.keyboardNavigationLabelProvider.mightProducePrintableCharacter!(e) : e => mightProducePrintableCharacter(e)) + .filter(e => this.delegate.mightProducePrintableCharacter(e)) .forEach(e => { e.stopPropagation(); e.preventDefault(); }) .map(event => event.browserEvent.key) .event; @@ -818,6 +821,7 @@ export interface IListOptions extends IListStyles { readonly enableKeyboardNavigation?: boolean; readonly automaticKeyboardNavigation?: boolean; readonly keyboardNavigationLabelProvider?: IKeyboardNavigationLabelProvider; + readonly keyboardNavigationDelegate?: IKeyboardNavigationDelegate; readonly ariaRole?: ListAriaRootRole; readonly ariaLabel?: string; readonly keyboardSupport?: boolean; @@ -1110,10 +1114,8 @@ export class List implements ISpliceable, IDisposable { private _onDidOpen = new Emitter>(); readonly onDidOpen: Event> = this._onDidOpen.event; - private _onPin = new Emitter(); - @memoize get onPin(): Event> { - return Event.map(this._onPin.event, indexes => this.toListEvent({ indexes })); - } + private _onDidPin = new Emitter>(); + readonly onDidPin: Event> = this._onDidPin.event; get domId(): string { return this.view.domId; } get onDidScroll(): Event { return this.view.onDidScroll; } @@ -1228,7 +1230,8 @@ export class List implements ISpliceable, IDisposable { } if (_options.keyboardNavigationLabelProvider) { - this.typeLabelController = new TypeLabelController(this, this.view, _options.keyboardNavigationLabelProvider); + const delegate = _options.keyboardNavigationDelegate || DefaultKeyboardNavigationDelegate; + this.typeLabelController = new TypeLabelController(this, this.view, _options.keyboardNavigationLabelProvider, delegate); this.disposables.add(this.typeLabelController); } @@ -1582,14 +1585,14 @@ export class List implements ISpliceable, IDisposable { this._onDidOpen.fire({ indexes, elements: indexes.map(i => this.view.element(i)), browserEvent }); } - pin(indexes: number[]): void { + pin(indexes: number[], browserEvent?: UIEvent): void { for (const index of indexes) { if (index < 0 || index >= this.length) { throw new ListError(this.user, `Invalid index ${index}`); } } - this._onPin.fire(indexes); + this._onDidPin.fire({ indexes, elements: indexes.map(i => this.view.element(i)), browserEvent }); } style(styles: IListStyles): void { @@ -1626,7 +1629,7 @@ export class List implements ISpliceable, IDisposable { this.disposables.dispose(); this._onDidOpen.dispose(); - this._onPin.dispose(); + this._onDidPin.dispose(); this._onDidDispose.dispose(); } } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 2f739b87132..610a5374551 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -5,11 +5,11 @@ import 'vs/css!./media/tree'; import { IDisposable, dispose, Disposable, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { IListOptions, List, IListStyles, mightProducePrintableCharacter, MouseController } from 'vs/base/browser/ui/list/listWidget'; -import { IListVirtualDelegate, IListRenderer, IListMouseEvent, IListEvent, IListContextMenuEvent, IListDragAndDrop, IListDragOverReaction, IKeyboardNavigationLabelProvider, IIdentityProvider } from 'vs/base/browser/ui/list/list'; +import { IListOptions, List, IListStyles, MouseController, DefaultKeyboardNavigationDelegate } from 'vs/base/browser/ui/list/listWidget'; +import { IListVirtualDelegate, IListRenderer, IListMouseEvent, IListEvent, IListContextMenuEvent, IListDragAndDrop, IListDragOverReaction, IKeyboardNavigationLabelProvider, IIdentityProvider, IKeyboardNavigationDelegate } from 'vs/base/browser/ui/list/list'; import { append, $, toggleClass, getDomNodePagePosition, removeClass, addClass, hasClass, hasParentWithClass, createStyleSheet, clearNode } from 'vs/base/browser/dom'; import { Event, Relay, Emitter, EventBufferer } from 'vs/base/common/event'; -import { StandardKeyboardEvent, IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyCode } from 'vs/base/common/keyCodes'; import { ITreeModel, ITreeNode, ITreeRenderer, ITreeEvent, ITreeMouseEvent, ITreeContextMenuEvent, ITreeFilter, ITreeNavigator, ICollapseStateChangeEvent, ITreeDragAndDrop, TreeDragOverBubble, TreeVisibility, TreeFilterResult, ITreeModelSpliceEvent, TreeMouseEventTarget } from 'vs/base/browser/ui/tree/tree'; import { ISpliceable } from 'vs/base/common/sequence'; @@ -592,7 +592,7 @@ class TypeFilterController implements IDisposable { model: ITreeModel, private view: List>, private filter: TypeFilter, - private keyboardNavigationLabelProvider: IKeyboardNavigationLabelProvider + private keyboardNavigationDelegate: IKeyboardNavigationDelegate ) { this.domNode = $(`.monaco-list-type-filter.${this.positionClassName}`); this.domNode.draggable = true; @@ -658,13 +658,12 @@ class TypeFilterController implements IDisposable { return; } - const isPrintableCharEvent = this.keyboardNavigationLabelProvider.mightProducePrintableCharacter ? (e: IKeyboardEvent) => this.keyboardNavigationLabelProvider.mightProducePrintableCharacter!(e) : (e: IKeyboardEvent) => mightProducePrintableCharacter(e); const onKeyDown = Event.chain(domEvent(this.view.getHTMLElement(), 'keydown')) .filter(e => !isInputElement(e.target as HTMLElement) || e.target === this.filterOnTypeDomNode) .map(e => new StandardKeyboardEvent(e)) .filter(this.keyboardNavigationEventFilter || (() => true)) .filter(() => this.automaticKeyboardNavigation || this.triggered) - .filter(e => isPrintableCharEvent(e) || ((this.pattern.length > 0 || this.triggered) && ((e.keyCode === KeyCode.Escape || e.keyCode === KeyCode.Backspace) && !e.altKey && !e.ctrlKey && !e.metaKey) || (e.keyCode === KeyCode.Backspace && (isMacintosh ? (e.altKey && !e.metaKey) : e.ctrlKey) && !e.shiftKey))) + .filter(e => this.keyboardNavigationDelegate.mightProducePrintableCharacter(e) || ((this.pattern.length > 0 || this.triggered) && ((e.keyCode === KeyCode.Escape || e.keyCode === KeyCode.Backspace) && !e.altKey && !e.ctrlKey && !e.metaKey) || (e.keyCode === KeyCode.Backspace && (isMacintosh ? (e.altKey && !e.metaKey) : e.ctrlKey) && !e.shiftKey))) .forEach(e => { e.stopPropagation(); e.preventDefault(); }) .event; @@ -1189,6 +1188,7 @@ export abstract class AbstractTree implements IDisposable get onDidChangeFocus(): Event> { return this.eventBufferer.wrapEvent(this.focus.onDidChange); } get onDidChangeSelection(): Event> { return this.eventBufferer.wrapEvent(this.selection.onDidChange); } get onDidOpen(): Event> { return Event.map(this.view.onDidOpen, asTreeEvent); } + get onDidPin(): Event> { return Event.map(this.view.onDidPin, asTreeEvent); } get onMouseClick(): Event> { return Event.map(this.view.onMouseClick, asTreeMouseEvent); } get onMouseDblClick(): Event> { return Event.map(this.view.onMouseDblClick, asTreeMouseEvent); } @@ -1228,7 +1228,7 @@ export abstract class AbstractTree implements IDisposable const treeDelegate = new ComposedTreeDelegate>(delegate); const onDidChangeCollapseStateRelay = new Relay>(); - const onDidChangeActiveNodes = new Relay[]>(); + const onDidChangeActiveNodes = new Emitter[]>(); const activeNodes = new EventCollection(onDidChangeActiveNodes.event); this.disposables.push(activeNodes); @@ -1251,11 +1251,23 @@ export abstract class AbstractTree implements IDisposable onDidChangeCollapseStateRelay.input = this.model.onDidChangeCollapseState; this.model.onDidSplice(e => { - this.focus.onDidModelSplice(e); - this.selection.onDidModelSplice(e); - }, null, this.disposables); + this.eventBufferer.bufferEvents(() => { + this.focus.onDidModelSplice(e); + this.selection.onDidModelSplice(e); + }); - onDidChangeActiveNodes.input = Event.map(Event.any(this.focus.onDidChange, this.selection.onDidChange, this.model.onDidSplice), () => [...this.focus.getNodes(), ...this.selection.getNodes()]); + const set = new Set>(); + + for (const node of this.focus.getNodes()) { + set.add(node); + } + + for (const node of this.selection.getNodes()) { + set.add(node); + } + + onDidChangeActiveNodes.fire(Array.from(set.values())); + }, null, this.disposables); if (_options.keyboardSupport !== false) { const onKeyDown = Event.chain(this.view.onKeyDown) @@ -1268,7 +1280,8 @@ export abstract class AbstractTree implements IDisposable } if (_options.keyboardNavigationLabelProvider) { - this.typeFilterController = new TypeFilterController(this, this.model, this.view, filter!, _options.keyboardNavigationLabelProvider); + const delegate = _options.keyboardNavigationDelegate || DefaultKeyboardNavigationDelegate; + this.typeFilterController = new TypeFilterController(this, this.model, this.view, filter!, delegate); this.focusNavigationFilter = node => this.typeFilterController!.shouldAllowFocus(node); this.disposables.push(this.typeFilterController!); } diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 1c9aa7ff1e5..6e6efad8cac 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ComposedTreeDelegate, IAbstractTreeOptions, IAbstractTreeOptionsUpdate } from 'vs/base/browser/ui/tree/abstractTree'; -import { ObjectTree, IObjectTreeOptions, CompressibleObjectTree, ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; +import { ObjectTree, IObjectTreeOptions, CompressibleObjectTree, ICompressibleTreeRenderer, ICompressibleKeyboardNavigationLabelProvider, ICompressibleObjectTreeOptions } from 'vs/base/browser/ui/tree/objectTree'; import { IListVirtualDelegate, IIdentityProvider, IListDragAndDrop, IListDragOverReaction } from 'vs/base/browser/ui/list/list'; import { ITreeElement, ITreeNode, ITreeRenderer, ITreeEvent, ITreeMouseEvent, ITreeContextMenuEvent, ITreeSorter, ICollapseStateChangeEvent, IAsyncDataSource, ITreeDragAndDrop, TreeError, WeakMapper } from 'vs/base/browser/ui/tree/tree'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; @@ -1010,6 +1010,24 @@ export interface ITreeCompressionDelegate { isIncompressible(element: T): boolean; } +function asCompressibleObjectTreeOptions(options?: ICompressibleAsyncDataTreeOptions): ICompressibleObjectTreeOptions, TFilterData> | undefined { + const objectTreeOptions = options && asObjectTreeOptions(options); + + return objectTreeOptions && { + ...objectTreeOptions, + keyboardNavigationLabelProvider: objectTreeOptions.keyboardNavigationLabelProvider && { + ...objectTreeOptions.keyboardNavigationLabelProvider, + getCompressedNodeKeyboardNavigationLabel(els) { + return options!.keyboardNavigationLabelProvider!.getCompressedNodeKeyboardNavigationLabel(els.map(e => e.element as T)); + } + } + }; +} + +export interface ICompressibleAsyncDataTreeOptions extends IAsyncDataTreeOptions { + readonly keyboardNavigationLabelProvider?: ICompressibleKeyboardNavigationLabelProvider; +} + export class CompressibleAsyncDataTree extends AsyncDataTree { protected readonly compressibleNodeMapper: CompressibleAsyncDataTreeNodeMapper = new WeakMapper(node => new CompressibleAsyncDataTreeNodeWrapper(node)); @@ -1031,11 +1049,11 @@ export class CompressibleAsyncDataTree extends As container: HTMLElement, delegate: IListVirtualDelegate, renderers: ICompressibleTreeRenderer[], - options: IAsyncDataTreeOptions + options: ICompressibleAsyncDataTreeOptions ): ObjectTree, TFilterData> { const objectTreeDelegate = new ComposedTreeDelegate>(delegate); const objectTreeRenderers = renderers.map(r => new CompressibleAsyncDataTreeRenderer(r, this.nodeMapper, () => this.compressibleNodeMapper, this._onDidChangeNodeSlowState.event)); - const objectTreeOptions = asObjectTreeOptions(options) || {}; + const objectTreeOptions = asCompressibleObjectTreeOptions(options) || {}; return new CompressibleObjectTree(user, container, objectTreeDelegate, objectTreeRenderers, objectTreeOptions); } diff --git a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts index 5551203b350..7abfbd15b6a 100644 --- a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts +++ b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts @@ -336,7 +336,7 @@ function mapOptions(compressedNodeUnwrapper: CompressedNodeUnwra ...options, sorter: options.sorter && { compare(node: ICompressedTreeNode, otherNode: ICompressedTreeNode): number { - return options.sorter!.compare(compressedNodeUnwrapper(node), compressedNodeUnwrapper(otherNode)); + return options.sorter!.compare(node.elements[0], otherNode.elements[0]); } }, identityProvider: options.identityProvider && { diff --git a/src/vs/base/browser/ui/tree/dataTree.ts b/src/vs/base/browser/ui/tree/dataTree.ts index f9f8263655e..9771eb4a544 100644 --- a/src/vs/base/browser/ui/tree/dataTree.ts +++ b/src/vs/base/browser/ui/tree/dataTree.ts @@ -162,9 +162,10 @@ export class DataTree extends AbstractTree>(Iterator.fromArray(children), element => { const { elements: children, size } = this.iterate(element, isCollapsed); + const collapsible = this.dataSource.hasChildren ? this.dataSource.hasChildren(element) : undefined; const collapsed = size === 0 ? undefined : (isCollapsed && isCollapsed(element)); - return { element, children, collapsed }; + return { element, children, collapsible, collapsed }; }); return { elements, size: children.length }; diff --git a/src/vs/base/browser/ui/tree/indexTreeModel.ts b/src/vs/base/browser/ui/tree/indexTreeModel.ts index 6911c5fac8d..182caa98612 100644 --- a/src/vs/base/browser/ui/tree/indexTreeModel.ts +++ b/src/vs/base/browser/ui/tree/indexTreeModel.ts @@ -169,7 +169,7 @@ export class IndexTreeModel, TFilterData = voi parentNode.visibleChildrenCount += insertedVisibleChildrenCount - deletedVisibleChildrenCount; if (revealed && visible) { - const visibleDeleteCount = deletedNodes.reduce((r, node) => r + node.renderNodeCount, 0); + const visibleDeleteCount = deletedNodes.reduce((r, node) => r + (node.visible ? node.renderNodeCount : 0), 0); this._updateAncestorsRenderNodeCount(parentNode, renderNodeCount - visibleDeleteCount); this.list.splice(listIndex, visibleDeleteCount, treeListElementsToInsert); diff --git a/src/vs/base/browser/ui/tree/objectTree.ts b/src/vs/base/browser/ui/tree/objectTree.ts index 243bb7aab21..74203443bac 100644 --- a/src/vs/base/browser/ui/tree/objectTree.ts +++ b/src/vs/base/browser/ui/tree/objectTree.ts @@ -8,9 +8,10 @@ import { AbstractTree, IAbstractTreeOptions } from 'vs/base/browser/ui/tree/abst import { ISpliceable } from 'vs/base/common/sequence'; import { ITreeNode, ITreeModel, ITreeElement, ITreeRenderer, ITreeSorter, ICollapseStateChangeEvent } from 'vs/base/browser/ui/tree/tree'; import { ObjectTreeModel, IObjectTreeModel } from 'vs/base/browser/ui/tree/objectTreeModel'; -import { IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; +import { IListVirtualDelegate, IKeyboardNavigationLabelProvider } from 'vs/base/browser/ui/list/list'; import { Event } from 'vs/base/common/event'; import { CompressibleObjectTreeModel, ElementMapper, ICompressedTreeNode, ICompressedTreeElement } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; +import { memoize } from 'vs/base/common/decorators'; export interface IObjectTreeOptions extends IAbstractTreeOptions { sorter?: ITreeSorter; @@ -77,9 +78,12 @@ class CompressibleRenderer implements ITreeRender readonly templateId: string; readonly onDidChangeTwistieState: Event | undefined; - compressedTreeNodeProvider: ICompressedTreeNodeProvider; + @memoize + private get compressedTreeNodeProvider(): ICompressedTreeNodeProvider { + return this._compressedTreeNodeProvider(); + } - constructor(private renderer: ICompressibleTreeRenderer) { + constructor(private _compressedTreeNodeProvider: () => ICompressedTreeNodeProvider, private renderer: ICompressibleTreeRenderer) { this.templateId = renderer.templateId; if (renderer.onDidChangeTwistieState) { @@ -127,6 +131,37 @@ class CompressibleRenderer implements ITreeRender } } +export interface ICompressibleKeyboardNavigationLabelProvider extends IKeyboardNavigationLabelProvider { + getCompressedNodeKeyboardNavigationLabel(elements: T[]): { toString(): string | undefined; } | undefined; +} + +export interface ICompressibleObjectTreeOptions extends IObjectTreeOptions { + readonly keyboardNavigationLabelProvider?: ICompressibleKeyboardNavigationLabelProvider; +} + +function asObjectTreeOptions(compressedTreeNodeProvider: () => ICompressedTreeNodeProvider, options?: ICompressibleObjectTreeOptions): IObjectTreeOptions | undefined { + return options && { + ...options, + keyboardNavigationLabelProvider: options.keyboardNavigationLabelProvider && { + getKeyboardNavigationLabel(e: T) { + let compressedTreeNode: ITreeNode, TFilterData>; + + try { + compressedTreeNode = compressedTreeNodeProvider().getCompressedTreeNode(e); + } catch { + return options.keyboardNavigationLabelProvider!.getKeyboardNavigationLabel(e); + } + + if (compressedTreeNode.element.elements.length === 1) { + return options.keyboardNavigationLabelProvider!.getKeyboardNavigationLabel(e); + } else { + return options.keyboardNavigationLabelProvider!.getCompressedNodeKeyboardNavigationLabel(compressedTreeNode.element.elements); + } + } + } + }; +} + export class CompressibleObjectTree, TFilterData = void> extends ObjectTree { protected model: CompressibleObjectTreeModel; @@ -136,11 +171,11 @@ export class CompressibleObjectTree, TFilterData = vo container: HTMLElement, delegate: IListVirtualDelegate, renderers: ICompressibleTreeRenderer[], - options: IObjectTreeOptions = {} + options: ICompressibleObjectTreeOptions = {} ) { - const compressibleRenderers = renderers.map(r => new CompressibleRenderer(r)); - super(user, container, delegate, compressibleRenderers, options); - compressibleRenderers.forEach(r => r.compressedTreeNodeProvider = this); + const compressedTreeNodeProvider = () => this; + const compressibleRenderers = renderers.map(r => new CompressibleRenderer(compressedTreeNodeProvider, r)); + super(user, container, delegate, compressibleRenderers, asObjectTreeOptions(compressedTreeNodeProvider, options)); } setChildren(element: T | null, children?: ISequence>): void { diff --git a/src/vs/base/browser/ui/tree/objectTreeModel.ts b/src/vs/base/browser/ui/tree/objectTreeModel.ts index 33fde84435c..d21b8ba3836 100644 --- a/src/vs/base/browser/ui/tree/objectTreeModel.ts +++ b/src/vs/base/browser/ui/tree/objectTreeModel.ts @@ -9,6 +9,7 @@ import { IndexTreeModel, IIndexTreeModelOptions } from 'vs/base/browser/ui/tree/ import { Event } from 'vs/base/common/event'; import { ITreeModel, ITreeNode, ITreeElement, ITreeSorter, ICollapseStateChangeEvent, ITreeModelSpliceEvent, TreeError } from 'vs/base/browser/ui/tree/tree'; import { IIdentityProvider } from 'vs/base/browser/ui/list/list'; +import { mergeSort } from 'vs/base/common/arrays'; export type ITreeNodeCallback = (node: ITreeNode) => void; @@ -123,7 +124,7 @@ export class ObjectTreeModel, TFilterData extends Non let iterator = elements ? getSequenceIterator(elements) : Iterator.empty>(); if (this.sorter) { - iterator = Iterator.fromArray(Iterator.collect(iterator).sort(this.sorter.compare.bind(this.sorter))); + iterator = Iterator.fromArray(mergeSort(Iterator.collect(iterator), this.sorter.compare.bind(this.sorter))); } return Iterator.map(iterator, treeElement => { diff --git a/src/vs/base/browser/ui/tree/tree.ts b/src/vs/base/browser/ui/tree/tree.ts index bd94fd3d21b..374296fa573 100644 --- a/src/vs/base/browser/ui/tree/tree.ts +++ b/src/vs/base/browser/ui/tree/tree.ts @@ -164,6 +164,7 @@ export interface ITreeNavigator { } export interface IDataSource { + hasChildren?(element: TInput | T): boolean; getChildren(element: TInput | T): T[]; } diff --git a/src/vs/base/common/iterator.ts b/src/vs/base/common/iterator.ts index 7a3c234ba56..5dee646f4a9 100644 --- a/src/vs/base/common/iterator.ts +++ b/src/vs/base/common/iterator.ts @@ -56,6 +56,20 @@ export module Iterator { }; } + export function fromIterableIterator(it: IterableIterator): Iterator { + return { + next(): IteratorResult { + const result = it.next(); + + if (result.done) { + return FIN; + } + + return { done: false, value: result.value }; + } + }; + } + export function from(elements: Iterator | T[] | undefined): Iterator { if (!elements) { return Iterator.empty(); diff --git a/src/vs/base/common/resourceTree.ts b/src/vs/base/common/resourceTree.ts new file mode 100644 index 00000000000..094c49dc2e8 --- /dev/null +++ b/src/vs/base/common/resourceTree.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { memoize } from 'vs/base/common/decorators'; +import * as paths from 'vs/base/common/path'; +import { Iterator } from 'vs/base/common/iterator'; +import { relativePath, joinPath } from 'vs/base/common/resources'; +import { URI } from 'vs/base/common/uri'; + +export interface ILeafNode { + readonly uri: URI; + readonly relativePath: string; + readonly name: string; + readonly element: T; + readonly context: C; +} + +export interface IBranchNode { + readonly uri: URI; + readonly relativePath: string; + readonly name: string; + readonly size: number; + readonly children: Iterator>; + readonly parent: IBranchNode | undefined; + readonly context: C; + get(childName: string): INode | undefined; +} + +export type INode = IBranchNode | ILeafNode; + +// Internals + +class Node { + + @memoize + get name(): string { return paths.posix.basename(this.relativePath); } + + constructor(readonly uri: URI, readonly relativePath: string, readonly context: C) { } +} + +class BranchNode extends Node implements IBranchNode { + + private _children = new Map | LeafNode>(); + + get size(): number { + return this._children.size; + } + + get children(): Iterator | LeafNode> { + return Iterator.fromIterableIterator(this._children.values()); + } + + constructor(uri: URI, relativePath: string, context: C, readonly parent: IBranchNode | undefined = undefined) { + super(uri, relativePath, context); + } + + get(path: string): BranchNode | LeafNode | undefined { + return this._children.get(path); + } + + set(path: string, child: BranchNode | LeafNode): void { + this._children.set(path, child); + } + + delete(path: string): void { + this._children.delete(path); + } +} + +class LeafNode extends Node implements ILeafNode { + + constructor(uri: URI, path: string, context: C, readonly element: T) { + super(uri, path, context); + } +} + +function collect(node: INode, result: T[]): T[] { + if (ResourceTree.isBranchNode(node)) { + Iterator.forEach(node.children, child => collect(child, result)); + } else { + result.push(node.element); + } + + return result; +} + +export class ResourceTree, C> { + + readonly root: BranchNode; + + static isBranchNode(obj: any): obj is IBranchNode { + return obj instanceof BranchNode; + } + + static getRoot(node: IBranchNode): IBranchNode { + while (node.parent) { + node = node.parent; + } + + return node; + } + + static collect(node: INode): T[] { + return collect(node, []); + } + + constructor(context: C, rootURI: URI = URI.file('/')) { + this.root = new BranchNode(rootURI, '', context); + } + + add(uri: URI, element: T): void { + const key = relativePath(this.root.uri, uri) || uri.fsPath; + const parts = key.split(/[\\\/]/).filter(p => !!p); + let node = this.root; + let path = ''; + + for (let i = 0; i < parts.length; i++) { + const name = parts[i]; + path = path + '/' + name; + + let child = node.get(name); + + if (!child) { + if (i < parts.length - 1) { + child = new BranchNode(joinPath(this.root.uri, path), path, this.root.context, node); + node.set(name, child); + } else { + child = new LeafNode(uri, path, this.root.context, element); + node.set(name, child); + return; + } + } + + if (!(child instanceof BranchNode)) { + if (i < parts.length - 1) { + throw new Error('Inconsistent tree: can\'t override leaf with branch.'); + } + + // replace + node.set(name, new LeafNode(uri, path, this.root.context, element)); + return; + } else if (i === parts.length - 1) { + throw new Error('Inconsistent tree: can\'t override branch with leaf.'); + } + + node = child; + } + } + + delete(uri: URI): T | undefined { + const key = relativePath(this.root.uri, uri) || uri.fsPath; + const parts = key.split(/[\\\/]/).filter(p => !!p); + return this._delete(this.root, parts, 0); + } + + private _delete(node: BranchNode, parts: string[], index: number): T | undefined { + const name = parts[index]; + const child = node.get(name); + + if (!child) { + return undefined; + } + + // not at end + if (index < parts.length - 1) { + if (child instanceof BranchNode) { + const result = this._delete(child, parts, index + 1); + + if (typeof result !== 'undefined' && child.size === 0) { + node.delete(name); + } + + return result; + } else { + throw new Error('Inconsistent tree: Expected a branch, found a leaf instead.'); + } + } + + //at end + if (child instanceof BranchNode) { + // TODO: maybe we can allow this + throw new Error('Inconsistent tree: Expected a leaf, found a branch instead.'); + } + + node.delete(name); + return child.element; + } +} diff --git a/src/vs/base/test/browser/ui/tree/indexTreeModel.test.ts b/src/vs/base/test/browser/ui/tree/indexTreeModel.test.ts index 01a42eae236..e11b73ed9d8 100644 --- a/src/vs/base/test/browser/ui/tree/indexTreeModel.test.ts +++ b/src/vs/base/test/browser/ui/tree/indexTreeModel.test.ts @@ -690,4 +690,38 @@ suite('IndexTreeModel', function () { assert.deepEqual(model.getNodeLocation(list[3]), [0, 5]); }); }); + + test('refilter with filtered out nodes', function () { + const list: ITreeNode[] = []; + let query = new RegExp(''); + const filter = new class implements ITreeFilter { + filter(element: string): boolean { + return query.test(element); + } + }; + + const model = new IndexTreeModel('test', toSpliceable(list), 'root', { filter }); + + model.splice([0], 0, Iterator.fromArray([ + { element: 'silver' }, + { element: 'gold' }, + { element: 'platinum' } + ])); + + assert.deepEqual(toArray(list), ['silver', 'gold', 'platinum']); + + query = /platinum/; + model.refilter(); + assert.deepEqual(toArray(list), ['platinum']); + + model.splice([0], Number.POSITIVE_INFINITY, Iterator.fromArray([ + { element: 'silver' }, + { element: 'gold' }, + { element: 'platinum' } + ])); + assert.deepEqual(toArray(list), ['platinum']); + + model.refilter(); + assert.deepEqual(toArray(list), ['platinum']); + }); }); diff --git a/src/vs/base/test/common/resourceTree.test.ts b/src/vs/base/test/common/resourceTree.test.ts new file mode 100644 index 00000000000..d3050bcd990 --- /dev/null +++ b/src/vs/base/test/common/resourceTree.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { ResourceTree, IBranchNode, ILeafNode } from 'vs/base/common/resourceTree'; +import { URI } from 'vs/base/common/uri'; + +suite('ResourceTree', function () { + test('ctor', function () { + const tree = new ResourceTree(null); + assert(ResourceTree.isBranchNode(tree.root)); + assert.equal(tree.root.size, 0); + }); + + test('simple', function () { + const tree = new ResourceTree(null); + + tree.add(URI.file('/foo/bar.txt'), 'bar contents'); + assert(ResourceTree.isBranchNode(tree.root)); + assert.equal(tree.root.size, 1); + + let foo = tree.root.get('foo') as IBranchNode; + assert(foo); + assert(ResourceTree.isBranchNode(foo)); + assert.equal(foo.size, 1); + + let bar = foo.get('bar.txt') as ILeafNode; + assert(bar); + assert(!ResourceTree.isBranchNode(bar)); + assert.equal(bar.element, 'bar contents'); + + tree.add(URI.file('/hello.txt'), 'hello contents'); + assert.equal(tree.root.size, 2); + + let hello = tree.root.get('hello.txt') as ILeafNode; + assert(hello); + assert(!ResourceTree.isBranchNode(hello)); + assert.equal(hello.element, 'hello contents'); + + tree.delete(URI.file('/foo/bar.txt')); + assert.equal(tree.root.size, 1); + hello = tree.root.get('hello.txt') as ILeafNode; + assert(hello); + assert(!ResourceTree.isBranchNode(hello)); + assert.equal(hello.element, 'hello contents'); + }); +}); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index c7bcd9b066c..a88f4f6968e 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -87,6 +87,7 @@ export const enum MenuId { ProblemsPanelContext, SCMChangeContext, SCMResourceContext, + SCMResourceFolderContext, SCMResourceGroupContext, SCMSourceControl, SCMTitle, diff --git a/src/vs/platform/list/browser/listService.ts b/src/vs/platform/list/browser/listService.ts index 73ed8295cc0..e3c42e0b054 100644 --- a/src/vs/platform/list/browser/listService.ts +++ b/src/vs/platform/list/browser/listService.ts @@ -25,7 +25,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { attachListStyler, computeStyles, defaultListStyles } from 'vs/platform/theme/common/styler'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { InputFocusedContextKey } from 'vs/platform/contextkey/common/contextkeys'; -import { ObjectTree, IObjectTreeOptions, ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; +import { ObjectTree, IObjectTreeOptions, ICompressibleTreeRenderer, CompressibleObjectTree, ICompressibleObjectTreeOptions } from 'vs/base/browser/ui/tree/objectTree'; import { ITreeEvent, ITreeRenderer, IAsyncDataSource, IDataSource, ITreeMouseEvent } from 'vs/base/browser/ui/tree/tree'; import { AsyncDataTree, IAsyncDataTreeOptions, CompressibleAsyncDataTree, ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree'; import { DataTree, IDataTreeOptions } from 'vs/base/browser/ui/tree/dataTree'; @@ -212,14 +212,11 @@ function toWorkbenchListOptions(options: IListOptions, configurationServic result.openController = openController; disposables.add(openController); - if (options.keyboardNavigationLabelProvider) { - const tlp = options.keyboardNavigationLabelProvider; - - result.keyboardNavigationLabelProvider = { - getKeyboardNavigationLabel(e) { return tlp.getKeyboardNavigationLabel(e); }, - mightProducePrintableCharacter(e) { return keybindingService.mightProducePrintableCharacter(e); } - }; - } + result.keyboardNavigationDelegate = { + mightProducePrintableCharacter(e) { + return keybindingService.mightProducePrintableCharacter(e); + } + }; return [result, disposables]; } @@ -807,6 +804,33 @@ export class WorkbenchObjectTree, TFilterData = void> } } +export class WorkbenchCompressibleObjectTree, TFilterData = void> extends CompressibleObjectTree { + + private internals: WorkbenchTreeInternals; + get contextKeyService(): IContextKeyService { return this.internals.contextKeyService; } + get useAltAsMultipleSelectionModifier(): boolean { return this.internals.useAltAsMultipleSelectionModifier; } + + constructor( + user: string, + container: HTMLElement, + delegate: IListVirtualDelegate, + renderers: ICompressibleTreeRenderer[], + options: ICompressibleObjectTreeOptions, + @IContextKeyService contextKeyService: IContextKeyService, + @IListService listService: IListService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IKeybindingService keybindingService: IKeybindingService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + const { options: treeOptions, getAutomaticKeyboardNavigation, disposable } = workbenchTreeDataPreamble(container, options, contextKeyService, themeService, configurationService, keybindingService, accessibilityService); + super(user, container, delegate, renderers, treeOptions); + this.disposables.push(disposable); + this.internals = new WorkbenchTreeInternals(this, treeOptions, getAutomaticKeyboardNavigation, contextKeyService, listService, themeService, configurationService, accessibilityService); + this.disposables.push(this.internals); + } +} + export class WorkbenchDataTree extends DataTree { private internals: WorkbenchTreeInternals; @@ -957,7 +981,7 @@ class WorkbenchTreeInternals { private disposables: IDisposable[] = []; constructor( - tree: WorkbenchObjectTree | WorkbenchDataTree | WorkbenchAsyncDataTree | WorkbenchCompressibleAsyncDataTree, + tree: WorkbenchObjectTree | CompressibleObjectTree | WorkbenchDataTree | WorkbenchAsyncDataTree | WorkbenchCompressibleAsyncDataTree, options: IAbstractTreeOptions | IAsyncDataTreeOptions, getAutomaticKeyboardNavigation: () => boolean | undefined, @IContextKeyService contextKeyService: IContextKeyService, diff --git a/src/vs/workbench/api/common/menusExtensionPoint.ts b/src/vs/workbench/api/common/menusExtensionPoint.ts index cb6cddcb57d..0dd6facb692 100644 --- a/src/vs/workbench/api/common/menusExtensionPoint.ts +++ b/src/vs/workbench/api/common/menusExtensionPoint.ts @@ -39,8 +39,9 @@ namespace schema { case 'menuBar/file': return MenuId.MenubarFileMenu; case 'scm/title': return MenuId.SCMTitle; case 'scm/sourceControl': return MenuId.SCMSourceControl; - case 'scm/resourceGroup/context': return MenuId.SCMResourceGroupContext; case 'scm/resourceState/context': return MenuId.SCMResourceContext; + case 'scm/resourceFolder/context': return MenuId.SCMResourceFolderContext; + case 'scm/resourceGroup/context': return MenuId.SCMResourceGroupContext; case 'scm/change/title': return MenuId.SCMChangeContext; case 'statusBar/windowIndicator': return MenuId.StatusBarWindowIndicatorMenu; case 'view/title': return MenuId.ViewTitle; diff --git a/src/vs/workbench/browser/parts/views/media/views.css b/src/vs/workbench/browser/parts/views/media/views.css index 2e5ebe1420b..1127eae5c34 100644 --- a/src/vs/workbench/browser/parts/views/media/views.css +++ b/src/vs/workbench/browser/parts/views/media/views.css @@ -52,9 +52,9 @@ /* File icons in trees */ -.file-icon-themable-tree.align-icons-and-twisties .monaco-tl-twistie:not(.collapsible), -.file-icon-themable-tree .align-icon-with-twisty .monaco-tl-twistie:not(.collapsible), -.file-icon-themable-tree.hide-arrows .monaco-tl-twistie { +.file-icon-themable-tree.align-icons-and-twisties .monaco-tl-twistie:not(.force-twistie):not(.collapsible), +.file-icon-themable-tree .align-icon-with-twisty .monaco-tl-twistie:not(.force-twistie):not(.collapsible), +.file-icon-themable-tree.hide-arrows .monaco-tl-twistie:not(.force-twistie) { background-image: none !important; width: 0 !important; margin-right: 0 !important; diff --git a/src/vs/workbench/contrib/scm/browser/scmActivity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts similarity index 100% rename from src/vs/workbench/contrib/scm/browser/scmActivity.ts rename to src/vs/workbench/contrib/scm/browser/activity.ts diff --git a/src/vs/workbench/contrib/scm/browser/mainPanel.ts b/src/vs/workbench/contrib/scm/browser/mainPanel.ts new file mode 100644 index 00000000000..8bad75a2aa1 --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/mainPanel.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 'vs/css!./media/scmViewlet'; +import { localize } from 'vs/nls'; +import { Event, Emitter } from 'vs/base/common/event'; +import { basename } from 'vs/base/common/resources'; +import { IDisposable, dispose, Disposable, DisposableStore, combinedDisposable } from 'vs/base/common/lifecycle'; +import { ViewletPanel, IViewletPanelOptions } from 'vs/workbench/browser/parts/views/panelViewlet'; +import { append, $, toggleClass } from 'vs/base/browser/dom'; +import { List } from 'vs/base/browser/ui/list/listWidget'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent, IListEvent } from 'vs/base/browser/ui/list/list'; +import { ISCMService, ISCMRepository } from 'vs/workbench/contrib/scm/common/scm'; +import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IContextKeyService, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { IMenuService, MenuId } from 'vs/platform/actions/common/actions'; +import { IAction, Action } from 'vs/base/common/actions'; +import { createAndFillInContextMenuActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { ActionBar, ActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { attachBadgeStyler } from 'vs/platform/theme/common/styler'; +import { Command } from 'vs/editor/common/modes'; +import { renderOcticons } from 'vs/base/browser/ui/octiconLabel/octiconLabel'; +import { WorkbenchList } from 'vs/platform/list/browser/listService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IViewDescriptor } from 'vs/workbench/common/views'; + +export interface ISpliceEvent { + index: number; + deleteCount: number; + elements: T[]; +} + +export interface IViewModel { + readonly repositories: ISCMRepository[]; + readonly onDidSplice: Event>; + + readonly visibleRepositories: ISCMRepository[]; + readonly onDidChangeVisibleRepositories: Event; + setVisibleRepositories(repositories: ISCMRepository[]): void; + + isVisible(): boolean; + readonly onDidChangeVisibility: Event; +} + +class ProvidersListDelegate implements IListVirtualDelegate { + + getHeight(): number { + return 22; + } + + getTemplateId(): string { + return 'provider'; + } +} + +class StatusBarAction extends Action { + + constructor( + private command: Command, + private commandService: ICommandService + ) { + super(`statusbaraction{${command.id}}`, command.title, '', true); + this.tooltip = command.tooltip || ''; + } + + run(): Promise { + return this.commandService.executeCommand(this.command.id, ...(this.command.arguments || [])); + } +} + +class StatusBarActionViewItem extends ActionViewItem { + + constructor(action: StatusBarAction) { + super(null, action, {}); + } + + updateLabel(): void { + if (this.options.label) { + this.label.innerHTML = renderOcticons(this.getAction().label); + } + } +} + +interface RepositoryTemplateData { + title: HTMLElement; + type: HTMLElement; + countContainer: HTMLElement; + count: CountBadge; + actionBar: ActionBar; + disposable: IDisposable; + templateDisposable: IDisposable; +} + +class ProviderRenderer implements IListRenderer { + + readonly templateId = 'provider'; + + private _onDidRenderElement = new Emitter(); + readonly onDidRenderElement = this._onDidRenderElement.event; + + constructor( + @ICommandService protected commandService: ICommandService, + @IThemeService protected themeService: IThemeService + ) { } + + renderTemplate(container: HTMLElement): RepositoryTemplateData { + const provider = append(container, $('.scm-provider')); + const name = append(provider, $('.name')); + const title = append(name, $('span.title')); + const type = append(name, $('span.type')); + const countContainer = append(provider, $('.count')); + const count = new CountBadge(countContainer); + const badgeStyler = attachBadgeStyler(count, this.themeService); + const actionBar = new ActionBar(provider, { actionViewItemProvider: a => new StatusBarActionViewItem(a as StatusBarAction) }); + const disposable = Disposable.None; + const templateDisposable = combinedDisposable(actionBar, badgeStyler); + + return { title, type, countContainer, count, actionBar, disposable, templateDisposable }; + } + + renderElement(repository: ISCMRepository, index: number, templateData: RepositoryTemplateData): void { + templateData.disposable.dispose(); + const disposables = new DisposableStore(); + + if (repository.provider.rootUri) { + templateData.title.textContent = basename(repository.provider.rootUri); + templateData.type.textContent = repository.provider.label; + } else { + templateData.title.textContent = repository.provider.label; + templateData.type.textContent = ''; + } + + const actions: IAction[] = []; + const disposeActions = () => dispose(actions); + disposables.add({ dispose: disposeActions }); + + const update = () => { + disposeActions(); + + const commands = repository.provider.statusBarCommands || []; + actions.splice(0, actions.length, ...commands.map(c => new StatusBarAction(c, this.commandService))); + templateData.actionBar.clear(); + templateData.actionBar.push(actions); + + const count = repository.provider.count || 0; + toggleClass(templateData.countContainer, 'hidden', count === 0); + templateData.count.setCount(count); + + this._onDidRenderElement.fire(repository); + }; + + disposables.add(repository.provider.onDidChange(update, null)); + update(); + + templateData.disposable = disposables; + } + + disposeTemplate(templateData: RepositoryTemplateData): void { + templateData.disposable.dispose(); + templateData.templateDisposable.dispose(); + } +} + +export class MainPanel extends ViewletPanel { + + static readonly ID = 'scm.mainPanel'; + static readonly TITLE = localize('scm providers', "Source Control Providers"); + + private list: List; + + constructor( + protected viewModel: IViewModel, + options: IViewletPanelOptions, + @IKeybindingService protected keybindingService: IKeybindingService, + @IContextMenuService protected contextMenuService: IContextMenuService, + @ISCMService protected scmService: ISCMService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IMenuService private readonly menuService: IMenuService, + @IConfigurationService configurationService: IConfigurationService + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService); + } + + protected renderBody(container: HTMLElement): void { + const delegate = new ProvidersListDelegate(); + const renderer = this.instantiationService.createInstance(ProviderRenderer); + const identityProvider = { getId: (r: ISCMRepository) => r.provider.id }; + + this.list = this.instantiationService.createInstance(WorkbenchList, `SCM Main`, container, delegate, [renderer], { + identityProvider, + horizontalScrolling: false + }); + + this._register(renderer.onDidRenderElement(e => this.list.updateWidth(this.viewModel.repositories.indexOf(e)), null)); + this._register(this.list.onSelectionChange(this.onListSelectionChange, this)); + this._register(this.list.onFocusChange(this.onListFocusChange, this)); + this._register(this.list.onContextMenu(this.onListContextMenu, this)); + + this._register(this.viewModel.onDidChangeVisibleRepositories(this.updateListSelection, this)); + + this._register(this.viewModel.onDidSplice(({ index, deleteCount, elements }) => this.splice(index, deleteCount, elements), null)); + this.splice(0, 0, this.viewModel.repositories); + + this._register(this.list); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('scm.providers.visible')) { + this.updateBodySize(); + } + })); + + this.updateListSelection(); + } + + private splice(index: number, deleteCount: number, repositories: ISCMRepository[] = []): void { + this.list.splice(index, deleteCount, repositories); + + const empty = this.list.length === 0; + toggleClass(this.element, 'empty', empty); + + this.updateBodySize(); + } + + protected layoutBody(height: number, width: number): void { + this.list.layout(height, width); + } + + private updateBodySize(): void { + const visibleCount = this.configurationService.getValue('scm.providers.visible'); + const empty = this.list.length === 0; + const size = Math.min(this.viewModel.repositories.length, visibleCount) * 22; + + this.minimumBodySize = visibleCount === 0 ? 22 : size; + this.maximumBodySize = visibleCount === 0 ? Number.POSITIVE_INFINITY : empty ? Number.POSITIVE_INFINITY : size; + } + + private onListContextMenu(e: IListContextMenuEvent): void { + if (!e.element) { + return; + } + + const repository = e.element; + const contextKeyService = this.contextKeyService.createScoped(); + const scmProviderKey = contextKeyService.createKey('scmProvider', undefined); + scmProviderKey.set(repository.provider.contextValue); + + const menu = this.menuService.createMenu(MenuId.SCMSourceControl, contextKeyService); + const primary: IAction[] = []; + const secondary: IAction[] = []; + const result = { primary, secondary }; + + const disposable = createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, this.contextMenuService, g => g === 'inline'); + + menu.dispose(); + contextKeyService.dispose(); + + if (secondary.length === 0) { + return; + } + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => secondary, + getActionsContext: () => repository.provider + }); + + disposable.dispose(); + } + + private onListSelectionChange(e: IListEvent): void { + if (e.browserEvent && e.elements.length > 0) { + const scrollTop = this.list.scrollTop; + this.viewModel.setVisibleRepositories(e.elements); + this.list.scrollTop = scrollTop; + } + } + + private onListFocusChange(e: IListEvent): void { + if (e.browserEvent && e.elements.length > 0) { + e.elements[0].focus(); + } + } + + private updateListSelection(): void { + const set = new Set(); + + for (const repository of this.viewModel.visibleRepositories) { + set.add(repository); + } + + const selection: number[] = []; + + for (let i = 0; i < this.list.length; i++) { + if (set.has(this.list.element(i))) { + selection.push(i); + } + } + + this.list.setSelection(selection); + + if (selection.length > 0) { + this.list.setFocus([selection[0]]); + } + } +} + +export class MainPanelDescriptor implements IViewDescriptor { + + readonly id = MainPanel.ID; + readonly name = MainPanel.TITLE; + readonly ctorDescriptor: { ctor: any, arguments?: any[] }; + readonly canToggleVisibility = true; + readonly hideByDefault = false; + readonly order = -1000; + readonly workspace = true; + readonly when = ContextKeyExpr.or(ContextKeyExpr.equals('config.scm.alwaysShowProviders', true), ContextKeyExpr.and(ContextKeyExpr.notEquals('scm.providerCount', 0), ContextKeyExpr.notEquals('scm.providerCount', 1))); + + constructor(viewModel: IViewModel) { + this.ctorDescriptor = { ctor: MainPanel, arguments: [viewModel] }; + } +} diff --git a/src/vs/workbench/contrib/scm/browser/media/list-dark.svg b/src/vs/workbench/contrib/scm/browser/media/list-dark.svg new file mode 100644 index 00000000000..eb1964b511d --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/media/list-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/scm/browser/media/list-hc.svg b/src/vs/workbench/contrib/scm/browser/media/list-hc.svg new file mode 100644 index 00000000000..d3145dd9ae7 --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/media/list-hc.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/scm/browser/media/list-light.svg b/src/vs/workbench/contrib/scm/browser/media/list-light.svg new file mode 100644 index 00000000000..32fb1216f0d --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/media/list-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/scm/browser/media/scmViewlet.css b/src/vs/workbench/contrib/scm/browser/media/scmViewlet.css index d732bf2fae3..5aab7077548 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scmViewlet.css +++ b/src/vs/workbench/contrib/scm/browser/media/scmViewlet.css @@ -7,6 +7,30 @@ -webkit-mask: url('scm-activity-bar.svg') no-repeat 50% 50%; } +.monaco-workbench .scm-action.toggle-view-mode.list { + background: url('list-light.svg') center center no-repeat; +} + +.vs-dark .monaco-workbench .scm-action.toggle-view-mode.list { + background: url('list-dark.svg') center center no-repeat; +} + +.hc-black .monaco-workbench .scm-action.toggle-view-mode.list { + background: url('list-hc.svg') center center no-repeat; +} + +.monaco-workbench .scm-action.toggle-view-mode.tree { + background: url('tree-light.svg') center center no-repeat; +} + +.vs-dark .monaco-workbench .scm-action.toggle-view-mode.tree { + background: url('tree-dark.svg') center center no-repeat; +} + +.hc-black .monaco-workbench .scm-action.toggle-view-mode.tree { + background: url('tree-hc.svg') center center no-repeat; +} + .monaco-workbench .viewlet.scm-viewlet .collapsible.header .actions { width: initial; flex: 1; @@ -33,6 +57,7 @@ align-items: center; flex-wrap: wrap; height: 100%; + padding: 0 12px 0 20px; } .scm-viewlet .monaco-list-row > .scm-provider > .monaco-action-bar { @@ -60,16 +85,16 @@ } .scm-viewlet .monaco-list-row { - padding: 0 12px 0 20px; line-height: 22px; } -.scm-viewlet .monaco-list-row > .resource-group { +.scm-viewlet .monaco-list-row .resource-group { display: flex; height: 100%; + align-items: center; } -.scm-viewlet .monaco-list-row > .resource-group > .name { +.scm-viewlet .monaco-list-row .resource-group > .name { flex: 1; font-size: 11px; font-weight: bold; @@ -77,61 +102,66 @@ text-overflow: ellipsis; } -.scm-viewlet .monaco-list-row > .resource { +.scm-viewlet .monaco-list-row .resource { display: flex; height: 100%; } -.scm-viewlet .monaco-list-row > .resource.faded { +.scm-viewlet .monaco-list-row .resource.faded { opacity: 0.7; } -.scm-viewlet .monaco-list-row > .resource > .name { +.scm-viewlet .monaco-list-row .resource > .name { flex: 1; overflow: hidden; } -.scm-viewlet .monaco-list-row > .resource > .name.strike-through > .monaco-icon-label > .monaco-icon-label-description-container > .label-name { +.scm-viewlet .monaco-list-row .resource > .name.strike-through > .monaco-icon-label > .monaco-icon-label-description-container > .label-name { text-decoration: line-through; } -.scm-viewlet .monaco-list-row > .resource > .name > .monaco-icon-label::after { - padding: 0 4px; +.scm-viewlet .monaco-list-row .resource > .name > .monaco-icon-label::after { + padding: 0 8px; } -.scm-viewlet .monaco-list-row > .resource > .decoration-icon { +.scm-viewlet .monaco-list-row .resource-group > .count { + padding: 0 8px; + display: flex; +} + +.scm-viewlet .monaco-list-row .resource > .decoration-icon { width: 16px; height: 100%; background-repeat: no-repeat; background-position: 50% 50%; } -.scm-viewlet .monaco-list .monaco-list-row > .resource > .name > .monaco-icon-label > .actions { +.scm-viewlet .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions { flex-grow: 100; } -.scm-viewlet .monaco-list .monaco-list-row > .resource-group > .actions, -.scm-viewlet .monaco-list .monaco-list-row > .resource > .name > .monaco-icon-label > .actions { +.scm-viewlet .monaco-list .monaco-list-row .resource-group > .actions, +.scm-viewlet .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions { display: none; } -.scm-viewlet .monaco-list .monaco-list-row:hover > .resource-group > .actions, -.scm-viewlet .monaco-list .monaco-list-row:hover > .resource > .name > .monaco-icon-label > .actions, -.scm-viewlet .monaco-list .monaco-list-row.selected > .resource-group > .actions, -.scm-viewlet .monaco-list .monaco-list-row.focused > .resource-group > .actions, -.scm-viewlet .monaco-list .monaco-list-row.selected > .resource > .name > .monaco-icon-label > .actions, -.scm-viewlet .monaco-list .monaco-list-row.focused > .resource > .name > .monaco-icon-label > .actions, -.scm-viewlet .monaco-list:not(.selection-multiple) .monaco-list-row > .resource:hover > .actions { +.scm-viewlet .monaco-list .monaco-list-row:hover .resource-group > .actions, +.scm-viewlet .monaco-list .monaco-list-row:hover .resource > .name > .monaco-icon-label > .actions, +.scm-viewlet .monaco-list .monaco-list-row.selected .resource-group > .actions, +.scm-viewlet .monaco-list .monaco-list-row.focused .resource-group > .actions, +.scm-viewlet .monaco-list .monaco-list-row.selected .resource > .name > .monaco-icon-label > .actions, +.scm-viewlet .monaco-list .monaco-list-row.focused .resource > .name > .monaco-icon-label > .actions, +.scm-viewlet .monaco-list:not(.selection-multiple) .monaco-list-row .resource:hover > .actions { display: block; } -.scm-viewlet .scm-status.show-actions > .monaco-list .monaco-list-row > .resource-group > .actions, -.scm-viewlet .scm-status.show-actions > .monaco-list .monaco-list-row > .resource > .name > .monaco-icon-label > .actions { +.scm-viewlet .scm-status.show-actions > .monaco-list .monaco-list-row .resource-group > .actions, +.scm-viewlet .scm-status.show-actions > .monaco-list .monaco-list-row .resource > .name > .monaco-icon-label > .actions { display: block; } -.scm-viewlet .monaco-list-row > .resource > .name > .monaco-icon-label > .actions .action-label, -.scm-viewlet .monaco-list-row > .resource-group > .actions .action-label { +.scm-viewlet .monaco-list-row .resource > .name > .monaco-icon-label > .actions .action-label, +.scm-viewlet .monaco-list-row .resource-group > .actions .action-label { width: 16px; height: 100%; background-position: 50% 50%; @@ -162,3 +192,9 @@ .scm-viewlet .scm-editor.scroll > .monaco-inputbox > .wrapper > textarea.input { overflow-y: scroll; } + +.scm-viewlet .list-view-mode .monaco-tl-twistie:not(.force-twistie):not(.collapsible) { + background-image: none !important; + width: 8px !important; + margin-right: 0 !important; +} diff --git a/src/vs/workbench/contrib/scm/browser/media/tree-dark.svg b/src/vs/workbench/contrib/scm/browser/media/tree-dark.svg new file mode 100644 index 00000000000..3ef43a1c0ee --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/media/tree-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/scm/browser/media/tree-hc.svg b/src/vs/workbench/contrib/scm/browser/media/tree-hc.svg new file mode 100644 index 00000000000..1739392777e --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/media/tree-hc.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/scm/browser/media/tree-light.svg b/src/vs/workbench/contrib/scm/browser/media/tree-light.svg new file mode 100644 index 00000000000..58d6cc457bb --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/media/tree-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/vs/workbench/contrib/scm/browser/scmMenus.ts b/src/vs/workbench/contrib/scm/browser/menus.ts similarity index 83% rename from src/vs/workbench/contrib/scm/browser/scmMenus.ts rename to src/vs/workbench/contrib/scm/browser/menus.ts index 827765114ef..327801803c2 100644 --- a/src/vs/workbench/contrib/scm/browser/scmMenus.ts +++ b/src/vs/workbench/contrib/scm/browser/menus.ts @@ -5,13 +5,13 @@ import 'vs/css!./media/scmViewlet'; import { Event, Emitter } from 'vs/base/common/event'; -import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; +import { IDisposable, Disposable, DisposableStore, combinedDisposable } from 'vs/base/common/lifecycle'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IMenuService, MenuId, IMenu } from 'vs/platform/actions/common/actions'; import { IAction } from 'vs/base/common/actions'; import { createAndFillInContextMenuActions, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { ISCMProvider, ISCMResource, ISCMResourceGroup } from 'vs/workbench/contrib/scm/common/scm'; -import { isSCMResource } from './scmUtil'; +import { isSCMResource } from './util'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { equals } from 'vs/base/common/arrays'; import { ISplice } from 'vs/base/common/sequence'; @@ -20,13 +20,15 @@ function actionEquals(a: IAction, b: IAction): boolean { return a.id === b.id; } -interface ISCMResourceGroupMenuEntry extends IDisposable { +interface ISCMResourceGroupMenuEntry { readonly group: ISCMResourceGroup; + readonly disposable: IDisposable; } interface ISCMMenus { readonly resourceGroupMenu: IMenu; readonly resourceMenu: IMenu; + readonly resourceFolderMenu: IMenu; } export function getSCMResourceContextKey(resource: ISCMResourceGroup | ISCMResource): string { @@ -48,7 +50,7 @@ export class SCMMenus implements IDisposable { private readonly resourceGroupMenuEntries: ISCMResourceGroupMenuEntry[] = []; private readonly resourceGroupMenus = new Map(); - private readonly disposables: IDisposable[] = []; + private readonly disposables = new DisposableStore(); constructor( provider: ISCMProvider | undefined, @@ -68,7 +70,7 @@ export class SCMMenus implements IDisposable { } this.titleMenu = this.menuService.createMenu(MenuId.SCMTitle, this.contextKeyService); - this.disposables.push(this.titleMenu); + this.disposables.add(this.titleMenu); this.titleMenu.onDidChange(this.updateTitleActions, this, this.disposables); this.updateTitleActions(); @@ -109,6 +111,10 @@ export class SCMMenus implements IDisposable { return this.getActions(MenuId.SCMResourceContext, resource).secondary; } + getResourceFolderContextActions(group: ISCMResourceGroup): IAction[] { + return this.getActions(MenuId.SCMResourceFolderContext, group).secondary; + } + private getActions(menuId: MenuId, resource: ISCMResourceGroup | ISCMResource): { primary: IAction[]; secondary: IAction[]; } { const contextKeyService = this.contextKeyService.createScoped(); contextKeyService.createKey('scmResourceGroup', getSCMResourceContextKey(resource)); @@ -141,6 +147,14 @@ export class SCMMenus implements IDisposable { return this.resourceGroupMenus.get(group)!.resourceMenu; } + getResourceFolderMenu(group: ISCMResourceGroup): IMenu { + if (!this.resourceGroupMenus.has(group)) { + throw new Error('SCM Resource Group menu not found'); + } + + return this.resourceGroupMenus.get(group)!.resourceFolderMenu; + } + private onDidSpliceGroups({ start, deleteCount, toInsert }: ISplice): void { const menuEntriesToInsert = toInsert.map(group => { const contextKeyService = this.contextKeyService.createScoped(); @@ -149,30 +163,23 @@ export class SCMMenus implements IDisposable { const resourceGroupMenu = this.menuService.createMenu(MenuId.SCMResourceGroupContext, contextKeyService); const resourceMenu = this.menuService.createMenu(MenuId.SCMResourceContext, contextKeyService); + const resourceFolderMenu = this.menuService.createMenu(MenuId.SCMResourceFolderContext, contextKeyService); + const disposable = combinedDisposable(contextKeyService, resourceGroupMenu, resourceMenu, resourceFolderMenu); - this.resourceGroupMenus.set(group, { resourceGroupMenu, resourceMenu }); - - return { - group, - dispose() { - contextKeyService.dispose(); - resourceGroupMenu.dispose(); - resourceMenu.dispose(); - } - }; + this.resourceGroupMenus.set(group, { resourceGroupMenu, resourceMenu, resourceFolderMenu }); + return { group, disposable }; }); const deleted = this.resourceGroupMenuEntries.splice(start, deleteCount, ...menuEntriesToInsert); for (const entry of deleted) { this.resourceGroupMenus.delete(entry.group); - entry.dispose(); + entry.disposable.dispose(); } } dispose(): void { - dispose(this.disposables); - dispose(this.resourceGroupMenuEntries); - this.resourceGroupMenus.clear(); + this.disposables.dispose(); + this.resourceGroupMenuEntries.forEach(e => e.disposable.dispose()); } } diff --git a/src/vs/workbench/contrib/scm/browser/repositoryPanel.ts b/src/vs/workbench/contrib/scm/browser/repositoryPanel.ts new file mode 100644 index 00000000000..a1a17249418 --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/repositoryPanel.ts @@ -0,0 +1,864 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./media/scmViewlet'; +import { Event, Emitter } from 'vs/base/common/event'; +import { domEvent } from 'vs/base/browser/event'; +import { basename } from 'vs/base/common/resources'; +import { IDisposable, Disposable, DisposableStore, combinedDisposable } from 'vs/base/common/lifecycle'; +import { ViewletPanel, IViewletPanelOptions } from 'vs/workbench/browser/parts/views/panelViewlet'; +import { append, $, addClass, toggleClass, trackFocus, removeClass } from 'vs/base/browser/dom'; +import { IListVirtualDelegate, IIdentityProvider } from 'vs/base/browser/ui/list/list'; +import { ISCMRepository, ISCMResourceGroup, ISCMResource, InputValidationType } from 'vs/workbench/contrib/scm/common/scm'; +import { ResourceLabels, IResourceLabel } from 'vs/workbench/browser/labels'; +import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IContextViewService, IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { MenuItemAction, IMenuService } from 'vs/platform/actions/common/actions'; +import { IAction, IActionViewItem, ActionRunner, Action } from 'vs/base/common/actions'; +import { ContextAwareMenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { SCMMenus } from './menus'; +import { ActionBar, IActionViewItemProvider } from 'vs/base/browser/ui/actionbar/actionbar'; +import { IThemeService, LIGHT } from 'vs/platform/theme/common/themeService'; +import { isSCMResource, isSCMResourceGroup, connectPrimaryMenuToInlineActionBar } from './util'; +import { attachBadgeStyler, attachInputBoxStyler } from 'vs/platform/theme/common/styler'; +import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; +import { format } from 'vs/base/common/strings'; +import { WorkbenchCompressibleObjectTree } from 'vs/platform/list/browser/listService'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ThrottledDelayer } from 'vs/base/common/async'; +import { INotificationService } from 'vs/platform/notification/common/notification'; +import * as platform from 'vs/base/common/platform'; +import { ITreeNode, ITreeFilter, ITreeSorter, ITreeContextMenuEvent } from 'vs/base/browser/ui/tree/tree'; +import { ISequence, ISplice } from 'vs/base/common/sequence'; +import { ResourceTree, IBranchNode, INode } from 'vs/base/common/resourceTree'; +import { ObjectTree, ICompressibleTreeRenderer, ICompressibleKeyboardNavigationLabelProvider } from 'vs/base/browser/ui/tree/objectTree'; +import { Iterator } from 'vs/base/common/iterator'; +import { ICompressedTreeNode, ICompressedTreeElement } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; +import { URI } from 'vs/base/common/uri'; +import { FileKind } from 'vs/platform/files/common/files'; +import { compareFileNames } from 'vs/base/common/comparers'; +import { FuzzyScore, createMatches } from 'vs/base/common/filters'; +import { IViewDescriptor } from 'vs/workbench/common/views'; +import { localize } from 'vs/nls'; +import { flatten } from 'vs/base/common/arrays'; +import { memoize } from 'vs/base/common/decorators'; +import { IWorkbenchThemeService, IFileIconTheme } from 'vs/workbench/services/themes/common/workbenchThemeService'; + +type TreeElement = ISCMResourceGroup | IBranchNode | ISCMResource; + +interface ResourceGroupTemplate { + readonly name: HTMLElement; + readonly count: CountBadge; + readonly actionBar: ActionBar; + elementDisposables: IDisposable; + readonly disposables: IDisposable; +} + +class ResourceGroupRenderer implements ICompressibleTreeRenderer { + + static TEMPLATE_ID = 'resource group'; + get templateId(): string { return ResourceGroupRenderer.TEMPLATE_ID; } + + constructor( + private actionViewItemProvider: IActionViewItemProvider, + private themeService: IThemeService, + private menus: SCMMenus + ) { } + + renderTemplate(container: HTMLElement): ResourceGroupTemplate { + // hack + addClass(container.parentElement!.parentElement!.querySelector('.monaco-tl-twistie')! as HTMLElement, 'force-twistie'); + + const element = append(container, $('.resource-group')); + const name = append(element, $('.name')); + const actionsContainer = append(element, $('.actions')); + const actionBar = new ActionBar(actionsContainer, { actionViewItemProvider: this.actionViewItemProvider }); + const countContainer = append(element, $('.count')); + const count = new CountBadge(countContainer); + const styler = attachBadgeStyler(count, this.themeService); + const elementDisposables = Disposable.None; + const disposables = combinedDisposable(actionBar, styler); + + return { name, count, actionBar, elementDisposables, disposables }; + } + + renderElement(node: ITreeNode, index: number, template: ResourceGroupTemplate): void { + template.elementDisposables.dispose(); + + const group = node.element; + template.name.textContent = group.label; + template.actionBar.clear(); + template.actionBar.context = group; + template.count.setCount(group.elements.length); + + const disposables = new DisposableStore(); + disposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceGroupMenu(group), template.actionBar)); + + template.elementDisposables = disposables; + } + + renderCompressedElements(node: ITreeNode, FuzzyScore>, index: number, templateData: ResourceGroupTemplate, height: number | undefined): void { + throw new Error('Should never happen since node is incompressible'); + } + + disposeElement(group: ITreeNode, index: number, template: ResourceGroupTemplate): void { + template.elementDisposables.dispose(); + } + + disposeTemplate(template: ResourceGroupTemplate): void { + template.elementDisposables.dispose(); + template.disposables.dispose(); + } +} + +interface ResourceTemplate { + element: HTMLElement; + name: HTMLElement; + fileLabel: IResourceLabel; + decorationIcon: HTMLElement; + actionBar: ActionBar; + elementDisposables: IDisposable; + disposables: IDisposable; +} + +class MultipleSelectionActionRunner extends ActionRunner { + + constructor(private getSelectedResources: () => (ISCMResource | IBranchNode)[]) { + super(); + } + + runAction(action: IAction, context: ISCMResource | IBranchNode): Promise { + if (!(action instanceof MenuItemAction)) { + return super.runAction(action, context); + } + + const selection = this.getSelectedResources(); + const contextIsSelected = selection.some(s => s === context); + const actualContext = contextIsSelected ? selection : [context]; + const args = flatten(actualContext.map(e => ResourceTree.isBranchNode(e) ? ResourceTree.collect(e) : [e])); + return action.run(...args); + } +} + +class ResourceRenderer implements ICompressibleTreeRenderer, FuzzyScore, ResourceTemplate> { + + static TEMPLATE_ID = 'resource'; + get templateId(): string { return ResourceRenderer.TEMPLATE_ID; } + + constructor( + private viewModelProvider: () => ViewModel, + private labels: ResourceLabels, + private actionViewItemProvider: IActionViewItemProvider, + private getSelectedResources: () => (ISCMResource | IBranchNode)[], + private themeService: IThemeService, + private menus: SCMMenus + ) { } + + renderTemplate(container: HTMLElement): ResourceTemplate { + const element = append(container, $('.resource')); + const name = append(element, $('.name')); + const fileLabel = this.labels.create(name, { supportHighlights: true }); + const actionsContainer = append(fileLabel.element, $('.actions')); + const actionBar = new ActionBar(actionsContainer, { + actionViewItemProvider: this.actionViewItemProvider, + actionRunner: new MultipleSelectionActionRunner(this.getSelectedResources) + }); + + const decorationIcon = append(element, $('.decoration-icon')); + const disposables = combinedDisposable(actionBar, fileLabel); + + return { element, name, fileLabel, decorationIcon, actionBar, elementDisposables: Disposable.None, disposables }; + } + + renderElement(node: ITreeNode | ITreeNode, FuzzyScore>, index: number, template: ResourceTemplate): void { + template.elementDisposables.dispose(); + + const elementDisposables = new DisposableStore(); + const resourceOrFolder = node.element; + const theme = this.themeService.getTheme(); + const icon = !ResourceTree.isBranchNode(resourceOrFolder) && (theme.type === LIGHT ? resourceOrFolder.decorations.icon : resourceOrFolder.decorations.iconDark); + + const uri = ResourceTree.isBranchNode(resourceOrFolder) ? resourceOrFolder.uri : resourceOrFolder.sourceUri; + const fileKind = ResourceTree.isBranchNode(resourceOrFolder) ? FileKind.FOLDER : FileKind.FILE; + const viewModel = this.viewModelProvider(); + + template.fileLabel.setFile(uri, { + fileDecorations: { colors: false, badges: !icon }, + hidePath: viewModel.mode === ViewModelMode.Tree, + fileKind, + matches: createMatches(node.filterData) + }); + + template.actionBar.clear(); + template.actionBar.context = resourceOrFolder; + + if (ResourceTree.isBranchNode(resourceOrFolder)) { + elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceFolderMenu(resourceOrFolder.context), template.actionBar)); + removeClass(template.name, 'strike-through'); + removeClass(template.element, 'faded'); + } else { + elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceMenu(resourceOrFolder.resourceGroup), template.actionBar)); + toggleClass(template.name, 'strike-through', resourceOrFolder.decorations.strikeThrough); + toggleClass(template.element, 'faded', resourceOrFolder.decorations.faded); + } + + const tooltip = !ResourceTree.isBranchNode(resourceOrFolder) && resourceOrFolder.decorations.tooltip || ''; + + if (icon) { + template.decorationIcon.style.display = ''; + template.decorationIcon.style.backgroundImage = `url('${icon}')`; + template.decorationIcon.title = tooltip; + } else { + template.decorationIcon.style.display = 'none'; + template.decorationIcon.style.backgroundImage = ''; + template.decorationIcon.title = ''; + } + + template.element.setAttribute('data-tooltip', tooltip); + template.elementDisposables = elementDisposables; + } + + disposeElement(resource: ITreeNode | ITreeNode, FuzzyScore>, index: number, template: ResourceTemplate): void { + template.elementDisposables.dispose(); + } + + renderCompressedElements(node: ITreeNode | ICompressedTreeNode>, FuzzyScore>, index: number, template: ResourceTemplate, height: number | undefined): void { + template.elementDisposables.dispose(); + + const elementDisposables = new DisposableStore(); + const compressed = node.element as ICompressedTreeNode>; + const folder = compressed.elements[compressed.elements.length - 1]; + + const label = compressed.elements.map(e => e.name).join('/'); + const fileKind = FileKind.FOLDER; + + template.fileLabel.setResource({ resource: folder.uri, name: label }, { + fileDecorations: { colors: false, badges: true }, + fileKind, + matches: createMatches(node.filterData) + }); + + template.actionBar.clear(); + template.actionBar.context = folder; + + elementDisposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceFolderMenu(folder.context), template.actionBar)); + + removeClass(template.name, 'strike-through'); + removeClass(template.element, 'faded'); + template.decorationIcon.style.display = 'none'; + template.decorationIcon.style.backgroundImage = ''; + + template.element.setAttribute('data-tooltip', ''); + template.elementDisposables = elementDisposables; + } + + disposeCompressedElements(node: ITreeNode | ICompressedTreeNode>, FuzzyScore>, index: number, template: ResourceTemplate, height: number | undefined): void { + template.elementDisposables.dispose(); + } + + disposeTemplate(template: ResourceTemplate): void { + template.elementDisposables.dispose(); + template.disposables.dispose(); + } +} + +class ProviderListDelegate implements IListVirtualDelegate { + + getHeight() { return 22; } + + getTemplateId(element: TreeElement) { + if (ResourceTree.isBranchNode(element) || isSCMResource(element)) { + return ResourceRenderer.TEMPLATE_ID; + } else { + return ResourceGroupRenderer.TEMPLATE_ID; + } + } +} + +class SCMTreeFilter implements ITreeFilter { + + filter(element: TreeElement): boolean { + if (ResourceTree.isBranchNode(element)) { + return true; + } else if (isSCMResourceGroup(element)) { + return element.elements.length > 0 || !element.hideWhenEmpty; + } else { + return true; + } + } +} + +export class SCMTreeSorter implements ITreeSorter { + + @memoize + private get viewModel(): ViewModel { return this.viewModelProvider(); } + + constructor(private viewModelProvider: () => ViewModel) { } + + compare(one: TreeElement, other: TreeElement): number { + if (this.viewModel.mode === ViewModelMode.List) { + return 0; + } + + if (isSCMResourceGroup(one) && isSCMResourceGroup(other)) { + return 0; + } + + const oneIsDirectory = ResourceTree.isBranchNode(one); + const otherIsDirectory = ResourceTree.isBranchNode(other); + + if (oneIsDirectory !== otherIsDirectory) { + return oneIsDirectory ? -1 : 1; + } + + const oneName = ResourceTree.isBranchNode(one) ? one.name : basename((one as ISCMResource).sourceUri); + const otherName = ResourceTree.isBranchNode(other) ? other.name : basename((other as ISCMResource).sourceUri); + + return compareFileNames(oneName, otherName); + } +} + +export class SCMTreeKeyboardNavigationLabelProvider implements ICompressibleKeyboardNavigationLabelProvider { + + getKeyboardNavigationLabel(element: TreeElement): { toString(): string; } | undefined { + if (ResourceTree.isBranchNode(element)) { + return element.name; + } else if (isSCMResourceGroup(element)) { + return element.label; + } else { + return basename(element.sourceUri); + } + } + + getCompressedNodeKeyboardNavigationLabel(elements: TreeElement[]): { toString(): string | undefined; } | undefined { + const folders = elements as IBranchNode[]; + return folders.map(e => e.name).join('/'); + } +} + +class SCMResourceIdentityProvider implements IIdentityProvider { + + getId(element: TreeElement): string { + if (ResourceTree.isBranchNode(element)) { + const group = element.context; + return `${group.provider.contextValue}/${group.id}/$FOLDER/${element.uri.toString()}`; + } else if (isSCMResource(element)) { + const group = element.resourceGroup; + const provider = group.provider; + return `${provider.contextValue}/${group.id}/${element.sourceUri.toString()}`; + } else { + const provider = element.provider; + return `${provider.contextValue}/${element.id}`; + } + } +} + +interface IGroupItem { + readonly group: ISCMResourceGroup; + readonly resources: ISCMResource[]; + readonly tree: ResourceTree; + readonly disposable: IDisposable; +} + +function groupItemAsTreeElement(item: IGroupItem, mode: ViewModelMode): ICompressedTreeElement { + const children = mode === ViewModelMode.List + ? Iterator.map(Iterator.fromArray(item.resources), element => ({ element, incompressible: true })) + : Iterator.map(item.tree.root.children, node => asTreeElement(node, true)); + + return { element: item.group, children, incompressible: true }; +} + +function asTreeElement(node: INode, incompressible: boolean): ICompressedTreeElement { + if (ResourceTree.isBranchNode(node)) { + return { + element: node, + children: Iterator.map(node.children, node => asTreeElement(node, false)), + incompressible, + collapsed: false + }; + } + + return { element: node.element, incompressible: true }; +} + +const enum ViewModelMode { + List = 'list', + Tree = 'tree' +} + +class ViewModel { + + private _mode = ViewModelMode.Tree; + private _onDidChangeMode = new Emitter(); + readonly onDidChangeMode = this._onDidChangeMode.event; + + get mode(): ViewModelMode { return this._mode; } + set mode(mode: ViewModelMode) { + this._mode = mode; + this.refresh(); + this._onDidChangeMode.fire(mode); + } + + private items: IGroupItem[] = []; + private visibilityDisposables = new DisposableStore(); + private scrollTop: number | undefined; + private disposables = new DisposableStore(); + + constructor( + private groups: ISequence, + private tree: ObjectTree + ) { } + + private onDidSpliceGroups({ start, deleteCount, toInsert }: ISplice): void { + const itemsToInsert: IGroupItem[] = []; + + for (const group of toInsert) { + const tree = new ResourceTree(group, group.provider.rootUri || URI.file('/')); + const resources: ISCMResource[] = [...group.elements]; + const disposable = combinedDisposable( + group.onDidChange(() => this.tree.refilter()), + group.onDidSplice(splice => this.onDidSpliceGroup(item, splice)) + ); + + const item = { group, resources, tree, disposable }; + + for (const resource of resources) { + item.tree.add(resource.sourceUri, resource); + } + + itemsToInsert.push(item); + } + + const itemsToDispose = this.items.splice(start, deleteCount, ...itemsToInsert); + + for (const item of itemsToDispose) { + item.disposable.dispose(); + } + + this.refresh(); + } + + private onDidSpliceGroup(item: IGroupItem, { start, deleteCount, toInsert }: ISplice): void { + for (const resource of toInsert) { + item.tree.add(resource.sourceUri, resource); + } + + const deleted = item.resources.splice(start, deleteCount, ...toInsert); + + for (const resource of deleted) { + item.tree.delete(resource.sourceUri); + } + + this.refresh(item); + } + + setVisible(visible: boolean): void { + if (visible) { + this.visibilityDisposables = new DisposableStore(); + this.groups.onDidSplice(this.onDidSpliceGroups, this, this.visibilityDisposables); + this.onDidSpliceGroups({ start: 0, deleteCount: this.items.length, toInsert: this.groups.elements }); + + if (typeof this.scrollTop === 'number') { + this.tree.scrollTop = this.scrollTop; + this.scrollTop = undefined; + } + } else { + this.visibilityDisposables.dispose(); + this.onDidSpliceGroups({ start: 0, deleteCount: this.items.length, toInsert: [] }); + this.scrollTop = this.tree.scrollTop; + } + } + + private refresh(item?: IGroupItem): void { + if (item) { + this.tree.setChildren(item.group, groupItemAsTreeElement(item, this.mode).children); + } else { + this.tree.setChildren(null, this.items.map(item => groupItemAsTreeElement(item, this.mode))); + } + } + + dispose(): void { + this.visibilityDisposables.dispose(); + this.disposables.dispose(); + } +} + +export class ToggleViewModeAction extends Action { + + static readonly ID = 'workbench.scm.action.toggleViewMode'; + static readonly LABEL = localize('toggleViewMode', "ToggleViewMode"); + + constructor(private viewModel: ViewModel) { + super(ToggleViewModeAction.ID, ToggleViewModeAction.LABEL); + + this._register(this.viewModel.onDidChangeMode(this.onDidChangeMode, this)); + this.onDidChangeMode(this.viewModel.mode); + } + + async run(): Promise { + this.viewModel.mode = this.viewModel.mode === ViewModelMode.List ? ViewModelMode.Tree : ViewModelMode.List; + } + + private onDidChangeMode(mode: ViewModelMode): void { + this.class = `scm-action toggle-view-mode ${mode}`; + } +} + +function convertValidationType(type: InputValidationType): MessageType { + switch (type) { + case InputValidationType.Information: return MessageType.INFO; + case InputValidationType.Warning: return MessageType.WARNING; + case InputValidationType.Error: return MessageType.ERROR; + } +} + +export class RepositoryPanel extends ViewletPanel { + + private cachedHeight: number | undefined = undefined; + private cachedWidth: number | undefined = undefined; + private inputBoxContainer: HTMLElement; + private inputBox: InputBox; + private listContainer: HTMLElement; + private tree: ObjectTree; + private viewModel: ViewModel; + private listLabels: ResourceLabels; + private menus: SCMMenus; + private toggleViewModelModeAction: ToggleViewModeAction | undefined; + protected contextKeyService: IContextKeyService; + + constructor( + readonly repository: ISCMRepository, + options: IViewletPanelOptions, + @IKeybindingService protected keybindingService: IKeybindingService, + @IWorkbenchThemeService protected themeService: IWorkbenchThemeService, + @IContextMenuService protected contextMenuService: IContextMenuService, + @IContextViewService protected contextViewService: IContextViewService, + @ICommandService protected commandService: ICommandService, + @INotificationService private readonly notificationService: INotificationService, + @IEditorService protected editorService: IEditorService, + @IInstantiationService protected instantiationService: IInstantiationService, + @IConfigurationService protected configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IMenuService protected menuService: IMenuService + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService); + + this.menus = instantiationService.createInstance(SCMMenus, this.repository.provider); + this._register(this.menus); + this._register(this.menus.onDidChangeTitle(this._onDidChangeTitleArea.fire, this._onDidChangeTitleArea)); + + this.contextKeyService = contextKeyService.createScoped(this.element); + this.contextKeyService.createKey('scmRepository', this.repository); + } + + render(): void { + super.render(); + this._register(this.menus.onDidChangeTitle(this.updateActions, this)); + } + + protected renderHeaderTitle(container: HTMLElement): void { + let title: string; + let type: string; + + if (this.repository.provider.rootUri) { + title = basename(this.repository.provider.rootUri); + type = this.repository.provider.label; + } else { + title = this.repository.provider.label; + type = ''; + } + + super.renderHeaderTitle(container, title); + addClass(container, 'scm-provider'); + append(container, $('span.type', undefined, type)); + } + + protected renderBody(container: HTMLElement): void { + const focusTracker = trackFocus(container); + this._register(focusTracker.onDidFocus(() => this.repository.focus())); + this._register(focusTracker); + + // Input + this.inputBoxContainer = append(container, $('.scm-editor')); + + const updatePlaceholder = () => { + const binding = this.keybindingService.lookupKeybinding('scm.acceptInput'); + const label = binding ? binding.getLabel() : (platform.isMacintosh ? 'Cmd+Enter' : 'Ctrl+Enter'); + const placeholder = format(this.repository.input.placeholder, label); + + this.inputBox.setPlaceHolder(placeholder); + }; + + const validationDelayer = new ThrottledDelayer(200); + const validate = () => { + return this.repository.input.validateInput(this.inputBox.value, this.inputBox.inputElement.selectionStart || 0).then(result => { + if (!result) { + this.inputBox.inputElement.removeAttribute('aria-invalid'); + this.inputBox.hideMessage(); + } else { + this.inputBox.inputElement.setAttribute('aria-invalid', 'true'); + this.inputBox.showMessage({ content: result.message, type: convertValidationType(result.type) }); + } + }); + }; + + const triggerValidation = () => validationDelayer.trigger(validate); + + this.inputBox = new InputBox(this.inputBoxContainer, this.contextViewService, { flexibleHeight: true, flexibleMaxHeight: 134 }); + this.inputBox.setEnabled(this.isBodyVisible()); + this._register(attachInputBoxStyler(this.inputBox, this.themeService)); + this._register(this.inputBox); + + this._register(this.inputBox.onDidChange(triggerValidation, null)); + + const onKeyUp = domEvent(this.inputBox.inputElement, 'keyup'); + const onMouseUp = domEvent(this.inputBox.inputElement, 'mouseup'); + this._register(Event.any(onKeyUp, onMouseUp)(triggerValidation, null)); + + this.inputBox.value = this.repository.input.value; + this._register(this.inputBox.onDidChange(value => this.repository.input.value = value, null)); + this._register(this.repository.input.onDidChange(value => this.inputBox.value = value, null)); + + updatePlaceholder(); + this._register(this.repository.input.onDidChangePlaceholder(updatePlaceholder, null)); + this._register(this.keybindingService.onDidUpdateKeybindings(updatePlaceholder, null)); + + this._register(this.inputBox.onDidHeightChange(() => this.layoutBody())); + + if (this.repository.provider.onDidChangeCommitTemplate) { + this._register(this.repository.provider.onDidChangeCommitTemplate(this.updateInputBox, this)); + } + + this.updateInputBox(); + + // Input box visibility + this._register(this.repository.input.onDidChangeVisibility(this.updateInputBoxVisibility, this)); + this.updateInputBoxVisibility(); + + // List + this.listContainer = append(container, $('.scm-status.show-file-icons')); + + const updateActionsVisibility = () => toggleClass(this.listContainer, 'show-actions', this.configurationService.getValue('scm.alwaysShowActions')); + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.alwaysShowActions'))(updateActionsVisibility); + updateActionsVisibility(); + + const delegate = new ProviderListDelegate(); + + const actionViewItemProvider = (action: IAction) => this.getActionViewItem(action); + + this.listLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility }); + this._register(this.listLabels); + + const renderers = [ + new ResourceGroupRenderer(actionViewItemProvider, this.themeService, this.menus), + new ResourceRenderer(() => this.viewModel, this.listLabels, actionViewItemProvider, () => this.getSelectedResources(), this.themeService, this.menus) + ]; + + const filter = new SCMTreeFilter(); + const sorter = new SCMTreeSorter(() => this.viewModel); + const keyboardNavigationLabelProvider = new SCMTreeKeyboardNavigationLabelProvider(); + const identityProvider = new SCMResourceIdentityProvider(); + + this.tree = this.instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + 'SCM Tree Repo', + this.listContainer, + delegate, + renderers, + { + identityProvider, + horizontalScrolling: false, + filter, + sorter, + keyboardNavigationLabelProvider + }); + + this._register(Event.chain(this.tree.onDidOpen) + .map(e => e.elements[0]) + .filter(e => !!e && !ResourceTree.isBranchNode(e) && isSCMResource(e)) + .on(this.open, this)); + + this._register(Event.chain(this.tree.onDidPin) + .map(e => e.elements[0]) + .filter(e => !!e && !ResourceTree.isBranchNode(e) && isSCMResource(e)) + .on(this.pin, this)); + + this._register(this.tree.onContextMenu(this.onListContextMenu, this)); + this._register(this.tree); + + this.viewModel = new ViewModel(this.repository.provider.groups, this.tree); + this._register(this.viewModel); + + addClass(this.listContainer, 'file-icon-themable-tree'); + addClass(this.listContainer, 'show-file-icons'); + + const updateIndentStyles = (theme: IFileIconTheme) => { + toggleClass(this.listContainer, 'list-view-mode', this.viewModel.mode === ViewModelMode.List); + toggleClass(this.listContainer, 'align-icons-and-twisties', this.viewModel.mode === ViewModelMode.Tree && theme.hasFileIcons && !theme.hasFolderIcons); + toggleClass(this.listContainer, 'hide-arrows', this.viewModel.mode === ViewModelMode.Tree && theme.hidesExplorerArrows === true); + }; + + updateIndentStyles(this.themeService.getFileIconTheme()); + this._register(this.themeService.onDidFileIconThemeChange(updateIndentStyles)); + this._register(this.viewModel.onDidChangeMode(() => updateIndentStyles(this.themeService.getFileIconTheme()))); + + this.toggleViewModelModeAction = new ToggleViewModeAction(this.viewModel); + this._register(this.toggleViewModelModeAction); + + this._register(this.onDidChangeBodyVisibility(this._onDidChangeVisibility, this)); + + this.updateActions(); + } + + layoutBody(height: number | undefined = this.cachedHeight, width: number | undefined = this.cachedWidth): void { + if (height === undefined) { + return; + } + + this.cachedHeight = height; + + if (this.repository.input.visible) { + removeClass(this.inputBoxContainer, 'hidden'); + this.inputBox.layout(); + + const editorHeight = this.inputBox.height; + const listHeight = height - (editorHeight + 12 /* margin */); + this.listContainer.style.height = `${listHeight}px`; + this.tree.layout(listHeight, width); + } else { + addClass(this.inputBoxContainer, 'hidden'); + + this.listContainer.style.height = `${height}px`; + this.tree.layout(height, width); + } + } + + focus(): void { + super.focus(); + + if (this.isExpanded()) { + if (this.repository.input.visible) { + this.inputBox.focus(); + } else { + this.tree.domFocus(); + } + + this.repository.focus(); + } + } + + private _onDidChangeVisibility(visible: boolean): void { + this.inputBox.setEnabled(visible); + this.viewModel.setVisible(visible); + } + + getActions(): IAction[] { + if (this.toggleViewModelModeAction) { + + return [ + this.toggleViewModelModeAction, + ...this.menus.getTitleActions() + ]; + } else { + return this.menus.getTitleActions(); + } + } + + getSecondaryActions(): IAction[] { + return this.menus.getTitleSecondaryActions(); + } + + getActionViewItem(action: IAction): IActionViewItem | undefined { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + + return new ContextAwareMenuEntryActionViewItem(action, this.keybindingService, this.notificationService, this.contextMenuService); + } + + getActionsContext(): any { + return this.repository.provider; + } + + private open(e: ISCMResource): void { + e.open(); + } + + private pin(): void { + const activeControl = this.editorService.activeControl; + + if (activeControl) { + activeControl.group.pinEditor(activeControl.input); + } + } + + private onListContextMenu(e: ITreeContextMenuEvent): void { + if (!e.element) { + return; + } + + const element = e.element; + let actions: IAction[] = []; + + if (ResourceTree.isBranchNode(element)) { + actions = this.menus.getResourceFolderContextActions(element.context); + } else if (isSCMResource(element)) { + actions = this.menus.getResourceContextActions(element); + } else { + actions = this.menus.getResourceGroupContextActions(element); + } + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => actions, + getActionsContext: () => element, + actionRunner: new MultipleSelectionActionRunner(() => this.getSelectedResources()) + }); + } + + private getSelectedResources(): (ISCMResource | IBranchNode)[] { + return this.tree.getSelection() + .filter(r => !!r && !isSCMResourceGroup(r))! as any; + } + + private updateInputBox(): void { + if (typeof this.repository.provider.commitTemplate === 'undefined' || !this.repository.input.visible || this.inputBox.value) { + return; + } + + this.inputBox.value = this.repository.provider.commitTemplate; + } + + private updateInputBoxVisibility(): void { + if (this.cachedHeight) { + this.layoutBody(this.cachedHeight); + } + } +} + +export class RepositoryViewDescriptor implements IViewDescriptor { + + private static counter = 0; + + readonly id: string; + readonly name: string; + readonly ctorDescriptor: { ctor: any, arguments?: any[] }; + readonly canToggleVisibility = true; + readonly order = -500; + readonly workspace = true; + + constructor(readonly repository: ISCMRepository, readonly hideByDefault: boolean) { + const repoId = repository.provider.rootUri ? repository.provider.rootUri.toString() : `#${RepositoryViewDescriptor.counter++}`; + this.id = `scm:repository:${repository.provider.label}:${repoId}`; + this.name = repository.provider.rootUri ? basename(repository.provider.rootUri) : repository.provider.label; + + this.ctorDescriptor = { ctor: RepositoryPanel, arguments: [repository] }; + } +} diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index 84504b8b1f5..bacb6796fa8 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -13,7 +13,7 @@ import { IWorkbenchActionRegistry, Extensions as WorkbenchActionExtensions } fro import { KeyMod, KeyCode } from 'vs/base/common/keyCodes'; import { SyncActionDescriptor, MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; -import { SCMStatusController } from './scmActivity'; +import { SCMStatusController } from './activity'; import { SCMViewlet } from 'vs/workbench/contrib/scm/browser/scmViewlet'; import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; diff --git a/src/vs/workbench/contrib/scm/browser/scmUtil.ts b/src/vs/workbench/contrib/scm/browser/scmUtil.ts deleted file mode 100644 index 0b9307b1213..00000000000 --- a/src/vs/workbench/contrib/scm/browser/scmUtil.ts +++ /dev/null @@ -1,10 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { ISCMResourceGroup, ISCMResource } from 'vs/workbench/contrib/scm/common/scm'; - -export function isSCMResource(element: ISCMResourceGroup | ISCMResource): element is ISCMResource { - return !!(element as ISCMResource).sourceUri; -} \ No newline at end of file diff --git a/src/vs/workbench/contrib/scm/browser/scmViewlet.ts b/src/vs/workbench/contrib/scm/browser/scmViewlet.ts index e01dbff4d32..35ebbf9ccad 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewlet.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewlet.ts @@ -6,50 +6,31 @@ import 'vs/css!./media/scmViewlet'; import { localize } from 'vs/nls'; import { Event, Emitter } from 'vs/base/common/event'; -import { domEvent } from 'vs/base/browser/event'; -import { basename } from 'vs/base/common/resources'; -import { IDisposable, dispose, Disposable, DisposableStore, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ViewletPanel, IViewletPanelOptions } from 'vs/workbench/browser/parts/views/panelViewlet'; -import { append, $, addClass, toggleClass, trackFocus, removeClass, addClasses } from 'vs/base/browser/dom'; +import { append, $, toggleClass, addClasses } from 'vs/base/browser/dom'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { List } from 'vs/base/browser/ui/list/listWidget'; -import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent, IListEvent, IKeyboardNavigationLabelProvider, IIdentityProvider } from 'vs/base/browser/ui/list/list'; -import { VIEWLET_ID, ISCMService, ISCMRepository, ISCMResourceGroup, ISCMResource, InputValidationType, VIEW_CONTAINER } from 'vs/workbench/contrib/scm/common/scm'; -import { ResourceLabels, IResourceLabel } from 'vs/workbench/browser/labels'; -import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { VIEWLET_ID, ISCMService, ISCMRepository, VIEW_CONTAINER } from 'vs/workbench/contrib/scm/common/scm'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IContextViewService, IContextMenuService } from 'vs/platform/contextview/browser/contextView'; -import { IContextKeyService, ContextKeyExpr, IContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; -import { MenuItemAction, IMenuService, MenuId, IMenu } from 'vs/platform/actions/common/actions'; -import { IAction, Action, IActionViewItem, ActionRunner } from 'vs/base/common/actions'; -import { createAndFillInContextMenuActions, ContextAwareMenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; -import { SCMMenus } from './scmMenus'; -import { ActionBar, IActionViewItemProvider, ActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; -import { IThemeService, LIGHT } from 'vs/platform/theme/common/themeService'; -import { isSCMResource } from './scmUtil'; -import { attachBadgeStyler, attachInputBoxStyler } from 'vs/platform/theme/common/styler'; +import { MenuItemAction } from 'vs/platform/actions/common/actions'; +import { IAction, IActionViewItem } from 'vs/base/common/actions'; +import { ContextAwareMenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { SCMMenus } from './menus'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IStorageService } from 'vs/platform/storage/common/storage'; -import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; -import { Command } from 'vs/editor/common/modes'; -import { renderOcticons } from 'vs/base/browser/ui/octiconLabel/octiconLabel'; -import { format } from 'vs/base/common/strings'; -import { ISpliceable, ISequence, ISplice } from 'vs/base/common/sequence'; -import { firstIndex, equals } from 'vs/base/common/arrays'; -import { WorkbenchList } from 'vs/platform/list/browser/listService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ThrottledDelayer } from 'vs/base/common/async'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; -import * as platform from 'vs/base/common/platform'; import { ViewContainerViewlet } from 'vs/workbench/browser/parts/views/viewsViewlet'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { IViewsRegistry, IViewDescriptor, Extensions } from 'vs/workbench/common/views'; +import { IViewsRegistry, Extensions } from 'vs/workbench/common/views'; import { Registry } from 'vs/platform/registry/common/platform'; import { nextTick } from 'vs/base/common/process'; +import { RepositoryPanel, RepositoryViewDescriptor } from 'vs/workbench/contrib/scm/browser/repositoryPanel'; +import { MainPanelDescriptor, MainPanel } from 'vs/workbench/contrib/scm/browser/mainPanel'; export interface ISpliceEvent { index: number; @@ -69,977 +50,6 @@ export interface IViewModel { readonly onDidChangeVisibility: Event; } -class ProvidersListDelegate implements IListVirtualDelegate { - - getHeight(element: ISCMRepository): number { - return 22; - } - - getTemplateId(element: ISCMRepository): string { - return 'provider'; - } -} - -class StatusBarAction extends Action { - - constructor( - private command: Command, - private commandService: ICommandService - ) { - super(`statusbaraction{${command.id}}`, command.title, '', true); - this.tooltip = command.tooltip || ''; - } - - run(): Promise { - return this.commandService.executeCommand(this.command.id, ...(this.command.arguments || [])); - } -} - -class StatusBarActionViewItem extends ActionViewItem { - - constructor(action: StatusBarAction) { - super(null, action, {}); - } - - updateLabel(): void { - if (this.options.label) { - this.label.innerHTML = renderOcticons(this.getAction().label); - } - } -} - -function connectPrimaryMenuToInlineActionBar(menu: IMenu, actionBar: ActionBar): IDisposable { - let cachedDisposable: IDisposable = Disposable.None; - let cachedPrimary: IAction[] = []; - - const updateActions = () => { - const primary: IAction[] = []; - const secondary: IAction[] = []; - - const disposable = createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, { primary, secondary }, g => /^inline/.test(g)); - - if (equals(cachedPrimary, primary, (a, b) => a.id === b.id)) { - disposable.dispose(); - return; - } - - cachedDisposable = disposable; - cachedPrimary = primary; - - actionBar.clear(); - actionBar.push(primary, { icon: true, label: false }); - }; - - updateActions(); - - return combinedDisposable(menu.onDidChange(updateActions), toDisposable(() => { - cachedDisposable.dispose(); - })); -} - -interface RepositoryTemplateData { - title: HTMLElement; - type: HTMLElement; - countContainer: HTMLElement; - count: CountBadge; - actionBar: ActionBar; - disposable: IDisposable; - templateDisposable: IDisposable; -} - -class ProviderRenderer implements IListRenderer { - - readonly templateId = 'provider'; - - private _onDidRenderElement = new Emitter(); - readonly onDidRenderElement = this._onDidRenderElement.event; - - constructor( - @ICommandService protected commandService: ICommandService, - @IThemeService protected themeService: IThemeService - ) { } - - renderTemplate(container: HTMLElement): RepositoryTemplateData { - const provider = append(container, $('.scm-provider')); - const name = append(provider, $('.name')); - const title = append(name, $('span.title')); - const type = append(name, $('span.type')); - const countContainer = append(provider, $('.count')); - const count = new CountBadge(countContainer); - const badgeStyler = attachBadgeStyler(count, this.themeService); - const actionBar = new ActionBar(provider, { actionViewItemProvider: a => new StatusBarActionViewItem(a as StatusBarAction) }); - const disposable = Disposable.None; - const templateDisposable = combinedDisposable(actionBar, badgeStyler); - - return { title, type, countContainer, count, actionBar, disposable, templateDisposable }; - } - - renderElement(repository: ISCMRepository, index: number, templateData: RepositoryTemplateData): void { - templateData.disposable.dispose(); - const disposables = new DisposableStore(); - - if (repository.provider.rootUri) { - templateData.title.textContent = basename(repository.provider.rootUri); - templateData.type.textContent = repository.provider.label; - } else { - templateData.title.textContent = repository.provider.label; - templateData.type.textContent = ''; - } - - const actions: IAction[] = []; - const disposeActions = () => dispose(actions); - disposables.add({ dispose: disposeActions }); - - const update = () => { - disposeActions(); - - const commands = repository.provider.statusBarCommands || []; - actions.splice(0, actions.length, ...commands.map(c => new StatusBarAction(c, this.commandService))); - templateData.actionBar.clear(); - templateData.actionBar.push(actions); - - const count = repository.provider.count || 0; - toggleClass(templateData.countContainer, 'hidden', count === 0); - templateData.count.setCount(count); - - this._onDidRenderElement.fire(repository); - }; - - disposables.add(repository.provider.onDidChange(update, null)); - update(); - - templateData.disposable = disposables; - } - - disposeTemplate(templateData: RepositoryTemplateData): void { - templateData.disposable.dispose(); - templateData.templateDisposable.dispose(); - } -} - -export class MainPanel extends ViewletPanel { - - static readonly ID = 'scm.mainPanel'; - static readonly TITLE = localize('scm providers', "Source Control Providers"); - - private list: List; - - constructor( - protected viewModel: IViewModel, - options: IViewletPanelOptions, - @IKeybindingService protected keybindingService: IKeybindingService, - @IContextMenuService protected contextMenuService: IContextMenuService, - @ISCMService protected scmService: ISCMService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IMenuService private readonly menuService: IMenuService, - @IConfigurationService configurationService: IConfigurationService - ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService); - } - - protected renderBody(container: HTMLElement): void { - const delegate = new ProvidersListDelegate(); - const renderer = this.instantiationService.createInstance(ProviderRenderer); - const identityProvider = { getId: (r: ISCMRepository) => r.provider.id }; - - this.list = this.instantiationService.createInstance(WorkbenchList, `SCM Main`, container, delegate, [renderer], { - identityProvider, - horizontalScrolling: false - }); - - this._register(renderer.onDidRenderElement(e => this.list.updateWidth(this.viewModel.repositories.indexOf(e)), null)); - this._register(this.list.onSelectionChange(this.onListSelectionChange, this)); - this._register(this.list.onFocusChange(this.onListFocusChange, this)); - this._register(this.list.onContextMenu(this.onListContextMenu, this)); - - this._register(this.viewModel.onDidChangeVisibleRepositories(this.updateListSelection, this)); - - this._register(this.viewModel.onDidSplice(({ index, deleteCount, elements }) => this.splice(index, deleteCount, elements), null)); - this.splice(0, 0, this.viewModel.repositories); - - this._register(this.list); - - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('scm.providers.visible')) { - this.updateBodySize(); - } - })); - - this.updateListSelection(); - } - - private splice(index: number, deleteCount: number, repositories: ISCMRepository[] = []): void { - this.list.splice(index, deleteCount, repositories); - - const empty = this.list.length === 0; - toggleClass(this.element, 'empty', empty); - - this.updateBodySize(); - } - - protected layoutBody(height: number, width: number): void { - this.list.layout(height, width); - } - - private updateBodySize(): void { - const visibleCount = this.configurationService.getValue('scm.providers.visible'); - const empty = this.list.length === 0; - const size = Math.min(this.viewModel.repositories.length, visibleCount) * 22; - - this.minimumBodySize = visibleCount === 0 ? 22 : size; - this.maximumBodySize = visibleCount === 0 ? Number.POSITIVE_INFINITY : empty ? Number.POSITIVE_INFINITY : size; - } - - private onListContextMenu(e: IListContextMenuEvent): void { - if (!e.element) { - return; - } - - const repository = e.element; - const contextKeyService = this.contextKeyService.createScoped(); - const scmProviderKey = contextKeyService.createKey('scmProvider', undefined); - scmProviderKey.set(repository.provider.contextValue); - - const menu = this.menuService.createMenu(MenuId.SCMSourceControl, contextKeyService); - const primary: IAction[] = []; - const secondary: IAction[] = []; - const result = { primary, secondary }; - - const disposable = createAndFillInContextMenuActions(menu, { shouldForwardArgs: true }, result, this.contextMenuService, g => g === 'inline'); - - menu.dispose(); - contextKeyService.dispose(); - - if (secondary.length === 0) { - return; - } - - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => secondary, - getActionsContext: () => repository.provider - }); - - disposable.dispose(); - } - - private onListSelectionChange(e: IListEvent): void { - if (e.browserEvent && e.elements.length > 0) { - const scrollTop = this.list.scrollTop; - this.viewModel.setVisibleRepositories(e.elements); - this.list.scrollTop = scrollTop; - } - } - - private onListFocusChange(e: IListEvent): void { - if (e.browserEvent && e.elements.length > 0) { - e.elements[0].focus(); - } - } - - private updateListSelection(): void { - const set = new Set(); - - for (const repository of this.viewModel.visibleRepositories) { - set.add(repository); - } - - const selection: number[] = []; - - for (let i = 0; i < this.list.length; i++) { - if (set.has(this.list.element(i))) { - selection.push(i); - } - } - - this.list.setSelection(selection); - - if (selection.length > 0) { - this.list.setFocus([selection[0]]); - } - } -} - -interface ResourceGroupTemplate { - name: HTMLElement; - count: CountBadge; - actionBar: ActionBar; - elementDisposable: IDisposable; - dispose: () => void; -} - -class ResourceGroupRenderer implements IListRenderer { - - static TEMPLATE_ID = 'resource group'; - get templateId(): string { return ResourceGroupRenderer.TEMPLATE_ID; } - - constructor( - private actionViewItemProvider: IActionViewItemProvider, - private themeService: IThemeService, - private menus: SCMMenus - ) { } - - renderTemplate(container: HTMLElement): ResourceGroupTemplate { - const element = append(container, $('.resource-group')); - const name = append(element, $('.name')); - const actionsContainer = append(element, $('.actions')); - const actionBar = new ActionBar(actionsContainer, { actionViewItemProvider: this.actionViewItemProvider }); - const countContainer = append(element, $('.count')); - const count = new CountBadge(countContainer); - const styler = attachBadgeStyler(count, this.themeService); - const elementDisposable = Disposable.None; - - return { - name, count, actionBar, elementDisposable, dispose: () => { - actionBar.dispose(); - styler.dispose(); - } - }; - } - - renderElement(group: ISCMResourceGroup, index: number, template: ResourceGroupTemplate): void { - template.elementDisposable.dispose(); - - template.name.textContent = group.label; - template.actionBar.clear(); - template.actionBar.context = group; - - const disposables = new DisposableStore(); - disposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceGroupMenu(group), template.actionBar)); - - const updateCount = () => template.count.setCount(group.elements.length); - disposables.add(group.onDidSplice(updateCount, null)); - updateCount(); - - template.elementDisposable = disposables; - } - - disposeElement(group: ISCMResourceGroup, index: number, template: ResourceGroupTemplate): void { - template.elementDisposable.dispose(); - } - - disposeTemplate(template: ResourceGroupTemplate): void { - template.dispose(); - } -} - -interface ResourceTemplate { - element: HTMLElement; - name: HTMLElement; - fileLabel: IResourceLabel; - decorationIcon: HTMLElement; - actionBar: ActionBar; - elementDisposable: IDisposable; - dispose: () => void; -} - -class MultipleSelectionActionRunner extends ActionRunner { - - constructor(private getSelectedResources: () => ISCMResource[]) { - super(); - } - - runAction(action: IAction, context: ISCMResource): Promise { - if (action instanceof MenuItemAction) { - const selection = this.getSelectedResources(); - const filteredSelection = selection.filter(s => s !== context); - - if (selection.length === filteredSelection.length || selection.length === 1) { - return action.run(context); - } - - return action.run(context, ...filteredSelection); - } - - return super.runAction(action, context); - } -} - -class ResourceRenderer implements IListRenderer { - - static TEMPLATE_ID = 'resource'; - get templateId(): string { return ResourceRenderer.TEMPLATE_ID; } - - constructor( - private labels: ResourceLabels, - private actionViewItemProvider: IActionViewItemProvider, - private getSelectedResources: () => ISCMResource[], - private themeService: IThemeService, - private menus: SCMMenus - ) { } - - renderTemplate(container: HTMLElement): ResourceTemplate { - const element = append(container, $('.resource')); - const name = append(element, $('.name')); - const fileLabel = this.labels.create(name); - const actionsContainer = append(fileLabel.element, $('.actions')); - const actionBar = new ActionBar(actionsContainer, { - actionViewItemProvider: this.actionViewItemProvider, - actionRunner: new MultipleSelectionActionRunner(this.getSelectedResources) - }); - - const decorationIcon = append(element, $('.decoration-icon')); - - return { - element, name, fileLabel, decorationIcon, actionBar, elementDisposable: Disposable.None, dispose: () => { - actionBar.dispose(); - fileLabel.dispose(); - } - }; - } - - renderElement(resource: ISCMResource, index: number, template: ResourceTemplate): void { - template.elementDisposable.dispose(); - - const theme = this.themeService.getTheme(); - const icon = theme.type === LIGHT ? resource.decorations.icon : resource.decorations.iconDark; - - template.fileLabel.setFile(resource.sourceUri, { fileDecorations: { colors: false, badges: !icon } }); - template.actionBar.clear(); - template.actionBar.context = resource; - - const disposables = new DisposableStore(); - disposables.add(connectPrimaryMenuToInlineActionBar(this.menus.getResourceMenu(resource.resourceGroup), template.actionBar)); - - toggleClass(template.name, 'strike-through', resource.decorations.strikeThrough); - toggleClass(template.element, 'faded', resource.decorations.faded); - - if (icon) { - template.decorationIcon.style.display = ''; - template.decorationIcon.style.backgroundImage = `url('${icon}')`; - template.decorationIcon.title = resource.decorations.tooltip || ''; - } else { - template.decorationIcon.style.display = 'none'; - template.decorationIcon.style.backgroundImage = ''; - } - - template.element.setAttribute('data-tooltip', resource.decorations.tooltip || ''); - template.elementDisposable = disposables; - } - - disposeElement(resource: ISCMResource, index: number, template: ResourceTemplate): void { - template.elementDisposable.dispose(); - } - - disposeTemplate(template: ResourceTemplate): void { - template.elementDisposable.dispose(); - template.dispose(); - } -} - -class ProviderListDelegate implements IListVirtualDelegate { - - getHeight() { return 22; } - - getTemplateId(element: ISCMResourceGroup | ISCMResource) { - return isSCMResource(element) ? ResourceRenderer.TEMPLATE_ID : ResourceGroupRenderer.TEMPLATE_ID; - } -} - -const scmResourceIdentityProvider = new class implements IIdentityProvider { - getId(r: ISCMResourceGroup | ISCMResource): string { - if (isSCMResource(r)) { - const group = r.resourceGroup; - const provider = group.provider; - return `${provider.contextValue}/${group.id}/${r.sourceUri.toString()}`; - } else { - const provider = r.provider; - return `${provider.contextValue}/${r.id}`; - } - } -}; - -const scmKeyboardNavigationLabelProvider = new class implements IKeyboardNavigationLabelProvider { - getKeyboardNavigationLabel(e: ISCMResourceGroup | ISCMResource) { - if (isSCMResource(e)) { - return basename(e.sourceUri); - } else { - return e.label; - } - } -}; - -function isGroupVisible(group: ISCMResourceGroup) { - return group.elements.length > 0 || !group.hideWhenEmpty; -} - -interface IGroupItem { - readonly group: ISCMResourceGroup; - visible: boolean; - readonly disposable: IDisposable; -} - -class ResourceGroupSplicer { - - private items: IGroupItem[] = []; - private disposables: IDisposable[] = []; - - constructor( - groupSequence: ISequence, - private spliceable: ISpliceable - ) { - groupSequence.onDidSplice(this.onDidSpliceGroups, this, this.disposables); - this.onDidSpliceGroups({ start: 0, deleteCount: 0, toInsert: groupSequence.elements }); - } - - private onDidSpliceGroups({ start, deleteCount, toInsert }: ISplice): void { - let absoluteStart = 0; - - for (let i = 0; i < start; i++) { - const item = this.items[i]; - absoluteStart += (item.visible ? 1 : 0) + item.group.elements.length; - } - - let absoluteDeleteCount = 0; - - for (let i = 0; i < deleteCount; i++) { - const item = this.items[start + i]; - absoluteDeleteCount += (item.visible ? 1 : 0) + item.group.elements.length; - } - - const itemsToInsert: IGroupItem[] = []; - const absoluteToInsert: Array = []; - - for (const group of toInsert) { - const visible = isGroupVisible(group); - - if (visible) { - absoluteToInsert.push(group); - } - - for (const element of group.elements) { - absoluteToInsert.push(element); - } - - const disposable = combinedDisposable( - group.onDidChange(() => this.onDidChangeGroup(group)), - group.onDidSplice(splice => this.onDidSpliceGroup(group, splice)) - ); - - itemsToInsert.push({ group, visible, disposable }); - } - - const itemsToDispose = this.items.splice(start, deleteCount, ...itemsToInsert); - - for (const item of itemsToDispose) { - item.disposable.dispose(); - } - - this.spliceable.splice(absoluteStart, absoluteDeleteCount, absoluteToInsert); - } - - private onDidChangeGroup(group: ISCMResourceGroup): void { - const itemIndex = firstIndex(this.items, item => item.group === group); - - if (itemIndex < 0) { - return; - } - - const item = this.items[itemIndex]; - const visible = isGroupVisible(group); - - if (item.visible === visible) { - return; - } - - let absoluteStart = 0; - - for (let i = 0; i < itemIndex; i++) { - const item = this.items[i]; - absoluteStart += (item.visible ? 1 : 0) + item.group.elements.length; - } - - if (visible) { - this.spliceable.splice(absoluteStart, 0, [group, ...group.elements]); - } else { - this.spliceable.splice(absoluteStart, 1 + group.elements.length, []); - } - - item.visible = visible; - } - - private onDidSpliceGroup(group: ISCMResourceGroup, { start, deleteCount, toInsert }: ISplice): void { - const itemIndex = firstIndex(this.items, item => item.group === group); - - if (itemIndex < 0) { - return; - } - - const item = this.items[itemIndex]; - const visible = isGroupVisible(group); - - if (!item.visible && !visible) { - return; - } - - let absoluteStart = start; - - for (let i = 0; i < itemIndex; i++) { - const item = this.items[i]; - absoluteStart += (item.visible ? 1 : 0) + item.group.elements.length; - } - - if (item.visible && !visible) { - this.spliceable.splice(absoluteStart, 1 + deleteCount, toInsert); - } else if (!item.visible && visible) { - this.spliceable.splice(absoluteStart, deleteCount, [group, ...toInsert]); - } else { - this.spliceable.splice(absoluteStart + 1, deleteCount, toInsert); - } - - item.visible = visible; - } - - dispose(): void { - this.onDidSpliceGroups({ start: 0, deleteCount: this.items.length, toInsert: [] }); - this.disposables = dispose(this.disposables); - } -} - -function convertValidationType(type: InputValidationType): MessageType { - switch (type) { - case InputValidationType.Information: return MessageType.INFO; - case InputValidationType.Warning: return MessageType.WARNING; - case InputValidationType.Error: return MessageType.ERROR; - } -} - -export class RepositoryPanel extends ViewletPanel { - - private cachedHeight: number | undefined = undefined; - private cachedWidth: number | undefined = undefined; - private cachedScrollTop: number | undefined = undefined; - private inputBoxContainer: HTMLElement; - private inputBox: InputBox; - private listContainer: HTMLElement; - private list: List; - private listLabels: ResourceLabels; - private menus: SCMMenus; - private visibilityDisposables: IDisposable[] = []; - protected contextKeyService: IContextKeyService; - - constructor( - readonly repository: ISCMRepository, - private readonly viewModel: IViewModel, - options: IViewletPanelOptions, - @IKeybindingService protected keybindingService: IKeybindingService, - @IThemeService protected themeService: IThemeService, - @IContextMenuService protected contextMenuService: IContextMenuService, - @IContextViewService protected contextViewService: IContextViewService, - @ICommandService protected commandService: ICommandService, - @INotificationService private readonly notificationService: INotificationService, - @IEditorService protected editorService: IEditorService, - @IInstantiationService protected instantiationService: IInstantiationService, - @IConfigurationService protected configurationService: IConfigurationService, - @IContextKeyService contextKeyService: IContextKeyService, - @IMenuService protected menuService: IMenuService - ) { - super(options, keybindingService, contextMenuService, configurationService, contextKeyService); - - this.menus = instantiationService.createInstance(SCMMenus, this.repository.provider); - this._register(this.menus); - this._register(this.menus.onDidChangeTitle(this._onDidChangeTitleArea.fire, this._onDidChangeTitleArea)); - - this.contextKeyService = contextKeyService.createScoped(this.element); - this.contextKeyService.createKey('scmRepository', this.repository); - } - - render(): void { - super.render(); - this._register(this.menus.onDidChangeTitle(this.updateActions, this)); - } - - protected renderHeaderTitle(container: HTMLElement): void { - let title: string; - let type: string; - - if (this.repository.provider.rootUri) { - title = basename(this.repository.provider.rootUri); - type = this.repository.provider.label; - } else { - title = this.repository.provider.label; - type = ''; - } - - super.renderHeaderTitle(container, title); - addClass(container, 'scm-provider'); - append(container, $('span.type', undefined, type)); - } - - protected renderBody(container: HTMLElement): void { - const focusTracker = trackFocus(container); - this._register(focusTracker.onDidFocus(() => this.repository.focus())); - this._register(focusTracker); - - // Input - this.inputBoxContainer = append(container, $('.scm-editor')); - - const updatePlaceholder = () => { - const binding = this.keybindingService.lookupKeybinding('scm.acceptInput'); - const label = binding ? binding.getLabel() : (platform.isMacintosh ? 'Cmd+Enter' : 'Ctrl+Enter'); - const placeholder = format(this.repository.input.placeholder, label); - - this.inputBox.setPlaceHolder(placeholder); - }; - - const validationDelayer = new ThrottledDelayer(200); - const validate = () => { - return this.repository.input.validateInput(this.inputBox.value, this.inputBox.inputElement.selectionStart || 0).then(result => { - if (!result) { - this.inputBox.inputElement.removeAttribute('aria-invalid'); - this.inputBox.hideMessage(); - } else { - this.inputBox.inputElement.setAttribute('aria-invalid', 'true'); - this.inputBox.showMessage({ content: result.message, type: convertValidationType(result.type) }); - } - }); - }; - - const triggerValidation = () => validationDelayer.trigger(validate); - - this.inputBox = new InputBox(this.inputBoxContainer, this.contextViewService, { flexibleHeight: true, flexibleMaxHeight: 134 }); - this.inputBox.setEnabled(this.isBodyVisible()); - this._register(attachInputBoxStyler(this.inputBox, this.themeService)); - this._register(this.inputBox); - - this._register(this.inputBox.onDidChange(triggerValidation, null)); - - const onKeyUp = domEvent(this.inputBox.inputElement, 'keyup'); - const onMouseUp = domEvent(this.inputBox.inputElement, 'mouseup'); - this._register(Event.any(onKeyUp, onMouseUp)(triggerValidation, null)); - - this.inputBox.value = this.repository.input.value; - this._register(this.inputBox.onDidChange(value => this.repository.input.value = value, null)); - this._register(this.repository.input.onDidChange(value => this.inputBox.value = value, null)); - - updatePlaceholder(); - this._register(this.repository.input.onDidChangePlaceholder(updatePlaceholder, null)); - this._register(this.keybindingService.onDidUpdateKeybindings(updatePlaceholder, null)); - - this._register(this.inputBox.onDidHeightChange(() => this.layoutBody())); - - if (this.repository.provider.onDidChangeCommitTemplate) { - this._register(this.repository.provider.onDidChangeCommitTemplate(this.updateInputBox, this)); - } - - this.updateInputBox(); - - // Input box visibility - this._register(this.repository.input.onDidChangeVisibility(this.updateInputBoxVisibility, this)); - this.updateInputBoxVisibility(); - - // List - this.listContainer = append(container, $('.scm-status.show-file-icons')); - - const updateActionsVisibility = () => toggleClass(this.listContainer, 'show-actions', this.configurationService.getValue('scm.alwaysShowActions')); - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('scm.alwaysShowActions'))(updateActionsVisibility); - updateActionsVisibility(); - - const delegate = new ProviderListDelegate(); - - const actionViewItemProvider = (action: IAction) => this.getActionViewItem(action); - - this.listLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility }); - this._register(this.listLabels); - - const renderers = [ - new ResourceGroupRenderer(actionViewItemProvider, this.themeService, this.menus), - new ResourceRenderer(this.listLabels, actionViewItemProvider, () => this.getSelectedResources(), this.themeService, this.menus) - ]; - - this.list = this.instantiationService.createInstance(WorkbenchList, `SCM Repo`, this.listContainer, delegate, renderers, { - identityProvider: scmResourceIdentityProvider, - keyboardNavigationLabelProvider: scmKeyboardNavigationLabelProvider, - horizontalScrolling: false - }); - - this._register(Event.chain(this.list.onDidOpen) - .map(e => e.elements[0]) - .filter(e => !!e && isSCMResource(e)) - .on(this.open, this)); - - this._register(Event.chain(this.list.onPin) - .map(e => e.elements[0]) - .filter(e => !!e && isSCMResource(e)) - .on(this.pin, this)); - - this._register(this.list.onContextMenu(this.onListContextMenu, this)); - this._register(this.list); - - this._register(this.viewModel.onDidChangeVisibility(this.onDidChangeVisibility, this)); - this.onDidChangeVisibility(this.viewModel.isVisible()); - this.onDidChangeBodyVisibility(visible => this.inputBox.setEnabled(visible)); - } - - private onDidChangeVisibility(visible: boolean): void { - if (visible) { - const listSplicer = new ResourceGroupSplicer(this.repository.provider.groups, this.list); - this.visibilityDisposables.push(listSplicer); - } else { - this.cachedScrollTop = this.list.scrollTop; - this.visibilityDisposables = dispose(this.visibilityDisposables); - } - } - - layoutBody(height: number | undefined = this.cachedHeight, width: number | undefined = this.cachedWidth): void { - if (height === undefined) { - return; - } - - this.cachedHeight = height; - - if (this.repository.input.visible) { - removeClass(this.inputBoxContainer, 'hidden'); - this.inputBox.layout(); - - const editorHeight = this.inputBox.height; - const listHeight = height - (editorHeight + 12 /* margin */); - this.listContainer.style.height = `${listHeight}px`; - this.list.layout(listHeight, width); - } else { - addClass(this.inputBoxContainer, 'hidden'); - - this.listContainer.style.height = `${height}px`; - this.list.layout(height, width); - } - - if (this.cachedScrollTop !== undefined && this.list.scrollTop !== this.cachedScrollTop) { - this.list.scrollTop = Math.min(this.cachedScrollTop, this.list.scrollHeight); - // Applying the cached scroll position just once until the next leave. - // This, also, avoids the scrollbar to flicker when resizing the sidebar. - this.cachedScrollTop = undefined; - } - } - - focus(): void { - super.focus(); - - if (this.isExpanded()) { - if (this.repository.input.visible) { - this.inputBox.focus(); - } else { - this.list.domFocus(); - } - - this.repository.focus(); - } - } - - getActions(): IAction[] { - return this.menus.getTitleActions(); - } - - getSecondaryActions(): IAction[] { - return this.menus.getTitleSecondaryActions(); - } - - getActionViewItem(action: IAction): IActionViewItem | undefined { - if (!(action instanceof MenuItemAction)) { - return undefined; - } - - return new ContextAwareMenuEntryActionViewItem(action, this.keybindingService, this.notificationService, this.contextMenuService); - } - - getActionsContext(): any { - return this.repository.provider; - } - - private open(e: ISCMResource): void { - e.open(); - } - - private pin(): void { - const activeControl = this.editorService.activeControl; - if (activeControl) { - activeControl.group.pinEditor(activeControl.input); - } - } - - private onListContextMenu(e: IListContextMenuEvent): void { - if (!e.element) { - return; - } - - const element = e.element; - let actions: IAction[]; - - if (isSCMResource(element)) { - actions = this.menus.getResourceContextActions(element); - } else { - actions = this.menus.getResourceGroupContextActions(element); - } - - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => actions, - getActionsContext: () => element, - actionRunner: new MultipleSelectionActionRunner(() => this.getSelectedResources()) - }); - } - - private getSelectedResources(): ISCMResource[] { - return this.list.getSelectedElements() - .filter(r => isSCMResource(r)) as ISCMResource[]; - } - - private updateInputBox(): void { - if (typeof this.repository.provider.commitTemplate === 'undefined' || !this.repository.input.visible || this.inputBox.value) { - return; - } - - this.inputBox.value = this.repository.provider.commitTemplate; - } - - private updateInputBoxVisibility(): void { - if (this.cachedHeight) { - this.layoutBody(this.cachedHeight); - } - } - - dispose(): void { - this.visibilityDisposables = dispose(this.visibilityDisposables); - super.dispose(); - } -} - -class RepositoryViewDescriptor implements IViewDescriptor { - - private static counter = 0; - - readonly id: string; - readonly name: string; - readonly ctorDescriptor: { ctor: any, arguments?: any[] }; - readonly canToggleVisibility = true; - readonly order = -500; - readonly workspace = true; - - constructor(readonly repository: ISCMRepository, viewModel: IViewModel, readonly hideByDefault: boolean) { - const repoId = repository.provider.rootUri ? repository.provider.rootUri.toString() : `#${RepositoryViewDescriptor.counter++}`; - this.id = `scm:repository:${repository.provider.label}:${repoId}`; - this.name = repository.provider.rootUri ? basename(repository.provider.rootUri) : repository.provider.label; - - this.ctorDescriptor = { ctor: RepositoryPanel, arguments: [repository, viewModel] }; - } -} - -class MainPanelDescriptor implements IViewDescriptor { - - readonly id = MainPanel.ID; - readonly name = MainPanel.TITLE; - readonly ctorDescriptor: { ctor: any, arguments?: any[] }; - readonly canToggleVisibility = true; - readonly hideByDefault = false; - readonly order = -1000; - readonly workspace = true; - readonly when = ContextKeyExpr.or(ContextKeyExpr.equals('config.scm.alwaysShowProviders', true), ContextKeyExpr.and(ContextKeyExpr.notEquals('scm.providerCount', 0), ContextKeyExpr.notEquals('scm.providerCount', 1))); - - constructor(viewModel: IViewModel) { - this.ctorDescriptor = { ctor: MainPanel, arguments: [viewModel] }; - } -} - export class SCMViewlet extends ViewContainerViewlet implements IViewModel { private static readonly STATE_KEY = 'workbench.scm.views.state'; @@ -1125,7 +135,7 @@ export class SCMViewlet extends ViewContainerViewlet implements IViewModel { const index = this._repositories.length; this._repositories.push(repository); - const viewDescriptor = new RepositoryViewDescriptor(repository, this, false); + const viewDescriptor = new RepositoryViewDescriptor(repository, false); Registry.as(Extensions.ViewsRegistry).registerViews([viewDescriptor], VIEW_CONTAINER); this.viewDescriptors.push(viewDescriptor); diff --git a/src/vs/workbench/contrib/scm/browser/util.ts b/src/vs/workbench/contrib/scm/browser/util.ts new file mode 100644 index 00000000000..97a4c1eb80a --- /dev/null +++ b/src/vs/workbench/contrib/scm/browser/util.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ISCMResource, ISCMRepository, ISCMResourceGroup } from 'vs/workbench/contrib/scm/common/scm'; +import { IMenu } from 'vs/platform/actions/common/actions'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { IDisposable, Disposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IAction } from 'vs/base/common/actions'; +import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { equals } from 'vs/base/common/arrays'; + +export function isSCMRepository(element: any): element is ISCMRepository { + return !!(element as ISCMRepository).provider && typeof (element as ISCMRepository).setSelected === 'function'; +} + +export function isSCMResourceGroup(element: any): element is ISCMResourceGroup { + return !!(element as ISCMResourceGroup).provider && !!(element as ISCMResourceGroup).elements; +} + +export function isSCMResource(element: any): element is ISCMResource { + return !!(element as ISCMResource).sourceUri && isSCMResourceGroup((element as ISCMResource).resourceGroup); +} + +export function connectPrimaryMenuToInlineActionBar(menu: IMenu, actionBar: ActionBar): IDisposable { + let cachedDisposable: IDisposable = Disposable.None; + let cachedPrimary: IAction[] = []; + + const updateActions = () => { + const primary: IAction[] = []; + const secondary: IAction[] = []; + + const disposable = createAndFillInActionBarActions(menu, { shouldForwardArgs: true }, { primary, secondary }, g => /^inline/.test(g)); + + if (equals(cachedPrimary, primary, (a, b) => a.id === b.id)) { + disposable.dispose(); + return; + } + + cachedDisposable = disposable; + cachedPrimary = primary; + + actionBar.clear(); + actionBar.push(primary, { icon: true, label: false }); + }; + + updateActions(); + + return combinedDisposable(menu.onDidChange(updateActions), toDisposable(() => { + cachedDisposable.dispose(); + })); +}