Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autocomplete for extension search @-operators #53915

Merged
merged 19 commits into from
Jul 19, 2018
2 changes: 1 addition & 1 deletion src/vs/platform/theme/common/colorRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export const inputBackground = registerColor('input.background', { dark: '#3C3C3
export const inputForeground = registerColor('input.foreground', { dark: foreground, light: foreground, hc: foreground }, nls.localize('inputBoxForeground', "Input box foreground."));
export const inputBorder = registerColor('input.border', { dark: null, light: null, hc: contrastBorder }, nls.localize('inputBoxBorder', "Input box border."));
export const inputActiveOptionBorder = registerColor('inputOption.activeBorder', { dark: '#007ACC', light: '#007ACC', hc: activeContrastBorder }, nls.localize('inputBoxActiveOptionBorder', "Border color of activated options in input fields."));
export const inputPlaceholderForeground = registerColor('input.placeholderForeground', { dark: null, light: null, hc: null }, nls.localize('inputPlaceholderForeground', "Input box foreground color for placeholder text."));
export const inputPlaceholderForeground = registerColor('input.placeholderForeground', { light: transparent(foreground, 0.5), dark: transparent(foreground, 0.5), hc: transparent(foreground, 0.7) }, nls.localize('inputPlaceholderForeground', "Input box foreground color for placeholder text."));

export const inputValidationInfoBackground = registerColor('inputValidation.infoBackground', { dark: '#063B49', light: '#D6ECF2', hc: Color.black }, nls.localize('inputValidationInfoBackground', "Input validation background color for information severity."));
export const inputValidationInfoBorder = registerColor('inputValidation.infoBorder', { dark: '#007acc', light: '#007acc', hc: contrastBorder }, nls.localize('inputValidationInfoBorder', "Input validation border color for information severity."));
Expand Down
15 changes: 15 additions & 0 deletions src/vs/workbench/parts/extensions/common/extensionQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/


import { flatten } from 'vs/base/common/arrays';

export class Query {

constructor(public value: string, public sortBy: string, public groupBy: string) {
this.value = value.trim();
}

static autocompletions(): string[] {
const commands = ['installed', 'outdated', 'enabled', 'disabled', 'builtin', 'recommended', 'sort', 'category', 'tag', 'ext'];
const subcommands = {
'sort': ['installs', 'rating', 'name'],
'category': ['"programming languages"', 'snippets', 'linters', 'themes', 'debuggers', 'formatters', 'keymaps', '"scm providers"', 'other', '"extension packs"', '"language packs"'],
'tag': [''],
'ext': ['']
};

return flatten(commands.map(command => subcommands[command] ? subcommands[command].map(subcommand => `${command}:${subcommand}`) : [command]));
}

static parse(value: string): Query {
let sortBy = '';
value = value.replace(/@sort:(\w+)(-\w*)?/g, (match, by: string, order: string) => {
Expand Down
168 changes: 124 additions & 44 deletions src/vs/workbench/parts/extensions/electron-browser/extensionsViewlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@
'use strict';

import 'vs/css!./media/extensionsViewlet';
import uri from 'vs/base/common/uri';
import * as modes from 'vs/editor/common/modes';
import { localize } from 'vs/nls';
import { ThrottledDelayer, always } from 'vs/base/common/async';
import { TPromise } from 'vs/base/common/winjs.base';
import { isPromiseCanceledError, onUnexpectedError, create as createError } from 'vs/base/common/errors';
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { IDisposable, dispose } from 'vs/base/common/lifecycle';
import { Event as EventOf, mapEvent, chain } from 'vs/base/common/event';
import { Event as EventOf, Emitter, chain } from 'vs/base/common/event';
import { IAction } from 'vs/base/common/actions';
import { domEvent } from 'vs/base/browser/event';
import { Separator } from 'vs/base/browser/ui/actionbar/actionbar';
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { KeyCode } from 'vs/base/common/keyCodes';
import { IViewlet } from 'vs/workbench/common/viewlet';
import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet';
import { append, $, addStandardDisposableListener, EventType, addClass, removeClass, toggleClass, Dimension } from 'vs/base/browser/dom';
import { append, $, addClass, removeClass, toggleClass, Dimension } from 'vs/base/browser/dom';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
Expand All @@ -39,7 +39,7 @@ import { IEditorGroupsService } from 'vs/workbench/services/group/common/editorG
import Severity from 'vs/base/common/severity';
import { IActivityService, ProgressBadge, NumberBadge } from 'vs/workbench/services/activity/common/activity';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { inputForeground, inputBackground, inputBorder } from 'vs/platform/theme/common/colorRegistry';
import { inputForeground, inputBackground, inputBorder, inputPlaceholderForeground } from 'vs/platform/theme/common/colorRegistry';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ViewsRegistry, IViewDescriptor } from 'vs/workbench/common/views';
import { ViewContainerViewlet, IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet';
Expand All @@ -58,6 +58,13 @@ import { ServiceCollection } from 'vs/platform/instantiation/common/serviceColle
import { ExtensionsWorkbenchService } from 'vs/workbench/parts/extensions/node/extensionsWorkbenchService';
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
import { SingleServerExtensionManagementServerService } from 'vs/workbench/services/extensions/node/extensionManagementServerService';
import { Query } from 'vs/workbench/parts/extensions/common/extensionQuery';
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget';
import { IModelService } from 'vs/editor/common/services/modelService';
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
import { Range } from 'vs/editor/common/core/range';
import { Position } from 'vs/editor/common/core/position';
import { ITextModel } from 'vs/editor/common/model';

interface SearchInputEvent extends Event {
target: HTMLInputElement;
Expand Down Expand Up @@ -252,12 +259,14 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
private searchDelayer: ThrottledDelayer<any>;
private root: HTMLElement;

private searchBox: HTMLInputElement;
private searchBox: CodeEditorWidget;
private extensionsBox: HTMLElement;
private primaryActions: IAction[];
private secondaryActions: IAction[];
private groupByServerAction: IAction;
private disposables: IDisposable[] = [];
private monacoStyleContainer: HTMLDivElement;
private placeholderText: HTMLDivElement;

constructor(
@IPartService partService: IPartService,
Expand All @@ -275,7 +284,8 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
@IContextKeyService contextKeyService: IContextKeyService,
@IContextMenuService contextMenuService: IContextMenuService,
@IExtensionService extensionService: IExtensionService,
@IExtensionManagementServerService private extensionManagementServerService: IExtensionManagementServerService
@IExtensionManagementServerService private extensionManagementServerService: IExtensionManagementServerService,
@IModelService private modelService: IModelService,
) {
super(VIEWLET_ID, `${VIEWLET_ID}.state`, true, partService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService);

Expand All @@ -299,39 +309,65 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
this.defaultRecommendedExtensionsContextKey.set(!this.configurationService.getValue<boolean>(ShowRecommendationsOnlyOnDemandKey));
}
}, this, this.disposables);

modes.SuggestRegistry.register({ scheme: 'extensions', pattern: '**/searchinput', hasAccessToAllModels: true }, {
triggerCharacters: ['@'],
provideCompletionItems: (model: ITextModel, position: Position, _context: modes.SuggestContext) => {
const sortKey = (item: string) => {
if (item.indexOf(':') === -1) { return 'a'; }
else if (/ext:/.test(item) || /tag:/.test(item)) { return 'b'; }
else if (/sort:/.test(item)) { return 'c'; }
else { return 'd'; }
};
return {
suggestions: this.autoComplete(model.getValue(), position.column).map(item => (
{
label: item.fullText,
insertText: item.fullText,
overwriteBefore: item.overwrite,
sortText: sortKey(item.fullText),
type: <modes.SuggestionType>'keyword'
}))
};
}
});
}

create(parent: HTMLElement): TPromise<void> {
addClass(parent, 'extensions-viewlet');
this.root = parent;

const header = append(this.root, $('.header'));

this.searchBox = append(header, $<HTMLInputElement>('input.search-box'));
this.searchBox.placeholder = localize('searchExtensions', "Search Extensions in Marketplace");
this.disposables.push(addStandardDisposableListener(this.searchBox, EventType.FOCUS, () => addClass(this.searchBox, 'synthetic-focus')));
this.disposables.push(addStandardDisposableListener(this.searchBox, EventType.BLUR, () => removeClass(this.searchBox, 'synthetic-focus')));
this.monacoStyleContainer = append(header, $('.monaco-container'));
this.searchBox = this.instantiationService.createInstance(CodeEditorWidget, this.monacoStyleContainer, SEARCH_INPUT_OPTIONS, { isSimpleWidget: true });
this.placeholderText = append(this.monacoStyleContainer, $('.search-placeholder', null, localize('searchExtensions', "Search Extensions in Marketplace")));

this.extensionsBox = append(this.root, $('.extensions'));

const onKeyDown = chain(domEvent(this.searchBox, 'keydown'))
.map(e => new StandardKeyboardEvent(e));
onKeyDown.filter(e => e.keyCode === KeyCode.Escape).on(this.onEscape, this, this.disposables);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are removing the binding of esc key to clearing of the text?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both because of the above regarding the escape needing to operate on the suggest widget, but also more generally is "esc" to clear input a common thing? We use it to get rid of modals, which technically also clears their input, but besides that we don't use it anywhere else in the program.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking around on Win10 and MacOS it turns out there are a fair number of places where Esc clears input (I've personally never known about that until now). But we still don't do in in Code, and browsers inputs don't do it by default

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, we do it in settings search and keybindings. But still nowhere in the viewlets. I don't know what the best move is here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, lets not try to add this back. esc when suggest widget is open should remove the widget, otherwise esc need not do anything

this.searchBox.setModel(this.modelService.createModel('', null, uri.parse('extensions:searchinput'), true));

const onKeyDownForList = onKeyDown.filter(() => this.count() > 0);
onKeyDownForList.filter(e => e.keyCode === KeyCode.Enter).on(this.onEnter, this, this.disposables);
onKeyDownForList.filter(e => e.keyCode === KeyCode.DownArrow).on(this.focusListView, this, this.disposables);
this.disposables.push(this.searchBox.onDidFocusEditorText(() => addClass(this.monacoStyleContainer, 'synthetic-focus')));
this.disposables.push(this.searchBox.onDidBlurEditorText(() => removeClass(this.monacoStyleContainer, 'synthetic-focus')));

const onSearchInput = domEvent(this.searchBox, 'input') as EventOf<SearchInputEvent>;
onSearchInput(e => this.triggerSearch(e.immediate), null, this.disposables);
const onKeyDownMonaco = chain(this.searchBox.onKeyDown);
onKeyDownMonaco.filter(e => e.keyCode === KeyCode.Enter).on(e => e.preventDefault(), this, this.disposables);
onKeyDownMonaco.filter(e => e.keyCode === KeyCode.DownArrow).on(() => this.focusListView(), this, this.disposables);

this.onSearchChange = mapEvent(onSearchInput, e => e.target.value);
const searchChangeEvent = new Emitter<string>();
this.onSearchChange = searchChangeEvent.event;

this.disposables.push(this.searchBox.getModel().onDidChangeContent(() => {
this.triggerSearch();
const content = this.searchBox.getValue();
searchChangeEvent.fire(content);
this.placeholderText.style.visibility = content ? 'hidden' : 'visible';
}));

return super.create(this.extensionsBox)
.then(() => this.extensionManagementService.getInstalled(LocalExtensionType.User))
.then(installed => {
if (installed.length === 0) {
this.searchBox.value = '@sort:installs';
this.searchBox.setValue('@sort:installs');
this.searchExtensionsContextKey.set(true);
}
});
Expand All @@ -340,13 +376,19 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
public updateStyles(): void {
super.updateStyles();

this.searchBox.style.backgroundColor = this.getColor(inputBackground);
this.searchBox.style.color = this.getColor(inputForeground);
this.monacoStyleContainer.style.backgroundColor = this.getColor(inputBackground);
this.monacoStyleContainer.style.color = this.getColor(inputForeground);
this.placeholderText.style.color = this.getColor(inputPlaceholderForeground);

const inputBorderColor = this.getColor(inputBorder);
this.searchBox.style.borderWidth = inputBorderColor ? '1px' : null;
this.searchBox.style.borderStyle = inputBorderColor ? 'solid' : null;
this.searchBox.style.borderColor = inputBorderColor;
this.monacoStyleContainer.style.borderWidth = inputBorderColor ? '1px' : null;
this.monacoStyleContainer.style.borderStyle = inputBorderColor ? 'solid' : null;
this.monacoStyleContainer.style.borderColor = inputBorderColor;

let cursor = this.monacoStyleContainer.getElementsByClassName('cursor')[0] as HTMLDivElement;
if (cursor) {
cursor.style.backgroundColor = this.getColor(inputForeground);
}
}

setVisible(visible: boolean): TPromise<void> {
Expand All @@ -355,7 +397,7 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
if (isVisibilityChanged) {
if (visible) {
this.searchBox.focus();
this.searchBox.setSelectionRange(0, this.searchBox.value.length);
this.searchBox.setSelection(new Range(1, 1, 1, this.searchBox.getValue().length + 1));
}
}
});
Expand All @@ -367,6 +409,9 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio

layout(dimension: Dimension): void {
toggleClass(this.root, 'narrow', dimension.width <= 300);
this.searchBox.layout({ height: 20, width: dimension.width - 30 });
this.placeholderText.style.width = '' + (dimension.width - 30) + 'px';

super.layout(new Dimension(dimension.width, dimension.height - 38));
}

Expand Down Expand Up @@ -421,17 +466,19 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
const event = new Event('input', { bubbles: true }) as SearchInputEvent;
event.immediate = true;

this.searchBox.value = value;
this.searchBox.dispatchEvent(event);
this.searchBox.setValue(value);
}

private triggerSearch(immediate = false): void {
this.searchDelayer.trigger(() => this.doSearch(), immediate || !this.searchBox.value ? 0 : 500)
.done(null, err => this.onError(err));
this.searchDelayer.trigger(() => this.doSearch(), immediate || !this.searchBox.getValue() ? 0 : 500).done(null, err => this.onError(err));
}

private normalizedQuery(): string {
return (this.searchBox.getValue() || '').replace(/@category/g, 'category').replace(/@tag:/g, 'tag:').replace(/@ext:/g, 'ext:');
}

private doSearch(): TPromise<any> {
const value = this.searchBox.value || '';
const value = this.normalizedQuery();
this.searchExtensionsContextKey.set(!!value);
this.searchInstalledExtensionsContextKey.set(InstalledExtensionsView.isInstalledExtensionsQuery(value));
this.searchBuiltInExtensionsContextKey.set(ExtensionsListView.isBuiltInExtensionsQuery(value));
Expand All @@ -440,14 +487,14 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
this.nonEmptyWorkspaceContextKey.set(this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY);

if (value) {
return this.progress(TPromise.join(this.panels.map(view => (<ExtensionsListView>view).show(this.searchBox.value))));
return this.progress(TPromise.join(this.panels.map(view => (<ExtensionsListView>view).show(this.normalizedQuery()))));
}
return TPromise.as(null);
}

protected onDidAddViews(added: IAddedViewDescriptorRef[]): ViewletPanel[] {
const addedViews = super.onDidAddViews(added);
this.progress(TPromise.join(addedViews.map(addedView => (<ExtensionsListView>addedView).show(this.searchBox.value))));
this.progress(TPromise.join(addedViews.map(addedView => (<ExtensionsListView>addedView).show(this.normalizedQuery()))));
return addedViews;
}

Expand All @@ -465,20 +512,23 @@ export class ExtensionsViewlet extends ViewContainerViewlet implements IExtensio
return this.instantiationService.createInstance(viewDescriptor.ctor, options) as ViewletPanel;
}

private count(): number {
return this.panels.reduce((count, view) => (<ExtensionsListView>view).count() + count, 0);
}
private autoComplete(query: string, position: number): { fullText: string, overwrite: number }[] {
if (query.lastIndexOf('@', position - 1) !== query.lastIndexOf(' ', position - 1) + 1) { return []; }

let wordStart = query.lastIndexOf('@', position - 1) + 1;
let alreadyTypedCount = position - wordStart - 1;

private onEscape(): void {
this.search('');
return Query.autocompletions().map(replacement => ({ fullText: replacement, overwrite: alreadyTypedCount }));
}

private onEnter(): void {
(<ExtensionsListView>this.panels[0]).select();
private count(): number {
return this.panels.reduce((count, view) => (<ExtensionsListView>view).count() + count, 0);
}

private focusListView(): void {
this.panels[0].focus();
if (this.count() > 0) {
this.panels[0].focus();
}
}

private onViewletOpen(viewlet: IViewlet): void {
Expand Down Expand Up @@ -612,4 +662,34 @@ export class MaliciousExtensionChecker implements IWorkbenchContribution {
dispose(): void {
this.disposables = dispose(this.disposables);
}
}
}

let SEARCH_INPUT_OPTIONS: IEditorOptions =
{
fontSize: 13,

This comment was marked as resolved.

lineHeight: 22,
wordWrap: 'off',
overviewRulerLanes: 0,
glyphMargin: false,
lineNumbers: 'off',
folding: false,
selectOnLineNumbers: false,
hideCursorInOverviewRuler: true,
selectionHighlight: false,
scrollbar: {
horizontal: 'hidden',
vertical: 'hidden'
},
ariaLabel: localize('searchExtensions', "Search Extensions in Marketplace"),
cursorWidth: 1,
lineDecorationsWidth: 0,
overviewRulerBorder: false,
scrollBeyondLastLine: false,
renderLineHighlight: 'none',
fixedOverflowWidgets: true,
acceptSuggestionOnEnter: 'smart',
minimap: {
enabled: false
},
fontFamily: ' -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "HelveticaNeue-Light", "Ubuntu", "Droid Sans", sans-serif'
};
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,32 @@
opacity: 0.9;
}

.extensions-viewlet .header .monaco-container {
padding: 3px 4px 5px;
}

.extensions-viewlet .header .monaco-container .suggest-widget {
width: 275px;
}

.extensions-viewlet .header .monaco-container .monaco-editor-background,
.extensions-viewlet .header .monaco-container .monaco-editor,
.extensions-viewlet .header .monaco-container .mtk1 {
/* allow the embedded monaco to be styled from the outer context */
background-color: inherit;
color: inherit;
}

.extensions-viewlet .header .search-placeholder {
position: absolute;
z-index: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
margin-top: 2px;
}

.vs .extensions-viewlet > .extensions .monaco-list-row.disabled > .bookmark,
.vs-dark .extensions-viewlet > .extensions .monaco-list-row.disabled > .bookmark,
.vs .extensions-viewlet > .extensions .monaco-list-row.disabled > .extension > .icon,
Expand Down