diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index 72c0a025499..b4f0cb2b278 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -52,4 +52,8 @@ export interface IListContextMenuEvent { export interface IIdentityProvider { getId(element: T): { toString(): string; }; +} + +export interface ITypeLabelProvider { + getTypeLabel(element: T): { toString(): string; }; } \ No newline at end of file diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index ce6d5345d04..44faa05a06b 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -14,9 +14,9 @@ import * as platform from 'vs/base/common/platform'; import { Gesture } from 'vs/base/browser/touch'; import { KeyCode } from 'vs/base/common/keyCodes'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; -import { Event, Emitter, EventBufferer, chain, mapEvent, anyEvent } from 'vs/base/common/event'; +import { Event, Emitter, EventBufferer, chain, mapEvent, anyEvent, debounceEvent, reduceEvent } from 'vs/base/common/event'; import { domEvent } from 'vs/base/browser/event'; -import { IListVirtualDelegate, IListRenderer, IListEvent, IListContextMenuEvent, IListMouseEvent, IListTouchEvent, IListGestureEvent, IIdentityProvider } from './list'; +import { IListVirtualDelegate, IListRenderer, IListEvent, IListContextMenuEvent, IListMouseEvent, IListTouchEvent, IListGestureEvent, IIdentityProvider, ITypeLabelProvider } from './list'; import { ListView, IListViewOptions } from './listView'; import { Color } from 'vs/base/common/color'; import { mixin } from 'vs/base/common/objects'; @@ -24,6 +24,7 @@ import { ScrollbarVisibility } from 'vs/base/common/scrollable'; import { ISpliceable } from 'vs/base/common/sequence'; import { CombinedSpliceable } from 'vs/base/browser/ui/list/splice'; import { clamp } from 'vs/base/common/numbers'; +import { matchesPrefix } from 'vs/base/common/filters'; interface ITraitChangeEvent { indexes: number[]; @@ -322,6 +323,69 @@ class KeyboardController implements IDisposable { } } +enum TypeLabelControllerState { + Idle, + Typing +} + +class TypeLabelController implements IDisposable { + + private state: TypeLabelControllerState = TypeLabelControllerState.Idle; + private disposables: IDisposable[] = []; + + constructor( + private list: List, + private view: ListView, + private typeLabelProvider: ITypeLabelProvider + ) { + const onChar = chain(domEvent(view.domNode, 'keydown')) + .map(event => new StandardKeyboardEvent(event)) + .filter(event => { + 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.US_SEMICOLON && event.keyCode <= KeyCode.US_QUOTE); + }) + .map(event => event.browserEvent.key) + .event; + + const onClear = debounceEvent(onChar, () => null, 800); + const onInput = reduceEvent(anyEvent(onChar, onClear), (r, i) => i === null ? null : ((r || '') + i)); + + onInput(this.onInput, this, this.disposables); + } + + private onInput(word: string | null): void { + if (!word) { + this.state = TypeLabelControllerState.Idle; + return; + } + + const focus = this.list.getFocus(); + const start = focus.length > 0 ? focus[0] : 0; + const delta = this.state === TypeLabelControllerState.Idle ? 1 : 0; + this.state = TypeLabelControllerState.Typing; + + for (let i = 0; i < this.list.length; i++) { + const index = (start + i + delta) % this.list.length; + const label = this.typeLabelProvider.getTypeLabel(this.view.element(index)); + + if (matchesPrefix(word, label.toString())) { + this.list.setFocus([index]); + this.list.reveal(index); + return; + } + } + } + + dispose() { + this.disposables = dispose(this.disposables); + } +} + class DOMFocusController implements IDisposable { private disposables: IDisposable[] = []; @@ -666,6 +730,7 @@ export class DefaultStyleController implements IStyleController { export interface IListOptions extends IListViewOptions, IListStyles { identityProvider?: IIdentityProvider; + typeLabelProvider?: ITypeLabelProvider; ariaLabel?: string; mouseSupport?: boolean; selectOnMouseDown?: boolean; @@ -1002,6 +1067,11 @@ export class List implements ISpliceable, IDisposable { this.disposables.push(controller); } + if (options.typeLabelProvider) { + const controller = new TypeLabelController(this, this.view, options.typeLabelProvider); + this.disposables.push(controller); + } + if (typeof options.mouseSupport === 'boolean' ? options.mouseSupport : true) { this.disposables.push(new MouseController(this, this.view, options)); } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index c74e5842466..130b0a0c428 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -34,6 +34,11 @@ function asListOptions(options?: IAbstractTreeOptions(options?: IAsyncDataTreeOptions(...events: Event[]): Event { return (listener, thisArgs = null, disposables?) => combinedDisposable(events.map(event => event(e => listener.call(thisArgs, e), null, disposables))); } +export function reduceEvent(event: Event, merger: (last: O | undefined, event: I) => O): Event { + let output: O | undefined = undefined; + + return mapEvent(event, e => { + output = merger(output, e); + return output; + }); +} + export function debounceEvent(event: Event, merger: (last: T, event: T) => T, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event; export function debounceEvent(event: Event, merger: (last: O | undefined, event: I) => O, delay?: number, leading?: boolean, leakWarningThreshold?: number): Event; export function debounceEvent(event: Event, merger: (last: O | undefined, event: I) => O, delay: number = 100, leading = false, leakWarningThreshold?: number): Event { diff --git a/src/vs/workbench/parts/scm/electron-browser/scmViewlet.ts b/src/vs/workbench/parts/scm/electron-browser/scmViewlet.ts index cad634c6e88..84a0ebea6dd 100644 --- a/src/vs/workbench/parts/scm/electron-browser/scmViewlet.ts +++ b/src/vs/workbench/parts/scm/electron-browser/scmViewlet.ts @@ -13,7 +13,7 @@ import { PanelViewlet, ViewletPanel, IViewletPanelOptions } from 'vs/workbench/b import { append, $, addClass, toggleClass, trackFocus, Dimension, addDisposableListener, removeClass } 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 } from 'vs/base/browser/ui/list/list'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent, IListEvent, ITypeLabelProvider, IIdentityProvider } from 'vs/base/browser/ui/list/list'; import { VIEWLET_ID, VIEW_CONTAINER } from 'vs/workbench/parts/scm/common/scm'; import { FileLabel } from 'vs/workbench/browser/labels'; import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; @@ -557,7 +557,7 @@ class ProviderListDelegate implements IListVirtualDelegate { getId(r: ISCMResourceGroup | ISCMResource): string { if (isSCMResource(r)) { const group = r.resourceGroup; @@ -570,6 +570,16 @@ const scmResourceIdentityProvider = { } }; +const scmTypeLabelProvider = new class implements ITypeLabelProvider { + getTypeLabel(e: ISCMResourceGroup | ISCMResource) { + if (isSCMResource(e)) { + return basename(e.sourceUri.fsPath); + } else { + return e.label; + } + } +}; + function isGroupVisible(group: ISCMResourceGroup) { return group.elements.length > 0 || !group.hideWhenEmpty; } @@ -869,7 +879,8 @@ export class RepositoryPanel extends ViewletPanel { ]; this.list = this.instantiationService.createInstance(WorkbenchList, this.listContainer, delegate, renderers, { - identityProvider: scmResourceIdentityProvider + identityProvider: scmResourceIdentityProvider, + typeLabelProvider: scmTypeLabelProvider }) as WorkbenchList; chain(this.list.onOpen)