Merge commit 'be3e8236086165e5e45a5a10783823874b3f3ebd' as 'lib/vscode'

This commit is contained in:
Joe Previte
2020-12-15 15:52:33 -07:00
4649 changed files with 1311795 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { fuzzyScore, fuzzyScoreGracefulAggressive, FuzzyScorer, FuzzyScore, anyScore } from 'vs/base/common/filters';
import { CompletionItemProvider, CompletionItemKind } from 'vs/editor/common/modes';
import { CompletionItem } from './suggest';
import { InternalSuggestOptions } from 'vs/editor/common/config/editorOptions';
import { WordDistance } from 'vs/editor/contrib/suggest/wordDistance';
import { CharCode } from 'vs/base/common/charCode';
import { compareIgnoreCase } from 'vs/base/common/strings';
import { quickSelect } from 'vs/base/common/arrays';
type StrictCompletionItem = Required<CompletionItem>;
export interface ICompletionStats {
pLabelLen: number;
}
export class LineContext {
constructor(
readonly leadingLineContent: string,
readonly characterCountDelta: number,
) { }
}
const enum Refilter {
Nothing = 0,
All = 1,
Incr = 2
}
/**
* Sorted, filtered completion view model
* */
export class CompletionModel {
private readonly _items: CompletionItem[];
private readonly _column: number;
private readonly _wordDistance: WordDistance;
private readonly _options: InternalSuggestOptions;
private readonly _snippetCompareFn = CompletionModel._compareCompletionItems;
private _lineContext: LineContext;
private _refilterKind: Refilter;
private _filteredItems?: StrictCompletionItem[];
private _providerInfo?: Map<CompletionItemProvider, boolean>;
private _stats?: ICompletionStats;
constructor(
items: CompletionItem[],
column: number,
lineContext: LineContext,
wordDistance: WordDistance,
options: InternalSuggestOptions,
snippetSuggestions: 'top' | 'bottom' | 'inline' | 'none',
readonly clipboardText: string | undefined
) {
this._items = items;
this._column = column;
this._wordDistance = wordDistance;
this._options = options;
this._refilterKind = Refilter.All;
this._lineContext = lineContext;
if (snippetSuggestions === 'top') {
this._snippetCompareFn = CompletionModel._compareCompletionItemsSnippetsUp;
} else if (snippetSuggestions === 'bottom') {
this._snippetCompareFn = CompletionModel._compareCompletionItemsSnippetsDown;
}
}
get lineContext(): LineContext {
return this._lineContext;
}
set lineContext(value: LineContext) {
if (this._lineContext.leadingLineContent !== value.leadingLineContent
|| this._lineContext.characterCountDelta !== value.characterCountDelta
) {
this._refilterKind = this._lineContext.characterCountDelta < value.characterCountDelta && this._filteredItems ? Refilter.Incr : Refilter.All;
this._lineContext = value;
}
}
get items(): CompletionItem[] {
this._ensureCachedState();
return this._filteredItems!;
}
get allProvider(): IterableIterator<CompletionItemProvider> {
this._ensureCachedState();
return this._providerInfo!.keys();
}
get incomplete(): Set<CompletionItemProvider> {
this._ensureCachedState();
const result = new Set<CompletionItemProvider>();
for (let [provider, incomplete] of this._providerInfo!) {
if (incomplete) {
result.add(provider);
}
}
return result;
}
adopt(except: Set<CompletionItemProvider>): CompletionItem[] {
let res: CompletionItem[] = [];
for (let i = 0; i < this._items.length;) {
if (!except.has(this._items[i].provider)) {
res.push(this._items[i]);
// unordered removed
this._items[i] = this._items[this._items.length - 1];
this._items.pop();
} else {
// continue with next item
i++;
}
}
this._refilterKind = Refilter.All;
return res;
}
get stats(): ICompletionStats {
this._ensureCachedState();
return this._stats!;
}
private _ensureCachedState(): void {
if (this._refilterKind !== Refilter.Nothing) {
this._createCachedState();
}
}
private _createCachedState(): void {
this._providerInfo = new Map();
const labelLengths: number[] = [];
const { leadingLineContent, characterCountDelta } = this._lineContext;
let word = '';
let wordLow = '';
// incrementally filter less
const source = this._refilterKind === Refilter.All ? this._items : this._filteredItems!;
const target: StrictCompletionItem[] = [];
// picks a score function based on the number of
// items that we have to score/filter and based on the
// user-configuration
const scoreFn: FuzzyScorer = (!this._options.filterGraceful || source.length > 2000) ? fuzzyScore : fuzzyScoreGracefulAggressive;
for (let i = 0; i < source.length; i++) {
const item = source[i];
if (item.isInvalid) {
continue; // SKIP invalid items
}
// collect all support, know if their result is incomplete
this._providerInfo.set(item.provider, Boolean(item.container.incomplete));
// 'word' is that remainder of the current line that we
// filter and score against. In theory each suggestion uses a
// different word, but in practice not - that's why we cache
const overwriteBefore = item.position.column - item.editStart.column;
const wordLen = overwriteBefore + characterCountDelta - (item.position.column - this._column);
if (word.length !== wordLen) {
word = wordLen === 0 ? '' : leadingLineContent.slice(-wordLen);
wordLow = word.toLowerCase();
}
const textLabel = typeof item.completion.label === 'string' ? item.completion.label : item.completion.label.name;
// remember the word against which this item was
// scored
item.word = word;
if (wordLen === 0) {
// when there is nothing to score against, don't
// event try to do. Use a const rank and rely on
// the fallback-sort using the initial sort order.
// use a score of `-100` because that is out of the
// bound of values `fuzzyScore` will return
item.score = FuzzyScore.Default;
} else {
// skip word characters that are whitespace until
// we have hit the replace range (overwriteBefore)
let wordPos = 0;
while (wordPos < overwriteBefore) {
const ch = word.charCodeAt(wordPos);
if (ch === CharCode.Space || ch === CharCode.Tab) {
wordPos += 1;
} else {
break;
}
}
if (wordPos >= wordLen) {
// the wordPos at which scoring starts is the whole word
// and therefore the same rules as not having a word apply
item.score = FuzzyScore.Default;
} else if (typeof item.completion.filterText === 'string') {
// when there is a `filterText` it must match the `word`.
// if it matches we check with the label to compute highlights
// and if that doesn't yield a result we have no highlights,
// despite having the match
let match = scoreFn(word, wordLow, wordPos, item.completion.filterText, item.filterTextLow!, 0, false);
if (!match) {
continue; // NO match
}
if (compareIgnoreCase(item.completion.filterText, textLabel) === 0) {
// filterText and label are actually the same -> use good highlights
item.score = match;
} else {
// re-run the scorer on the label in the hope of a result BUT use the rank
// of the filterText-match
item.score = anyScore(word, wordLow, wordPos, textLabel, item.labelLow, 0);
item.score[0] = match[0]; // use score from filterText
}
} else {
// by default match `word` against the `label`
let match = scoreFn(word, wordLow, wordPos, textLabel, item.labelLow, 0, false);
if (!match) {
continue; // NO match
}
item.score = match;
}
}
item.idx = i;
item.distance = this._wordDistance.distance(item.position, item.completion);
target.push(item as StrictCompletionItem);
// update stats
labelLengths.push(textLabel.length);
}
this._filteredItems = target.sort(this._snippetCompareFn);
this._refilterKind = Refilter.Nothing;
this._stats = {
pLabelLen: labelLengths.length ?
quickSelect(labelLengths.length - .85, labelLengths, (a, b) => a - b)
: 0
};
}
private static _compareCompletionItems(a: StrictCompletionItem, b: StrictCompletionItem): number {
if (a.score[0] > b.score[0]) {
return -1;
} else if (a.score[0] < b.score[0]) {
return 1;
} else if (a.distance < b.distance) {
return -1;
} else if (a.distance > b.distance) {
return 1;
} else if (a.idx < b.idx) {
return -1;
} else if (a.idx > b.idx) {
return 1;
} else {
return 0;
}
}
private static _compareCompletionItemsSnippetsDown(a: StrictCompletionItem, b: StrictCompletionItem): number {
if (a.completion.kind !== b.completion.kind) {
if (a.completion.kind === CompletionItemKind.Snippet) {
return 1;
} else if (b.completion.kind === CompletionItemKind.Snippet) {
return -1;
}
}
return CompletionModel._compareCompletionItems(a, b);
}
private static _compareCompletionItemsSnippetsUp(a: StrictCompletionItem, b: StrictCompletionItem): number {
if (a.completion.kind !== b.completion.kind) {
if (a.completion.kind === CompletionItemKind.Snippet) {
return -1;
} else if (b.completion.kind === CompletionItemKind.Snippet) {
return 1;
}
}
return CompletionModel._compareCompletionItems(a, b);
}
}

View File

