diff --git a/src/vs/workbench/contrib/debug/browser/repl.ts b/src/vs/workbench/contrib/debug/browser/repl.ts index a5e01950a9aa6f2277f9b609ebeaf66f3e291357..5ec4fa0aa673b27d9daf7342f6e825b7e0080d5a 100644 --- a/src/vs/workbench/contrib/debug/browser/repl.ts +++ b/src/vs/workbench/contrib/debug/browser/repl.ts @@ -59,12 +59,14 @@ import { ReplGroup } from 'vs/workbench/contrib/debug/common/replModel'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { EDITOR_FONT_DEFAULTS, EditorOption } from 'vs/editor/common/config/editorOptions'; import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from 'vs/base/browser/ui/mouseCursor/mouseCursor'; +import { TreeFilterPanelActionViewItem, TreeFilterState } from 'vs/workbench/contrib/treeFilter/browser/treeFilterView'; +import { ReplFilter } from 'vs/workbench/contrib/debug/browser/replFilter'; const $ = dom.$; const HISTORY_STORAGE_KEY = 'debug.repl.history'; const DECORATION_KEY = 'replinputdecoration'; - +const FILTER_ACTION_ID = `workbench.actions.treeView.repl.filter`; function revealLastElement(tree: WorkbenchAsyncDataTree) { tree.scrollTop = tree.scrollHeight - tree.renderHeight; @@ -93,6 +95,8 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { private styleElement: HTMLStyleElement | undefined; private completionItemProvider: IDisposable | undefined; private modelChangeListener: IDisposable = Disposable.None; + private filter: ReplFilter; + private filterState: TreeFilterState; constructor( options: IViewPaneOptions, @@ -116,6 +120,13 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); this.history = new HistoryNavigator(JSON.parse(this.storageService.get(HISTORY_STORAGE_KEY, StorageScope.WORKSPACE, '[]')), 50); + this.filter = new ReplFilter(); + this.filterState = this._register(new TreeFilterState({ + filterText: '', + filterHistory: [], + layout: new dom.Dimension(0, 0), + })); + codeEditorService.registerDecorationType(DECORATION_KEY, {}); this.registerListeners(); } @@ -237,6 +248,15 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { this._register(this.editorService.onDidActiveEditorChange(() => { this.setMode(); })); + + this._register(this.filterState.onDidChange((e) => { + if (e.filterText) { + this.filter.filterQuery = this.filterState.filterText; + if (this.tree) { + this.tree.refilter(); + } + } + })); } get isReadonly(): boolean { @@ -428,6 +448,7 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { this.replInputContainer.style.height = `${replInputHeight}px`; this.replInput.layout({ width: width - 30, height: replInputHeight }); + this.filterState.layout = new dom.Dimension(width, height); } focus(): void { @@ -437,6 +458,8 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { getActionViewItem(action: IAction): IActionViewItem | undefined { if (action.id === SelectReplAction.ID) { return this.instantiationService.createInstance(SelectReplActionViewItem, this.selectReplAction); + } else if (action.id === FILTER_ACTION_ID) { + return this.instantiationService.createInstance(TreeFilterPanelActionViewItem, action, localize('workbench.debug.filter.placeholder', "Filter. E.g.: text, !exclude"), this.filterState); } return super.getActionViewItem(action); @@ -444,6 +467,7 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { getActions(): IAction[] { const result: IAction[] = []; + result.push(new Action(FILTER_ACTION_ID)); if (this.debugService.getModel().getSessions(true).filter(s => s.hasSeparateRepl() && !sessionsToIgnore.has(s)).length > 1) { result.push(this.selectReplAction); } @@ -532,6 +556,7 @@ export class Repl extends ViewPane implements IHistoryNavigationWidget { // https://github.com/microsoft/TypeScript/issues/32526 new ReplDataSource() as IAsyncDataSource, { + filter: this.filter, accessibilityProvider: new ReplAccessibilityProvider(), identityProvider: { getId: (element: IReplElement) => element.getId() }, mouseSupport: false, diff --git a/src/vs/workbench/contrib/debug/browser/replFilter.ts b/src/vs/workbench/contrib/debug/browser/replFilter.ts new file mode 100644 index 0000000000000000000000000000000000000000..243c10f8d6adc524256fa728e89345b5e90605f1 --- /dev/null +++ b/src/vs/workbench/contrib/debug/browser/replFilter.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { matchesFuzzy } from 'vs/base/common/filters'; +import { splitGlobAware } from 'vs/base/common/glob'; +import * as strings from 'vs/base/common/strings'; +import { ITreeFilter, TreeVisibility, TreeFilterResult } from 'vs/base/browser/ui/tree/tree'; +import { IReplElement } from 'vs/workbench/contrib/debug/common/debug'; + +type ParsedQuery = { + type: 'include' | 'exclude', + query: string, +}; + +export class ReplFilter implements ITreeFilter { + + static matchQuery = matchesFuzzy; + + private _parsedQueries: ParsedQuery[] = []; + set filterQuery(query: string) { + this._parsedQueries = []; + query = query.trim(); + + if (query && query !== '') { + const filters = splitGlobAware(query, ',').map(s => s.trim()).filter(s => !!s.length); + for (const f of filters) { + if (strings.startsWith(f, '!')) { + this._parsedQueries.push({ type: 'exclude', query: f.slice(1) }); + } else { + this._parsedQueries.push({ type: 'include', query: f }); + } + } + } + } + + filter(element: IReplElement, parentVisibility: TreeVisibility): TreeFilterResult { + if (this._parsedQueries.length === 0) { + return parentVisibility; + } + + let includeQueryPresent = false; + let includeQueryMatched = false; + + const text = element.toString(); + + for (let { type, query } of this._parsedQueries) { + if (type === 'exclude' && ReplFilter.matchQuery(query, text)) { + // If exclude query matches, ignore all other queries and hide + return false; + } else if (type === 'include') { + includeQueryPresent = true; + if (ReplFilter.matchQuery(query, text)) { + includeQueryMatched = true; + } + } + } + + return includeQueryPresent ? includeQueryMatched : parentVisibility; + } +} diff --git a/src/vs/workbench/contrib/debug/common/replModel.ts b/src/vs/workbench/contrib/debug/common/replModel.ts index 87d3348b97c8c3b56c9b5e3881fdf06874bd083c..b0aa0128449b1aecc0f69c037ed54f03d47645dc 100644 --- a/src/vs/workbench/contrib/debug/common/replModel.ts +++ b/src/vs/workbench/contrib/debug/common/replModel.ts @@ -174,13 +174,18 @@ export class ReplGroup implements IReplElement { } } +type FilterFunc = ((element: IReplElement) => void); + export class ReplModel { private replElements: IReplElement[] = []; private readonly _onDidChangeElements = new Emitter(); readonly onDidChangeElements = this._onDidChangeElements.event; + private filterFunc: FilterFunc | undefined; getReplElements(): IReplElement[] { - return this.replElements; + return this.replElements.filter(element => + this.filterFunc ? this.filterFunc(element) : true + ); } async addReplExpression(session: IDebugSession, stackFrame: IStackFrame | undefined, name: string): Promise { @@ -315,6 +320,10 @@ export class ReplModel { } } + setFilter(filterFunc: FilterFunc): void { + this.filterFunc = filterFunc; + } + removeReplExpressions(): void { if (this.replElements.length > 0) { this.replElements = []; diff --git a/src/vs/workbench/contrib/debug/test/browser/repl.test.ts b/src/vs/workbench/contrib/debug/test/browser/repl.test.ts index a8c0c42173ec249eba306a791027abdf35a9ec0e..7440c4df8b8d9aebec49f84eb2ff11d0920eae87 100644 --- a/src/vs/workbench/contrib/debug/test/browser/repl.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/repl.test.ts @@ -12,6 +12,8 @@ import { SimpleReplElement, RawObjectReplElement, ReplEvaluationInput, ReplModel import { RawDebugSession } from 'vs/workbench/contrib/debug/browser/rawDebugSession'; import { timeout } from 'vs/base/common/async'; import { createMockSession } from 'vs/workbench/contrib/debug/test/browser/callStack.test'; +import { ReplFilter } from 'vs/workbench/contrib/debug/browser/replFilter'; +import { TreeVisibility } from 'vs/base/browser/ui/tree/tree'; suite('Debug - REPL', () => { let model: DebugModel; @@ -189,4 +191,59 @@ suite('Debug - REPL', () => { assert.equal(repl.getReplElements().length, 3); assert.equal((repl.getReplElements()[2]).value, 'second global line'); }); + + test('repl filter', async () => { + const session = createMockSession(model); + const repl = new ReplModel(); + const replFilter = new ReplFilter(); + + repl.setFilter((element) => { + const filterResult = replFilter.filter(element, TreeVisibility.Visible); + return filterResult === true || filterResult === TreeVisibility.Visible; + }); + + repl.appendToRepl(session, 'first line\n', severity.Info); + repl.appendToRepl(session, 'second line\n', severity.Info); + repl.appendToRepl(session, 'third line\n', severity.Info); + repl.appendToRepl(session, 'fourth line\n', severity.Info); + + replFilter.filterQuery = 'first'; + let r1 = repl.getReplElements(); + assert.equal(r1.length, 1); + assert.equal(r1[0].value, 'first line\n'); + + replFilter.filterQuery = '!first'; + let r2 = repl.getReplElements(); + assert.equal(r1.length, 1); + assert.equal(r2[0].value, 'second line\n'); + assert.equal(r2[1].value, 'third line\n'); + assert.equal(r2[2].value, 'fourth line\n'); + + replFilter.filterQuery = 'first, line'; + let r3 = repl.getReplElements(); + assert.equal(r3.length, 4); + assert.equal(r3[0].value, 'first line\n'); + assert.equal(r3[1].value, 'second line\n'); + assert.equal(r3[2].value, 'third line\n'); + assert.equal(r3[3].value, 'fourth line\n'); + + replFilter.filterQuery = 'line, !second'; + let r4 = repl.getReplElements(); + assert.equal(r4.length, 3); + assert.equal(r4[0].value, 'first line\n'); + assert.equal(r4[1].value, 'third line\n'); + assert.equal(r4[2].value, 'fourth line\n'); + + replFilter.filterQuery = '!second, line'; + let r4_same = repl.getReplElements(); + assert.equal(r4.length, r4_same.length); + + replFilter.filterQuery = '!line'; + let r5 = repl.getReplElements(); + assert.equal(r5.length, 0); + + replFilter.filterQuery = 'smth'; + let r6 = repl.getReplElements(); + assert.equal(r6.length, 0); + }); }); diff --git a/src/vs/workbench/contrib/treeFilter/browser/media/treeFilter.css b/src/vs/workbench/contrib/treeFilter/browser/media/treeFilter.css new file mode 100644 index 0000000000000000000000000000000000000000..30c2246279744254a8762a7f3b8af04acd3b9595 --- /dev/null +++ b/src/vs/workbench/contrib/treeFilter/browser/media/treeFilter.css @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-action-bar .action-item.panel-action-tree-filter-container { + cursor: default; + display: flex; +} + +.monaco-action-bar .panel-action-tree-filter{ + display: flex; + align-items: center; + flex: 1; +} + +.monaco-action-bar .panel-action-tree-filter .monaco-inputbox { + height: 24px; + font-size: 12px; + flex: 1; +} + +.pane-header .monaco-action-bar .panel-action-tree-filter .monaco-inputbox { + height: 20px; + line-height: 18px; +} + +.monaco-workbench.vs .monaco-action-bar .panel-action-tree-filter .monaco-inputbox { + height: 25px; +} + +.panel > .title .monaco-action-bar .action-item.panel-action-tree-filter-container { + max-width: 600px; + min-width: 300px; + margin-right: 10px; +} + +.monaco-action-bar .action-item.panel-action-tree-filter-container, +.panel > .title .monaco-action-bar .action-item.panel-action-tree-filter-container.grow { + flex: 1; +} diff --git a/src/vs/workbench/contrib/treeFilter/browser/treeFilterView.ts b/src/vs/workbench/contrib/treeFilter/browser/treeFilterView.ts new file mode 100644 index 0000000000000000000000000000000000000000..87b785171ffff20d79b7e54e84eb76f129a3e9f7 --- /dev/null +++ b/src/vs/workbench/contrib/treeFilter/browser/treeFilterView.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * 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/treeFilter'; +import * as DOM from 'vs/base/browser/dom'; +import { BaseActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems'; +import { Delayer } from 'vs/base/common/async'; +import { IAction } from 'vs/base/common/actions'; +import { HistoryInputBox } from 'vs/base/browser/ui/inputbox/inputBox'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; +import { toDisposable, Disposable } from 'vs/base/common/lifecycle'; +import { Event, Emitter } from 'vs/base/common/event'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; +import { KeyCode } from 'vs/base/common/keyCodes'; +import { ContextScopedHistoryInputBox } from 'vs/platform/browser/contextScopedHistoryWidget'; +import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; +import { IThemeService } from 'vs/platform/theme/common/themeService'; + +export interface IReplFiltersChangeEvent { + filterText?: boolean; + layout?: boolean; +} + +export interface IReplFiltersOptions { + filterText: string; + filterHistory: string[]; + layout: DOM.Dimension; +} + +export class TreeFilterState extends Disposable { + + private readonly _onDidChange: Emitter = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor(options: IReplFiltersOptions) { + super(); + this._filterText = options.filterText; + this.filterHistory = options.filterHistory; + this._layout = options.layout; + } + + private _filterText: string; + get filterText(): string { + return this._filterText; + } + set filterText(filterText: string) { + if (this._filterText !== filterText) { + this._filterText = filterText; + this._onDidChange.fire({ filterText: true }); + } + } + + filterHistory: string[]; + + private _layout: DOM.Dimension = new DOM.Dimension(0, 0); + get layout(): DOM.Dimension { + return this._layout; + } + set layout(layout: DOM.Dimension) { + if (this._layout.width !== layout.width || this._layout.height !== layout.height) { + this._layout = layout; + this._onDidChange.fire({ layout: true }); + } + } +} + +export class TreeFilterPanelActionViewItem extends BaseActionViewItem { + + private delayedFilterUpdate: Delayer; + private container: HTMLElement | undefined; + private filterInputBox: HistoryInputBox | undefined; + + constructor( + action: IAction, + private placeholder: string, + private filters: TreeFilterState, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IThemeService private readonly themeService: IThemeService, + @IContextViewService private readonly contextViewService: IContextViewService) { + super(null, action); + this.delayedFilterUpdate = new Delayer(200); + this._register(toDisposable(() => this.delayedFilterUpdate.cancel())); + } + + render(container: HTMLElement): void { + this.container = container; + DOM.addClass(this.container, 'panel-action-tree-filter-container'); + + this.element = DOM.append(this.container, DOM.$('')); + this.element.className = this.class; + this.createInput(this.element); + this.updateClass(); + + this.adjustInputBox(); + } + + focus(): void { + if (this.filterInputBox) { + this.filterInputBox.focus(); + } + } + + private clearFilterText(): void { + if (this.filterInputBox) { + this.filterInputBox.value = ''; + } + } + + private createInput(container: HTMLElement): void { + this.filterInputBox = this._register(this.instantiationService.createInstance(ContextScopedHistoryInputBox, container, this.contextViewService, { + placeholder: this.placeholder, + history: this.filters.filterHistory + })); + this._register(attachInputBoxStyler(this.filterInputBox, this.themeService)); + this.filterInputBox.value = this.filters.filterText; + this._register(this.filterInputBox.onDidChange(() => this.delayedFilterUpdate.trigger(() => this.onDidInputChange(this.filterInputBox!)))); + this._register(this.filters.onDidChange((event: IReplFiltersChangeEvent) => { + if (event.filterText) { + this.filterInputBox!.value = this.filters.filterText; + } + })); + this._register(DOM.addStandardDisposableListener(this.filterInputBox.inputElement, DOM.EventType.KEY_DOWN, (e: any) => this.onInputKeyDown(e))); + this._register(DOM.addStandardDisposableListener(container, DOM.EventType.KEY_DOWN, this.handleKeyboardEvent)); + this._register(DOM.addStandardDisposableListener(container, DOM.EventType.KEY_UP, this.handleKeyboardEvent)); + this._register(DOM.addStandardDisposableListener(this.filterInputBox.inputElement, DOM.EventType.CLICK, (e) => { + e.stopPropagation(); + e.preventDefault(); + })); + this._register(this.filters.onDidChange(e => this.onDidFiltersChange(e))); + } + + private onDidFiltersChange(e: IReplFiltersChangeEvent): void { + if (e.layout) { + this.updateClass(); + } + } + + private onDidInputChange(inputbox: HistoryInputBox) { + inputbox.addToHistory(); + this.filters.filterText = inputbox.value; + this.filters.filterHistory = inputbox.getHistory(); + } + + // Action toolbar is swallowing some keys for action items which should not be for an input box + private handleKeyboardEvent(event: StandardKeyboardEvent) { + if (event.equals(KeyCode.Space) + || event.equals(KeyCode.LeftArrow) + || event.equals(KeyCode.RightArrow) + || event.equals(KeyCode.Escape) + ) { + event.stopPropagation(); + } + } + + private onInputKeyDown(event: StandardKeyboardEvent) { + if (event.equals(KeyCode.Escape)) { + this.clearFilterText(); + event.stopPropagation(); + event.preventDefault(); + } + } + + private adjustInputBox(): void { + if (this.element && this.filterInputBox) { + this.filterInputBox.inputElement.style.paddingRight = DOM.hasClass(this.element, 'small') ? '25px' : '150px'; + } + } + + protected updateClass(): void { + if (this.element && this.container) { + this.element.className = this.class; + DOM.toggleClass(this.container, 'grow', DOM.hasClass(this.element, 'grow')); + this.adjustInputBox(); + } + } + + protected get class(): string { + if (this.filters.layout.width > 800) { + return 'panel-action-tree-filter grow'; + } else if (this.filters.layout.width < 600) { + return 'panel-action-tree-filter small'; + } else { + return 'panel-action-tree-filter'; + } + } +}