@@ -0,0 +1,420 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/* Suggest widget*/
.monaco-editor .suggest-widget {
width: 430px;
z-index: 40;
display: flex;
flex-direction: column;
}
.monaco-editor .suggest-widget.message {
flex-direction: row;
align-items: center;
}
.monaco-editor .suggest-widget,
.monaco-editor .suggest-details {
flex: 0 1 auto;
width: 100%;
border-style: solid;
border-width: 1px;
}
.monaco-editor.hc-black .suggest-widget,
.monaco-editor.hc-black .suggest-details {
border-width: 2px;
}
/* Styles for status bar part */
.monaco-editor .suggest-widget .suggest-status-bar {
box-sizing: border-box;
display: none;
flex-flow: row nowrap;
justify-content: space-between;
width: 100%;
font-size: 80%;
padding: 0 4px 0 4px;
border-top: 1px solid transparent;
overflow: hidden;
}
.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar {
display: flex;
}
.monaco-editor .suggest-widget .suggest-status-bar .left {
padding-right: 8px;
}
.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar .action-label {
opacity: 0.5;
color: inherit;
}
.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar .action-item:not(:last-of-type) .action-label {
margin-right: 0;
}
.monaco-editor .suggest-widget.with-status-bar .suggest-status-bar .action-item:not(:last-of-type) .action-label::after {
content: ', ';
margin-right: 0.3em;
}
.monaco-editor .suggest-widget.with-status-bar .monaco-list .monaco-list-row>.contents>.main>.right>.readMore,
.monaco-editor .suggest-widget.with-status-bar .monaco-list .monaco-list-row.focused.string-label>.contents>.main>.right>.readMore {
display: none;
}
.monaco-editor .suggest-widget.with-status-bar:not(.docs-side) .monaco-list .monaco-list-row:hover>.contents>.main>.right.can-expand-details>.details-label {
width: 100%;
}
/* Styles for Message element for when widget is loading or is empty */
.monaco-editor .suggest-widget>.message {
padding-left: 22px;
}
/** Styles for the list element **/
.monaco-editor .suggest-widget>.tree {
height: 100%;
width: 100%;
}
.monaco-editor .suggest-widget .monaco-list {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
/** Styles for each row in the list element **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row {
display: flex;
-mox-box-sizing: border-box;
box-sizing: border-box;
padding-right: 10px;
background-repeat: no-repeat;
background-position: 2px 2px;
white-space: nowrap;
cursor: pointer;
touch-action: none;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents {
flex: 1;
height: 100%;
overflow: hidden;
padding-left: 2px;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main {
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
justify-content: space-between;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left, .monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right {
display: flex;
}
.monaco-editor .suggest-widget:not(.frozen) .monaco-highlighted-label .highlight {
font-weight: bold;
}
/** ReadMore Icon styles **/
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.codicon-close,
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.readMore::before {
color: inherit;
opacity: 1;
font-size: 14px;
cursor: pointer;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.codicon-close {
position: absolute;
top: 6px;
right: 2px;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.codicon-close:hover,
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.readMore:hover {
opacity: 1;
}
/** signature, qualifier, type/details opacity **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.signature-label,
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.qualifier-label,
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label {
opacity: 0.7;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.signature-label {
overflow: hidden;
text-overflow: ellipsis;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.qualifier-label {
margin-left: 4px;
opacity: 0.4;
font-size: 90%;
text-overflow: ellipsis;
overflow: hidden;
align-self: center;
}
/** Type Info and icon next to the label in the focused completion item **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label {
margin-left: 1.1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label>.monaco-tokenized-source {
display: inline;
}
/** Details: if using CompletionItem#details, show on focus **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.details-label {
display: none;
}
.monaco-editor .suggest-widget:not(.shows-details) .monaco-list .monaco-list-row.focused>.contents>.main>.right>.details-label {
display: inline;
}
/** Details: if using CompletionItemLabel#details, always show **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row:not(.string-label)>.contents>.main>.right>.details-label,
.monaco-editor .suggest-widget.docs-side .monaco-list .monaco-list-row.focused:not(.string-label)>.contents>.main>.right>.details-label {
display: inline;
}
/** Ellipsis on hover **/
.monaco-editor .suggest-widget:not(.docs-side) .monaco-list .monaco-list-row:hover>.contents>.main>.right.can-expand-details>.details-label {
width: calc(100% - 26px);
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left {
flex-shrink: 1;
flex-grow: 1;
overflow: hidden;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.left>.monaco-icon-label {
flex-shrink: 0;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row:not(.string-label)>.contents>.main>.left>.monaco-icon-label {
max-width: 100%;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row.string-label>.contents>.main>.left>.monaco-icon-label {
flex-shrink: 1;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right {
overflow: hidden;
flex-shrink: 4;
max-width: 70%;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row>.contents>.main>.right>.readMore {
display: inline-block;
position: absolute;
right: 10px;
width: 18px;
height: 18px;
visibility: hidden;
}
/** Do NOT display ReadMore when docs is side/below **/
.monaco-editor .suggest-widget.docs-side .monaco-list .monaco-list-row>.contents>.main>.right>.readMore, .monaco-editor .suggest-widget.docs-below .monaco-list .monaco-list-row>.contents>.main>.right>.readMore {
display: none !important;
}
/** Do NOT display ReadMore when using plain CompletionItemLabel (details/documentation might not be resolved) **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row.string-label>.contents>.main>.right>.readMore {
display: none;
}
/** Focused item can show ReadMore, but can't when docs is side/below **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row.focused.string-label>.contents>.main>.right>.readMore {
display: inline-block;
}
.monaco-editor .suggest-widget.docs-side .monaco-list .monaco-list-row>.contents>.main>.right>.readMore,
.monaco-editor .suggest-widget.docs-below .monaco-list .monaco-list-row>.contents>.main>.right>.readMore {
display: none;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row:hover>.contents>.main>.right>.readMore {
visibility: visible;
}
/** Styles for each row in the list **/
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-icon-label.deprecated {
opacity: 0.66;
text-decoration: unset;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-icon-label.deprecated>.monaco-icon-label-container>.monaco-icon-name-container {
text-decoration: line-through;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-icon-label::before {
height: 100%;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .icon {
display: block;
height: 16px;
width: 16px;
margin-left: 2px;
background-repeat: no-repeat;
background-size: 80%;
background-position: center;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .icon.hide {
display: none;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .suggest-icon {
display: flex;
align-items: center;
margin-right: 4px;
}
.monaco-editor .suggest-widget.no-icons .monaco-list .monaco-list-row .icon, .monaco-editor .suggest-widget.no-icons .monaco-list .monaco-list-row .suggest-icon::before {
display: none;
}
.monaco-editor .suggest-widget .monaco-list .monaco-list-row .icon.customcolor .colorspan {
margin: 0 0 0 0.3em;
border: 0.1em solid #000;
width: 0.7em;
height: 0.7em;
display: inline-block;
}
/** Styles for the docs of the completion item in focus **/
.monaco-editor .suggest-details-container {
z-index: 41;
}
.monaco-editor .suggest-details {
display: flex;
flex-direction: column;
cursor: default;
}
.monaco-editor .suggest-details.no-docs {
display: none;
}
.monaco-editor .suggest-details>.monaco-scrollable-element {
flex: 1;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body {
box-sizing: border-box;
height: 100%;
width: 100%;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.type {
flex: 2;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.7;
white-space: pre;
margin: 0 24px 0 0;
padding: 4px 0 12px 5px;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.header>.type.auto-wrap {
white-space: normal;
word-break: break-all;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs {
margin: 0;
padding: 4px 5px;
white-space: pre-wrap;
}
.monaco-editor .suggest-details.no-type>.monaco-scrollable-element>.body>.docs {
margin-right: 24px;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs {
padding: 0;
white-space: initial;
min-height: calc(1rem + 8px);
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>div,
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>span:not(:empty) {
padding: 4px 5px;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>div>p:first-child {
margin-top: 0;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs>div>p:last-child {
margin-bottom: 0;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs .code {
white-space: pre-wrap;
word-wrap: break-word;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>.docs.markdown-docs .codicon {
vertical-align: sub;
}
.monaco-editor .suggest-details>.monaco-scrollable-element>.body>p:empty {
display: none;
}
.monaco-editor .suggest-details code {
border-radius: 3px;
padding: 0 0.4em;
}
.monaco-editor .suggest-details ul {
padding-left: 20px;
}
.monaco-editor .suggest-details ol {
padding-left: 20px;
}
.monaco-editor .suggest-details p code {
font-family: var(--monaco-monospace-font);
}

View File

@@ -0,0 +1,181 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event, Emitter } from 'vs/base/common/event';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { Dimension } from 'vs/base/browser/dom';
import { Orientation, Sash, SashState } from 'vs/base/browser/ui/sash/sash';
export interface IResizeEvent {
dimension: Dimension;
done: boolean;
north?: boolean;
east?: boolean;
south?: boolean;
west?: boolean;
}
export class ResizableHTMLElement {
readonly domNode: HTMLElement;
private readonly _onDidWillResize = new Emitter<void>();
readonly onDidWillResize: Event<void> = this._onDidWillResize.event;
private readonly _onDidResize = new Emitter<IResizeEvent>();
readonly onDidResize: Event<IResizeEvent> = this._onDidResize.event;
private readonly _northSash: Sash;
private readonly _eastSash: Sash;
private readonly _southSash: Sash;
private readonly _westSash: Sash;
private readonly _sashListener = new DisposableStore();
private _size = new Dimension(0, 0);
private _minSize = new Dimension(0, 0);
private _maxSize = new Dimension(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
private _preferredSize?: Dimension;
constructor() {
this.domNode = document.createElement('div');
this._eastSash = new Sash(this.domNode, { getVerticalSashLeft: () => this._size.width }, { orientation: Orientation.VERTICAL });
this._westSash = new Sash(this.domNode, { getVerticalSashLeft: () => 0 }, { orientation: Orientation.VERTICAL });
this._northSash = new Sash(this.domNode, { getHorizontalSashTop: () => 0 }, { orientation: Orientation.HORIZONTAL });
this._southSash = new Sash(this.domNode, { getHorizontalSashTop: () => this._size.height }, { orientation: Orientation.HORIZONTAL });
this._northSash.orthogonalStartSash = this._westSash;
this._northSash.orthogonalEndSash = this._eastSash;
this._southSash.orthogonalStartSash = this._westSash;
this._southSash.orthogonalEndSash = this._eastSash;
let currentSize: Dimension | undefined;
let deltaY = 0;
let deltaX = 0;
this._sashListener.add(Event.any(this._northSash.onDidStart, this._eastSash.onDidStart, this._southSash.onDidStart, this._westSash.onDidStart)(() => {
if (currentSize === undefined) {
this._onDidWillResize.fire();
currentSize = this._size;
deltaY = 0;
deltaX = 0;
}
}));
this._sashListener.add(Event.any(this._northSash.onDidEnd, this._eastSash.onDidEnd, this._southSash.onDidEnd, this._westSash.onDidEnd)(() => {
if (currentSize !== undefined) {
currentSize = undefined;
deltaY = 0;
deltaX = 0;
this._onDidResize.fire({ dimension: this._size, done: true });
}
}));
this._sashListener.add(this._eastSash.onDidChange(e => {
if (currentSize) {
deltaX = e.currentX - e.startX;
this.layout(currentSize.height + deltaY, currentSize.width + deltaX);
this._onDidResize.fire({ dimension: this._size, done: false, east: true });
}
}));
this._sashListener.add(this._westSash.onDidChange(e => {
if (currentSize) {
deltaX = -(e.currentX - e.startX);
this.layout(currentSize.height + deltaY, currentSize.width + deltaX);
this._onDidResize.fire({ dimension: this._size, done: false, west: true });
}
}));
this._sashListener.add(this._northSash.onDidChange(e => {
if (currentSize) {
deltaY = -(e.currentY - e.startY);
this.layout(currentSize.height + deltaY, currentSize.width + deltaX);
this._onDidResize.fire({ dimension: this._size, done: false, north: true });
}
}));
this._sashListener.add(this._southSash.onDidChange(e => {
if (currentSize) {
deltaY = e.currentY - e.startY;
this.layout(currentSize.height + deltaY, currentSize.width + deltaX);
this._onDidResize.fire({ dimension: this._size, done: false, south: true });
}
}));
this._sashListener.add(Event.any(this._eastSash.onDidReset, this._westSash.onDidReset)(e => {
if (this._preferredSize) {
this.layout(this._size.height, this._preferredSize.width);
this._onDidResize.fire({ dimension: this._size, done: true });
}
}));
this._sashListener.add(Event.any(this._northSash.onDidReset, this._southSash.onDidReset)(e => {
if (this._preferredSize) {
this.layout(this._preferredSize.height, this._size.width);
this._onDidResize.fire({ dimension: this._size, done: true });
}
}));
}
dispose(): void {
this._northSash.dispose();
this._southSash.dispose();
this._eastSash.dispose();
this._westSash.dispose();
this._sashListener.dispose();
this.domNode.remove();
}
enableSashes(north: boolean, east: boolean, south: boolean, west: boolean): void {
this._northSash.state = north ? SashState.Enabled : SashState.Disabled;
this._eastSash.state = east ? SashState.Enabled : SashState.Disabled;
this._southSash.state = south ? SashState.Enabled : SashState.Disabled;
this._westSash.state = west ? SashState.Enabled : SashState.Disabled;
}
layout(height: number = this.size.height, width: number = this.size.width): void {
const { height: minHeight, width: minWidth } = this._minSize;
const { height: maxHeight, width: maxWidth } = this._maxSize;
height = Math.max(minHeight, Math.min(maxHeight, height));
width = Math.max(minWidth, Math.min(maxWidth, width));
const newSize = new Dimension(width, height);
if (!Dimension.equals(newSize, this._size)) {
this.domNode.style.height = height + 'px';
this.domNode.style.width = width + 'px';
this._size = newSize;
this._northSash.layout();
this._eastSash.layout();
this._southSash.layout();
this._westSash.layout();
}
}
get size() {
return this._size;
}
set maxSize(value: Dimension) {
this._maxSize = value;
}
get maxSize() {
return this._maxSize;
}
set minSize(value: Dimension) {
this._minSize = value;
}
get minSize() {
return this._minSize;
}
set preferredSize(value: Dimension | undefined) {
this._preferredSize = value;
}
get preferredSize() {
return this._preferredSize;
}
}

View File

@@ -0,0 +1,394 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { onUnexpectedExternalError, canceled, isPromiseCanceledError } from 'vs/base/common/errors';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { ITextModel } from 'vs/editor/common/model';
import { registerDefaultLanguageCommand } from 'vs/editor/browser/editorExtensions';
import * as modes from 'vs/editor/common/modes';
import { Position, IPosition } from 'vs/editor/common/core/position';
import { RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { CancellationToken } from 'vs/base/common/cancellation';
import { Range } from 'vs/editor/common/core/range';
import { FuzzyScore } from 'vs/base/common/filters';
import { isDisposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { MenuId } from 'vs/platform/actions/common/actions';
import { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
import { StopWatch } from 'vs/base/common/stopwatch';
export const Context = {
Visible: new RawContextKey<boolean>('suggestWidgetVisible', false),
DetailsVisible: new RawContextKey<boolean>('suggestWidgetDetailsVisible', false),
MultipleSuggestions: new RawContextKey<boolean>('suggestWidgetMultipleSuggestions', false),
MakesTextEdit: new RawContextKey('suggestionMakesTextEdit', true),
AcceptSuggestionsOnEnter: new RawContextKey<boolean>('acceptSuggestionOnEnter', true),
HasInsertAndReplaceRange: new RawContextKey('suggestionHasInsertAndReplaceRange', false),
InsertMode: new RawContextKey<'insert' | 'replace'>('suggestionInsertMode', undefined),
CanResolve: new RawContextKey('suggestionCanResolve', false),
};
export const suggestWidgetStatusbarMenu = new MenuId('suggestWidgetStatusBar');
export class CompletionItem {
_brand!: 'ISuggestionItem';
//
readonly editStart: IPosition;
readonly editInsertEnd: IPosition;
readonly editReplaceEnd: IPosition;
//
readonly textLabel: string;
// perf
readonly labelLow: string;
readonly sortTextLow?: string;
readonly filterTextLow?: string;
// validation
readonly isInvalid: boolean = false;
// sorting, filtering
score: FuzzyScore = FuzzyScore.Default;
distance: number = 0;
idx?: number;
word?: string;
// resolving
private _isResolved?: boolean;
private _resolveCache?: Promise<void>;
constructor(
readonly position: IPosition,
readonly completion: modes.CompletionItem,
readonly container: modes.CompletionList,
readonly provider: modes.CompletionItemProvider,
) {
this.textLabel = typeof completion.label === 'string'
? completion.label
: completion.label.name;
// ensure lower-variants (perf)
this.labelLow = this.textLabel.toLowerCase();
// validate label
this.isInvalid = !this.textLabel;
this.sortTextLow = completion.sortText && completion.sortText.toLowerCase();
this.filterTextLow = completion.filterText && completion.filterText.toLowerCase();
// normalize ranges
if (Range.isIRange(completion.range)) {
this.editStart = new Position(completion.range.startLineNumber, completion.range.startColumn);
this.editInsertEnd = new Position(completion.range.endLineNumber, completion.range.endColumn);
this.editReplaceEnd = new Position(completion.range.endLineNumber, completion.range.endColumn);
// validate range
this.isInvalid = this.isInvalid
|| Range.spansMultipleLines(completion.range) || completion.range.startLineNumber !== position.lineNumber;
} else {
this.editStart = new Position(completion.range.insert.startLineNumber, completion.range.insert.startColumn);
this.editInsertEnd = new Position(completion.range.insert.endLineNumber, completion.range.insert.endColumn);
this.editReplaceEnd = new Position(completion.range.replace.endLineNumber, completion.range.replace.endColumn);
// validate ranges
this.isInvalid = this.isInvalid
|| Range.spansMultipleLines(completion.range.insert) || Range.spansMultipleLines(completion.range.replace)
|| completion.range.insert.startLineNumber !== position.lineNumber || completion.range.replace.startLineNumber !== position.lineNumber
|| completion.range.insert.startColumn !== completion.range.replace.startColumn;
}
// create the suggestion resolver
if (typeof provider.resolveCompletionItem !== 'function') {
this._resolveCache = Promise.resolve();
this._isResolved = true;
}
}
// ---- resolving
get isResolved(): boolean {
return !!this._isResolved;
}
async resolve(token: CancellationToken) {
if (!this._resolveCache) {
const sub = token.onCancellationRequested(() => {
this._resolveCache = undefined;
this._isResolved = false;
});
this._resolveCache = Promise.resolve(this.provider.resolveCompletionItem!(this.completion, token)).then(value => {
Object.assign(this.completion, value);
this._isResolved = true;
sub.dispose();
}, err => {
if (isPromiseCanceledError(err)) {
// the IPC queue will reject the request with the
// cancellation error -> reset cached
this._resolveCache = undefined;
this._isResolved = false;
}
});
}
return this._resolveCache;
}
}
export const enum SnippetSortOrder {
Top, Inline, Bottom
}
export class CompletionOptions {
static readonly default = new CompletionOptions();
constructor(
readonly snippetSortOrder = SnippetSortOrder.Bottom,
readonly kindFilter = new Set<modes.CompletionItemKind>(),
readonly providerFilter = new Set<modes.CompletionItemProvider>(),
) { }
}
let _snippetSuggestSupport: modes.CompletionItemProvider;
export function getSnippetSuggestSupport(): modes.CompletionItemProvider {
return _snippetSuggestSupport;
}
export function setSnippetSuggestSupport(support: modes.CompletionItemProvider): modes.CompletionItemProvider {
const old = _snippetSuggestSupport;
_snippetSuggestSupport = support;
return old;
}
export interface CompletionDurationEntry {
readonly providerName: string;
readonly elapsedProvider: number;
readonly elapsedOverall: number;
}
export interface CompletionDurations {
readonly entries: readonly CompletionDurationEntry[];
readonly elapsed: number;
}
export class CompletionItemModel {
constructor(
readonly items: CompletionItem[],
readonly needsClipboard: boolean,
readonly durations: CompletionDurations,
readonly disposable: IDisposable,
) { }
}
export async function provideSuggestionItems(
model: ITextModel,
position: Position,
options: CompletionOptions = CompletionOptions.default,
context: modes.CompletionContext = { triggerKind: modes.CompletionTriggerKind.Invoke },
token: CancellationToken = CancellationToken.None
): Promise<CompletionItemModel> {
const sw = new StopWatch(true);
position = position.clone();
const word = model.getWordAtPosition(position);
const defaultReplaceRange = word ? new Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) : Range.fromPositions(position);
const defaultRange = { replace: defaultReplaceRange, insert: defaultReplaceRange.setEndPosition(position.lineNumber, position.column) };
const result: CompletionItem[] = [];
const disposables = new DisposableStore();
const durations: CompletionDurationEntry[] = [];
let needsClipboard = false;
const onCompletionList = (provider: modes.CompletionItemProvider, container: modes.CompletionList | null | undefined, sw: StopWatch) => {
if (!container) {
return;
}
for (let suggestion of container.suggestions) {
if (!options.kindFilter.has(suggestion.kind)) {
// fill in default range when missing
if (!suggestion.range) {
suggestion.range = defaultRange;
}
// fill in default sortText when missing
if (!suggestion.sortText) {
suggestion.sortText = typeof suggestion.label === 'string' ? suggestion.label : suggestion.label.name;
}
if (!needsClipboard && suggestion.insertTextRules && suggestion.insertTextRules & modes.CompletionItemInsertTextRule.InsertAsSnippet) {
needsClipboard = SnippetParser.guessNeedsClipboard(suggestion.insertText);
}
result.push(new CompletionItem(position, suggestion, container, provider));
}
}
if (isDisposable(container)) {
disposables.add(container);
}
durations.push({
providerName: provider._debugDisplayName ?? 'unkown_provider', elapsedProvider: container.duration ?? -1, elapsedOverall: sw.elapsed()
});
};
// ask for snippets in parallel to asking "real" providers. Only do something if configured to
// do so - no snippet filter, no special-providers-only request
const snippetCompletions = (async () => {
if (!_snippetSuggestSupport || options.kindFilter.has(modes.CompletionItemKind.Snippet)) {
return;
}
if (options.providerFilter.size > 0 && !options.providerFilter.has(_snippetSuggestSupport)) {
return;
}
const sw = new StopWatch(true);
const list = await _snippetSuggestSupport.provideCompletionItems(model, position, context, token);
onCompletionList(_snippetSuggestSupport, list, sw);
})();
// add suggestions from contributed providers - providers are ordered in groups of
// equal score and once a group produces a result the process stops
// get provider groups, always add snippet suggestion provider
for (let providerGroup of modes.CompletionProviderRegistry.orderedGroups(model)) {
// for each support in the group ask for suggestions
let lenBefore = result.length;
await Promise.all(providerGroup.map(async provider => {
if (options.providerFilter.size > 0 && !options.providerFilter.has(provider)) {
return;
}
try {
const sw = new StopWatch(true);
const list = await provider.provideCompletionItems(model, position, context, token);
onCompletionList(provider, list, sw);
} catch (err) {
onUnexpectedExternalError(err);
}
}));
if (lenBefore !== result.length || token.isCancellationRequested) {
break;
}
}
await snippetCompletions;
if (token.isCancellationRequested) {
disposables.dispose();
return Promise.reject<any>(canceled());
}
return new CompletionItemModel(
result.sort(getSuggestionComparator(options.snippetSortOrder)),
needsClipboard,
{ entries: durations, elapsed: sw.elapsed() },
disposables,
);
}
function defaultComparator(a: CompletionItem, b: CompletionItem): number {
// check with 'sortText'
if (a.sortTextLow && b.sortTextLow) {
if (a.sortTextLow < b.sortTextLow) {
return -1;
} else if (a.sortTextLow > b.sortTextLow) {
return 1;
}
}
// check with 'label'
if (a.completion.label < b.completion.label) {
return -1;
} else if (a.completion.label > b.completion.label) {
return 1;
}
// check with 'type'
return a.completion.kind - b.completion.kind;
}
function snippetUpComparator(a: CompletionItem, b: CompletionItem): number {
if (a.completion.kind !== b.completion.kind) {
if (a.completion.kind === modes.CompletionItemKind.Snippet) {
return -1;
} else if (b.completion.kind === modes.CompletionItemKind.Snippet) {
return 1;
}
}
return defaultComparator(a, b);
}
function snippetDownComparator(a: CompletionItem, b: CompletionItem): number {
if (a.completion.kind !== b.completion.kind) {
if (a.completion.kind === modes.CompletionItemKind.Snippet) {
return 1;
} else if (b.completion.kind === modes.CompletionItemKind.Snippet) {
return -1;
}
}
return defaultComparator(a, b);
}
interface Comparator<T> { (a: T, b: T): number; }
const _snippetComparators = new Map<SnippetSortOrder, Comparator<CompletionItem>>();
_snippetComparators.set(SnippetSortOrder.Top, snippetUpComparator);
_snippetComparators.set(SnippetSortOrder.Bottom, snippetDownComparator);
_snippetComparators.set(SnippetSortOrder.Inline, defaultComparator);
export function getSuggestionComparator(snippetConfig: SnippetSortOrder): (a: CompletionItem, b: CompletionItem) => number {
return _snippetComparators.get(snippetConfig)!;
}
registerDefaultLanguageCommand('_executeCompletionItemProvider', async (model, position, args) => {
const result: modes.CompletionList = {
incomplete: false,
suggestions: []
};
const resolving: Promise<any>[] = [];
const maxItemsToResolve = args['maxItemsToResolve'] || 0;
const completions = await provideSuggestionItems(model, position);
for (const item of completions.items) {
if (resolving.length < maxItemsToResolve) {
resolving.push(item.resolve(CancellationToken.None));
}
result.incomplete = result.incomplete || item.container.incomplete;
result.suggestions.push(item.completion);
}
try {
await Promise.all(resolving);
return result;
} finally {
setTimeout(() => completions.disposable.dispose(), 100);
}
});
interface SuggestController extends IEditorContribution {
triggerSuggest(onlyFrom?: Set<modes.CompletionItemProvider>): void;
}
const _provider = new class implements modes.CompletionItemProvider {
onlyOnceSuggestions: modes.CompletionItem[] = [];
provideCompletionItems(): modes.CompletionList {
let suggestions = this.onlyOnceSuggestions.slice(0);
let result = { suggestions };
this.onlyOnceSuggestions.length = 0;
return result;
}
};
modes.CompletionProviderRegistry.register('*', _provider);
export function showSimpleSuggestions(editor: ICodeEditor, suggestions: modes.CompletionItem[]) {
setTimeout(() => {
_provider.onlyOnceSuggestions.push(...suggestions);
editor.getContribution<SuggestController>('editor.contrib.suggestController').triggerSuggest(new Set<modes.CompletionItemProvider>().add(_provider));
}, 0);
}

View File

@@ -0,0 +1,104 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IDisposable } from 'vs/base/common/lifecycle';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { CompletionModel } from './completionModel';
import { ISelectedSuggestion } from './suggestWidget';
export class SuggestAlternatives {
static readonly OtherSuggestions = new RawContextKey<boolean>('hasOtherSuggestions', false);
private readonly _ckOtherSuggestions: IContextKey<boolean>;
private _index: number = 0;
private _model: CompletionModel | undefined;
private _acceptNext: ((selected: ISelectedSuggestion) => any) | undefined;
private _listener: IDisposable | undefined;
private _ignore: boolean | undefined;
constructor(
private readonly _editor: ICodeEditor,
@IContextKeyService contextKeyService: IContextKeyService
) {
this._ckOtherSuggestions = SuggestAlternatives.OtherSuggestions.bindTo(contextKeyService);
}
dispose(): void {
this.reset();
}
reset(): void {
this._ckOtherSuggestions.reset();
this._listener?.dispose();
this._model = undefined;
this._acceptNext = undefined;
this._ignore = false;
}
set({ model, index }: ISelectedSuggestion, acceptNext: (selected: ISelectedSuggestion) => any): void {
// no suggestions -> nothing to do
if (model.items.length === 0) {
this.reset();
return;
}
// no alternative suggestions -> nothing to do
let nextIndex = SuggestAlternatives._moveIndex(true, model, index);
if (nextIndex === index) {
this.reset();
return;
}
this._acceptNext = acceptNext;
this._model = model;
this._index = index;
this._listener = this._editor.onDidChangeCursorPosition(() => {
if (!this._ignore) {
this.reset();
}
});
this._ckOtherSuggestions.set(true);
}
private static _moveIndex(fwd: boolean, model: CompletionModel, index: number): number {
let newIndex = index;
while (true) {
newIndex = (newIndex + model.items.length + (fwd ? +1 : -1)) % model.items.length;
if (newIndex === index) {
break;
}
if (!model.items[newIndex].completion.additionalTextEdits) {
break;
}
}
return newIndex;
}
next(): void {
this._move(true);
}
prev(): void {
this._move(false);
}
private _move(fwd: boolean): void {
if (!this._model) {
// nothing to reason about
return;
}
try {
this._ignore = true;
this._index = SuggestAlternatives._moveIndex(fwd, this._model, this._index);
this._acceptNext!({ index: this._index, item: this._model.items[this._index], model: this._model });
} finally {
this._ignore = false;
}
}
}

View File

@@ -0,0 +1,67 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isNonEmptyArray } from 'vs/base/common/arrays';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { ISelectedSuggestion, SuggestWidget } from './suggestWidget';
import { CharacterSet } from 'vs/editor/common/core/characterClassifier';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
export class CommitCharacterController {
private readonly _disposables = new DisposableStore();
private _active?: {
readonly acceptCharacters: CharacterSet;
readonly item: ISelectedSuggestion;
};
constructor(editor: ICodeEditor, widget: SuggestWidget, accept: (selected: ISelectedSuggestion) => any) {
this._disposables.add(widget.onDidShow(() => this._onItem(widget.getFocusedItem())));
this._disposables.add(widget.onDidFocus(this._onItem, this));
this._disposables.add(widget.onDidHide(this.reset, this));
this._disposables.add(editor.onWillType(text => {
if (this._active && !widget.isFrozen()) {
const ch = text.charCodeAt(text.length - 1);
if (this._active.acceptCharacters.has(ch) && editor.getOption(EditorOption.acceptSuggestionOnCommitCharacter)) {
accept(this._active.item);
}
}
}));
}
private _onItem(selected: ISelectedSuggestion | undefined): void {
if (!selected || !isNonEmptyArray(selected.item.completion.commitCharacters)) {
// no item or no commit characters
this.reset();
return;
}
if (this._active && this._active.item.item === selected.item) {
// still the same item
return;
}
// keep item and its commit characters
const acceptCharacters = new CharacterSet();
for (const ch of selected.item.completion.commitCharacters) {
if (ch.length > 0) {
acceptCharacters.add(ch.charCodeAt(0));
}
}
this._active = { acceptCharacters, item: selected };
}
reset(): void {
this._active = undefined;
}
dispose() {
this._disposables.dispose();
}
}

View File

@@ -0,0 +1,898 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { alert } from 'vs/base/browser/ui/aria/aria';
import { isNonEmptyArray } from 'vs/base/common/arrays';
import { onUnexpectedError } from 'vs/base/common/errors';
import { KeyCode, KeyMod, SimpleKeybinding } from 'vs/base/common/keyCodes';
import { dispose, IDisposable, DisposableStore, toDisposable, MutableDisposable } from 'vs/base/common/lifecycle';
import { StableEditorScrollState } from 'vs/editor/browser/core/editorState';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditorAction, EditorCommand, registerEditorAction, registerEditorCommand, registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { Range } from 'vs/editor/common/core/range';
import { IEditorContribution, ScrollType } from 'vs/editor/common/editorCommon';
import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
import { CompletionItemProvider, CompletionItemInsertTextRule } from 'vs/editor/common/modes';
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
import { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
import { ISuggestMemoryService } from 'vs/editor/contrib/suggest/suggestMemory';
import * as nls from 'vs/nls';
import { ICommandService, CommandsRegistry } from 'vs/platform/commands/common/commands';
import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { KeybindingWeight, KeybindingsRegistry } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { Context as SuggestContext, CompletionItem, suggestWidgetStatusbarMenu } from './suggest';
import { SuggestAlternatives } from './suggestAlternatives';
import { State, SuggestModel } from './suggestModel';
import { ISelectedSuggestion, SuggestWidget } from './suggestWidget';
import { WordContextKey } from 'vs/editor/contrib/suggest/wordContextKey';
import { Event } from 'vs/base/common/event';
import { IdleValue } from 'vs/base/common/async';
import { isObject, assertType } from 'vs/base/common/types';
import { CommitCharacterController } from './suggestCommitCharacters';
import { OvertypingCapturer } from './suggestOvertypingCapturer';
import { IPosition, Position } from 'vs/editor/common/core/position';
import { TrackedRangeStickiness, ITextModel } from 'vs/editor/common/model';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import * as platform from 'vs/base/common/platform';
import { MenuRegistry } from 'vs/platform/actions/common/actions';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { ILogService } from 'vs/platform/log/common/log';
import { StopWatch } from 'vs/base/common/stopwatch';
// sticky suggest widget which doesn't disappear on focus out and such
let _sticky = false;
// _sticky = Boolean("true"); // done "weirdly" so that a lint warning prevents you from pushing this
class LineSuffix {
private readonly _marker: string[] | undefined;
constructor(private readonly _model: ITextModel, private readonly _position: IPosition) {
// spy on what's happening right of the cursor. two cases:
// 1. end of line -> check that it's still end of line
// 2. mid of line -> add a marker and compute the delta
const maxColumn = _model.getLineMaxColumn(_position.lineNumber);
if (maxColumn !== _position.column) {
const offset = _model.getOffsetAt(_position);
const end = _model.getPositionAt(offset + 1);
this._marker = _model.deltaDecorations([], [{
range: Range.fromPositions(_position, end),
options: { stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges }
}]);
}
}
dispose(): void {
if (this._marker && !this._model.isDisposed()) {
this._model.deltaDecorations(this._marker, []);
}
}
delta(position: IPosition): number {
if (this._model.isDisposed() || this._position.lineNumber !== position.lineNumber) {
// bail out early if things seems fishy
return 0;
}
// read the marker (in case suggest was triggered at line end) or compare
// the cursor to the line end.
if (this._marker) {
const range = this._model.getDecorationRange(this._marker[0]);
const end = this._model.getOffsetAt(range!.getStartPosition());
return end - this._model.getOffsetAt(position);
} else {
return this._model.getLineMaxColumn(position.lineNumber) - position.column;
}
}
}
const enum InsertFlags {
NoBeforeUndoStop = 1,
NoAfterUndoStop = 2,
KeepAlternativeSuggestions = 4,
AlternativeOverwriteConfig = 8
}
export class SuggestController implements IEditorContribution {
public static readonly ID: string = 'editor.contrib.suggestController';
public static get(editor: ICodeEditor): SuggestController {
return editor.getContribution<SuggestController>(SuggestController.ID);
}
readonly editor: ICodeEditor;
readonly model: SuggestModel;
readonly widget: IdleValue<SuggestWidget>;
private readonly _alternatives: IdleValue<SuggestAlternatives>;
private readonly _lineSuffix = new MutableDisposable<LineSuffix>();
private readonly _toDispose = new DisposableStore();
private readonly _overtypingCapturer: IdleValue<OvertypingCapturer>;
constructor(
editor: ICodeEditor,
@ISuggestMemoryService private readonly _memoryService: ISuggestMemoryService,
@ICommandService private readonly _commandService: ICommandService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
) {
this.editor = editor;
this.model = _instantiationService.createInstance(SuggestModel, this.editor,);
// context key: update insert/replace mode
const ctxInsertMode = SuggestContext.InsertMode.bindTo(_contextKeyService);
ctxInsertMode.set(editor.getOption(EditorOption.suggest).insertMode);
this.model.onDidTrigger(() => ctxInsertMode.set(editor.getOption(EditorOption.suggest).insertMode));
this.widget = this._toDispose.add(new IdleValue(() => {
const widget = this._instantiationService.createInstance(SuggestWidget, this.editor);
this._toDispose.add(widget);
this._toDispose.add(widget.onDidSelect(item => this._insertSuggestion(item, 0), this));
// Wire up logic to accept a suggestion on certain characters
const commitCharacterController = new CommitCharacterController(this.editor, widget, item => this._insertSuggestion(item, InsertFlags.NoAfterUndoStop));
this._toDispose.add(commitCharacterController);
this._toDispose.add(this.model.onDidSuggest(e => {
if (e.completionModel.items.length === 0) {
commitCharacterController.reset();
}
}));
// Wire up makes text edit context key
const ctxMakesTextEdit = SuggestContext.MakesTextEdit.bindTo(this._contextKeyService);
const ctxHasInsertAndReplace = SuggestContext.HasInsertAndReplaceRange.bindTo(this._contextKeyService);
const ctxCanResolve = SuggestContext.CanResolve.bindTo(this._contextKeyService);
this._toDispose.add(toDisposable(() => {
ctxMakesTextEdit.reset();
ctxHasInsertAndReplace.reset();
ctxCanResolve.reset();
}));
this._toDispose.add(widget.onDidFocus(({ item }) => {
// (ctx: makesTextEdit)
const position = this.editor.getPosition()!;
const startColumn = item.editStart.column;
const endColumn = position.column;
let value = true;
if (
this.editor.getOption(EditorOption.acceptSuggestionOnEnter) === 'smart'
&& this.model.state === State.Auto
&& !item.completion.command
&& !item.completion.additionalTextEdits
&& !(item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet)
&& endColumn - startColumn === item.completion.insertText.length
) {
const oldText = this.editor.getModel()!.getValueInRange({
startLineNumber: position.lineNumber,
startColumn,
endLineNumber: position.lineNumber,
endColumn
});
value = oldText !== item.completion.insertText;
}
ctxMakesTextEdit.set(value);
// (ctx: hasInsertAndReplaceRange)
ctxHasInsertAndReplace.set(!Position.equals(item.editInsertEnd, item.editReplaceEnd));
// (ctx: canResolve)
ctxCanResolve.set(Boolean(item.provider.resolveCompletionItem) || Boolean(item.completion.documentation) || item.completion.detail !== item.completion.label);
}));
this._toDispose.add(widget.onDetailsKeyDown(e => {
// cmd + c on macOS, ctrl + c on Win / Linux
if (
e.toKeybinding().equals(new SimpleKeybinding(true, false, false, false, KeyCode.KEY_C)) ||
(platform.isMacintosh && e.toKeybinding().equals(new SimpleKeybinding(false, false, false, true, KeyCode.KEY_C)))
) {
e.stopPropagation();
return;
}
if (!e.toKeybinding().isModifierKey()) {
this.editor.focus();
}
}));
return widget;
}));
// Wire up text overtyping capture
this._overtypingCapturer = this._toDispose.add(new IdleValue(() => {
return this._toDispose.add(new OvertypingCapturer(this.editor, this.model));
}));
this._alternatives = this._toDispose.add(new IdleValue(() => {
return this._toDispose.add(new SuggestAlternatives(this.editor, this._contextKeyService));
}));
this._toDispose.add(_instantiationService.createInstance(WordContextKey, editor));
this._toDispose.add(this.model.onDidTrigger(e => {
this.widget.value.showTriggered(e.auto, e.shy ? 250 : 50);
this._lineSuffix.value = new LineSuffix(this.editor.getModel()!, e.position);
}));
this._toDispose.add(this.model.onDidSuggest(e => {
if (!e.shy) {
let index = this._memoryService.select(this.editor.getModel()!, this.editor.getPosition()!, e.completionModel.items);
this.widget.value.showSuggestions(e.completionModel, index, e.isFrozen, e.auto);
}
}));
this._toDispose.add(this.model.onDidCancel(e => {
if (!e.retrigger) {
this.widget.value.hideWidget();
}
}));
this._toDispose.add(this.editor.onDidBlurEditorWidget(() => {
if (!_sticky) {
this.model.cancel();
this.model.clear();
}
}));
// Manage the acceptSuggestionsOnEnter context key
let acceptSuggestionsOnEnter = SuggestContext.AcceptSuggestionsOnEnter.bindTo(_contextKeyService);
let updateFromConfig = () => {
const acceptSuggestionOnEnter = this.editor.getOption(EditorOption.acceptSuggestionOnEnter);
acceptSuggestionsOnEnter.set(acceptSuggestionOnEnter === 'on' || acceptSuggestionOnEnter === 'smart');
};
this._toDispose.add(this.editor.onDidChangeConfiguration(() => updateFromConfig()));
updateFromConfig();
}
dispose(): void {
this._alternatives.dispose();
this._toDispose.dispose();
this.widget.dispose();
this.model.dispose();
this._lineSuffix.dispose();
}
protected _insertSuggestion(
event: ISelectedSuggestion | undefined,
flags: InsertFlags
): void {
if (!event || !event.item) {
this._alternatives.value.reset();
this.model.cancel();
this.model.clear();
return;
}
if (!this.editor.hasModel()) {
return;
}
const model = this.editor.getModel();
const modelVersionNow = model.getAlternativeVersionId();
const { item } = event;
//
const tasks: Promise<any>[] = [];
const cts = new CancellationTokenSource();
// pushing undo stops *before* additional text edits and
// *after* the main edit
if (!(flags & InsertFlags.NoBeforeUndoStop)) {
this.editor.pushUndoStop();
}
// compute overwrite[Before|After] deltas BEFORE applying extra edits
const info = this.getOverwriteInfo(item, Boolean(flags & InsertFlags.AlternativeOverwriteConfig));
// keep item in memory
this._memoryService.memorize(model, this.editor.getPosition(), item);
if (Array.isArray(item.completion.additionalTextEdits)) {
// sync additional edits
const scrollState = StableEditorScrollState.capture(this.editor);
this.editor.executeEdits(
'suggestController.additionalTextEdits.sync',
item.completion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text))
);
scrollState.restoreRelativeVerticalPositionOfCursor(this.editor);
} else if (!item.isResolved) {
// async additional edits
const sw = new StopWatch(true);
let position: IPosition | undefined;
const docListener = model.onDidChangeContent(e => {
if (e.isFlush) {
cts.cancel();
docListener.dispose();
return;
}
for (let change of e.changes) {
const thisPosition = Range.getEndPosition(change.range);
if (!position || Position.isBefore(thisPosition, position)) {
position = thisPosition;
}
}
});
let oldFlags = flags;
flags |= InsertFlags.NoAfterUndoStop;
let didType = false;
let typeListener = this.editor.onWillType(() => {
typeListener.dispose();
didType = true;
if (!(oldFlags & InsertFlags.NoAfterUndoStop)) {
this.editor.pushUndoStop();
}
});
tasks.push(item.resolve(cts.token).then(() => {
if (!item.completion.additionalTextEdits || cts.token.isCancellationRequested) {
return false;
}
if (position && item.completion.additionalTextEdits.some(edit => Position.isBefore(position!, Range.getStartPosition(edit.range)))) {
return false;
}
if (didType) {
this.editor.pushUndoStop();
}
const scrollState = StableEditorScrollState.capture(this.editor);
this.editor.executeEdits(
'suggestController.additionalTextEdits.async',
item.completion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text))
);
scrollState.restoreRelativeVerticalPositionOfCursor(this.editor);
if (didType || !(oldFlags & InsertFlags.NoAfterUndoStop)) {
this.editor.pushUndoStop();
}
return true;
}).then(applied => {
this._logService.trace('[suggest] async resolving of edits DONE (ms, applied?)', sw.elapsed(), applied);
docListener.dispose();
typeListener.dispose();
}));
}
let { insertText } = item.completion;
if (!(item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet)) {
insertText = SnippetParser.escape(insertText);
}
SnippetController2.get(this.editor).insert(insertText, {
overwriteBefore: info.overwriteBefore,
overwriteAfter: info.overwriteAfter,
undoStopBefore: false,
undoStopAfter: false,
adjustWhitespace: !(item.completion.insertTextRules! & CompletionItemInsertTextRule.KeepWhitespace),
clipboardText: event.model.clipboardText,
overtypingCapturer: this._overtypingCapturer.value
});
if (!(flags & InsertFlags.NoAfterUndoStop)) {
this.editor.pushUndoStop();
}
if (!item.completion.command) {
// done
this.model.cancel();
} else if (item.completion.command.id === TriggerSuggestAction.id) {
// retigger
this.model.trigger({ auto: true, shy: false }, true);
} else {
// exec command, done
tasks.push(this._commandService.executeCommand(item.completion.command.id, ...(item.completion.command.arguments ? [...item.completion.command.arguments] : [])).catch(onUnexpectedError));
this.model.cancel();
}
if (flags & InsertFlags.KeepAlternativeSuggestions) {
this._alternatives.value.set(event, next => {
// cancel resolving of additional edits
cts.cancel();
// this is not so pretty. when inserting the 'next'
// suggestion we undo until we are at the state at
// which we were before inserting the previous suggestion...
while (model.canUndo()) {
if (modelVersionNow !== model.getAlternativeVersionId()) {
model.undo();
}
this._insertSuggestion(
next,
InsertFlags.NoBeforeUndoStop | InsertFlags.NoAfterUndoStop | (flags & InsertFlags.AlternativeOverwriteConfig ? InsertFlags.AlternativeOverwriteConfig : 0)
);
break;
}
});
}
this._alertCompletionItem(item);
// clear only now - after all tasks are done
Promise.all(tasks).finally(() => {
this.model.clear();
cts.dispose();
});
}
getOverwriteInfo(item: CompletionItem, toggleMode: boolean): { overwriteBefore: number, overwriteAfter: number } {
assertType(this.editor.hasModel());
let replace = this.editor.getOption(EditorOption.suggest).insertMode === 'replace';
if (toggleMode) {
replace = !replace;
}
const overwriteBefore = item.position.column - item.editStart.column;
const overwriteAfter = (replace ? item.editReplaceEnd.column : item.editInsertEnd.column) - item.position.column;
const columnDelta = this.editor.getPosition().column - item.position.column;
const suffixDelta = this._lineSuffix.value ? this._lineSuffix.value.delta(this.editor.getPosition()) : 0;
return {
overwriteBefore: overwriteBefore + columnDelta,
overwriteAfter: overwriteAfter + suffixDelta
};
}
private _alertCompletionItem({ completion: suggestion }: CompletionItem): void {
const textLabel = typeof suggestion.label === 'string' ? suggestion.label : suggestion.label.name;
if (isNonEmptyArray(suggestion.additionalTextEdits)) {
let msg = nls.localize('aria.alert.snippet', "Accepting '{0}' made {1} additional edits", textLabel, suggestion.additionalTextEdits.length);
alert(msg);
}
}
triggerSuggest(onlyFrom?: Set<CompletionItemProvider>): void {
if (this.editor.hasModel()) {
this.model.trigger({ auto: false, shy: false }, false, onlyFrom);
this.editor.revealLine(this.editor.getPosition().lineNumber, ScrollType.Smooth);
this.editor.focus();
}
}
triggerSuggestAndAcceptBest(arg: { fallback: string }): void {
if (!this.editor.hasModel()) {
return;
}
const positionNow = this.editor.getPosition();
const fallback = () => {
if (positionNow.equals(this.editor.getPosition()!)) {
this._commandService.executeCommand(arg.fallback);
}
};
const makesTextEdit = (item: CompletionItem): boolean => {
if (item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet || item.completion.additionalTextEdits) {
// snippet, other editor -> makes edit
return true;
}
const position = this.editor.getPosition()!;
const startColumn = item.editStart.column;
const endColumn = position.column;
if (endColumn - startColumn !== item.completion.insertText.length) {
// unequal lengths -> makes edit
return true;
}
const textNow = this.editor.getModel()!.getValueInRange({
startLineNumber: position.lineNumber,
startColumn,
endLineNumber: position.lineNumber,
endColumn
});
// unequal text -> makes edit
return textNow !== item.completion.insertText;
};
Event.once(this.model.onDidTrigger)(_ => {
// wait for trigger because only then the cancel-event is trustworthy
let listener: IDisposable[] = [];
Event.any<any>(this.model.onDidTrigger, this.model.onDidCancel)(() => {
// retrigger or cancel -> try to type default text
dispose(listener);
fallback();
}, undefined, listener);
this.model.onDidSuggest(({ completionModel }) => {
dispose(listener);
if (completionModel.items.length === 0) {
fallback();
return;
}
const index = this._memoryService.select(this.editor.getModel()!, this.editor.getPosition()!, completionModel.items);
const item = completionModel.items[index];
if (!makesTextEdit(item)) {
fallback();
return;
}
this.editor.pushUndoStop();
this._insertSuggestion({ index, item, model: completionModel }, InsertFlags.KeepAlternativeSuggestions | InsertFlags.NoBeforeUndoStop | InsertFlags.NoAfterUndoStop);
}, undefined, listener);
});
this.model.trigger({ auto: false, shy: true });
this.editor.revealLine(positionNow.lineNumber, ScrollType.Smooth);
this.editor.focus();
}
acceptSelectedSuggestion(keepAlternativeSuggestions: boolean, alternativeOverwriteConfig: boolean): void {
const item = this.widget.value.getFocusedItem();
let flags = 0;
if (keepAlternativeSuggestions) {
flags |= InsertFlags.KeepAlternativeSuggestions;
}
if (alternativeOverwriteConfig) {
flags |= InsertFlags.AlternativeOverwriteConfig;
}
this._insertSuggestion(item, flags);
}
acceptNextSuggestion() {
this._alternatives.value.next();
}
acceptPrevSuggestion() {
this._alternatives.value.prev();
}
cancelSuggestWidget(): void {
this.model.cancel();
this.model.clear();
this.widget.value.hideWidget();
}
selectNextSuggestion(): void {
this.widget.value.selectNext();
}
selectNextPageSuggestion(): void {
this.widget.value.selectNextPage();
}
selectLastSuggestion(): void {
this.widget.value.selectLast();
}
selectPrevSuggestion(): void {
this.widget.value.selectPrevious();
}
selectPrevPageSuggestion(): void {
this.widget.value.selectPreviousPage();
}
selectFirstSuggestion(): void {
this.widget.value.selectFirst();
}
toggleSuggestionDetails(): void {
this.widget.value.toggleDetails();
}
toggleExplainMode(): void {
this.widget.value.toggleExplainMode();
}
toggleSuggestionFocus(): void {
this.widget.value.toggleDetailsFocus();
}
resetWidgetSize(): void {
this.widget.value.resetPersistedSize();
}
}
export class TriggerSuggestAction extends EditorAction {
static readonly id = 'editor.action.triggerSuggest';
constructor() {
super({
id: TriggerSuggestAction.id,
label: nls.localize('suggest.trigger.label', "Trigger Suggest"),
alias: 'Trigger Suggest',
precondition: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasCompletionItemProvider),
kbOpts: {
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyMod.CtrlCmd | KeyCode.Space,
secondary: [KeyMod.CtrlCmd | KeyCode.KEY_I],
mac: { primary: KeyMod.WinCtrl | KeyCode.Space, secondary: [KeyMod.Alt | KeyCode.Escape, KeyMod.CtrlCmd | KeyCode.KEY_I] },
weight: KeybindingWeight.EditorContrib
}
});
}
public run(accessor: ServicesAccessor, editor: ICodeEditor): void {
const controller = SuggestController.get(editor);
if (!controller) {
return;
}
controller.triggerSuggest();
}
}
registerEditorContribution(SuggestController.ID, SuggestController);
registerEditorAction(TriggerSuggestAction);
const weight = KeybindingWeight.EditorContrib + 90;
const SuggestCommand = EditorCommand.bindToContribution<SuggestController>(SuggestController.get);
registerEditorCommand(new SuggestCommand({
id: 'acceptSelectedSuggestion',
precondition: SuggestContext.Visible,
handler(x) {
x.acceptSelectedSuggestion(true, false);
}
}));
// normal tab
KeybindingsRegistry.registerKeybindingRule({
id: 'acceptSelectedSuggestion',
when: ContextKeyExpr.and(SuggestContext.Visible, EditorContextKeys.textInputFocus),
primary: KeyCode.Tab,
weight
});
// accept on enter has special rules
KeybindingsRegistry.registerKeybindingRule({
id: 'acceptSelectedSuggestion',
when: ContextKeyExpr.and(SuggestContext.Visible, EditorContextKeys.textInputFocus, SuggestContext.AcceptSuggestionsOnEnter, SuggestContext.MakesTextEdit),
primary: KeyCode.Enter,
weight,
});
MenuRegistry.appendMenuItem(suggestWidgetStatusbarMenu, {
command: { id: 'acceptSelectedSuggestion', title: nls.localize('accept.insert', "Insert") },
group: 'left',
order: 1,
when: SuggestContext.HasInsertAndReplaceRange.toNegated()
});
MenuRegistry.appendMenuItem(suggestWidgetStatusbarMenu, {
command: { id: 'acceptSelectedSuggestion', title: nls.localize('accept.insert', "Insert") },
group: 'left',
order: 1,
when: ContextKeyExpr.and(SuggestContext.HasInsertAndReplaceRange, SuggestContext.InsertMode.isEqualTo('insert'))
});
MenuRegistry.appendMenuItem(suggestWidgetStatusbarMenu, {
command: { id: 'acceptSelectedSuggestion', title: nls.localize('accept.replace', "Replace") },
group: 'left',
order: 1,
when: ContextKeyExpr.and(SuggestContext.HasInsertAndReplaceRange, SuggestContext.InsertMode.isEqualTo('replace'))
});
registerEditorCommand(new SuggestCommand({
id: 'acceptAlternativeSelectedSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, EditorContextKeys.textInputFocus),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyMod.Shift | KeyCode.Enter,
secondary: [KeyMod.Shift | KeyCode.Tab],
},
handler(x) {
x.acceptSelectedSuggestion(false, true);
},
menuOpts: [{
menuId: suggestWidgetStatusbarMenu,
group: 'left',
order: 2,
when: ContextKeyExpr.and(SuggestContext.HasInsertAndReplaceRange, SuggestContext.InsertMode.isEqualTo('insert')),
title: nls.localize('accept.replace', "Replace")
}, {
menuId: suggestWidgetStatusbarMenu,
group: 'left',
order: 2,
when: ContextKeyExpr.and(SuggestContext.HasInsertAndReplaceRange, SuggestContext.InsertMode.isEqualTo('replace')),
title: nls.localize('accept.insert', "Insert")
}]
}));
// continue to support the old command
CommandsRegistry.registerCommandAlias('acceptSelectedSuggestionOnEnter', 'acceptSelectedSuggestion');
registerEditorCommand(new SuggestCommand({
id: 'hideSuggestWidget',
precondition: SuggestContext.Visible,
handler: x => x.cancelSuggestWidget(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyCode.Escape,
secondary: [KeyMod.Shift | KeyCode.Escape]
}
}));
registerEditorCommand(new SuggestCommand({
id: 'selectNextSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, SuggestContext.MultipleSuggestions),
handler: c => c.selectNextSuggestion(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyCode.DownArrow,
secondary: [KeyMod.CtrlCmd | KeyCode.DownArrow],
mac: { primary: KeyCode.DownArrow, secondary: [KeyMod.CtrlCmd | KeyCode.DownArrow, KeyMod.WinCtrl | KeyCode.KEY_N] }
}
}));
registerEditorCommand(new SuggestCommand({
id: 'selectNextPageSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, SuggestContext.MultipleSuggestions),
handler: c => c.selectNextPageSuggestion(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyCode.PageDown,
secondary: [KeyMod.CtrlCmd | KeyCode.PageDown]
}
}));
registerEditorCommand(new SuggestCommand({
id: 'selectLastSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, SuggestContext.MultipleSuggestions),
handler: c => c.selectLastSuggestion()
}));
registerEditorCommand(new SuggestCommand({
id: 'selectPrevSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, SuggestContext.MultipleSuggestions),
handler: c => c.selectPrevSuggestion(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyCode.UpArrow,
secondary: [KeyMod.CtrlCmd | KeyCode.UpArrow],
mac: { primary: KeyCode.UpArrow, secondary: [KeyMod.CtrlCmd | KeyCode.UpArrow, KeyMod.WinCtrl | KeyCode.KEY_P] }
}
}));
registerEditorCommand(new SuggestCommand({
id: 'selectPrevPageSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, SuggestContext.MultipleSuggestions),
handler: c => c.selectPrevPageSuggestion(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyCode.PageUp,
secondary: [KeyMod.CtrlCmd | KeyCode.PageUp]
}
}));
registerEditorCommand(new SuggestCommand({
id: 'selectFirstSuggestion',
precondition: ContextKeyExpr.and(SuggestContext.Visible, SuggestContext.MultipleSuggestions),
handler: c => c.selectFirstSuggestion()
}));
registerEditorCommand(new SuggestCommand({
id: 'toggleSuggestionDetails',
precondition: SuggestContext.Visible,
handler: x => x.toggleSuggestionDetails(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyMod.CtrlCmd | KeyCode.Space,
mac: { primary: KeyMod.WinCtrl | KeyCode.Space }
},
menuOpts: [{
menuId: suggestWidgetStatusbarMenu,
group: 'right',
order: 1,
when: ContextKeyExpr.and(SuggestContext.DetailsVisible, SuggestContext.CanResolve),
title: nls.localize('detail.more', "show less")
}, {
menuId: suggestWidgetStatusbarMenu,
group: 'right',
order: 1,
when: ContextKeyExpr.and(SuggestContext.DetailsVisible.toNegated(), SuggestContext.CanResolve),
title: nls.localize('detail.less', "show more")
}]
}));
registerEditorCommand(new SuggestCommand({
id: 'toggleExplainMode',
precondition: SuggestContext.Visible,
handler: x => x.toggleExplainMode(),
kbOpts: {
weight: KeybindingWeight.EditorContrib,
primary: KeyMod.CtrlCmd | KeyCode.US_SLASH,
}
}));
registerEditorCommand(new SuggestCommand({
id: 'toggleSuggestionFocus',
precondition: SuggestContext.Visible,
handler: x => x.toggleSuggestionFocus(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.Space,
mac: { primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.Space }
}
}));
//#region tab completions
registerEditorCommand(new SuggestCommand({
id: 'insertBestCompletion',
precondition: ContextKeyExpr.and(
EditorContextKeys.textInputFocus,
ContextKeyExpr.equals('config.editor.tabCompletion', 'on'),
WordContextKey.AtEnd,
SuggestContext.Visible.toNegated(),
SuggestAlternatives.OtherSuggestions.toNegated(),
SnippetController2.InSnippetMode.toNegated()
),
handler: (x, arg) => {
x.triggerSuggestAndAcceptBest(isObject(arg) ? { fallback: 'tab', ...arg } : { fallback: 'tab' });
},
kbOpts: {
weight,
primary: KeyCode.Tab
}
}));
registerEditorCommand(new SuggestCommand({
id: 'insertNextSuggestion',
precondition: ContextKeyExpr.and(
EditorContextKeys.textInputFocus,
ContextKeyExpr.equals('config.editor.tabCompletion', 'on'),
SuggestAlternatives.OtherSuggestions,
SuggestContext.Visible.toNegated(),
SnippetController2.InSnippetMode.toNegated()
),
handler: x => x.acceptNextSuggestion(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyCode.Tab
}
}));
registerEditorCommand(new SuggestCommand({
id: 'insertPrevSuggestion',
precondition: ContextKeyExpr.and(
EditorContextKeys.textInputFocus,
ContextKeyExpr.equals('config.editor.tabCompletion', 'on'),
SuggestAlternatives.OtherSuggestions,
SuggestContext.Visible.toNegated(),
SnippetController2.InSnippetMode.toNegated()
),
handler: x => x.acceptPrevSuggestion(),
kbOpts: {
weight: weight,
kbExpr: EditorContextKeys.textInputFocus,
primary: KeyMod.Shift | KeyCode.Tab
}
}));
registerEditorAction(class extends EditorAction {
constructor() {
super({
id: 'editor.action.resetSuggestSize',
label: nls.localize('suggest.reset.label', "Reset Suggest Widget Size"),
alias: 'Reset Suggest Widget Size',
precondition: undefined
});
}
run(_accessor: ServicesAccessor, editor: ICodeEditor): void {
SuggestController.get(editor).resetWidgetSize();
}
});

View File

@@ -0,0 +1,312 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { LRUCache, TernarySearchTree } from 'vs/base/common/map';
import { IStorageService, StorageScope, WillSaveStateReason } from 'vs/platform/storage/common/storage';
import { ITextModel } from 'vs/editor/common/model';
import { IPosition } from 'vs/editor/common/core/position';
import { CompletionItemKind, completionKindFromString } from 'vs/editor/common/modes';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { RunOnceScheduler } from 'vs/base/common/async';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { CompletionItem } from 'vs/editor/contrib/suggest/suggest';
import { IModeService } from 'vs/editor/common/services/modeService';
export abstract class Memory {
constructor(readonly name: MemMode) { }
select(model: ITextModel, pos: IPosition, items: CompletionItem[]): number {
if (items.length === 0) {
return 0;
}
let topScore = items[0].score[0];
for (let i = 0; i < items.length; i++) {
const { score, completion: suggestion } = items[i];
if (score[0] !== topScore) {
// stop when leaving the group of top matches
break;
}
if (suggestion.preselect) {
// stop when seeing an auto-select-item
return i;
}
}
return 0;
}
abstract memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void;
abstract toJSON(): object | undefined;
abstract fromJSON(data: object): void;
}
export class NoMemory extends Memory {
constructor() {
super('first');
}
memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void {
// no-op
}
toJSON() {
return undefined;
}
fromJSON() {
//
}
}
export interface MemItem {
type: string | CompletionItemKind;
insertText: string;
touch: number;
}
export class LRUMemory extends Memory {
constructor() {
super('recentlyUsed');
}
private _cache = new LRUCache<string, MemItem>(300, 0.66);
private _seq = 0;
memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void {
const { label } = item.completion;
const key = `${model.getLanguageIdentifier().language}/${label}`;
this._cache.set(key, {
touch: this._seq++,
type: item.completion.kind,
insertText: item.completion.insertText
});
}
select(model: ITextModel, pos: IPosition, items: CompletionItem[]): number {
if (items.length === 0) {
return 0;
}
const lineSuffix = model.getLineContent(pos.lineNumber).substr(pos.column - 10, pos.column - 1);
if (/\s$/.test(lineSuffix)) {
return super.select(model, pos, items);
}
let topScore = items[0].score[0];
let indexPreselect = -1;
let indexRecency = -1;
let seq = -1;
for (let i = 0; i < items.length; i++) {
if (items[i].score[0] !== topScore) {
// consider only top items
break;
}
const key = `${model.getLanguageIdentifier().language}/${items[i].completion.label}`;
const item = this._cache.peek(key);
if (item && item.touch > seq && item.type === items[i].completion.kind && item.insertText === items[i].completion.insertText) {
seq = item.touch;
indexRecency = i;
}
if (items[i].completion.preselect && indexPreselect === -1) {
// stop when seeing an auto-select-item
return indexPreselect = i;
}
}
if (indexRecency !== -1) {
return indexRecency;
} else if (indexPreselect !== -1) {
return indexPreselect;
} else {
return 0;
}
}
toJSON(): object {
return this._cache.toJSON();
}
fromJSON(data: [string, MemItem][]): void {
this._cache.clear();
let seq = 0;
for (const [key, value] of data) {
value.touch = seq;
value.type = typeof value.type === 'number' ? value.type : completionKindFromString(value.type);
this._cache.set(key, value);
}
this._seq = this._cache.size;
}
}
export class PrefixMemory extends Memory {
constructor() {
super('recentlyUsedByPrefix');
}
private _trie = TernarySearchTree.forStrings<MemItem>();
private _seq = 0;
memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void {
const { word } = model.getWordUntilPosition(pos);
const key = `${model.getLanguageIdentifier().language}/${word}`;
this._trie.set(key, {
type: item.completion.kind,
insertText: item.completion.insertText,
touch: this._seq++
});
}
select(model: ITextModel, pos: IPosition, items: CompletionItem[]): number {
let { word } = model.getWordUntilPosition(pos);
if (!word) {
return super.select(model, pos, items);
}
let key = `${model.getLanguageIdentifier().language}/${word}`;
let item = this._trie.get(key);
if (!item) {
item = this._trie.findSubstr(key);
}
if (item) {
for (let i = 0; i < items.length; i++) {
let { kind, insertText } = items[i].completion;
if (kind === item.type && insertText === item.insertText) {
return i;
}
}
}
return super.select(model, pos, items);
}
toJSON(): object {
let entries: [string, MemItem][] = [];
this._trie.forEach((value, key) => entries.push([key, value]));
// sort by last recently used (touch), then
// take the top 200 item and normalize their
// touch
entries
.sort((a, b) => -(a[1].touch - b[1].touch))
.forEach((value, i) => value[1].touch = i);
return entries.slice(0, 200);
}
fromJSON(data: [string, MemItem][]): void {
this._trie.clear();
if (data.length > 0) {
this._seq = data[0][1].touch + 1;
for (const [key, value] of data) {
value.type = typeof value.type === 'number' ? value.type : completionKindFromString(value.type);
this._trie.set(key, value);
}
}
}
}
export type MemMode = 'first' | 'recentlyUsed' | 'recentlyUsedByPrefix';
export class SuggestMemoryService implements ISuggestMemoryService {
private static readonly _strategyCtors = new Map<MemMode, { new(): Memory }>([
['recentlyUsedByPrefix', PrefixMemory],
['recentlyUsed', LRUMemory],
['first', NoMemory]
]);
private static readonly _storagePrefix = 'suggest/memories';
readonly _serviceBrand: undefined;
private readonly _persistSoon: RunOnceScheduler;
private readonly _disposables = new DisposableStore();
private _strategy?: Memory;
constructor(
@IStorageService private readonly _storageService: IStorageService,
@IModeService private readonly _modeService: IModeService,
@IConfigurationService private readonly _configService: IConfigurationService,
) {
this._persistSoon = new RunOnceScheduler(() => this._saveState(), 500);
this._disposables.add(_storageService.onWillSaveState(e => {
if (e.reason === WillSaveStateReason.SHUTDOWN) {
this._saveState();
}
}));
}
dispose(): void {
this._disposables.dispose();
this._persistSoon.dispose();
}
memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void {
this._withStrategy(model, pos).memorize(model, pos, item);
this._persistSoon.schedule();
}
select(model: ITextModel, pos: IPosition, items: CompletionItem[]): number {
return this._withStrategy(model, pos).select(model, pos, items);
}
private _withStrategy(model: ITextModel, pos: IPosition): Memory {
const mode = this._configService.getValue<MemMode>('editor.suggestSelection', {
overrideIdentifier: this._modeService.getLanguageIdentifier(model.getLanguageIdAtPosition(pos.lineNumber, pos.column))?.language,
resource: model.uri
});
if (this._strategy?.name !== mode) {
this._saveState();
const ctor = SuggestMemoryService._strategyCtors.get(mode) || NoMemory;
this._strategy = new ctor();
try {
const share = this._configService.getValue<boolean>('editor.suggest.shareSuggestSelections');
const scope = share ? StorageScope.GLOBAL : StorageScope.WORKSPACE;
const raw = this._storageService.get(`${SuggestMemoryService._storagePrefix}/${mode}`, scope);
if (raw) {
this._strategy.fromJSON(JSON.parse(raw));
}
} catch (e) {
// things can go wrong with JSON...
}
}
return this._strategy;
}
private _saveState() {
if (this._strategy) {
const share = this._configService.getValue<boolean>('editor.suggest.shareSuggestSelections');
const scope = share ? StorageScope.GLOBAL : StorageScope.WORKSPACE;
const raw = JSON.stringify(this._strategy);
this._storageService.store(`${SuggestMemoryService._storagePrefix}/${this._strategy.name}`, raw, scope);
}
}
}
export const ISuggestMemoryService = createDecorator<ISuggestMemoryService>('ISuggestMemories');
export interface ISuggestMemoryService {
readonly _serviceBrand: undefined;
memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void;
select(model: ITextModel, pos: IPosition, items: CompletionItem[]): number;
}
registerSingleton(ISuggestMemoryService, SuggestMemoryService, true);

View File

@@ -0,0 +1,642 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { TimeoutTimer } from 'vs/base/common/async';
import { onUnexpectedError } from 'vs/base/common/errors';
import { Emitter, Event } from 'vs/base/common/event';
import { IDisposable, dispose, DisposableStore } from 'vs/base/common/lifecycle';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { CursorChangeReason, ICursorSelectionChangedEvent } from 'vs/editor/common/controller/cursorEvents';
import { Position, IPosition } from 'vs/editor/common/core/position';
import { Selection } from 'vs/editor/common/core/selection';
import { ITextModel, IWordAtPosition } from 'vs/editor/common/model';
import { CompletionItemProvider, StandardTokenType, CompletionContext, CompletionProviderRegistry, CompletionTriggerKind, CompletionItemKind } from 'vs/editor/common/modes';
import { CompletionModel } from './completionModel';
import { CompletionItem, getSuggestionComparator, provideSuggestionItems, getSnippetSuggestSupport, SnippetSortOrder, CompletionOptions, CompletionDurations } from './suggest';
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { WordDistance } from 'vs/editor/contrib/suggest/wordDistance';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { isLowSurrogate, isHighSurrogate } from 'vs/base/common/strings';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { ILogService } from 'vs/platform/log/common/log';
export interface ICancelEvent {
readonly retrigger: boolean;
}
export interface ITriggerEvent {
readonly auto: boolean;
readonly shy: boolean;
readonly position: IPosition;
}
export interface ISuggestEvent {
readonly completionModel: CompletionModel;
readonly isFrozen: boolean;
readonly auto: boolean;
readonly shy: boolean;
}
export interface SuggestTriggerContext {
readonly auto: boolean;
readonly shy: boolean;
readonly triggerKind?: CompletionTriggerKind;
readonly triggerCharacter?: string;
}
export class LineContext {
static shouldAutoTrigger(editor: ICodeEditor): boolean {
if (!editor.hasModel()) {
return false;
}
const model = editor.getModel();
const pos = editor.getPosition();
model.tokenizeIfCheap(pos.lineNumber);
const word = model.getWordAtPosition(pos);
if (!word) {
return false;
}
if (word.endColumn !== pos.column) {
return false;
}
if (!isNaN(Number(word.word))) {
return false;
}
return true;
}
readonly lineNumber: number;
readonly column: number;
readonly leadingLineContent: string;
readonly leadingWord: IWordAtPosition;
readonly auto: boolean;
readonly shy: boolean;
constructor(model: ITextModel, position: Position, auto: boolean, shy: boolean) {
this.leadingLineContent = model.getLineContent(position.lineNumber).substr(0, position.column - 1);
this.leadingWord = model.getWordUntilPosition(position);
this.lineNumber = position.lineNumber;
this.column = position.column;
this.auto = auto;
this.shy = shy;
}
}
export const enum State {
Idle = 0,
Manual = 1,
Auto = 2
}
export class SuggestModel implements IDisposable {
private readonly _toDispose = new DisposableStore();
private _quickSuggestDelay: number = 10;
private readonly _triggerCharacterListener = new DisposableStore();
private readonly _triggerQuickSuggest = new TimeoutTimer();
private _state: State = State.Idle;
private _requestToken?: CancellationTokenSource;
private _context?: LineContext;
private _currentSelection: Selection;
private _completionModel: CompletionModel | undefined;
private readonly _completionDisposables = new DisposableStore();
private readonly _onDidCancel = new Emitter<ICancelEvent>();
private readonly _onDidTrigger = new Emitter<ITriggerEvent>();
private readonly _onDidSuggest = new Emitter<ISuggestEvent>();
readonly onDidCancel: Event<ICancelEvent> = this._onDidCancel.event;
readonly onDidTrigger: Event<ITriggerEvent> = this._onDidTrigger.event;
readonly onDidSuggest: Event<ISuggestEvent> = this._onDidSuggest.event;
constructor(
private readonly _editor: ICodeEditor,
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
@IClipboardService private readonly _clipboardService: IClipboardService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@ILogService private readonly _logService: ILogService,
) {
this._currentSelection = this._editor.getSelection() || new Selection(1, 1, 1, 1);
// wire up various listeners
this._toDispose.add(this._editor.onDidChangeModel(() => {
this._updateTriggerCharacters();
this.cancel();
}));
this._toDispose.add(this._editor.onDidChangeModelLanguage(() => {
this._updateTriggerCharacters();
this.cancel();
}));
this._toDispose.add(this._editor.onDidChangeConfiguration(() => {
this._updateTriggerCharacters();
this._updateQuickSuggest();
}));
this._toDispose.add(CompletionProviderRegistry.onDidChange(() => {
this._updateTriggerCharacters();
this._updateActiveSuggestSession();
}));
this._toDispose.add(this._editor.onDidChangeCursorSelection(e => {
this._onCursorChange(e);
}));
let editorIsComposing = false;
this._toDispose.add(this._editor.onDidCompositionStart(() => {
editorIsComposing = true;
}));
this._toDispose.add(this._editor.onDidCompositionEnd(() => {
// refilter when composition ends
editorIsComposing = false;
this._refilterCompletionItems();
}));
this._toDispose.add(this._editor.onDidChangeModelContent(() => {
// only filter completions when the editor isn't
// composing a character, e.g. ¨ + u makes ü but just
// ¨ cannot be used for filtering
if (!editorIsComposing) {
this._refilterCompletionItems();
}
}));
this._updateTriggerCharacters();
this._updateQuickSuggest();
}
dispose(): void {
dispose(this._triggerCharacterListener);
dispose([this._onDidCancel, this._onDidSuggest, this._onDidTrigger, this._triggerQuickSuggest]);
this._toDispose.dispose();
this._completionDisposables.dispose();
this.cancel();
}
// --- handle configuration & precondition changes
private _updateQuickSuggest(): void {
this._quickSuggestDelay = this._editor.getOption(EditorOption.quickSuggestionsDelay);
if (isNaN(this._quickSuggestDelay) || (!this._quickSuggestDelay && this._quickSuggestDelay !== 0) || this._quickSuggestDelay < 0) {
this._quickSuggestDelay = 10;
}
}
private _updateTriggerCharacters(): void {
this._triggerCharacterListener.clear();
if (this._editor.getOption(EditorOption.readOnly)
|| !this._editor.hasModel()
|| !this._editor.getOption(EditorOption.suggestOnTriggerCharacters)) {
return;
}
const supportsByTriggerCharacter = new Map<string, Set<CompletionItemProvider>>();
for (const support of CompletionProviderRegistry.all(this._editor.getModel())) {
for (const ch of support.triggerCharacters || []) {
let set = supportsByTriggerCharacter.get(ch);
if (!set) {
set = new Set();
set.add(getSnippetSuggestSupport());
supportsByTriggerCharacter.set(ch, set);
}
set.add(support);
}
}
const checkTriggerCharacter = (text?: string) => {
if (!text) {
// came here from the compositionEnd-event
const position = this._editor.getPosition()!;
const model = this._editor.getModel()!;
text = model.getLineContent(position.lineNumber).substr(0, position.column - 1);
}
let lastChar = '';
if (isLowSurrogate(text.charCodeAt(text.length - 1))) {
if (isHighSurrogate(text.charCodeAt(text.length - 2))) {
lastChar = text.substr(text.length - 2);
}
} else {
lastChar = text.charAt(text.length - 1);
}
const supports = supportsByTriggerCharacter.get(lastChar);
if (supports) {
// keep existing items that where not computed by the
// supports/providers that want to trigger now
const existing = this._completionModel
? { items: this._completionModel.adopt(supports), clipboardText: this._completionModel.clipboardText }
: undefined;
this.trigger({ auto: true, shy: false, triggerCharacter: lastChar }, Boolean(this._completionModel), supports, existing);
}
};
this._triggerCharacterListener.add(this._editor.onDidType(checkTriggerCharacter));
this._triggerCharacterListener.add(this._editor.onDidCompositionEnd(checkTriggerCharacter));
}
// --- trigger/retrigger/cancel suggest
get state(): State {
return this._state;
}
cancel(retrigger: boolean = false): void {
if (this._state !== State.Idle) {
this._triggerQuickSuggest.cancel();
this._requestToken?.cancel();
this._requestToken = undefined;
this._state = State.Idle;
this._completionModel = undefined;
this._context = undefined;
this._onDidCancel.fire({ retrigger });
}
}
clear() {
this._completionDisposables.clear();
}
private _updateActiveSuggestSession(): void {
if (this._state !== State.Idle) {
if (!this._editor.hasModel() || !CompletionProviderRegistry.has(this._editor.getModel())) {
this.cancel();
} else {
this.trigger({ auto: this._state === State.Auto, shy: false }, true);
}
}
}
private _onCursorChange(e: ICursorSelectionChangedEvent): void {
if (!this._editor.hasModel()) {
return;
}
const model = this._editor.getModel();
const prevSelection = this._currentSelection;
this._currentSelection = this._editor.getSelection();
if (!e.selection.isEmpty()
|| (e.reason !== CursorChangeReason.NotSet && e.reason !== CursorChangeReason.Explicit)
|| (e.source !== 'keyboard' && e.source !== 'deleteLeft')
) {
// Early exit if nothing needs to be done!
// Leave some form of early exit check here if you wish to continue being a cursor position change listener ;)
this.cancel();
return;
}
if (!CompletionProviderRegistry.has(model)) {
return;
}
if (this._state === State.Idle && e.reason === CursorChangeReason.NotSet) {
if (this._editor.getOption(EditorOption.quickSuggestions) === false) {
// not enabled
return;
}
if (!prevSelection.containsRange(this._currentSelection) && !prevSelection.getEndPosition().isBeforeOrEqual(this._currentSelection.getPosition())) {
// cursor didn't move RIGHT
return;
}
if (this._editor.getOption(EditorOption.suggest).snippetsPreventQuickSuggestions && SnippetController2.get(this._editor).isInSnippet()) {
// no quick suggestion when in snippet mode
return;
}
this.cancel();
this._triggerQuickSuggest.cancelAndSet(() => {
if (this._state !== State.Idle) {
return;
}
if (!LineContext.shouldAutoTrigger(this._editor)) {
return;
}
if (!this._editor.hasModel()) {
return;
}
const model = this._editor.getModel();
const pos = this._editor.getPosition();
// validate enabled now
const quickSuggestions = this._editor.getOption(EditorOption.quickSuggestions);
if (quickSuggestions === false) {
return;
} else if (quickSuggestions === true) {
// all good
} else {
// Check the type of the token that triggered this
model.tokenizeIfCheap(pos.lineNumber);
const lineTokens = model.getLineTokens(pos.lineNumber);
const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0)));
const inValidScope = quickSuggestions.other && tokenType === StandardTokenType.Other
|| quickSuggestions.comments && tokenType === StandardTokenType.Comment
|| quickSuggestions.strings && tokenType === StandardTokenType.String;
if (!inValidScope) {
return;
}
}
// we made it till here -> trigger now
this.trigger({ auto: true, shy: false });
}, this._quickSuggestDelay);
} else if (this._state !== State.Idle && e.reason === CursorChangeReason.Explicit) {
// suggest is active and something like cursor keys are used to move
// the cursor. this means we can refilter at the new position
this._refilterCompletionItems();
}
}
private _refilterCompletionItems(): void {
// Re-filter suggestions. This MUST run async because filtering/scoring
// uses the model content AND the cursor position. The latter is NOT
// updated when the document has changed (the event which drives this method)
// and therefore a little pause (next mirco task) is needed. See:
// https://stackoverflow.com/questions/25915634/difference-between-microtask-and-macrotask-within-an-event-loop-context#25933985
Promise.resolve().then(() => {
if (this._state === State.Idle) {
return;
}
if (!this._editor.hasModel()) {
return;
}
const model = this._editor.getModel();
const position = this._editor.getPosition();
const ctx = new LineContext(model, position, this._state === State.Auto, false);
this._onNewContext(ctx);
});
}
trigger(context: SuggestTriggerContext, retrigger: boolean = false, onlyFrom?: Set<CompletionItemProvider>, existing?: { items: CompletionItem[], clipboardText: string | undefined }): void {
if (!this._editor.hasModel()) {
return;
}
const model = this._editor.getModel();
const auto = context.auto;
const ctx = new LineContext(model, this._editor.getPosition(), auto, context.shy);
// Cancel previous requests, change state & update UI
this.cancel(retrigger);
this._state = auto ? State.Auto : State.Manual;
this._onDidTrigger.fire({ auto, shy: context.shy, position: this._editor.getPosition() });
// Capture context when request was sent
this._context = ctx;
// Build context for request
let suggestCtx: CompletionContext = { triggerKind: context.triggerKind ?? CompletionTriggerKind.Invoke };
if (context.triggerCharacter) {
suggestCtx = {
triggerKind: CompletionTriggerKind.TriggerCharacter,
triggerCharacter: context.triggerCharacter
};
}
this._requestToken = new CancellationTokenSource();
// kind filter and snippet sort rules
const snippetSuggestions = this._editor.getOption(EditorOption.snippetSuggestions);
let snippetSortOrder = SnippetSortOrder.Inline;
switch (snippetSuggestions) {
case 'top':
snippetSortOrder = SnippetSortOrder.Top;
break;
// ↓ that's the default anyways...
// case 'inline':
// snippetSortOrder = SnippetSortOrder.Inline;
// break;
case 'bottom':
snippetSortOrder = SnippetSortOrder.Bottom;
break;
}
const itemKindFilter = SuggestModel._createItemKindFilter(this._editor);
const wordDistance = WordDistance.create(this._editorWorkerService, this._editor);
const completions = provideSuggestionItems(
model,
this._editor.getPosition(),
new CompletionOptions(snippetSortOrder, itemKindFilter, onlyFrom),
suggestCtx,
this._requestToken.token
);
Promise.all([completions, wordDistance]).then(async ([completions, wordDistance]) => {
this._requestToken?.dispose();
if (this._state === State.Idle) {
return;
}
if (!this._editor.hasModel()) {
return;
}
let clipboardText = existing?.clipboardText;
if (!clipboardText && completions.needsClipboard) {
clipboardText = await this._clipboardService.readText();
}
const model = this._editor.getModel();
let items = completions.items;
if (existing) {
const cmpFn = getSuggestionComparator(snippetSortOrder);
items = items.concat(existing.items).sort(cmpFn);
}
const ctx = new LineContext(model, this._editor.getPosition(), auto, context.shy);
this._completionModel = new CompletionModel(items, this._context!.column, {
leadingLineContent: ctx.leadingLineContent,
characterCountDelta: ctx.column - this._context!.column
},
wordDistance,
this._editor.getOption(EditorOption.suggest),
this._editor.getOption(EditorOption.snippetSuggestions),
clipboardText
);
// store containers so that they can be disposed later
this._completionDisposables.add(completions.disposable);
this._onNewContext(ctx);
// finally report telemetry about durations
this._reportDurationsTelemetry(completions.durations);
}).catch(onUnexpectedError);
}
private _reportDurationsTelemetry(durations: CompletionDurations): void {
setTimeout(() => {
type Durations = { data: string; };
type DurationsClassification = { data: { classification: 'SystemMetaData', purpose: 'PerformanceAndHealth' } };
this._telemetryService.publicLog2<Durations, DurationsClassification>('suggest.durations.json', { data: JSON.stringify(durations) });
this._logService.debug('suggest.durations.json', durations);
});
}
private static _createItemKindFilter(editor: ICodeEditor): Set<CompletionItemKind> {
// kind filter and snippet sort rules
const result = new Set<CompletionItemKind>();
// snippet setting
const snippetSuggestions = editor.getOption(EditorOption.snippetSuggestions);
if (snippetSuggestions === 'none') {
result.add(CompletionItemKind.Snippet);
}
// type setting
const suggestOptions = editor.getOption(EditorOption.suggest);
if (!suggestOptions.showMethods) { result.add(CompletionItemKind.Method); }
if (!suggestOptions.showFunctions) { result.add(CompletionItemKind.Function); }
if (!suggestOptions.showConstructors) { result.add(CompletionItemKind.Constructor); }
if (!suggestOptions.showFields) { result.add(CompletionItemKind.Field); }
if (!suggestOptions.showVariables) { result.add(CompletionItemKind.Variable); }
if (!suggestOptions.showClasses) { result.add(CompletionItemKind.Class); }
if (!suggestOptions.showStructs) { result.add(CompletionItemKind.Struct); }
if (!suggestOptions.showInterfaces) { result.add(CompletionItemKind.Interface); }
if (!suggestOptions.showModules) { result.add(CompletionItemKind.Module); }
if (!suggestOptions.showProperties) { result.add(CompletionItemKind.Property); }
if (!suggestOptions.showEvents) { result.add(CompletionItemKind.Event); }
if (!suggestOptions.showOperators) { result.add(CompletionItemKind.Operator); }
if (!suggestOptions.showUnits) { result.add(CompletionItemKind.Unit); }
if (!suggestOptions.showValues) { result.add(CompletionItemKind.Value); }
if (!suggestOptions.showConstants) { result.add(CompletionItemKind.Constant); }
if (!suggestOptions.showEnums) { result.add(CompletionItemKind.Enum); }
if (!suggestOptions.showEnumMembers) { result.add(CompletionItemKind.EnumMember); }
if (!suggestOptions.showKeywords) { result.add(CompletionItemKind.Keyword); }
if (!suggestOptions.showWords) { result.add(CompletionItemKind.Text); }
if (!suggestOptions.showColors) { result.add(CompletionItemKind.Color); }
if (!suggestOptions.showFiles) { result.add(CompletionItemKind.File); }
if (!suggestOptions.showReferences) { result.add(CompletionItemKind.Reference); }
if (!suggestOptions.showColors) { result.add(CompletionItemKind.Customcolor); }
if (!suggestOptions.showFolders) { result.add(CompletionItemKind.Folder); }
if (!suggestOptions.showTypeParameters) { result.add(CompletionItemKind.TypeParameter); }
if (!suggestOptions.showSnippets) { result.add(CompletionItemKind.Snippet); }
if (!suggestOptions.showUsers) { result.add(CompletionItemKind.User); }
if (!suggestOptions.showIssues) { result.add(CompletionItemKind.Issue); }
return result;
}
private _onNewContext(ctx: LineContext): void {
if (!this._context) {
// happens when 24x7 IntelliSense is enabled and still in its delay
return;
}
if (ctx.lineNumber !== this._context.lineNumber) {
// e.g. happens when pressing Enter while IntelliSense is computed
this.cancel();
return;
}
if (ctx.leadingWord.startColumn < this._context.leadingWord.startColumn) {
// happens when the current word gets outdented
this.cancel();
return;
}
if (ctx.column < this._context.column) {
// typed -> moved cursor LEFT -> retrigger if still on a word
if (ctx.leadingWord.word) {
this.trigger({ auto: this._context.auto, shy: false }, true);
} else {
this.cancel();
}
return;
}
if (!this._completionModel) {
// happens when IntelliSense is not yet computed
return;
}
if (ctx.leadingWord.word.length !== 0 && ctx.leadingWord.startColumn > this._context.leadingWord.startColumn) {
// started a new word while IntelliSense shows -> retrigger
// Select those providers have not contributed to this completion model and re-trigger completions for
// them. Also adopt the existing items and merge them into the new completion model
const inactiveProvider = new Set(CompletionProviderRegistry.all(this._editor.getModel()!));
for (let provider of this._completionModel.allProvider) {
inactiveProvider.delete(provider);
}
const items = this._completionModel.adopt(new Set());
this.trigger({ auto: this._context.auto, shy: false }, true, inactiveProvider, { items, clipboardText: this._completionModel.clipboardText });
return;
}
if (ctx.column > this._context.column && this._completionModel.incomplete.size > 0 && ctx.leadingWord.word.length !== 0) {
// typed -> moved cursor RIGHT & incomple model & still on a word -> retrigger
const { incomplete } = this._completionModel;
const items = this._completionModel.adopt(incomplete);
this.trigger({ auto: this._state === State.Auto, shy: false, triggerKind: CompletionTriggerKind.TriggerForIncompleteCompletions }, true, incomplete, { items, clipboardText: this._completionModel.clipboardText });
} else {
// typed -> moved cursor RIGHT -> update UI
let oldLineContext = this._completionModel.lineContext;
let isFrozen = false;
this._completionModel.lineContext = {
leadingLineContent: ctx.leadingLineContent,
characterCountDelta: ctx.column - this._context.column
};
if (this._completionModel.items.length === 0) {
if (LineContext.shouldAutoTrigger(this._editor) && this._context.leadingWord.endColumn < ctx.leadingWord.startColumn) {
// retrigger when heading into a new word
this.trigger({ auto: this._context.auto, shy: false }, true);
return;
}
if (!this._context.auto) {
// freeze when IntelliSense was manually requested
this._completionModel.lineContext = oldLineContext;
isFrozen = this._completionModel.items.length > 0;
if (isFrozen && ctx.leadingWord.word.length === 0) {
// there were results before but now there aren't
// and also we are not on a word anymore -> cancel
this.cancel();
return;
}
} else {
// nothing left
this.cancel();
return;
}
}
this._onDidSuggest.fire({
completionModel: this._completionModel,
auto: this._context.auto,
shy: this._context.shy,
isFrozen,
});
}
}
}

View File

@@ -0,0 +1,73 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IDisposable, DisposableStore } from 'vs/base/common/lifecycle';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { SuggestModel } from 'vs/editor/contrib/suggest/suggestModel';
export class OvertypingCapturer implements IDisposable {
private static readonly _maxSelectionLength = 51200;
private readonly _disposables = new DisposableStore();
private _lastOvertyped: { value: string; multiline: boolean }[] = [];
private _empty: boolean = true;
constructor(editor: ICodeEditor, suggestModel: SuggestModel) {
this._disposables.add(editor.onWillType(() => {
if (!this._empty) {
return;
}
if (!editor.hasModel()) {
return;
}
const selections = editor.getSelections();
const selectionsLength = selections.length;
// Check if it will overtype any selections
let willOvertype = false;
for (let i = 0; i < selectionsLength; i++) {
if (!selections[i].isEmpty()) {
willOvertype = true;
break;
}
}
if (!willOvertype) {
return;
}
this._lastOvertyped = [];
const model = editor.getModel();
for (let i = 0; i < selectionsLength; i++) {
const selection = selections[i];
// Check for overtyping capturer restrictions
if (model.getValueLengthInRange(selection) > OvertypingCapturer._maxSelectionLength) {
return;
}
this._lastOvertyped[i] = { value: model.getValueInRange(selection), multiline: selection.startLineNumber !== selection.endLineNumber };
}
this._empty = false;
}));
this._disposables.add(suggestModel.onDidCancel(e => {
if (!this._empty && !e.retrigger) {
this._empty = true;
}
}));
}
getLastOvertypedInfo(idx: number): { value: string; multiline: boolean } | undefined {
if (!this._empty && idx >= 0 && idx < this._lastOvertyped.length) {
return this._lastOvertyped[idx];
}
return undefined;
}
dispose() {
this._disposables.dispose();
}
}

View File

@@ -0,0 +1,972 @@
/*---------------------------------------------------------------------------------------------
* 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/suggest';
import 'vs/base/browser/ui/codicons/codiconStyles'; // The codicon symbol styles are defined here and must be loaded
import 'vs/editor/contrib/documentSymbols/outlineTree'; // The codicon symbol colors are defined here and must be loaded
import * as nls from 'vs/nls';
import * as strings from 'vs/base/common/strings';
import * as dom from 'vs/base/browser/dom';
import { Event, Emitter } from 'vs/base/common/event';
import { onUnexpectedError } from 'vs/base/common/errors';
import { IDisposable, DisposableStore, Disposable } from 'vs/base/common/lifecycle';
import { IListEvent, IListMouseEvent, IListGestureEvent } from 'vs/base/browser/ui/list/list';
import { List } from 'vs/base/browser/ui/list/listWidget';
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent } from 'vs/editor/browser/editorBrowser';
import { Context as SuggestContext, CompletionItem } from './suggest';
import { CompletionModel } from './completionModel';
import { attachListStyler } from 'vs/platform/theme/common/styler';
import { IThemeService, IColorTheme, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { registerColor, editorWidgetBackground, listFocusBackground, activeContrastBorder, listHighlightForeground, editorForeground, editorWidgetBorder, focusBorder, textLinkForeground, textCodeBlockBackground } from 'vs/platform/theme/common/colorRegistry';
import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage';
import { TimeoutTimer, CancelablePromise, createCancelablePromise, disposableTimeout } from 'vs/base/common/async';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent';
import { SuggestDetailsWidget, canExpandCompletionItem, SuggestDetailsOverlay } from './suggestWidgetDetails';
import { SuggestWidgetStatus } from 'vs/editor/contrib/suggest/suggestWidgetStatus';
import { getAriaId, ItemRenderer } from './suggestWidgetRenderer';
import { ResizableHTMLElement } from './resizable';
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget';
import { IPosition } from 'vs/editor/common/core/position';
/**
* Suggest widget colors
*/
export const editorSuggestWidgetBackground = registerColor('editorSuggestWidget.background', { dark: editorWidgetBackground, light: editorWidgetBackground, hc: editorWidgetBackground }, nls.localize('editorSuggestWidgetBackground', 'Background color of the suggest widget.'));
export const editorSuggestWidgetBorder = registerColor('editorSuggestWidget.border', { dark: editorWidgetBorder, light: editorWidgetBorder, hc: editorWidgetBorder }, nls.localize('editorSuggestWidgetBorder', 'Border color of the suggest widget.'));
export const editorSuggestWidgetForeground = registerColor('editorSuggestWidget.foreground', { dark: editorForeground, light: editorForeground, hc: editorForeground }, nls.localize('editorSuggestWidgetForeground', 'Foreground color of the suggest widget.'));
export const editorSuggestWidgetSelectedBackground = registerColor('editorSuggestWidget.selectedBackground', { dark: listFocusBackground, light: listFocusBackground, hc: listFocusBackground }, nls.localize('editorSuggestWidgetSelectedBackground', 'Background color of the selected entry in the suggest widget.'));
export const editorSuggestWidgetHighlightForeground = registerColor('editorSuggestWidget.highlightForeground', { dark: listHighlightForeground, light: listHighlightForeground, hc: listHighlightForeground }, nls.localize('editorSuggestWidgetHighlightForeground', 'Color of the match highlights in the suggest widget.'));
const enum State {
Hidden,
Loading,
Empty,
Open,
Frozen,
Details
}
export interface ISelectedSuggestion {
item: CompletionItem;
index: number;
model: CompletionModel;
}
class PersistedWidgetSize {
private readonly _key: string;
constructor(
private readonly _service: IStorageService,
editor: ICodeEditor
) {
this._key = `suggestWidget.size/${editor.getEditorType()}/${editor instanceof EmbeddedCodeEditorWidget}`;
}
restore(): dom.Dimension | undefined {
const raw = this._service.get(this._key, StorageScope.GLOBAL) ?? '';
try {
const obj = JSON.parse(raw);
if (dom.Dimension.is(obj)) {
return dom.Dimension.lift(obj);
}
} catch {
// ignore
}
return undefined;
}
store(size: dom.Dimension) {
this._service.store(this._key, JSON.stringify(size), StorageScope.GLOBAL);
}
reset(): void {
this._service.remove(this._key, StorageScope.GLOBAL);
}
}
export class SuggestWidget implements IDisposable {
private static LOADING_MESSAGE: string = nls.localize('suggestWidget.loading', "Loading...");
private static NO_SUGGESTIONS_MESSAGE: string = nls.localize('suggestWidget.noSuggestions', "No suggestions.");
private state: State = State.Hidden;
private isAuto: boolean = false;
private loadingTimeout: IDisposable = Disposable.None;
private currentSuggestionDetails?: CancelablePromise<void>;
private focusedItem?: CompletionItem;
private ignoreFocusEvents: boolean = false;
private completionModel?: CompletionModel;
private _cappedHeight?: { wanted: number, capped: number };
readonly element: ResizableHTMLElement;
private readonly messageElement: HTMLElement;
private readonly listElement: HTMLElement;
private readonly list: List<CompletionItem>;
private readonly status: SuggestWidgetStatus;
private readonly _details: SuggestDetailsOverlay;
private readonly _contentWidget: SuggestContentWidget;
private readonly ctxSuggestWidgetVisible: IContextKey<boolean>;
private readonly ctxSuggestWidgetDetailsVisible: IContextKey<boolean>;
private readonly ctxSuggestWidgetMultipleSuggestions: IContextKey<boolean>;
private readonly showTimeout = new TimeoutTimer();
private readonly _disposables = new DisposableStore();
private readonly _persistedSize: PersistedWidgetSize;
private readonly onDidSelectEmitter = new Emitter<ISelectedSuggestion>();
private readonly onDidFocusEmitter = new Emitter<ISelectedSuggestion>();
private readonly onDidHideEmitter = new Emitter<this>();
private readonly onDidShowEmitter = new Emitter<this>();
readonly onDidSelect: Event<ISelectedSuggestion> = this.onDidSelectEmitter.event;
readonly onDidFocus: Event<ISelectedSuggestion> = this.onDidFocusEmitter.event;
readonly onDidHide: Event<this> = this.onDidHideEmitter.event;
readonly onDidShow: Event<this> = this.onDidShowEmitter.event;
private detailsFocusBorderColor?: string;
private detailsBorderColor?: string;
private explainMode: boolean = false;
private readonly _onDetailsKeydown = new Emitter<IKeyboardEvent>();
public readonly onDetailsKeyDown: Event<IKeyboardEvent> = this._onDetailsKeydown.event;
constructor(
private readonly editor: ICodeEditor,
@IStorageService private readonly _storageService: IStorageService,
@IContextKeyService _contextKeyService: IContextKeyService,
@IThemeService _themeService: IThemeService,
@IInstantiationService instantiationService: IInstantiationService,
) {
this.element = new ResizableHTMLElement();
this.element.domNode.classList.add('editor-widget', 'suggest-widget');
this._contentWidget = new SuggestContentWidget(this, editor);
this._persistedSize = new PersistedWidgetSize(_storageService, editor);
let persistedSize: dom.Dimension | undefined;
let currentSize: dom.Dimension | undefined;
let persistHeight = false;
let persistWidth = false;
this._disposables.add(this.element.onDidWillResize(() => {
this._contentWidget.lockPreference();
persistedSize = this._persistedSize.restore();
currentSize = this.element.size;
}));
this._disposables.add(this.element.onDidResize(e => {
this._resize(e.dimension.width, e.dimension.height);
persistHeight = persistHeight || !!e.north || !!e.south;
persistWidth = persistWidth || !!e.east || !!e.west;
if (e.done) {
// only store width or height value that have changed and also
// only store changes that are above a certain threshold
const threshold = Math.floor(this.getLayoutInfo().itemHeight / 2);
let { width, height } = this.element.size;
if (persistedSize && currentSize) {
if (!persistHeight || Math.abs(currentSize.height - height) <= threshold) {
height = persistedSize.height;
}
if (!persistWidth || Math.abs(currentSize.width - width) <= threshold) {
width = persistedSize.width;
}
}
this._persistedSize.store(new dom.Dimension(width, height));
// reset working state
this._contentWidget.unlockPreference();
persistedSize = undefined;
currentSize = undefined;
persistHeight = false;
persistWidth = false;
}
}));
this.messageElement = dom.append(this.element.domNode, dom.$('.message'));
this.listElement = dom.append(this.element.domNode, dom.$('.tree'));
const details = instantiationService.createInstance(SuggestDetailsWidget, this.editor);
details.onDidClose(this.toggleDetails, this, this._disposables);
this._details = new SuggestDetailsOverlay(details, this.editor);
const applyIconStyle = () => this.element.domNode.classList.toggle('no-icons', !this.editor.getOption(EditorOption.suggest).showIcons);
applyIconStyle();
const renderer = instantiationService.createInstance(ItemRenderer, this.editor);
this._disposables.add(renderer);
this._disposables.add(renderer.onDidToggleDetails(() => this.toggleDetails()));
this.list = new List('SuggestWidget', this.listElement, {
getHeight: (_element: CompletionItem): number => this.getLayoutInfo().itemHeight,
getTemplateId: (_element: CompletionItem): string => 'suggestion'
}, [renderer], {
useShadows: false,
mouseSupport: false,
accessibilityProvider: {
getRole: () => 'option',
getAriaLabel: (item: CompletionItem) => {
const textLabel = typeof item.completion.label === 'string' ? item.completion.label : item.completion.label.name;
if (item.isResolved && this._isDetailsVisible()) {
const { documentation, detail } = item.completion;
const docs = strings.format(
'{0}{1}',
detail || '',
documentation ? (typeof documentation === 'string' ? documentation : documentation.value) : '');
return nls.localize('ariaCurrenttSuggestionReadDetails', "{0}, docs: {1}", textLabel, docs);
} else {
return textLabel;
}
},
getWidgetAriaLabel: () => nls.localize('suggest', "Suggest"),
getWidgetRole: () => 'listbox'
}
});
this.status = instantiationService.createInstance(SuggestWidgetStatus, this.element.domNode);
const applyStatusBarStyle = () => this.element.domNode.classList.toggle('with-status-bar', this.editor.getOption(EditorOption.suggest).showStatusBar);
applyStatusBarStyle();
this._disposables.add(attachListStyler(this.list, _themeService, {
listInactiveFocusBackground: editorSuggestWidgetSelectedBackground,
listInactiveFocusOutline: activeContrastBorder
}));
this._disposables.add(_themeService.onDidColorThemeChange(t => this.onThemeChange(t)));
this.onThemeChange(_themeService.getColorTheme());
this._disposables.add(this.list.onMouseDown(e => this.onListMouseDownOrTap(e)));
this._disposables.add(this.list.onTap(e => this.onListMouseDownOrTap(e)));
this._disposables.add(this.list.onDidChangeSelection(e => this.onListSelection(e)));
this._disposables.add(this.list.onDidChangeFocus(e => this.onListFocus(e)));
this._disposables.add(this.editor.onDidChangeCursorSelection(() => this.onCursorSelectionChanged()));
this._disposables.add(this.editor.onDidChangeConfiguration(e => {
if (e.hasChanged(EditorOption.suggest)) {
applyStatusBarStyle();
applyIconStyle();
}
}));
this.ctxSuggestWidgetVisible = SuggestContext.Visible.bindTo(_contextKeyService);
this.ctxSuggestWidgetDetailsVisible = SuggestContext.DetailsVisible.bindTo(_contextKeyService);
this.ctxSuggestWidgetMultipleSuggestions = SuggestContext.MultipleSuggestions.bindTo(_contextKeyService);
this._disposables.add(dom.addStandardDisposableListener(this._details.widget.domNode, 'keydown', e => {
this._onDetailsKeydown.fire(e);
}));
this._disposables.add(this.editor.onMouseDown((e: IEditorMouseEvent) => this.onEditorMouseDown(e)));
}
dispose(): void {
this._details.widget.dispose();
this._details.dispose();
this.list.dispose();
this.status.dispose();
this._disposables.dispose();
this.loadingTimeout.dispose();
this.showTimeout.dispose();
this._contentWidget.dispose();
this.element.dispose();
}
private onEditorMouseDown(mouseEvent: IEditorMouseEvent): void {
if (this._details.widget.domNode.contains(mouseEvent.target.element)) {
// Clicking inside details
this._details.widget.domNode.focus();
} else {
// Clicking outside details and inside suggest
if (this.element.domNode.contains(mouseEvent.target.element)) {
this.editor.focus();
}
}
}
private onCursorSelectionChanged(): void {
if (this.state !== State.Hidden) {
this._contentWidget.layout();
}
}
private onListMouseDownOrTap(e: IListMouseEvent<CompletionItem> | IListGestureEvent<CompletionItem>): void {
if (typeof e.element === 'undefined' || typeof e.index === 'undefined') {
return;
}
// prevent stealing browser focus from the editor
e.browserEvent.preventDefault();
e.browserEvent.stopPropagation();
this.select(e.element, e.index);
}
private onListSelection(e: IListEvent<CompletionItem>): void {
if (!e.elements.length) {
return;
}
this.select(e.elements[0], e.indexes[0]);
}
private select(item: CompletionItem, index: number): void {
const completionModel = this.completionModel;
if (!completionModel) {
return;
}
this.onDidSelectEmitter.fire({ item, index, model: completionModel });
this.editor.focus();
}
private onThemeChange(theme: IColorTheme) {
const backgroundColor = theme.getColor(editorSuggestWidgetBackground);
if (backgroundColor) {
this.element.domNode.style.backgroundColor = backgroundColor.toString();
this.messageElement.style.backgroundColor = backgroundColor.toString();
this._details.widget.domNode.style.backgroundColor = backgroundColor.toString();
}
const borderColor = theme.getColor(editorSuggestWidgetBorder);
if (borderColor) {
this.element.domNode.style.borderColor = borderColor.toString();
this.messageElement.style.borderColor = borderColor.toString();
this.status.element.style.borderTopColor = borderColor.toString();
this._details.widget.domNode.style.borderColor = borderColor.toString();
this.detailsBorderColor = borderColor.toString();
}
const focusBorderColor = theme.getColor(focusBorder);
if (focusBorderColor) {
this.detailsFocusBorderColor = focusBorderColor.toString();
}
this._details.widget.borderWidth = theme.type === 'hc' ? 2 : 1;
}
private onListFocus(e: IListEvent<CompletionItem>): void {
if (this.ignoreFocusEvents) {
return;
}
if (!e.elements.length) {
if (this.currentSuggestionDetails) {
this.currentSuggestionDetails.cancel();
this.currentSuggestionDetails = undefined;
this.focusedItem = undefined;
}
this.editor.setAriaOptions({ activeDescendant: undefined });
return;
}
if (!this.completionModel) {
return;
}
const item = e.elements[0];
const index = e.indexes[0];
if (item !== this.focusedItem) {
this.currentSuggestionDetails?.cancel();
this.currentSuggestionDetails = undefined;
this.focusedItem = item;
this.list.reveal(index);
this.currentSuggestionDetails = createCancelablePromise(async token => {
const loading = disposableTimeout(() => {
if (this._isDetailsVisible()) {
this.showDetails(true);
}
}, 250);
token.onCancellationRequested(() => loading.dispose());
const result = await item.resolve(token);
loading.dispose();
return result;
});
this.currentSuggestionDetails.then(() => {
if (index >= this.list.length || item !== this.list.element(index)) {
return;
}
// item can have extra information, so re-render
this.ignoreFocusEvents = true;
this.list.splice(index, 1, [item]);
this.list.setFocus([index]);
this.ignoreFocusEvents = false;
if (this._isDetailsVisible()) {
this.showDetails(false);
} else {
this.element.domNode.classList.remove('docs-side');
}
this.editor.setAriaOptions({ activeDescendant: getAriaId(index) });
}).catch(onUnexpectedError);
}
// emit an event
this.onDidFocusEmitter.fire({ item, index, model: this.completionModel });
}
private _setState(state: State): void {
if (this.state === state) {
return;
}
this.state = state;
this.element.domNode.classList.toggle('frozen', state === State.Frozen);
this.element.domNode.classList.remove('message');
switch (state) {
case State.Hidden:
dom.hide(this.messageElement, this.listElement, this.status.element);
this._details.hide(true);
this._contentWidget.hide();
this.ctxSuggestWidgetVisible.reset();
this.ctxSuggestWidgetMultipleSuggestions.reset();
this.element.domNode.classList.remove('visible');
this.list.splice(0, this.list.length);
this.focusedItem = undefined;
this._cappedHeight = undefined;
this.explainMode = false;
break;
case State.Loading:
this.element.domNode.classList.add('message');
this.messageElement.textContent = SuggestWidget.LOADING_MESSAGE;
dom.hide(this.listElement, this.status.element);
dom.show(this.messageElement);
this._details.hide();
this._show();
this.focusedItem = undefined;
break;
case State.Empty:
this.element.domNode.classList.add('message');
this.messageElement.textContent = SuggestWidget.NO_SUGGESTIONS_MESSAGE;
dom.hide(this.listElement, this.status.element);
dom.show(this.messageElement);
this._details.hide();
this._show();
this.focusedItem = undefined;
break;
case State.Open:
dom.hide(this.messageElement);
dom.show(this.listElement, this.status.element);
this._show();
break;
case State.Frozen:
dom.hide(this.messageElement);
dom.show(this.listElement, this.status.element);
this._show();
break;
case State.Details:
dom.hide(this.messageElement);
dom.show(this.listElement, this.status.element);
this._details.show();
this._show();
break;
}
}
private _show(): void {
this._contentWidget.show();
this._layout(this._persistedSize.restore());
this.ctxSuggestWidgetVisible.set(true);
this.showTimeout.cancelAndSet(() => {
this.element.domNode.classList.add('visible');
this.onDidShowEmitter.fire(this);
}, 100);
}
showTriggered(auto: boolean, delay: number) {
if (this.state !== State.Hidden) {
return;
}
this._contentWidget.setPosition(this.editor.getPosition());
this.isAuto = !!auto;
if (!this.isAuto) {
this.loadingTimeout = disposableTimeout(() => this._setState(State.Loading), delay);
}
}
showSuggestions(completionModel: CompletionModel, selectionIndex: number, isFrozen: boolean, isAuto: boolean): void {
this._contentWidget.setPosition(this.editor.getPosition());
this.loadingTimeout.dispose();
this.currentSuggestionDetails?.cancel();
this.currentSuggestionDetails = undefined;
if (this.completionModel !== completionModel) {
this.completionModel = completionModel;
}
if (isFrozen && this.state !== State.Empty && this.state !== State.Hidden) {
this._setState(State.Frozen);
return;
}
const visibleCount = this.completionModel.items.length;
const isEmpty = visibleCount === 0;
this.ctxSuggestWidgetMultipleSuggestions.set(visibleCount > 1);
if (isEmpty) {
this._setState(isAuto ? State.Hidden : State.Empty);
this.completionModel = undefined;
return;
}
this.focusedItem = undefined;
this.list.splice(0, this.list.length, this.completionModel.items);
this._setState(isFrozen ? State.Frozen : State.Open);
this.list.reveal(selectionIndex, 0);
this.list.setFocus([selectionIndex]);
this._layout(this.element.size);
// Reset focus border
if (this.detailsBorderColor) {
this._details.widget.domNode.style.borderColor = this.detailsBorderColor;
}
}
selectNextPage(): boolean {
switch (this.state) {
case State.Hidden:
return false;
case State.Details:
this._details.widget.pageDown();
return true;
case State.Loading:
return !this.isAuto;
default:
this.list.focusNextPage();
return true;
}
}
selectNext(): boolean {
switch (this.state) {
case State.Hidden:
return false;
case State.Loading:
return !this.isAuto;
default:
this.list.focusNext(1, true);
return true;
}
}
selectLast(): boolean {
switch (this.state) {
case State.Hidden:
return false;
case State.Details:
this._details.widget.scrollBottom();
return true;
case State.Loading:
return !this.isAuto;
default:
this.list.focusLast();
return true;
}
}
selectPreviousPage(): boolean {
switch (this.state) {
case State.Hidden:
return false;
case State.Details:
this._details.widget.pageUp();
return true;
case State.Loading:
return !this.isAuto;
default:
this.list.focusPreviousPage();
return true;
}
}
selectPrevious(): boolean {
switch (this.state) {
case State.Hidden:
return false;
case State.Loading:
return !this.isAuto;
default:
this.list.focusPrevious(1, true);
return false;
}
}
selectFirst(): boolean {
switch (this.state) {
case State.Hidden:
return false;
case State.Details:
this._details.widget.scrollTop();
return true;
case State.Loading:
return !this.isAuto;
default:
this.list.focusFirst();
return true;
}
}
getFocusedItem(): ISelectedSuggestion | undefined {
if (this.state !== State.Hidden
&& this.state !== State.Empty
&& this.state !== State.Loading
&& this.completionModel
) {
return {
item: this.list.getFocusedElements()[0],
index: this.list.getFocus()[0],
model: this.completionModel
};
}
return undefined;
}
toggleDetailsFocus(): void {
if (this.state === State.Details) {
this._setState(State.Open);
if (this.detailsBorderColor) {
this._details.widget.domNode.style.borderColor = this.detailsBorderColor;
}
} else if (this.state === State.Open && this._isDetailsVisible()) {
this._setState(State.Details);
if (this.detailsFocusBorderColor) {
this._details.widget.domNode.style.borderColor = this.detailsFocusBorderColor;
}
}
}
toggleDetails(): void {
if (this._isDetailsVisible()) {
// hide details widget
this.ctxSuggestWidgetDetailsVisible.set(false);
this._setDetailsVisible(false);
this._details.hide();
this.element.domNode.classList.remove('shows-details');
} else if (canExpandCompletionItem(this.list.getFocusedElements()[0]) && (this.state === State.Open || this.state === State.Details || this.state === State.Frozen)) {
// show details widget (iff possible)
this.ctxSuggestWidgetDetailsVisible.set(true);
this._setDetailsVisible(true);
this.showDetails(false);
}
}
showDetails(loading: boolean): void {
this._details.show();
if (loading) {
this._details.widget.renderLoading();
} else {
this._details.widget.renderItem(this.list.getFocusedElements()[0], this.explainMode);
}
this._positionDetails();
this.editor.focus();
this.element.domNode.classList.add('shows-details');
}
toggleExplainMode(): void {
if (this.list.getFocusedElements()[0] && this._isDetailsVisible()) {
this.explainMode = !this.explainMode;
this.showDetails(false);
}
}
resetPersistedSize(): void {
this._persistedSize.reset();
}
hideWidget(): void {
this.loadingTimeout.dispose();
this._setState(State.Hidden);
this.onDidHideEmitter.fire(this);
}
isFrozen(): boolean {
return this.state === State.Frozen;
}
_afterRender(position: ContentWidgetPositionPreference | null) {
if (position === null) {
if (this._isDetailsVisible()) {
this._details.hide(); //todo@jrieken soft-hide
}
return;
}
if (this.state === State.Empty || this.state === State.Loading) {
// no special positioning when widget isn't showing list
return;
}
if (this._isDetailsVisible()) {
this._details.show();
}
this._positionDetails();
}
private _layout(size: dom.Dimension | undefined): void {
if (!this.editor.hasModel()) {
return;
}
if (!this.editor.getDomNode()) {
// happens when running tests
return;
}
let height = size?.height;
let width = size?.width;
const bodyBox = dom.getClientArea(document.body);
const info = this.getLayoutInfo();
// status bar
this.status.element.style.lineHeight = `${info.itemHeight}px`;
if (this.state === State.Empty || this.state === State.Loading) {
// showing a message only
height = info.itemHeight + info.borderHeight;
width = 230;
this.element.enableSashes(false, false, false, false);
this.element.minSize = this.element.maxSize = new dom.Dimension(width, height);
this._contentWidget.setPreference(ContentWidgetPositionPreference.BELOW);
} else {
// showing items
// width math
const maxWidth = bodyBox.width - info.borderHeight - 2 * info.horizontalPadding;
if (width === undefined) {
width = 430;
}
if (width > maxWidth) {
width = maxWidth;
}
const preferredWidth = this.completionModel ? this.completionModel.stats.pLabelLen * info.typicalHalfwidthCharacterWidth : width;
// height math
const fullHeight = info.statusBarHeight + this.list.contentHeight + info.borderHeight;
const preferredHeight = info.statusBarHeight + 12 * info.itemHeight + info.borderHeight;
const minHeight = info.itemHeight + info.statusBarHeight;
const editorBox = dom.getDomNodePagePosition(this.editor.getDomNode());
const cursorBox = this.editor.getScrolledVisiblePosition(this.editor.getPosition());
const cursorBottom = editorBox.top + cursorBox.top + cursorBox.height;
const maxHeightBelow = Math.min(bodyBox.height - cursorBottom - info.verticalPadding, fullHeight);
const maxHeightAbove = Math.min(editorBox.top + cursorBox.top - info.verticalPadding, fullHeight);
let maxHeight = Math.min(Math.max(maxHeightAbove, maxHeightBelow) + info.borderHeight, fullHeight);
if (height && height === this._cappedHeight?.capped) {
// Restore the old (wanted) height when the current
// height is capped to fit
height = this._cappedHeight.wanted;
}
if (height === undefined) {
height = Math.min(preferredHeight, fullHeight);
}
if (height < minHeight) {
height = minHeight;
}
if (height > maxHeight) {
height = maxHeight;
}
if (height > maxHeightBelow) {
this._contentWidget.setPreference(ContentWidgetPositionPreference.ABOVE);
this.element.enableSashes(true, true, false, false);
maxHeight = maxHeightAbove;
} else {
this._contentWidget.setPreference(ContentWidgetPositionPreference.BELOW);
this.element.enableSashes(false, true, true, false);
maxHeight = maxHeightBelow;
}
this.element.preferredSize = new dom.Dimension(preferredWidth, preferredHeight);
this.element.maxSize = new dom.Dimension(maxWidth, maxHeight);
this.element.minSize = new dom.Dimension(220, minHeight);
// Know when the height was capped to fit and remember
// the wanted height for later. This is required when going
// left to widen suggestions.
this._cappedHeight = size && height === fullHeight
? { wanted: this._cappedHeight?.wanted ?? size.height, capped: height }
: undefined;
}
this._resize(width, height);
}
private _resize(width: number, height: number): void {
const { width: maxWidth, height: maxHeight } = this.element.maxSize;
width = Math.min(maxWidth, width);
height = Math.min(maxHeight, height);
const { statusBarHeight } = this.getLayoutInfo();
this.list.layout(height - statusBarHeight, width);
this.listElement.style.height = `${height - statusBarHeight}px`;
this.element.layout(height, width);
this._contentWidget.layout();
this._positionDetails();
}
private _positionDetails(): void {
if (this._isDetailsVisible()) {
this._details.placeAtAnchor(this.element.domNode);
}
}
getLayoutInfo() {
const fontInfo = this.editor.getOption(EditorOption.fontInfo);
const itemHeight = this.editor.getOption(EditorOption.suggestLineHeight) || fontInfo.lineHeight;
const statusBarHeight = !this.editor.getOption(EditorOption.suggest).showStatusBar || this.state === State.Empty || this.state === State.Loading ? 0 : itemHeight;
const borderWidth = this._details.widget.borderWidth;
const borderHeight = 2 * borderWidth;
return {
itemHeight,
statusBarHeight,
borderWidth,
borderHeight,
typicalHalfwidthCharacterWidth: fontInfo.typicalHalfwidthCharacterWidth,
verticalPadding: 22,
horizontalPadding: 14
};
}
private _isDetailsVisible(): boolean {
return this._storageService.getBoolean('expandSuggestionDocs', StorageScope.GLOBAL, false);
}
private _setDetailsVisible(value: boolean) {
this._storageService.store('expandSuggestionDocs', value, StorageScope.GLOBAL);
}
}
export class SuggestContentWidget implements IContentWidget {
readonly allowEditorOverflow = true;
readonly suppressMouseDown = false;
private _position?: IPosition | null;
private _preference?: ContentWidgetPositionPreference;
private _preferenceLocked = false;
private _added: boolean = false;
private _hidden: boolean = false;
constructor(
private readonly _widget: SuggestWidget,
private readonly _editor: ICodeEditor
) { }
dispose(): void {
if (this._added) {
this._added = false;
this._editor.removeContentWidget(this);
}
}
getId(): string {
return 'editor.widget.suggestWidget';
}
getDomNode(): HTMLElement {
return this._widget.element.domNode;
}
show(): void {
this._hidden = false;
if (!this._added) {
this._added = true;
this._editor.addContentWidget(this);
}
}
hide(): void {
if (!this._hidden) {
this._hidden = true;
this.layout();
}
}
layout(): void {
this._editor.layoutContentWidget(this);
}
getPosition(): IContentWidgetPosition | null {
if (this._hidden || !this._position || !this._preference) {
return null;
}
return {
position: this._position,
preference: [this._preference]
};
}
beforeRender() {
const { height, width } = this._widget.element.size;
const { borderWidth, horizontalPadding } = this._widget.getLayoutInfo();
return new dom.Dimension(width + 2 * borderWidth + horizontalPadding, height + 2 * borderWidth);
}
afterRender(position: ContentWidgetPositionPreference | null) {
this._widget._afterRender(position);
}
setPreference(preference: ContentWidgetPositionPreference) {
if (!this._preferenceLocked) {
this._preference = preference;
}
}
lockPreference() {
this._preferenceLocked = true;
}
unlockPreference() {
this._preferenceLocked = false;
}
setPosition(position: IPosition | null): void {
this._position = position;
}
}
registerThemingParticipant((theme, collector) => {
const matchHighlight = theme.getColor(editorSuggestWidgetHighlightForeground);
if (matchHighlight) {
collector.addRule(`.monaco-editor .suggest-widget .monaco-list .monaco-list-row .monaco-highlighted-label .highlight { color: ${matchHighlight}; }`);
}
const foreground = theme.getColor(editorSuggestWidgetForeground);
if (foreground) {
collector.addRule(`.monaco-editor .suggest-widget, .monaco-editor .suggest-details { color: ${foreground}; }`);
}
const link = theme.getColor(textLinkForeground);
if (link) {
collector.addRule(`.monaco-editor .suggest-details a { color: ${link}; }`);
}
const codeBackground = theme.getColor(textCodeBlockBackground);
if (codeBackground) {
collector.addRule(`.monaco-editor .suggest-details code { background-color: ${codeBackground}; }`);
}
});

View File

@@ -0,0 +1,439 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vs/nls';
import { DisposableStore } from 'vs/base/common/lifecycle';
import * as dom from 'vs/base/browser/dom';
import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { ICodeEditor, IOverlayWidget } from 'vs/editor/browser/editorBrowser';
import { CompletionItem } from './suggest';
import { MarkdownRenderer } from 'vs/editor/browser/core/markdownRenderer';
import { MarkdownString } from 'vs/base/common/htmlContent';
import { Codicon } from 'vs/base/common/codicons';
import { Emitter, Event } from 'vs/base/common/event';
import { ResizableHTMLElement } from 'vs/editor/contrib/suggest/resizable';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
export function canExpandCompletionItem(item: CompletionItem | undefined): boolean {
return !!item && Boolean(item.completion.documentation || item.completion.detail && item.completion.detail !== item.completion.label);
}
export class SuggestDetailsWidget {
readonly domNode: HTMLDivElement;
private readonly _onDidClose = new Emitter<void>();
readonly onDidClose: Event<void> = this._onDidClose.event;
private readonly _onDidChangeContents = new Emitter<this>();
readonly onDidChangeContents: Event<this> = this._onDidChangeContents.event;
private readonly _close: HTMLElement;
private readonly _scrollbar: DomScrollableElement;
private readonly _body: HTMLElement;
private readonly _header: HTMLElement;
private readonly _type: HTMLElement;
private readonly _docs: HTMLElement;
private readonly _disposables = new DisposableStore();
private readonly _markdownRenderer: MarkdownRenderer;
private readonly _renderDisposeable = new DisposableStore();
private _borderWidth: number = 1;
private _size = new dom.Dimension(330, 0);
constructor(
private readonly _editor: ICodeEditor,
@IInstantiationService instaService: IInstantiationService,
) {
this.domNode = dom.$('.suggest-details');
this.domNode.classList.add('no-docs');
this._markdownRenderer = instaService.createInstance(MarkdownRenderer, { editor: _editor });
this._body = dom.$('.body');
this._scrollbar = new DomScrollableElement(this._body, {});
dom.append(this.domNode, this._scrollbar.getDomNode());
this._disposables.add(this._scrollbar);
this._header = dom.append(this._body, dom.$('.header'));
this._close = dom.append(this._header, dom.$('span' + Codicon.close.cssSelector));
this._close.title = nls.localize('details.close', "Close");
this._type = dom.append(this._header, dom.$('p.type'));
this._docs = dom.append(this._body, dom.$('p.docs'));
this._configureFont();
this._disposables.add(this._editor.onDidChangeConfiguration(e => {
if (e.hasChanged(EditorOption.fontInfo)) {
this._configureFont();
}
}));
}
dispose(): void {
this._disposables.dispose();
this._renderDisposeable.dispose();
}
private _configureFont(): void {
const options = this._editor.getOptions();
const fontInfo = options.get(EditorOption.fontInfo);
const fontFamily = fontInfo.fontFamily;
const fontSize = options.get(EditorOption.suggestFontSize) || fontInfo.fontSize;
const lineHeight = options.get(EditorOption.suggestLineHeight) || fontInfo.lineHeight;
const fontWeight = fontInfo.fontWeight;
const fontSizePx = `${fontSize}px`;
const lineHeightPx = `${lineHeight}px`;
this.domNode.style.fontSize = fontSizePx;
this.domNode.style.fontWeight = fontWeight;
this.domNode.style.fontFeatureSettings = fontInfo.fontFeatureSettings;
this._type.style.fontFamily = fontFamily;
this._close.style.height = lineHeightPx;
this._close.style.width = lineHeightPx;
}
getLayoutInfo() {
const lineHeight = this._editor.getOption(EditorOption.suggestLineHeight) || this._editor.getOption(EditorOption.fontInfo).lineHeight;
const borderWidth = this._borderWidth;
const borderHeight = borderWidth * 2;
return {
lineHeight,
borderWidth,
borderHeight,
verticalPadding: 22,
horizontalPadding: 14
};
}
renderLoading(): void {
this._type.textContent = nls.localize('loading', "Loading...");
this._docs.textContent = '';
this.domNode.classList.remove('no-docs', 'no-type');
this.layout(this.size.width, this.getLayoutInfo().lineHeight * 2);
this._onDidChangeContents.fire(this);
}
renderItem(item: CompletionItem, explainMode: boolean): void {
this._renderDisposeable.clear();
let { detail, documentation } = item.completion;
if (explainMode) {
let md = '';
md += `score: ${item.score[0]}${item.word ? `, compared '${item.completion.filterText && (item.completion.filterText + ' (filterText)') || item.completion.label}' with '${item.word}'` : ' (no prefix)'}\n`;
md += `distance: ${item.distance}, see localityBonus-setting\n`;
md += `index: ${item.idx}, based on ${item.completion.sortText && `sortText: "${item.completion.sortText}"` || 'label'}\n`;
documentation = new MarkdownString().appendCodeblock('empty', md);
detail = `Provider: ${item.provider._debugDisplayName}`;
}
if (!explainMode && !canExpandCompletionItem(item)) {
this.clearContents();
return;
}
this.domNode.classList.remove('no-docs', 'no-type');
// --- details
if (detail) {
const cappedDetail = detail.length > 100000 ? `${detail.substr(0, 100000)}` : detail;
this._type.textContent = cappedDetail;
this._type.title = cappedDetail;
dom.show(this._type);
this._type.classList.toggle('auto-wrap', !/\r?\n^\s+/gmi.test(cappedDetail));
} else {
dom.clearNode(this._type);
this._type.title = '';
dom.hide(this._type);
this.domNode.classList.add('no-type');
}
// --- documentation
dom.clearNode(this._docs);
if (typeof documentation === 'string') {
this._docs.classList.remove('markdown-docs');
this._docs.textContent = documentation;
} else if (documentation) {
this._docs.classList.add('markdown-docs');
dom.clearNode(this._docs);
const renderedContents = this._markdownRenderer.render(documentation);
this._docs.appendChild(renderedContents.element);
this._renderDisposeable.add(renderedContents);
this._renderDisposeable.add(this._markdownRenderer.onDidRenderCodeBlock(() => {
this.layout(this._size.width, this._type.clientHeight + this._docs.clientHeight);
this._onDidChangeContents.fire(this);
}));
}
this.domNode.style.userSelect = 'text';
this.domNode.tabIndex = -1;
this._close.onmousedown = e => {
e.preventDefault();
e.stopPropagation();
};
this._close.onclick = e => {
e.preventDefault();
e.stopPropagation();
this._onDidClose.fire();
};
this._body.scrollTop = 0;
this.layout(this._size.width, this._type.clientHeight + this._docs.clientHeight);
this._onDidChangeContents.fire(this);
}
clearContents() {
this.domNode.classList.add('no-docs');
this._type.textContent = '';
this._docs.textContent = '';
}
get size() {
return this._size;
}
layout(width: number, height: number): void {
const newSize = new dom.Dimension(width, height);
if (!dom.Dimension.equals(newSize, this._size)) {
this._size = newSize;
dom.size(this.domNode, width, height);
}
this._scrollbar.scanDomNode();
}
scrollDown(much = 8): void {
this._body.scrollTop += much;
}
scrollUp(much = 8): void {
this._body.scrollTop -= much;
}
scrollTop(): void {
this._body.scrollTop = 0;
}
scrollBottom(): void {
this._body.scrollTop = this._body.scrollHeight;
}
pageDown(): void {
this.scrollDown(80);
}
pageUp(): void {
this.scrollUp(80);
}
set borderWidth(width: number) {
this._borderWidth = width;
}
get borderWidth() {
return this._borderWidth;
}
}
interface TopLeftPosition {
top: number;
left: number;
}
export class SuggestDetailsOverlay implements IOverlayWidget {
private readonly _disposables = new DisposableStore();
private readonly _resizable: ResizableHTMLElement;
private _added: boolean = false;
private _anchorBox?: dom.IDomNodePagePosition;
private _userSize?: dom.Dimension;
private _topLeft?: TopLeftPosition;
constructor(
readonly widget: SuggestDetailsWidget,
private readonly _editor: ICodeEditor
) {
this._resizable = new ResizableHTMLElement();
this._resizable.domNode.classList.add('suggest-details-container');
this._resizable.domNode.appendChild(widget.domNode);
this._resizable.enableSashes(false, true, true, false);
let topLeftNow: TopLeftPosition | undefined;
let sizeNow: dom.Dimension | undefined;
let deltaTop: number = 0;
let deltaLeft: number = 0;
this._disposables.add(this._resizable.onDidWillResize(() => {
topLeftNow = this._topLeft;
sizeNow = this._resizable.size;
}));
this._disposables.add(this._resizable.onDidResize(e => {
if (topLeftNow && sizeNow) {
this.widget.layout(e.dimension.width, e.dimension.height);
let updateTopLeft = false;
if (e.west) {
deltaLeft = sizeNow.width - e.dimension.width;
updateTopLeft = true;
}
if (e.north) {
deltaTop = sizeNow.height - e.dimension.height;
updateTopLeft = true;
}
if (updateTopLeft) {
this._applyTopLeft({
top: topLeftNow.top + deltaTop,
left: topLeftNow.left + deltaLeft,
});
}
}
if (e.done) {
topLeftNow = undefined;
sizeNow = undefined;
deltaTop = 0;
deltaLeft = 0;
this._userSize = e.dimension;
}
}));
this._disposables.add(this.widget.onDidChangeContents(() => {
if (this._anchorBox) {
this._placeAtAnchor(this._anchorBox, this._userSize ?? this.widget.size);
}
}));
}
dispose(): void {
this._disposables.dispose();
this.hide();
}
getId(): string {
return 'suggest.details';
}
getDomNode(): HTMLElement {
return this._resizable.domNode;
}
getPosition(): null {
return null;
}
show(): void {
if (!this._added) {
this._editor.addOverlayWidget(this);
this.getDomNode().style.position = 'fixed';
this._added = true;
}
}
hide(sessionEnded: boolean = false): void {
if (this._added) {
this._editor.removeOverlayWidget(this);
this._added = false;
this._anchorBox = undefined;
this._topLeft = undefined;
}
if (sessionEnded) {
this._userSize = undefined;
this.widget.clearContents();
}
}
placeAtAnchor(anchor: HTMLElement) {
const anchorBox = dom.getDomNodePagePosition(anchor);
this._anchorBox = anchorBox;
this._placeAtAnchor(this._anchorBox, this._userSize ?? this.widget.size);
}
_placeAtAnchor(anchorBox: dom.IDomNodePagePosition, size: dom.Dimension) {
const bodyBox = dom.getClientArea(document.body);
const info = this.widget.getLayoutInfo();
let maxSizeTop: dom.Dimension;
let maxSizeBottom: dom.Dimension;
let minSize = new dom.Dimension(220, 2 * info.lineHeight);
let left = 0;
let top = anchorBox.top;
let bottom = anchorBox.top + anchorBox.height - info.borderHeight;
let alignAtTop: boolean;
let alignEast: boolean;
// position: EAST, west, south
let width = bodyBox.width - (anchorBox.left + anchorBox.width + info.borderWidth + info.horizontalPadding);
left = -info.borderWidth + anchorBox.left + anchorBox.width;
alignEast = true;
maxSizeTop = new dom.Dimension(width, bodyBox.height - anchorBox.top - info.borderHeight - info.verticalPadding);
maxSizeBottom = maxSizeTop.with(undefined, anchorBox.top + anchorBox.height - info.borderHeight - info.verticalPadding);
// find a better place if the widget is wider than there is space available
if (size.width > width) {
// position: east, WEST, south
if (anchorBox.left > width) {
// pos = SuggestDetailsPosition.West;
width = anchorBox.left - info.borderWidth - info.horizontalPadding;
alignEast = false;
left = Math.max(info.horizontalPadding, anchorBox.left - size.width - info.borderWidth);
maxSizeTop = maxSizeTop.with(width);
maxSizeBottom = maxSizeTop.with(undefined, maxSizeBottom.height);
}
// position: east, west, SOUTH
if (anchorBox.width > width * 1.3 && bodyBox.height - (anchorBox.top + anchorBox.height) > anchorBox.height) {
width = anchorBox.width;
left = anchorBox.left;
top = -info.borderWidth + anchorBox.top + anchorBox.height;
maxSizeTop = new dom.Dimension(anchorBox.width - info.borderHeight, bodyBox.height - anchorBox.top - anchorBox.height - info.verticalPadding);
maxSizeBottom = maxSizeTop.with(undefined, anchorBox.top - info.verticalPadding);
minSize = minSize.with(maxSizeTop.width);
}
}
// top/bottom placement
let height = size.height;
let maxHeight = Math.max(maxSizeTop.height, maxSizeBottom.height);
if (height > maxHeight) {
height = maxHeight;
}
let maxSize: dom.Dimension;
if (height <= maxSizeTop.height) {
alignAtTop = true;
maxSize = maxSizeTop;
} else {
alignAtTop = false;
maxSize = maxSizeBottom;
}
this._applyTopLeft({ left, top: alignAtTop ? top : bottom - height });
this.getDomNode().style.position = 'fixed';
this._resizable.enableSashes(!alignAtTop, alignEast, alignAtTop, !alignEast);
this._resizable.minSize = minSize;
this._resizable.maxSize = maxSize;
this._resizable.layout(height, Math.min(maxSize.width, size.width));
this.widget.layout(this._resizable.size.width, this._resizable.size.height);
}
private _applyTopLeft(topLeft: TopLeftPosition): void {
this._topLeft = topLeft;
this.getDomNode().style.left = `${this._topLeft.left}px`;
this.getDomNode().style.top = `${this._topLeft.top}px`;
}
}

View File

@@ -0,0 +1,240 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as nls from 'vs/nls';
import { createMatches } from 'vs/base/common/filters';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { append, $, hide, show } from 'vs/base/browser/dom';
import { IListRenderer } from 'vs/base/browser/ui/list/list';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { CompletionItem } from './suggest';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IModeService } from 'vs/editor/common/services/modeService';
import { CompletionItemKind, completionKindToCssClass, CompletionItemTag } from 'vs/editor/common/modes';
import { IconLabel, IIconLabelValueOptions } from 'vs/base/browser/ui/iconLabel/iconLabel';
import { getIconClasses } from 'vs/editor/common/services/getIconClasses';
import { IModelService } from 'vs/editor/common/services/modelService';
import { URI } from 'vs/base/common/uri';
import { FileKind } from 'vs/platform/files/common/files';
import { flatten } from 'vs/base/common/arrays';
import { canExpandCompletionItem } from './suggestWidgetDetails';
import { Codicon, registerIcon } from 'vs/base/common/codicons';
import { Emitter, Event } from 'vs/base/common/event';
export function getAriaId(index: number): string {
return `suggest-aria-id:${index}`;
}
export const suggestMoreInfoIcon = registerIcon('suggest-more-info', Codicon.chevronRight);
const colorRegExp = /^(#([\da-f]{3}){1,2}|(rgb|hsl)a\(\s*(\d{1,3}%?\s*,\s*){3}(1|0?\.\d+)\)|(rgb|hsl)\(\s*\d{1,3}%?(\s*,\s*\d{1,3}%?){2}\s*\))$/i;
function extractColor(item: CompletionItem, out: string[]): boolean {
const label = typeof item.completion.label === 'string'
? item.completion.label
: item.completion.label.name;
if (label.match(colorRegExp)) {
out[0] = label;
return true;
}
if (typeof item.completion.documentation === 'string' && item.completion.documentation.match(colorRegExp)) {
out[0] = item.completion.documentation;
return true;
}
return false;
}
export interface ISuggestionTemplateData {
root: HTMLElement;
/**
* Flexbox
* < ------------- left ------------ > < --- right -- >
* <icon><label><signature><qualifier> <type><readmore>
*/
left: HTMLElement;
right: HTMLElement;
icon: HTMLElement;
colorspan: HTMLElement;
iconLabel: IconLabel;
iconContainer: HTMLElement;
parametersLabel: HTMLElement;
qualifierLabel: HTMLElement;
/**
* Showing either `CompletionItem#details` or `CompletionItemLabel#type`
*/
detailsLabel: HTMLElement;
readMore: HTMLElement;
disposables: DisposableStore;
}
export class ItemRenderer implements IListRenderer<CompletionItem, ISuggestionTemplateData> {
private readonly _onDidToggleDetails = new Emitter<void>();
readonly onDidToggleDetails: Event<void> = this._onDidToggleDetails.event;
readonly templateId = 'suggestion';
constructor(
private readonly _editor: ICodeEditor,
@IModelService private readonly _modelService: IModelService,
@IModeService private readonly _modeService: IModeService,
@IThemeService private readonly _themeService: IThemeService
) { }
dispose(): void {
this._onDidToggleDetails.dispose();
}
renderTemplate(container: HTMLElement): ISuggestionTemplateData {
const data = <ISuggestionTemplateData>Object.create(null);
data.disposables = new DisposableStore();
data.root = container;
data.root.classList.add('show-file-icons');
data.icon = append(container, $('.icon'));
data.colorspan = append(data.icon, $('span.colorspan'));
const text = append(container, $('.contents'));
const main = append(text, $('.main'));
data.iconContainer = append(main, $('.icon-label.codicon'));
data.left = append(main, $('span.left'));
data.right = append(main, $('span.right'));
data.iconLabel = new IconLabel(data.left, { supportHighlights: true, supportCodicons: true });
data.disposables.add(data.iconLabel);
data.parametersLabel = append(data.left, $('span.signature-label'));
data.qualifierLabel = append(data.left, $('span.qualifier-label'));
data.detailsLabel = append(data.right, $('span.details-label'));
data.readMore = append(data.right, $('span.readMore' + suggestMoreInfoIcon.cssSelector));
data.readMore.title = nls.localize('readMore', "Read More");
const configureFont = () => {
const options = this._editor.getOptions();
const fontInfo = options.get(EditorOption.fontInfo);
const fontFamily = fontInfo.fontFamily;
const fontFeatureSettings = fontInfo.fontFeatureSettings;
const fontSize = options.get(EditorOption.suggestFontSize) || fontInfo.fontSize;
const lineHeight = options.get(EditorOption.suggestLineHeight) || fontInfo.lineHeight;
const fontWeight = fontInfo.fontWeight;
const fontSizePx = `${fontSize}px`;
const lineHeightPx = `${lineHeight}px`;
data.root.style.fontSize = fontSizePx;
data.root.style.fontWeight = fontWeight;
main.style.fontFamily = fontFamily;
main.style.fontFeatureSettings = fontFeatureSettings;
main.style.lineHeight = lineHeightPx;
data.icon.style.height = lineHeightPx;
data.icon.style.width = lineHeightPx;
data.readMore.style.height = lineHeightPx;
data.readMore.style.width = lineHeightPx;
};
configureFont();
data.disposables.add(this._editor.onDidChangeConfiguration(e => {
if (e.hasChanged(EditorOption.fontInfo) || e.hasChanged(EditorOption.suggestFontSize) || e.hasChanged(EditorOption.suggestLineHeight)) {
configureFont();
}
}));
return data;
}
renderElement(element: CompletionItem, index: number, data: ISuggestionTemplateData): void {
const { completion } = element;
const textLabel = typeof completion.label === 'string' ? completion.label : completion.label.name;
data.root.id = getAriaId(index);
data.colorspan.style.backgroundColor = '';
const labelOptions: IIconLabelValueOptions = {
labelEscapeNewLines: true,
matches: createMatches(element.score)
};
let color: string[] = [];
if (completion.kind === CompletionItemKind.Color && extractColor(element, color)) {
// special logic for 'color' completion items
data.icon.className = 'icon customcolor';
data.iconContainer.className = 'icon hide';
data.colorspan.style.backgroundColor = color[0];
} else if (completion.kind === CompletionItemKind.File && this._themeService.getFileIconTheme().hasFileIcons) {
// special logic for 'file' completion items
data.icon.className = 'icon hide';
data.iconContainer.className = 'icon hide';
const labelClasses = getIconClasses(this._modelService, this._modeService, URI.from({ scheme: 'fake', path: textLabel }), FileKind.FILE);
const detailClasses = getIconClasses(this._modelService, this._modeService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FILE);
labelOptions.extraClasses = labelClasses.length > detailClasses.length ? labelClasses : detailClasses;
} else if (completion.kind === CompletionItemKind.Folder && this._themeService.getFileIconTheme().hasFolderIcons) {
// special logic for 'folder' completion items
data.icon.className = 'icon hide';
data.iconContainer.className = 'icon hide';
labelOptions.extraClasses = flatten([
getIconClasses(this._modelService, this._modeService, URI.from({ scheme: 'fake', path: textLabel }), FileKind.FOLDER),
getIconClasses(this._modelService, this._modeService, URI.from({ scheme: 'fake', path: completion.detail }), FileKind.FOLDER)
]);
} else {
// normal icon
data.icon.className = 'icon hide';
data.iconContainer.className = '';
data.iconContainer.classList.add('suggest-icon', ...completionKindToCssClass(completion.kind).split(' '));
}
if (completion.tags && completion.tags.indexOf(CompletionItemTag.Deprecated) >= 0) {
labelOptions.extraClasses = (labelOptions.extraClasses || []).concat(['deprecated']);
labelOptions.matches = [];
}
data.iconLabel.setLabel(textLabel, undefined, labelOptions);
if (typeof completion.label === 'string') {
data.parametersLabel.textContent = '';
data.qualifierLabel.textContent = '';
data.detailsLabel.textContent = (completion.detail || '').replace(/\n.*$/m, '');
data.root.classList.add('string-label');
data.root.title = '';
} else {
data.parametersLabel.textContent = (completion.label.parameters || '').replace(/\n.*$/m, '');
data.qualifierLabel.textContent = (completion.label.qualifier || '').replace(/\n.*$/m, '');
data.detailsLabel.textContent = (completion.label.type || '').replace(/\n.*$/m, '');
data.root.classList.remove('string-label');
data.root.title = `${textLabel}${completion.label.parameters ?? ''} ${completion.label.qualifier ?? ''} ${completion.label.type ?? ''}`;
}
if (canExpandCompletionItem(element)) {
data.right.classList.add('can-expand-details');
show(data.readMore);
data.readMore.onmousedown = e => {
e.stopPropagation();
e.preventDefault();
};
data.readMore.onclick = e => {
e.stopPropagation();
e.preventDefault();
this._onDidToggleDetails.fire();
};
} else {
data.right.classList.remove('can-expand-details');
hide(data.readMore);
data.readMore.onmousedown = null;
data.readMore.onclick = null;
}
}
disposeTemplate(templateData: ISuggestionTemplateData): void {
templateData.disposables.dispose();
}
}

View File

@@ -0,0 +1,84 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from 'vs/base/browser/dom';
import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar';
import { IActionViewItemProvider, IAction } from 'vs/base/common/actions';
import { ResolvedKeybinding } from 'vs/base/common/keyCodes';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { suggestWidgetStatusbarMenu } from 'vs/editor/contrib/suggest/suggest';
import { localize } from 'vs/nls';
import { MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem';
import { IMenuService, MenuItemAction } from 'vs/platform/actions/common/actions';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
class StatusBarViewItem extends MenuEntryActionViewItem {
updateLabel() {
const kb = this._keybindingService.lookupKeybinding(this._action.id);
if (!kb) {
return super.updateLabel();
}
if (this.label) {
this.label.textContent = localize('ddd', '{0} ({1})', this._action.label, StatusBarViewItem.symbolPrintEnter(kb));
}
}
static symbolPrintEnter(kb: ResolvedKeybinding) {
return kb.getLabel()?.replace(/\benter\b/gi, '\u23CE');
}
}
export class SuggestWidgetStatus {
readonly element: HTMLElement;
private readonly _disposables = new DisposableStore();
constructor(
container: HTMLElement,
@IInstantiationService instantiationService: IInstantiationService,
@IMenuService menuService: IMenuService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
this.element = dom.append(container, dom.$('.suggest-status-bar'));
const actionViewItemProvider = <IActionViewItemProvider>(action => {
return action instanceof MenuItemAction
? instantiationService.createInstance(StatusBarViewItem, action)
: undefined;
});
const leftActions = new ActionBar(this.element, { actionViewItemProvider });
const rightActions = new ActionBar(this.element, { actionViewItemProvider });
const menu = menuService.createMenu(suggestWidgetStatusbarMenu, contextKeyService);
leftActions.domNode.classList.add('left');
rightActions.domNode.classList.add('right');
const renderMenu = () => {
const left: IAction[] = [];
const right: IAction[] = [];
for (let [group, actions] of menu.getActions()) {
if (group === 'left') {
left.push(...actions);
} else {
right.push(...actions);
}
}
leftActions.clear();
leftActions.push(left);
rightActions.clear();
rightActions.push(right);
};
this._disposables.add(menu.onDidChange(() => renderMenu()));
this._disposables.add(menu);
}
dispose(): void {
this._disposables.dispose();
this.element.remove();
}
}

View File

@@ -0,0 +1,345 @@
/*---------------------------------------------------------------------------------------------
* 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 { IPosition } from 'vs/editor/common/core/position';
import * as modes from 'vs/editor/common/modes';
import { CompletionModel } from 'vs/editor/contrib/suggest/completionModel';
import { CompletionItem, getSuggestionComparator, SnippetSortOrder } from 'vs/editor/contrib/suggest/suggest';
import { WordDistance } from 'vs/editor/contrib/suggest/wordDistance';
import { EditorOptions, InternalSuggestOptions } from 'vs/editor/common/config/editorOptions';
export function createSuggestItem(label: string, overwriteBefore: number, kind = modes.CompletionItemKind.Property, incomplete: boolean = false, position: IPosition = { lineNumber: 1, column: 1 }, sortText?: string, filterText?: string): CompletionItem {
const suggestion: modes.CompletionItem = {
label,
sortText,
filterText,
range: { startLineNumber: position.lineNumber, startColumn: position.column - overwriteBefore, endLineNumber: position.lineNumber, endColumn: position.column },
insertText: label,
kind
};
const container: modes.CompletionList = {
incomplete,
suggestions: [suggestion]
};
const provider: modes.CompletionItemProvider = {
provideCompletionItems(): any {
return;
}
};
return new CompletionItem(position, suggestion, container, provider);
}
suite('CompletionModel', function () {
let defaultOptions = <InternalSuggestOptions>{
insertMode: 'insert',
snippetsPreventQuickSuggestions: true,
filterGraceful: true,
localityBonus: false,
shareSuggestSelections: false,
showIcons: true,
showMethods: true,
showFunctions: true,
showConstructors: true,
showFields: true,
showVariables: true,
showClasses: true,
showStructs: true,
showInterfaces: true,
showModules: true,
showProperties: true,
showEvents: true,
showOperators: true,
showUnits: true,
showValues: true,
showConstants: true,
showEnums: true,
showEnumMembers: true,
showKeywords: true,
showWords: true,
showColors: true,
showFiles: true,
showReferences: true,
showFolders: true,
showTypeParameters: true,
showSnippets: true,
};
let model: CompletionModel;
setup(function () {
model = new CompletionModel([
createSuggestItem('foo', 3),
createSuggestItem('Foo', 3),
createSuggestItem('foo', 2),
], 1, {
leadingLineContent: 'foo',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
});
test('filtering - cached', function () {
const itemsNow = model.items;
let itemsThen = model.items;
assert.ok(itemsNow === itemsThen);
// still the same context
model.lineContext = { leadingLineContent: 'foo', characterCountDelta: 0 };
itemsThen = model.items;
assert.ok(itemsNow === itemsThen);
// different context, refilter
model.lineContext = { leadingLineContent: 'foo1', characterCountDelta: 1 };
itemsThen = model.items;
assert.ok(itemsNow !== itemsThen);
});
test('complete/incomplete', () => {
assert.equal(model.incomplete.size, 0);
let incompleteModel = new CompletionModel([
createSuggestItem('foo', 3, undefined, true),
createSuggestItem('foo', 2),
], 1, {
leadingLineContent: 'foo',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
assert.equal(incompleteModel.incomplete.size, 1);
});
test('replaceIncomplete', () => {
const completeItem = createSuggestItem('foobar', 1, undefined, false, { lineNumber: 1, column: 2 });
const incompleteItem = createSuggestItem('foofoo', 1, undefined, true, { lineNumber: 1, column: 2 });
const model = new CompletionModel([completeItem, incompleteItem], 2, { leadingLineContent: 'f', characterCountDelta: 0 }, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
assert.equal(model.incomplete.size, 1);
assert.equal(model.items.length, 2);
const { incomplete } = model;
const complete = model.adopt(incomplete);
assert.equal(incomplete.size, 1);
assert.ok(incomplete.has(incompleteItem.provider));
assert.equal(complete.length, 1);
assert.ok(complete[0] === completeItem);
});
test('Fuzzy matching of snippets stopped working with inline snippet suggestions #49895', function () {
const completeItem1 = createSuggestItem('foobar1', 1, undefined, false, { lineNumber: 1, column: 2 });
const completeItem2 = createSuggestItem('foobar2', 1, undefined, false, { lineNumber: 1, column: 2 });
const completeItem3 = createSuggestItem('foobar3', 1, undefined, false, { lineNumber: 1, column: 2 });
const completeItem4 = createSuggestItem('foobar4', 1, undefined, false, { lineNumber: 1, column: 2 });
const completeItem5 = createSuggestItem('foobar5', 1, undefined, false, { lineNumber: 1, column: 2 });
const incompleteItem1 = createSuggestItem('foofoo1', 1, undefined, true, { lineNumber: 1, column: 2 });
const model = new CompletionModel(
[
completeItem1,
completeItem2,
completeItem3,
completeItem4,
completeItem5,
incompleteItem1,
], 2, { leadingLineContent: 'f', characterCountDelta: 0 }, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined
);
assert.equal(model.incomplete.size, 1);
assert.equal(model.items.length, 6);
const { incomplete } = model;
const complete = model.adopt(incomplete);
assert.equal(incomplete.size, 1);
assert.ok(incomplete.has(incompleteItem1.provider));
assert.equal(complete.length, 5);
});
test('proper current word when length=0, #16380', function () {
model = new CompletionModel([
createSuggestItem(' </div', 4),
createSuggestItem('a', 0),
createSuggestItem('p', 0),
createSuggestItem(' </tag', 4),
createSuggestItem(' XYZ', 4),
], 1, {
leadingLineContent: ' <',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
assert.equal(model.items.length, 4);
const [a, b, c, d] = model.items;
assert.equal(a.completion.label, ' </div');
assert.equal(b.completion.label, ' </tag');
assert.equal(c.completion.label, 'a');
assert.equal(d.completion.label, 'p');
});
test('keep snippet sorting with prefix: top, #25495', function () {
model = new CompletionModel([
createSuggestItem('Snippet1', 1, modes.CompletionItemKind.Snippet),
createSuggestItem('tnippet2', 1, modes.CompletionItemKind.Snippet),
createSuggestItem('semver', 1, modes.CompletionItemKind.Property),
], 1, {
leadingLineContent: 's',
characterCountDelta: 0
}, WordDistance.None, defaultOptions, 'top', undefined);
assert.equal(model.items.length, 2);
const [a, b] = model.items;
assert.equal(a.completion.label, 'Snippet1');
assert.equal(b.completion.label, 'semver');
assert.ok(a.score < b.score); // snippet really promoted
});
test('keep snippet sorting with prefix: bottom, #25495', function () {
model = new CompletionModel([
createSuggestItem('snippet1', 1, modes.CompletionItemKind.Snippet),
createSuggestItem('tnippet2', 1, modes.CompletionItemKind.Snippet),
createSuggestItem('Semver', 1, modes.CompletionItemKind.Property),
], 1, {
leadingLineContent: 's',
characterCountDelta: 0
}, WordDistance.None, defaultOptions, 'bottom', undefined);
assert.equal(model.items.length, 2);
const [a, b] = model.items;
assert.equal(a.completion.label, 'Semver');
assert.equal(b.completion.label, 'snippet1');
assert.ok(a.score < b.score); // snippet really demoted
});
test('keep snippet sorting with prefix: inline, #25495', function () {
model = new CompletionModel([
createSuggestItem('snippet1', 1, modes.CompletionItemKind.Snippet),
createSuggestItem('tnippet2', 1, modes.CompletionItemKind.Snippet),
createSuggestItem('Semver', 1),
], 1, {
leadingLineContent: 's',
characterCountDelta: 0
}, WordDistance.None, defaultOptions, 'inline', undefined);
assert.equal(model.items.length, 2);
const [a, b] = model.items;
assert.equal(a.completion.label, 'snippet1');
assert.equal(b.completion.label, 'Semver');
assert.ok(a.score > b.score); // snippet really demoted
});
test('filterText seems ignored in autocompletion, #26874', function () {
const item1 = createSuggestItem('Map - java.util', 1, undefined, undefined, undefined, undefined, 'Map');
const item2 = createSuggestItem('Map - java.util', 1);
model = new CompletionModel([item1, item2], 1, {
leadingLineContent: 'M',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
assert.equal(model.items.length, 2);
model.lineContext = {
leadingLineContent: 'Map ',
characterCountDelta: 3
};
assert.equal(model.items.length, 1);
});
test('Vscode 1.12 no longer obeys \'sortText\' in completion items (from language server), #26096', function () {
const item1 = createSuggestItem('<- groups', 2, modes.CompletionItemKind.Property, false, { lineNumber: 1, column: 3 }, '00002', ' groups');
const item2 = createSuggestItem('source', 0, modes.CompletionItemKind.Property, false, { lineNumber: 1, column: 3 }, '00001', 'source');
const items = [item1, item2].sort(getSuggestionComparator(SnippetSortOrder.Inline));
model = new CompletionModel(items, 3, {
leadingLineContent: ' ',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
assert.equal(model.items.length, 2);
const [first, second] = model.items;
assert.equal(first.completion.label, 'source');
assert.equal(second.completion.label, '<- groups');
});
test('Score only filtered items when typing more, score all when typing less', function () {
model = new CompletionModel([
createSuggestItem('console', 0),
createSuggestItem('co_new', 0),
createSuggestItem('bar', 0),
createSuggestItem('car', 0),
createSuggestItem('foo', 0),
], 1, {
leadingLineContent: '',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
assert.equal(model.items.length, 5);
// narrow down once
model.lineContext = { leadingLineContent: 'c', characterCountDelta: 1 };
assert.equal(model.items.length, 3);
// query gets longer, narrow down the narrow-down'ed-set from before
model.lineContext = { leadingLineContent: 'cn', characterCountDelta: 2 };
assert.equal(model.items.length, 2);
// query gets shorter, refilter everything
model.lineContext = { leadingLineContent: '', characterCountDelta: 0 };
assert.equal(model.items.length, 5);
});
test('Have more relaxed suggest matching algorithm #15419', function () {
model = new CompletionModel([
createSuggestItem('result', 0),
createSuggestItem('replyToUser', 0),
createSuggestItem('randomLolut', 0),
createSuggestItem('car', 0),
createSuggestItem('foo', 0),
], 1, {
leadingLineContent: '',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
// query gets longer, narrow down the narrow-down'ed-set from before
model.lineContext = { leadingLineContent: 'rlut', characterCountDelta: 4 };
assert.equal(model.items.length, 3);
const [first, second, third] = model.items;
assert.equal(first.completion.label, 'result'); // best with `rult`
assert.equal(second.completion.label, 'replyToUser'); // best with `rltu`
assert.equal(third.completion.label, 'randomLolut'); // best with `rlut`
});
test('Emmet suggestion not appearing at the top of the list in jsx files, #39518', function () {
model = new CompletionModel([
createSuggestItem('from', 0),
createSuggestItem('form', 0),
createSuggestItem('form:get', 0),
createSuggestItem('testForeignMeasure', 0),
createSuggestItem('fooRoom', 0),
], 1, {
leadingLineContent: '',
characterCountDelta: 0
}, WordDistance.None, EditorOptions.suggest.defaultValue, EditorOptions.snippetSuggestions.defaultValue, undefined);
model.lineContext = { leadingLineContent: 'form', characterCountDelta: 4 };
assert.equal(model.items.length, 5);
const [first, second, third] = model.items;
assert.equal(first.completion.label, 'form'); // best with `form`
assert.equal(second.completion.label, 'form:get'); // best with `form`
assert.equal(third.completion.label, 'from'); // best with `from`
});
});

View File

@@ -0,0 +1,152 @@
/*---------------------------------------------------------------------------------------------
* 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 { URI } from 'vs/base/common/uri';
import { IDisposable } from 'vs/base/common/lifecycle';
import { CompletionProviderRegistry, CompletionItemKind, CompletionItemProvider } from 'vs/editor/common/modes';
import { provideSuggestionItems, SnippetSortOrder, CompletionOptions } from 'vs/editor/contrib/suggest/suggest';
import { Position } from 'vs/editor/common/core/position';
import { TextModel } from 'vs/editor/common/model/textModel';
import { Range } from 'vs/editor/common/core/range';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
suite('Suggest', function () {
let model: TextModel;
let registration: IDisposable;
setup(function () {
model = createTextModel('FOO\nbar\BAR\nfoo', undefined, undefined, URI.parse('foo:bar/path'));
registration = CompletionProviderRegistry.register({ pattern: 'bar/path', scheme: 'foo' }, {
provideCompletionItems(_doc, pos) {
return {
incomplete: false,
suggestions: [{
label: 'aaa',
kind: CompletionItemKind.Snippet,
insertText: 'aaa',
range: Range.fromPositions(pos)
}, {
label: 'zzz',
kind: CompletionItemKind.Snippet,
insertText: 'zzz',
range: Range.fromPositions(pos)
}, {
label: 'fff',
kind: CompletionItemKind.Property,
insertText: 'fff',
range: Range.fromPositions(pos)
}]
};
}
});
});
teardown(() => {
registration.dispose();
model.dispose();
});
test('sort - snippet inline', async function () {
const { items } = await provideSuggestionItems(model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Inline));
assert.equal(items.length, 3);
assert.equal(items[0].completion.label, 'aaa');
assert.equal(items[1].completion.label, 'fff');
assert.equal(items[2].completion.label, 'zzz');
});
test('sort - snippet top', async function () {
const { items } = await provideSuggestionItems(model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Top));
assert.equal(items.length, 3);
assert.equal(items[0].completion.label, 'aaa');
assert.equal(items[1].completion.label, 'zzz');
assert.equal(items[2].completion.label, 'fff');
});
test('sort - snippet bottom', async function () {
const { items } = await provideSuggestionItems(model, new Position(1, 1), new CompletionOptions(SnippetSortOrder.Bottom));
assert.equal(items.length, 3);
assert.equal(items[0].completion.label, 'fff');
assert.equal(items[1].completion.label, 'aaa');
assert.equal(items[2].completion.label, 'zzz');
});
test('sort - snippet none', async function () {
const { items } = await provideSuggestionItems(model, new Position(1, 1), new CompletionOptions(undefined, new Set<CompletionItemKind>().add(CompletionItemKind.Snippet)));
assert.equal(items.length, 1);
assert.equal(items[0].completion.label, 'fff');
});
test('only from', function () {
const foo: any = {
triggerCharacters: [],
provideCompletionItems() {
return {
currentWord: '',
incomplete: false,
suggestions: [{
label: 'jjj',
type: 'property',
insertText: 'jjj'
}]
};
}
};
const registration = CompletionProviderRegistry.register({ pattern: 'bar/path', scheme: 'foo' }, foo);
provideSuggestionItems(model, new Position(1, 1), new CompletionOptions(undefined, undefined, new Set<CompletionItemProvider>().add(foo))).then(({ items }) => {
registration.dispose();
assert.equal(items.length, 1);
assert.ok(items[0].provider === foo);
});
});
test('Ctrl+space completions stopped working with the latest Insiders, #97650', async function () {
const foo = new class implements CompletionItemProvider {
triggerCharacters = [];
provideCompletionItems() {
return {
suggestions: [{
label: 'one',
kind: CompletionItemKind.Class,
insertText: 'one',
range: {
insert: new Range(0, 0, 0, 0),
replace: new Range(0, 0, 0, 10)
}
}, {
label: 'two',
kind: CompletionItemKind.Class,
insertText: 'two',
range: {
insert: new Range(0, 0, 0, 0),
replace: new Range(0, 1, 0, 10)
}
}]
};
}
};
const registration = CompletionProviderRegistry.register({ pattern: 'bar/path', scheme: 'foo' }, foo);
const { items } = await provideSuggestionItems(model, new Position(0, 0), new CompletionOptions(undefined, undefined, new Set<CompletionItemProvider>().add(foo)));
registration.dispose();
assert.equal(items.length, 2);
const [a, b] = items;
assert.equal(a.completion.label, 'one');
assert.equal(a.isInvalid, false);
assert.equal(b.completion.label, 'two');
assert.equal(b.isInvalid, true);
});
});

View File

@@ -0,0 +1,417 @@
/*---------------------------------------------------------------------------------------------
* 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 { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
import { createTestCodeEditor, ITestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
import { TextModel } from 'vs/editor/common/model/textModel';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils';
import { IStorageService, InMemoryStorageService } from 'vs/platform/storage/common/storage';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { MockKeybindingService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
import { ISuggestMemoryService } from 'vs/editor/contrib/suggest/suggestMemory';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { mock } from 'vs/base/test/common/mock';
import { Selection } from 'vs/editor/common/core/selection';
import { CompletionProviderRegistry, CompletionItemKind, CompletionItemInsertTextRule } from 'vs/editor/common/modes';
import { Event } from 'vs/base/common/event';
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
import { IMenuService, IMenu } from 'vs/platform/actions/common/actions';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
import { Range } from 'vs/editor/common/core/range';
import { timeout } from 'vs/base/common/async';
import { NullLogService, ILogService } from 'vs/platform/log/common/log';
suite('SuggestController', function () {
const disposables = new DisposableStore();
let controller: SuggestController;
let editor: ITestCodeEditor;
let model: TextModel;
setup(function () {
disposables.clear();
const serviceCollection = new ServiceCollection(
[ITelemetryService, NullTelemetryService],
[ILogService, new NullLogService()],
[IStorageService, new InMemoryStorageService()],
[IKeybindingService, new MockKeybindingService()],
[IEditorWorkerService, new class extends mock<IEditorWorkerService>() {
computeWordRanges() {
return Promise.resolve({});
}
}],
[ISuggestMemoryService, new class extends mock<ISuggestMemoryService>() {
memorize(): void { }
select(): number { return 0; }
}],
[IMenuService, new class extends mock<IMenuService>() {
createMenu() {
return new class extends mock<IMenu>() {
onDidChange = Event.None;
};
}
}]
);
model = createTextModel('', undefined, undefined, URI.from({ scheme: 'test-ctrl', path: '/path.tst' }));
editor = createTestCodeEditor({
model,
serviceCollection,
});
editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);
controller = editor.registerAndInstantiateContribution(SuggestController.ID, SuggestController);
});
test('postfix completion reports incorrect position #86984', async function () {
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'let ${1:name} = foo$0',
insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet,
range: { startLineNumber: 1, startColumn: 9, endLineNumber: 1, endColumn: 11 },
additionalTextEdits: [{
text: '',
range: { startLineNumber: 1, startColumn: 5, endLineNumber: 1, endColumn: 9 }
}]
}]
};
}
}));
editor.setValue(' foo.le');
editor.setSelection(new Selection(1, 11, 1, 11));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(false, false);
await p2;
assert.equal(editor.getValue(), ' let name = foo');
});
test('use additionalTextEdits sync when possible', async function () {
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hello',
range: Range.fromPositions(pos),
additionalTextEdits: [{
text: 'I came sync',
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }
}]
}]
};
},
async resolveCompletionItem(item) {
return item;
}
}));
editor.setValue('hello\nhallo');
editor.setSelection(new Selection(2, 6, 2, 6));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(false, false);
await p2;
// insertText happens sync!
assert.equal(editor.getValue(), 'I came synchello\nhallohello');
});
test('resolve additionalTextEdits async when needed', async function () {
let resolveCallCount = 0;
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hello',
range: Range.fromPositions(pos)
}]
};
},
async resolveCompletionItem(item) {
resolveCallCount += 1;
await timeout(10);
item.additionalTextEdits = [{
text: 'I came late',
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }
}];
return item;
}
}));
editor.setValue('hello\nhallo');
editor.setSelection(new Selection(2, 6, 2, 6));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(false, false);
await p2;
// insertText happens sync!
assert.equal(editor.getValue(), 'hello\nhallohello');
assert.equal(resolveCallCount, 1);
// additional edits happened after a litte wait
await timeout(20);
assert.equal(editor.getValue(), 'I came latehello\nhallohello');
// single undo stop
editor.getModel()?.undo();
assert.equal(editor.getValue(), 'hello\nhallo');
});
test('resolve additionalTextEdits async when needed (typing)', async function () {
let resolveCallCount = 0;
let resolve: Function = () => { };
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hello',
range: Range.fromPositions(pos)
}]
};
},
async resolveCompletionItem(item) {
resolveCallCount += 1;
await new Promise(_resolve => resolve = _resolve);
item.additionalTextEdits = [{
text: 'I came late',
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }
}];
return item;
}
}));
editor.setValue('hello\nhallo');
editor.setSelection(new Selection(2, 6, 2, 6));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(false, false);
await p2;
// insertText happens sync!
assert.equal(editor.getValue(), 'hello\nhallohello');
assert.equal(resolveCallCount, 1);
// additional edits happened after a litte wait
assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 11, 2, 11)));
editor.trigger('test', 'type', { text: 'TYPING' });
assert.equal(editor.getValue(), 'hello\nhallohelloTYPING');
resolve();
await timeout(10);
assert.equal(editor.getValue(), 'I came latehello\nhallohelloTYPING');
assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 17, 2, 17)));
});
// additional edit come late and are AFTER the selection -> cancel
test('resolve additionalTextEdits async when needed (simple conflict)', async function () {
let resolveCallCount = 0;
let resolve: Function = () => { };
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hello',
range: Range.fromPositions(pos)
}]
};
},
async resolveCompletionItem(item) {
resolveCallCount += 1;
await new Promise(_resolve => resolve = _resolve);
item.additionalTextEdits = [{
text: 'I came late',
range: { startLineNumber: 1, startColumn: 6, endLineNumber: 1, endColumn: 6 }
}];
return item;
}
}));
editor.setValue('');
editor.setSelection(new Selection(1, 1, 1, 1));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(false, false);
await p2;
// insertText happens sync!
assert.equal(editor.getValue(), 'hello');
assert.equal(resolveCallCount, 1);
resolve();
await timeout(10);
assert.equal(editor.getValue(), 'hello');
});
// additional edit come late and are AFTER the position at which the user typed -> cancelled
test('resolve additionalTextEdits async when needed (conflict)', async function () {
let resolveCallCount = 0;
let resolve: Function = () => { };
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hello',
range: Range.fromPositions(pos)
}]
};
},
async resolveCompletionItem(item) {
resolveCallCount += 1;
await new Promise(_resolve => resolve = _resolve);
item.additionalTextEdits = [{
text: 'I came late',
range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 }
}];
return item;
}
}));
editor.setValue('hello\nhallo');
editor.setSelection(new Selection(2, 6, 2, 6));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(false, false);
await p2;
// insertText happens sync!
assert.equal(editor.getValue(), 'hello\nhallohello');
assert.equal(resolveCallCount, 1);
// additional edits happened after a litte wait
editor.setSelection(new Selection(1, 1, 1, 1));
editor.trigger('test', 'type', { text: 'TYPING' });
assert.equal(editor.getValue(), 'TYPINGhello\nhallohello');
resolve();
await timeout(10);
assert.equal(editor.getValue(), 'TYPINGhello\nhallohello');
assert.ok(editor.getSelection()?.equalsSelection(new Selection(1, 7, 1, 7)));
});
test('resolve additionalTextEdits async when needed (cancel)', async function () {
let resolve: Function[] = [];
disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, {
provideCompletionItems(doc, pos) {
return {
suggestions: [{
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hello',
range: Range.fromPositions(pos)
}, {
kind: CompletionItemKind.Snippet,
label: 'let',
insertText: 'hallo',
range: Range.fromPositions(pos)
}]
};
},
async resolveCompletionItem(item) {
await new Promise(_resolve => resolve.push(_resolve));
item.additionalTextEdits = [{
text: 'additionalTextEdits',
range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 }
}];
return item;
}
}));
editor.setValue('abc');
editor.setSelection(new Selection(1, 1, 1, 1));
// trigger
let p1 = Event.toPromise(controller.model.onDidSuggest);
controller.triggerSuggest();
await p1;
//
let p2 = Event.toPromise(controller.model.onDidCancel);
controller.acceptSelectedSuggestion(true, false);
await p2;
// insertText happens sync!
assert.equal(editor.getValue(), 'helloabc');
// next
controller.acceptNextSuggestion();
// resolve additional edits (MUST be cancelled)
resolve.forEach(fn => fn);
resolve.length = 0;
await timeout(10);
// next suggestion used
assert.equal(editor.getValue(), 'halloabc');
});
});

View File

@@ -0,0 +1,164 @@
/*---------------------------------------------------------------------------------------------
* 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 { LRUMemory, NoMemory, PrefixMemory, Memory } from 'vs/editor/contrib/suggest/suggestMemory';
import { ITextModel } from 'vs/editor/common/model';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
import { createSuggestItem } from 'vs/editor/contrib/suggest/test/completionModel.test';
import { IPosition } from 'vs/editor/common/core/position';
import { CompletionItem } from 'vs/editor/contrib/suggest/suggest';
suite('SuggestMemories', function () {
let pos: IPosition;
let buffer: ITextModel;
let items: CompletionItem[];
setup(function () {
pos = { lineNumber: 1, column: 1 };
buffer = createTextModel('This is some text.\nthis.\nfoo: ,');
items = [
createSuggestItem('foo', 0),
createSuggestItem('bar', 0)
];
});
test('AbstractMemory, select', function () {
const mem = new class extends Memory {
constructor() {
super('first');
}
memorize(model: ITextModel, pos: IPosition, item: CompletionItem): void {
throw new Error('Method not implemented.');
} toJSON(): object {
throw new Error('Method not implemented.');
}
fromJSON(data: object): void {
throw new Error('Method not implemented.');
}
};
let item1 = createSuggestItem('fazz', 0);
let item2 = createSuggestItem('bazz', 0);
let item3 = createSuggestItem('bazz', 0);
let item4 = createSuggestItem('bazz', 0);
item1.completion.preselect = false;
item2.completion.preselect = true;
item3.completion.preselect = true;
assert.equal(mem.select(buffer, pos, [item1, item2, item3, item4]), 1);
});
test('[No|Prefix|LRU]Memory honor selection boost', function () {
let item1 = createSuggestItem('fazz', 0);
let item2 = createSuggestItem('bazz', 0);
let item3 = createSuggestItem('bazz', 0);
let item4 = createSuggestItem('bazz', 0);
item1.completion.preselect = false;
item2.completion.preselect = true;
item3.completion.preselect = true;
let items = [item1, item2, item3, item4];
assert.equal(new NoMemory().select(buffer, pos, items), 1);
assert.equal(new LRUMemory().select(buffer, pos, items), 1);
assert.equal(new PrefixMemory().select(buffer, pos, items), 1);
});
test('NoMemory', () => {
const mem = new NoMemory();
assert.equal(mem.select(buffer, pos, items), 0);
assert.equal(mem.select(buffer, pos, []), 0);
mem.memorize(buffer, pos, items[0]);
mem.memorize(buffer, pos, null!);
});
test('LRUMemory', () => {
pos = { lineNumber: 2, column: 6 };
const mem = new LRUMemory();
mem.memorize(buffer, pos, items[1]);
assert.equal(mem.select(buffer, pos, items), 1);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 3 }, items), 0);
mem.memorize(buffer, pos, items[0]);
assert.equal(mem.select(buffer, pos, items), 0);
assert.equal(mem.select(buffer, pos, [
createSuggestItem('new', 0),
createSuggestItem('bar', 0)
]), 1);
assert.equal(mem.select(buffer, pos, [
createSuggestItem('new1', 0),
createSuggestItem('new2', 0)
]), 0);
});
test('`"editor.suggestSelection": "recentlyUsed"` should be a little more sticky #78571', function () {
let item1 = createSuggestItem('gamma', 0);
let item2 = createSuggestItem('game', 0);
items = [item1, item2];
let mem = new LRUMemory();
buffer.setValue(' foo.');
mem.memorize(buffer, { lineNumber: 1, column: 1 }, item2);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 2 }, items), 0); // leading whitespace -> ignore recent items
mem.memorize(buffer, { lineNumber: 1, column: 9 }, item2);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 9 }, items), 1); // foo.
buffer.setValue(' foo.g');
assert.equal(mem.select(buffer, { lineNumber: 1, column: 10 }, items), 1); // foo.g, 'gamma' and 'game' have the same score
item1.score = [10, 0, 0];
assert.equal(mem.select(buffer, { lineNumber: 1, column: 10 }, items), 0); // foo.g, 'gamma' has higher score
});
test('intellisense is not showing top options first #43429', function () {
// ensure we don't memorize for whitespace prefixes
pos = { lineNumber: 2, column: 6 };
const mem = new LRUMemory();
mem.memorize(buffer, pos, items[1]);
assert.equal(mem.select(buffer, pos, items), 1);
assert.equal(mem.select(buffer, { lineNumber: 3, column: 5 }, items), 0); // foo: |,
assert.equal(mem.select(buffer, { lineNumber: 3, column: 6 }, items), 1); // foo: ,|
});
test('PrefixMemory', () => {
const mem = new PrefixMemory();
buffer.setValue('constructor');
const item0 = createSuggestItem('console', 0);
const item1 = createSuggestItem('const', 0);
const item2 = createSuggestItem('constructor', 0);
const item3 = createSuggestItem('constant', 0);
const items = [item0, item1, item2, item3];
mem.memorize(buffer, { lineNumber: 1, column: 2 }, item1); // c -> const
mem.memorize(buffer, { lineNumber: 1, column: 3 }, item0); // co -> console
mem.memorize(buffer, { lineNumber: 1, column: 4 }, item2); // con -> constructor
assert.equal(mem.select(buffer, { lineNumber: 1, column: 1 }, items), 0);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 2 }, items), 1);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 3 }, items), 0);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 4 }, items), 2);
assert.equal(mem.select(buffer, { lineNumber: 1, column: 7 }, items), 2); // find substr
});
});

View File

@@ -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 * as assert from 'assert';
import { Event } from 'vs/base/common/event';
import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle';
import { URI } from 'vs/base/common/uri';
import { CoreEditingCommands } from 'vs/editor/browser/controller/coreCommands';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { Range } from 'vs/editor/common/core/range';
import { Position } from 'vs/editor/common/core/position';
import { Selection } from 'vs/editor/common/core/selection';
import { TokenizationResult2 } from 'vs/editor/common/core/token';
import { Handler } from 'vs/editor/common/editorCommon';
import { TextModel } from 'vs/editor/common/model/textModel';
import { IState, CompletionList, CompletionItemProvider, LanguageIdentifier, MetadataConsts, CompletionProviderRegistry, CompletionTriggerKind, TokenizationRegistry, CompletionItemKind } from 'vs/editor/common/modes';
import { LanguageConfigurationRegistry } from 'vs/editor/common/modes/languageConfigurationRegistry';
import { NULL_STATE } from 'vs/editor/common/modes/nullMode';
import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2';
import { SuggestController } from 'vs/editor/contrib/suggest/suggestController';
import { LineContext, SuggestModel } from 'vs/editor/contrib/suggest/suggestModel';
import { ISelectedSuggestion } from 'vs/editor/contrib/suggest/suggestWidget';
import { ITestCodeEditor, createTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
import { MockMode } from 'vs/editor/test/common/mocks/mockMode';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { IStorageService, InMemoryStorageService } from 'vs/platform/storage/common/storage';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { ISuggestMemoryService } from 'vs/editor/contrib/suggest/suggestMemory';
import { ITextModel } from 'vs/editor/common/model';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { MockKeybindingService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { mock } from 'vs/base/test/common/mock';
import { NullLogService } from 'vs/platform/log/common/log';
function createMockEditor(model: TextModel): ITestCodeEditor {
let editor = createTestCodeEditor({
model: model,
serviceCollection: new ServiceCollection(
[ITelemetryService, NullTelemetryService],
[IStorageService, new InMemoryStorageService()],
[IKeybindingService, new MockKeybindingService()],
[ISuggestMemoryService, new class implements ISuggestMemoryService {
declare readonly _serviceBrand: undefined;
memorize(): void {
}
select(): number {
return -1;
}
}],
),
});
editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);
return editor;
}
suite('SuggestModel - Context', function () {
const OUTER_LANGUAGE_ID = new LanguageIdentifier('outerMode', 3);
const INNER_LANGUAGE_ID = new LanguageIdentifier('innerMode', 4);
class OuterMode extends MockMode {
constructor() {
super(OUTER_LANGUAGE_ID);
this._register(LanguageConfigurationRegistry.register(this.getLanguageIdentifier(), {}));
this._register(TokenizationRegistry.register(this.getLanguageIdentifier().language, {
getInitialState: (): IState => NULL_STATE,
tokenize: undefined!,
tokenize2: (line: string, state: IState): TokenizationResult2 => {
const tokensArr: number[] = [];
let prevLanguageId: LanguageIdentifier | undefined = undefined;
for (let i = 0; i < line.length; i++) {
const languageId = (line.charAt(i) === 'x' ? INNER_LANGUAGE_ID : OUTER_LANGUAGE_ID);
if (prevLanguageId !== languageId) {
tokensArr.push(i);
tokensArr.push((languageId.id << MetadataConsts.LANGUAGEID_OFFSET));
}
prevLanguageId = languageId;
}
const tokens = new Uint32Array(tokensArr.length);
for (let i = 0; i < tokens.length; i++) {
tokens[i] = tokensArr[i];
}
return new TokenizationResult2(tokens, state);
}
}));
}
}
class InnerMode extends MockMode {
constructor() {
super(INNER_LANGUAGE_ID);
this._register(LanguageConfigurationRegistry.register(this.getLanguageIdentifier(), {}));
}
}
const assertAutoTrigger = (model: TextModel, offset: number, expected: boolean, message?: string): void => {
const pos = model.getPositionAt(offset);
const editor = createMockEditor(model);
editor.setPosition(pos);
assert.equal(LineContext.shouldAutoTrigger(editor), expected, message);
editor.dispose();
};
let disposables: Disposable[] = [];
setup(() => {
disposables = [];
});
teardown(function () {
dispose(disposables);
disposables = [];
});
test('Context - shouldAutoTrigger', function () {
const model = createTextModel('Das Pferd frisst keinen Gurkensalat - Philipp Reis 1861.\nWer hat\'s erfunden?');
disposables.push(model);
assertAutoTrigger(model, 3, true, 'end of word, Das|');
assertAutoTrigger(model, 4, false, 'no word Das |');
assertAutoTrigger(model, 1, false, 'middle of word D|as');
assertAutoTrigger(model, 55, false, 'number, 1861|');
});
test('shouldAutoTrigger at embedded language boundaries', () => {
const outerMode = new OuterMode();
const innerMode = new InnerMode();
disposables.push(outerMode, innerMode);
const model = createTextModel('a<xx>a<x>', undefined, outerMode.getLanguageIdentifier());
disposables.push(model);
assertAutoTrigger(model, 1, true, 'a|<x — should trigger at end of word');
assertAutoTrigger(model, 2, false, 'a<|x — should NOT trigger at start of word');
assertAutoTrigger(model, 3, false, 'a<x|x — should NOT trigger in middle of word');
assertAutoTrigger(model, 4, true, 'a<xx|> — should trigger at boundary between languages');
assertAutoTrigger(model, 5, false, 'a<xx>|a — should NOT trigger at start of word');
assertAutoTrigger(model, 6, true, 'a<xx>a|< — should trigger at end of word');
assertAutoTrigger(model, 8, true, 'a<xx>a<x|> — should trigger at end of word at boundary');
});
});
suite('SuggestModel - TriggerAndCancelOracle', function () {
function getDefaultSuggestRange(model: ITextModel, position: Position) {
const wordUntil = model.getWordUntilPosition(position);
return new Range(position.lineNumber, wordUntil.startColumn, position.lineNumber, wordUntil.endColumn);
}
const alwaysEmptySupport: CompletionItemProvider = {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: false,
suggestions: []
};
}
};
const alwaysSomethingSupport: CompletionItemProvider = {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: false,
suggestions: [{
label: doc.getWordUntilPosition(pos).word,
kind: CompletionItemKind.Property,
insertText: 'foofoo',
range: getDefaultSuggestRange(doc, pos)
}]
};
}
};
let disposables: IDisposable[] = [];
let model: TextModel;
setup(function () {
disposables = dispose(disposables);
model = createTextModel('abc def', undefined, undefined, URI.parse('test:somefile.ttt'));
disposables.push(model);
});
function withOracle(callback: (model: SuggestModel, editor: ITestCodeEditor) => any): Promise<any> {
return new Promise((resolve, reject) => {
const editor = createMockEditor(model);
const oracle = new SuggestModel(
editor,
new class extends mock<IEditorWorkerService>() {
computeWordRanges() {
return Promise.resolve({});
}
},
new class extends mock<IClipboardService>() {
readText() {
return Promise.resolve('CLIPPY');
}
},
NullTelemetryService,
new NullLogService()
);
disposables.push(oracle, editor);
try {
resolve(callback(oracle, editor));
} catch (err) {
reject(err);
}
});
}
function assertEvent<E>(event: Event<E>, action: () => any, assert: (e: E) => any) {
return new Promise((resolve, reject) => {
const sub = event(e => {
sub.dispose();
try {
resolve(assert(e));
} catch (err) {
reject(err);
}
});
try {
action();
} catch (err) {
sub.dispose();
reject(err);
}
});
}
test('events - cancel/trigger', function () {
return withOracle(model => {
return Promise.all([
assertEvent(model.onDidTrigger, function () {
model.trigger({ auto: true, shy: false });
}, function (event) {
assert.equal(event.auto, true);
return assertEvent(model.onDidCancel, function () {
model.cancel();
}, function (event) {
assert.equal(event.retrigger, false);
});
}),
assertEvent(model.onDidTrigger, function () {
model.trigger({ auto: true, shy: false });
}, function (event) {
assert.equal(event.auto, true);
}),
assertEvent(model.onDidTrigger, function () {
model.trigger({ auto: false, shy: false });
}, function (event) {
assert.equal(event.auto, false);
})
]);
});
});
test('events - suggest/empty', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, alwaysEmptySupport));
return withOracle(model => {
return Promise.all([
assertEvent(model.onDidCancel, function () {
model.trigger({ auto: true, shy: false });
}, function (event) {
assert.equal(event.retrigger, false);
}),
assertEvent(model.onDidSuggest, function () {
model.trigger({ auto: false, shy: false });
}, function (event) {
assert.equal(event.auto, false);
assert.equal(event.isFrozen, false);
assert.equal(event.completionModel.items.length, 0);
})
]);
});
});
test('trigger - on type', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, alwaysSomethingSupport));
return withOracle((model, editor) => {
return assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.provider, alwaysSomethingSupport);
});
});
});
test('#17400: Keep filtering suggestModel.ts after space', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: false,
suggestions: [{
label: 'My Table',
kind: CompletionItemKind.Property,
insertText: 'My Table',
range: getDefaultSuggestRange(doc, pos)
}]
};
}
}));
model.setValue('');
return withOracle((model, editor) => {
return assertEvent(model.onDidSuggest, () => {
// make sure completionModel starts here!
model.trigger({ auto: true, shy: false });
}, event => {
return assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 1 });
editor.trigger('keyboard', Handler.Type, { text: 'My' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.completion.label, 'My Table');
return assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 3 });
editor.trigger('keyboard', Handler.Type, { text: ' ' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.completion.label, 'My Table');
});
});
});
});
});
test('#21484: Trigger character always force a new completion session', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: false,
suggestions: [{
label: 'foo.bar',
kind: CompletionItemKind.Property,
insertText: 'foo.bar',
range: Range.fromPositions(pos.with(undefined, 1), pos)
}]
};
}
}));
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
triggerCharacters: ['.'],
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: false,
suggestions: [{
label: 'boom',
kind: CompletionItemKind.Property,
insertText: 'boom',
range: Range.fromPositions(
pos.delta(0, doc.getLineContent(pos.lineNumber)[pos.column - 2] === '.' ? 0 : -1),
pos
)
}]
};
}
}));
model.setValue('');
return withOracle((model, editor) => {
return assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 1 });
editor.trigger('keyboard', Handler.Type, { text: 'foo' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.completion.label, 'foo.bar');
return assertEvent(model.onDidSuggest, () => {
editor.trigger('keyboard', Handler.Type, { text: '.' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 2);
const [first, second] = event.completionModel.items;
assert.equal(first.completion.label, 'foo.bar');
assert.equal(second.completion.label, 'boom');
});
});
});
});
test('Intellisense Completion doesn\'t respect space after equal sign (.html file), #29353 [1/2]', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, alwaysSomethingSupport));
return withOracle((model, editor) => {
editor.getModel()!.setValue('fo');
editor.setPosition({ lineNumber: 1, column: 3 });
return assertEvent(model.onDidSuggest, () => {
model.trigger({ auto: false, shy: false });
}, event => {
assert.equal(event.auto, false);
assert.equal(event.isFrozen, false);
assert.equal(event.completionModel.items.length, 1);
return assertEvent(model.onDidCancel, () => {
editor.trigger('keyboard', Handler.Type, { text: '+' });
}, event => {
assert.equal(event.retrigger, false);
});
});
});
});
test('Intellisense Completion doesn\'t respect space after equal sign (.html file), #29353 [2/2]', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, alwaysSomethingSupport));
return withOracle((model, editor) => {
editor.getModel()!.setValue('fo');
editor.setPosition({ lineNumber: 1, column: 3 });
return assertEvent(model.onDidSuggest, () => {
model.trigger({ auto: false, shy: false });
}, event => {
assert.equal(event.auto, false);
assert.equal(event.isFrozen, false);
assert.equal(event.completionModel.items.length, 1);
return assertEvent(model.onDidCancel, () => {
editor.trigger('keyboard', Handler.Type, { text: ' ' });
}, event => {
assert.equal(event.retrigger, false);
});
});
});
});
test('Incomplete suggestion results cause re-triggering when typing w/o further context, #28400 (1/2)', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: true,
suggestions: [{
label: 'foo',
kind: CompletionItemKind.Property,
insertText: 'foo',
range: Range.fromPositions(pos.with(undefined, 1), pos)
}]
};
}
}));
return withOracle((model, editor) => {
editor.getModel()!.setValue('foo');
editor.setPosition({ lineNumber: 1, column: 4 });
return assertEvent(model.onDidSuggest, () => {
model.trigger({ auto: false, shy: false });
}, event => {
assert.equal(event.auto, false);
assert.equal(event.completionModel.incomplete.size, 1);
assert.equal(event.completionModel.items.length, 1);
return assertEvent(model.onDidCancel, () => {
editor.trigger('keyboard', Handler.Type, { text: ';' });
}, event => {
assert.equal(event.retrigger, false);
});
});
});
});
test('Incomplete suggestion results cause re-triggering when typing w/o further context, #28400 (2/2)', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: true,
suggestions: [{
label: 'foo;',
kind: CompletionItemKind.Property,
insertText: 'foo',
range: Range.fromPositions(pos.with(undefined, 1), pos)
}]
};
}
}));
return withOracle((model, editor) => {
editor.getModel()!.setValue('foo');
editor.setPosition({ lineNumber: 1, column: 4 });
return assertEvent(model.onDidSuggest, () => {
model.trigger({ auto: false, shy: false });
}, event => {
assert.equal(event.auto, false);
assert.equal(event.completionModel.incomplete.size, 1);
assert.equal(event.completionModel.items.length, 1);
return assertEvent(model.onDidSuggest, () => {
// while we cancel incrementally enriching the set of
// completions we still filter against those that we have
// until now
editor.trigger('keyboard', Handler.Type, { text: ';' });
}, event => {
assert.equal(event.auto, false);
assert.equal(event.completionModel.incomplete.size, 1);
assert.equal(event.completionModel.items.length, 1);
});
});
});
});
test('Trigger character is provided in suggest context', function () {
let triggerCharacter = '';
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
triggerCharacters: ['.'],
provideCompletionItems(doc, pos, context): CompletionList {
assert.equal(context.triggerKind, CompletionTriggerKind.TriggerCharacter);
triggerCharacter = context.triggerCharacter!;
return {
incomplete: false,
suggestions: [
{
label: 'foo.bar',
kind: CompletionItemKind.Property,
insertText: 'foo.bar',
range: Range.fromPositions(pos.with(undefined, 1), pos)
}
]
};
}
}));
model.setValue('');
return withOracle((model, editor) => {
return assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 1 });
editor.trigger('keyboard', Handler.Type, { text: 'foo.' });
}, event => {
assert.equal(triggerCharacter, '.');
});
});
});
test('Mac press and hold accent character insertion does not update suggestions, #35269', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: true,
suggestions: [{
label: 'abc',
kind: CompletionItemKind.Property,
insertText: 'abc',
range: Range.fromPositions(pos.with(undefined, 1), pos)
}, {
label: 'äbc',
kind: CompletionItemKind.Property,
insertText: 'äbc',
range: Range.fromPositions(pos.with(undefined, 1), pos)
}]
};
}
}));
model.setValue('');
return withOracle((model, editor) => {
return assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 1 });
editor.trigger('keyboard', Handler.Type, { text: 'a' });
}, event => {
assert.equal(event.completionModel.items.length, 1);
assert.equal(event.completionModel.items[0].completion.label, 'abc');
return assertEvent(model.onDidSuggest, () => {
editor.executeEdits('test', [EditOperation.replace(new Range(1, 1, 1, 2), 'ä')]);
}, event => {
// suggest model changed to äbc
assert.equal(event.completionModel.items.length, 1);
assert.equal(event.completionModel.items[0].completion.label, 'äbc');
});
});
});
});
test('Backspace should not always cancel code completion, #36491', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, alwaysSomethingSupport));
return withOracle(async (model, editor) => {
await assertEvent(model.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 4 });
editor.trigger('keyboard', Handler.Type, { text: 'd' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.provider, alwaysSomethingSupport);
});
await assertEvent(model.onDidSuggest, () => {
CoreEditingCommands.DeleteLeft.runEditorCommand(null, editor, null);
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.provider, alwaysSomethingSupport);
});
});
});
test('Text changes for completion CodeAction are affected by the completion #39893', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos): CompletionList {
return {
incomplete: true,
suggestions: [{
label: 'bar',
kind: CompletionItemKind.Property,
insertText: 'bar',
range: Range.fromPositions(pos.delta(0, -2), pos),
additionalTextEdits: [{
text: ', bar',
range: { startLineNumber: 1, endLineNumber: 1, startColumn: 17, endColumn: 17 }
}]
}]
};
}
}));
model.setValue('ba; import { foo } from "./b"');
return withOracle(async (sugget, editor) => {
class TestCtrl extends SuggestController {
_insertSuggestion(item: ISelectedSuggestion, flags: number = 0) {
super._insertSuggestion(item, flags);
}
}
const ctrl = <TestCtrl>editor.registerAndInstantiateContribution(TestCtrl.ID, TestCtrl);
editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2);
await assertEvent(sugget.onDidSuggest, () => {
editor.setPosition({ lineNumber: 1, column: 3 });
sugget.trigger({ auto: false, shy: false });
}, event => {
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.completion.label, 'bar');
ctrl._insertSuggestion({ item: first, index: 0, model: event.completionModel });
});
assert.equal(
model.getValue(),
'bar; import { foo, bar } from "./b"'
);
});
});
test('Completion unexpectedly triggers on second keypress of an edit group in a snippet #43523', function () {
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, alwaysSomethingSupport));
return withOracle((model, editor) => {
return assertEvent(model.onDidSuggest, () => {
editor.setValue('d');
editor.setSelection(new Selection(1, 1, 1, 2));
editor.trigger('keyboard', Handler.Type, { text: 'e' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
const [first] = event.completionModel.items;
assert.equal(first.provider, alwaysSomethingSupport);
});
});
});
test('Fails to render completion details #47988', function () {
let disposeA = 0;
let disposeB = 0;
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos) {
return {
incomplete: true,
suggestions: [{
kind: CompletionItemKind.Folder,
label: 'CompleteNot',
insertText: 'Incomplete',
sortText: 'a',
range: getDefaultSuggestRange(doc, pos)
}],
dispose() { disposeA += 1; }
};
}
}));
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos) {
return {
incomplete: false,
suggestions: [{
kind: CompletionItemKind.Folder,
label: 'Complete',
insertText: 'Complete',
sortText: 'z',
range: getDefaultSuggestRange(doc, pos)
}],
dispose() { disposeB += 1; }
};
},
resolveCompletionItem(item) {
return item;
},
}));
return withOracle(async (model, editor) => {
await assertEvent(model.onDidSuggest, () => {
editor.setValue('');
editor.setSelection(new Selection(1, 1, 1, 1));
editor.trigger('keyboard', Handler.Type, { text: 'c' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 2);
assert.equal(disposeA, 0);
assert.equal(disposeB, 0);
});
await assertEvent(model.onDidSuggest, () => {
editor.trigger('keyboard', Handler.Type, { text: 'o' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 2);
// clean up
model.clear();
assert.equal(disposeA, 2); // provide got called two times!
assert.equal(disposeB, 1);
});
});
});
test('Trigger (full) completions when (incomplete) completions are already active #99504', function () {
let countA = 0;
let countB = 0;
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos) {
countA += 1;
return {
incomplete: false, // doesn't matter if incomplete or not
suggestions: [{
kind: CompletionItemKind.Class,
label: 'Z aaa',
insertText: 'Z aaa',
range: new Range(1, 1, pos.lineNumber, pos.column)
}],
};
}
}));
disposables.push(CompletionProviderRegistry.register({ scheme: 'test' }, {
provideCompletionItems(doc, pos) {
countB += 1;
if (!doc.getWordUntilPosition(pos).word.startsWith('a')) {
return;
}
return {
incomplete: false,
suggestions: [{
kind: CompletionItemKind.Folder,
label: 'aaa',
insertText: 'aaa',
range: getDefaultSuggestRange(doc, pos)
}],
};
},
}));
return withOracle(async (model, editor) => {
await assertEvent(model.onDidSuggest, () => {
editor.setValue('');
editor.setSelection(new Selection(1, 1, 1, 1));
editor.trigger('keyboard', Handler.Type, { text: 'Z' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 1);
assert.equal(event.completionModel.items[0].textLabel, 'Z aaa');
});
await assertEvent(model.onDidSuggest, () => {
// started another word: Z a|
// item should be: Z aaa, aaa
editor.trigger('keyboard', Handler.Type, { text: ' a' });
}, event => {
assert.equal(event.auto, true);
assert.equal(event.completionModel.items.length, 2);
assert.equal(event.completionModel.items[0].textLabel, 'Z aaa');
assert.equal(event.completionModel.items[1].textLabel, 'aaa');
assert.equal(countA, 1); // should we keep the suggestions from the "active" provider?, Yes! See: #106573
assert.equal(countB, 2);
});
});
});
});

View File

@@ -0,0 +1,121 @@
/*---------------------------------------------------------------------------------------------
* 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 { EditorSimpleWorker } from 'vs/editor/common/services/editorSimpleWorker';
import { mock } from 'vs/base/test/common/mock';
import { EditorWorkerHost, EditorWorkerServiceImpl } from 'vs/editor/common/services/editorWorkerServiceImpl';
import { IModelService } from 'vs/editor/common/services/modelService';
import { createTextModel } from 'vs/editor/test/common/editorTestUtils';
import { URI } from 'vs/base/common/uri';
import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfigurationService';
import { NullLogService } from 'vs/platform/log/common/log';
import { WordDistance } from 'vs/editor/contrib/suggest/wordDistance';
import { createTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor';
import { IRange } from 'vs/editor/common/core/range';
import { DEFAULT_WORD_REGEXP } from 'vs/editor/common/model/wordHelper';
import { Event } from 'vs/base/common/event';
import { CompletionItem } from 'vs/editor/contrib/suggest/suggest';
import { IPosition } from 'vs/editor/common/core/position';
import * as modes from 'vs/editor/common/modes';
import { DisposableStore } from 'vs/base/common/lifecycle';
import { LanguageConfigurationRegistry } from 'vs/editor/common/modes/languageConfigurationRegistry';
import { MockMode } from 'vs/editor/test/common/mocks/mockMode';
suite('suggest, word distance', function () {
class BracketMode extends MockMode {
private static readonly _id = new modes.LanguageIdentifier('bracketMode', 3);
constructor() {
super(BracketMode._id);
this._register(LanguageConfigurationRegistry.register(this.getLanguageIdentifier(), {
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
]
}));
}
}
let distance: WordDistance;
let disposables = new DisposableStore();
setup(async function () {
disposables.clear();
let mode = new BracketMode();
let model = createTextModel('function abc(aa, ab){\na\n}', undefined, mode.getLanguageIdentifier(), URI.parse('test:///some.path'));
let editor = createTestCodeEditor({ model: model });
editor.updateOptions({ suggest: { localityBonus: true } });
editor.setPosition({ lineNumber: 2, column: 2 });
let modelService = new class extends mock<IModelService>() {
onModelRemoved = Event.None;
getModel(uri: URI) {
return uri.toString() === model.uri.toString() ? model : null;
}
};
let service = new class extends EditorWorkerServiceImpl {
private _worker = new EditorSimpleWorker(new class extends mock<EditorWorkerHost>() { }, null);
constructor() {
super(modelService, new class extends mock<ITextResourceConfigurationService>() { }, new NullLogService());
this._worker.acceptNewModel({
url: model.uri.toString(),
lines: model.getLinesContent(),
EOL: model.getEOL(),
versionId: model.getVersionId()
});
model.onDidChangeContent(e => this._worker.acceptModelChanged(model.uri.toString(), e));
}
computeWordRanges(resource: URI, range: IRange): Promise<{ [word: string]: IRange[] } | null> {
return this._worker.computeWordRanges(resource.toString(), range, DEFAULT_WORD_REGEXP.source, DEFAULT_WORD_REGEXP.flags);
}
};
distance = await WordDistance.create(service, editor);
disposables.add(service);
disposables.add(mode);
disposables.add(model);
disposables.add(editor);
});
teardown(function () {
disposables.clear();
});
function createSuggestItem(label: string, overwriteBefore: number, position: IPosition): CompletionItem {
const suggestion: modes.CompletionItem = {
label,
range: { startLineNumber: position.lineNumber, startColumn: position.column - overwriteBefore, endLineNumber: position.lineNumber, endColumn: position.column },
insertText: label,
kind: 0
};
const container: modes.CompletionList = {
suggestions: [suggestion]
};
const provider: modes.CompletionItemProvider = {
provideCompletionItems(): any {
return;
}
};
return new CompletionItem(position, suggestion, container, provider);
}
test('Suggest locality bonus can boost current word #90515', function () {
const pos = { lineNumber: 2, column: 2 };
const d1 = distance.distance(pos, createSuggestItem('a', 1, pos).completion);
const d2 = distance.distance(pos, createSuggestItem('aa', 1, pos).completion);
const d3 = distance.distance(pos, createSuggestItem('ab', 1, pos).completion);
assert.ok(d1 > d2);
assert.ok(d2 === d3);
});
});

View File

@@ -0,0 +1,68 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { RawContextKey, IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IDisposable, Disposable } from 'vs/base/common/lifecycle';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
export class WordContextKey extends Disposable {
static readonly AtEnd = new RawContextKey<boolean>('atEndOfWord', false);
private readonly _ckAtEnd: IContextKey<boolean>;
private _enabled: boolean = false;
private _selectionListener?: IDisposable;
constructor(
private readonly _editor: ICodeEditor,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super();
this._ckAtEnd = WordContextKey.AtEnd.bindTo(contextKeyService);
this._register(this._editor.onDidChangeConfiguration(e => e.hasChanged(EditorOption.tabCompletion) && this._update()));
this._update();
}
dispose(): void {
super.dispose();
this._selectionListener?.dispose();
this._ckAtEnd.reset();
}
private _update(): void {
// only update this when tab completions are enabled
const enabled = this._editor.getOption(EditorOption.tabCompletion) === 'on';
if (this._enabled === enabled) {
return;
}
this._enabled = enabled;
if (this._enabled) {
const checkForWordEnd = () => {
if (!this._editor.hasModel()) {
this._ckAtEnd.set(false);
return;
}
const model = this._editor.getModel();
const selection = this._editor.getSelection();
const word = model.getWordAtPosition(selection.getStartPosition());
if (!word) {
this._ckAtEnd.set(false);
return;
}
this._ckAtEnd.set(word.endColumn === selection.getStartPosition().column);
};
this._selectionListener = this._editor.onDidChangeCursorSelection(checkForWordEnd);
checkForWordEnd();
} else if (this._selectionListener) {
this._ckAtEnd.reset();
this._selectionListener.dispose();
this._selectionListener = undefined;
}
}
}

View File

@@ -0,0 +1,82 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { binarySearch, isFalsyOrEmpty } from 'vs/base/common/arrays';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService';
import { IPosition } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { CompletionItem, CompletionItemKind } from 'vs/editor/common/modes';
import { BracketSelectionRangeProvider } from 'vs/editor/contrib/smartSelect/bracketSelections';
import { EditorOption } from 'vs/editor/common/config/editorOptions';
export abstract class WordDistance {
static readonly None = new class extends WordDistance {
distance() { return 0; }
};
static async create(service: IEditorWorkerService, editor: ICodeEditor): Promise<WordDistance> {
if (!editor.getOption(EditorOption.suggest).localityBonus) {
return WordDistance.None;
}
if (!editor.hasModel()) {
return WordDistance.None;
}
const model = editor.getModel();
const position = editor.getPosition();
if (!service.canComputeWordRanges(model.uri)) {
return WordDistance.None;
}
const [ranges] = await new BracketSelectionRangeProvider().provideSelectionRanges(model, [position]);
if (ranges.length === 0) {
return WordDistance.None;
}
const wordRanges = await service.computeWordRanges(model.uri, ranges[0].range);
if (!wordRanges) {
return WordDistance.None;
}
// remove current word
const wordUntilPos = model.getWordUntilPosition(position);
delete wordRanges[wordUntilPos.word];
return new class extends WordDistance {
distance(anchor: IPosition, suggestion: CompletionItem) {
if (!position.equals(editor.getPosition())) {
return 0;
}
if (suggestion.kind === CompletionItemKind.Keyword) {
return 2 << 20;
}
let word = typeof suggestion.label === 'string' ? suggestion.label : suggestion.label.name;
let wordLines = wordRanges[word];
if (isFalsyOrEmpty(wordLines)) {
return 2 << 20;
}
let idx = binarySearch(wordLines, Range.fromPositions(anchor), Range.compareRangesUsingStarts);
let bestWordRange = idx >= 0 ? wordLines[idx] : wordLines[Math.max(0, ~idx - 1)];
let blockDistance = ranges.length;
for (const range of ranges) {
if (!Range.containsRange(range.range, bestWordRange)) {
break;
}
blockDistance -= 1;
}
return blockDistance;
}
};
}
abstract distance(anchor: IPosition, suggestion: CompletionItem): number;
}