diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 4b170409e03..8ab1eef12fc 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -2354,6 +2354,10 @@ export interface ISuggestOptions { * Overwrite word ends on accept. Default to false. */ overwriteOnAccept?: boolean; + /** + * Should the editor highlight what text suggest will replace. + */ + highlightReplaceRange?: boolean; /** * Enable graceful matching. Defaults to true. */ @@ -2487,6 +2491,7 @@ class EditorSuggest extends BaseEditorOption(SuggestController.ID); } - private readonly _model: SuggestModel; - private readonly _widget: IdleValue; + readonly editor: ICodeEditor; + readonly model: SuggestModel; + readonly widget: IdleValue; + private readonly _alternatives: IdleValue; private readonly _lineSuffix = new MutableDisposable(); private readonly _toDispose = new DisposableStore(); constructor( - private _editor: ICodeEditor, + editor: ICodeEditor, @IEditorWorkerService editorWorker: IEditorWorkerService, @ISuggestMemoryService private readonly _memoryService: ISuggestMemoryService, @ICommandService private readonly _commandService: ICommandService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { - this._model = new SuggestModel(this._editor, editorWorker); + this.editor = editor; + this.model = new SuggestModel(this.editor, editorWorker); - this._widget = new IdleValue(() => { + this.widget = new IdleValue(() => { - const widget = this._instantiationService.createInstance(SuggestWidget, this._editor); + 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)); + const commitCharacterController = new CommitCharacterController(this.editor, widget, item => this._insertSuggestion(item, InsertFlags.NoAfterUndoStop)); this._toDispose.add(commitCharacterController); - this._toDispose.add(this._model.onDidSuggest(e => { + this._toDispose.add(this.model.onDidSuggest(e => { if (e.completionModel.items.length === 0) { commitCharacterController.reset(); } @@ -137,19 +141,19 @@ export class SuggestController implements IEditorContribution { let makesTextEdit = SuggestContext.MakesTextEdit.bindTo(this._contextKeyService); this._toDispose.add(widget.onDidFocus(({ item }) => { - const position = this._editor.getPosition()!; + 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 + 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({ + const oldText = this.editor.getModel()!.getValueInRange({ startLineNumber: position.lineNumber, startColumn, endLineNumber: position.lineNumber, @@ -161,38 +165,40 @@ export class SuggestController implements IEditorContribution { })); this._toDispose.add(toDisposable(() => makesTextEdit.reset())); + + return widget; }); this._alternatives = new IdleValue(() => { - return this._toDispose.add(new SuggestAlternatives(this._editor, this._contextKeyService)); + return this._toDispose.add(new SuggestAlternatives(this.editor, this._contextKeyService)); }); - this._toDispose.add(_instantiationService.createInstance(WordContextKey, _editor)); + this._toDispose.add(_instantiationService.createInstance(WordContextKey, editor)); - this._toDispose.add(this._model.onDidTrigger(e => { - this._widget.getValue().showTriggered(e.auto, e.shy ? 250 : 50); - this._lineSuffix.value = new LineSuffix(this._editor.getModel()!, e.position); + this._toDispose.add(this.model.onDidTrigger(e => { + this.widget.getValue().showTriggered(e.auto, e.shy ? 250 : 50); + this._lineSuffix.value = new LineSuffix(this.editor.getModel()!, e.position); })); - this._toDispose.add(this._model.onDidSuggest(e => { + 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.getValue().showSuggestions(e.completionModel, index, e.isFrozen, e.auto); + let index = this._memoryService.select(this.editor.getModel()!, this.editor.getPosition()!, e.completionModel.items); + this.widget.getValue().showSuggestions(e.completionModel, index, e.isFrozen, e.auto); } })); - this._toDispose.add(this._model.onDidCancel(e => { + this._toDispose.add(this.model.onDidCancel(e => { if (!e.retrigger) { - this._widget.getValue().hideWidget(); + this.widget.getValue().hideWidget(); } })); - this._toDispose.add(this._editor.onDidBlurEditorWidget(() => { + this._toDispose.add(this.editor.onDidBlurEditorWidget(() => { if (!_sticky) { - this._model.cancel(); - this._model.clear(); + this.model.cancel(); + this.model.clear(); } })); - this._toDispose.add(this._widget.getValue().onDetailsKeyDown(e => { + this._toDispose.add(this.widget.getValue().onDetailsKeyDown(e => { // cmd + c on macOS, ctrl + c on Win / Linux if ( e.toKeybinding().equals(new SimpleKeybinding(true, false, false, false, KeyCode.KEY_C)) || @@ -203,25 +209,28 @@ export class SuggestController implements IEditorContribution { } if (!e.toKeybinding().isModifierKey()) { - this._editor.focus(); + this.editor.focus(); } })); // Manage the acceptSuggestionsOnEnter context key let acceptSuggestionsOnEnter = SuggestContext.AcceptSuggestionsOnEnter.bindTo(_contextKeyService); let updateFromConfig = () => { - const acceptSuggestionOnEnter = this._editor.getOption(EditorOption.acceptSuggestionOnEnter); + const acceptSuggestionOnEnter = this.editor.getOption(EditorOption.acceptSuggestionOnEnter); acceptSuggestionsOnEnter.set(acceptSuggestionOnEnter === 'on' || acceptSuggestionOnEnter === 'smart'); }; - this._toDispose.add(this._editor.onDidChangeConfiguration(() => updateFromConfig())); + this._toDispose.add(this.editor.onDidChangeConfiguration(() => updateFromConfig())); updateFromConfig(); + + // create range highlighter + this._toDispose.add(new SuggestRangeHighlighter(this)); } dispose(): void { this._alternatives.dispose(); this._toDispose.dispose(); - this._widget.dispose(); - this._model.dispose(); + this.widget.dispose(); + this.model.dispose(); this._lineSuffix.dispose(); } @@ -231,32 +240,31 @@ export class SuggestController implements IEditorContribution { ): void { if (!event || !event.item) { this._alternatives.getValue().reset(); - this._model.cancel(); - this._model.clear(); + this.model.cancel(); + this.model.clear(); return; } - if (!this._editor.hasModel()) { + if (!this.editor.hasModel()) { return; } - const model = this._editor.getModel(); + const model = this.editor.getModel(); const modelVersionNow = model.getAlternativeVersionId(); const { item } = event; - const { completion: suggestion, position } = item; - const columnDelta = this._editor.getPosition().column - position.column; + const { completion: suggestion } = item; // pushing undo stops *before* additional text edits and // *after* the main edit if (!(flags & InsertFlags.NoBeforeUndoStop)) { - this._editor.pushUndoStop(); + this.editor.pushUndoStop(); } if (Array.isArray(suggestion.additionalTextEdits)) { - this._editor.executeEdits('suggestController.additionalTextEdits', suggestion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text))); + this.editor.executeEdits('suggestController.additionalTextEdits', suggestion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text))); } // keep item in memory - this._memoryService.memorize(model, this._editor.getPosition(), item); + this._memoryService.memorize(model, this.editor.getPosition(), item); let { insertText } = suggestion; if (!(suggestion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet)) { @@ -264,40 +272,38 @@ export class SuggestController implements IEditorContribution { } const overwriteConfig = flags & InsertFlags.AlternativeOverwriteConfig - ? !this._editor.getOption(EditorOption.suggest).overwriteOnAccept - : this._editor.getOption(EditorOption.suggest).overwriteOnAccept; + ? !this.editor.getOption(EditorOption.suggest).overwriteOnAccept + : this.editor.getOption(EditorOption.suggest).overwriteOnAccept; - const overwriteBefore = position.column - item.editStart.column; - const overwriteAfter = (overwriteConfig ? item.editReplaceEnd.column : item.editInsertEnd.column) - position.column; - const suffixDelta = this._lineSuffix.value ? this._lineSuffix.value.delta(this._editor.getPosition()) : 0; + const info = this.getOverwriteInfo(item, overwriteConfig); - SnippetController2.get(this._editor).insert(insertText, { - overwriteBefore: overwriteBefore + columnDelta, - overwriteAfter: overwriteAfter + suffixDelta, + SnippetController2.get(this.editor).insert(insertText, { + overwriteBefore: info.overwriteBefore, + overwriteAfter: info.overwriteAfter, undoStopBefore: false, undoStopAfter: false, adjustWhitespace: !(suggestion.insertTextRules! & CompletionItemInsertTextRule.KeepWhitespace) }); if (!(flags & InsertFlags.NoAfterUndoStop)) { - this._editor.pushUndoStop(); + this.editor.pushUndoStop(); } if (!suggestion.command) { // done - this._model.cancel(); - this._model.clear(); + this.model.cancel(); + this.model.clear(); } else if (suggestion.command.id === TriggerSuggestAction.id) { // retigger - this._model.trigger({ auto: true, shy: false }, true); + this.model.trigger({ auto: true, shy: false }, true); } else { // exec command, done this._commandService.executeCommand(suggestion.command.id, ...(suggestion.command.arguments ? [...suggestion.command.arguments] : [])) .catch(onUnexpectedError) - .finally(() => this._model.clear()); // <- clear only now, keep commands alive - this._model.cancel(); + .finally(() => this.model.clear()); // <- clear only now, keep commands alive + this.model.cancel(); } if (flags & InsertFlags.KeepAlternativeSuggestions) { @@ -321,6 +327,20 @@ export class SuggestController implements IEditorContribution { this._alertCompletionItem(event.item); } + getOverwriteInfo(item: CompletionItem, overwriteOnAccept: boolean): { overwriteBefore: number, overwriteAfter: number } { + assertType(this.editor.hasModel()); + + const overwriteBefore = item.position.column - item.editStart.column; + const overwriteAfter = (overwriteOnAccept ? 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 { if (isNonEmptyArray(suggestion.additionalTextEdits)) { let msg = nls.localize('arai.alert.snippet', "Accepting '{0}' made {1} additional edits", suggestion.label, suggestion.additionalTextEdits.length); @@ -329,22 +349,22 @@ export class SuggestController implements IEditorContribution { } triggerSuggest(onlyFrom?: Set): 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(); + 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()) { + if (!this.editor.hasModel()) { return; } - const positionNow = this._editor.getPosition(); + const positionNow = this.editor.getPosition(); const fallback = () => { - if (positionNow.equals(this._editor.getPosition()!)) { + if (positionNow.equals(this.editor.getPosition()!)) { this._commandService.executeCommand(arg.fallback); } }; @@ -354,14 +374,14 @@ export class SuggestController implements IEditorContribution { // snippet, other editor -> makes edit return true; } - const position = this._editor.getPosition()!; + 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({ + const textNow = this.editor.getModel()!.getValueInRange({ startLineNumber: position.lineNumber, startColumn, endLineNumber: position.lineNumber, @@ -371,41 +391,41 @@ export class SuggestController implements IEditorContribution { return textNow !== item.completion.insertText; }; - Event.once(this._model.onDidTrigger)(_ => { + Event.once(this.model.onDidTrigger)(_ => { // wait for trigger because only then the cancel-event is trustworthy let listener: IDisposable[] = []; - Event.any(this._model.onDidTrigger, this._model.onDidCancel)(() => { + Event.any(this.model.onDidTrigger, this.model.onDidCancel)(() => { // retrigger or cancel -> try to type default text dispose(listener); fallback(); }, undefined, listener); - this._model.onDidSuggest(({ completionModel }) => { + 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 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.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(); + 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.getValue().getFocusedItem(); + const item = this.widget.getValue().getFocusedItem(); let flags = 0; if (keepAlternativeSuggestions) { flags |= InsertFlags.KeepAlternativeSuggestions; @@ -425,45 +445,45 @@ export class SuggestController implements IEditorContribution { } cancelSuggestWidget(): void { - this._model.cancel(); - this._model.clear(); - this._widget.getValue().hideWidget(); + this.model.cancel(); + this.model.clear(); + this.widget.getValue().hideWidget(); } selectNextSuggestion(): void { - this._widget.getValue().selectNext(); + this.widget.getValue().selectNext(); } selectNextPageSuggestion(): void { - this._widget.getValue().selectNextPage(); + this.widget.getValue().selectNextPage(); } selectLastSuggestion(): void { - this._widget.getValue().selectLast(); + this.widget.getValue().selectLast(); } selectPrevSuggestion(): void { - this._widget.getValue().selectPrevious(); + this.widget.getValue().selectPrevious(); } selectPrevPageSuggestion(): void { - this._widget.getValue().selectPreviousPage(); + this.widget.getValue().selectPreviousPage(); } selectFirstSuggestion(): void { - this._widget.getValue().selectFirst(); + this.widget.getValue().selectFirst(); } toggleSuggestionDetails(): void { - this._widget.getValue().toggleDetails(); + this.widget.getValue().toggleDetails(); } toggleExplainMode(): void { - this._widget.getValue().toggleExplainMode(); + this.widget.getValue().toggleExplainMode(); } toggleSuggestionFocus(): void { - this._widget.getValue().toggleDetailsFocus(); + this.widget.getValue().toggleDetailsFocus(); } } diff --git a/src/vs/editor/contrib/suggest/suggestRangeHighlighter.ts b/src/vs/editor/contrib/suggest/suggestRangeHighlighter.ts new file mode 100644 index 00000000000..6d6f55bd0ca --- /dev/null +++ b/src/vs/editor/contrib/suggest/suggestRangeHighlighter.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { Range } from 'vs/editor/common/core/range'; +import { registerThemingParticipant } from 'vs/platform/theme/common/themeService'; +import { editorSelectionBackground, registerColor, editorSelectionHighlightBorder } from 'vs/platform/theme/common/colorRegistry'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { CompletionItem } from 'vs/editor/contrib/suggest/suggest'; +import { TrackedRangeStickiness } from 'vs/editor/common/model'; +import { SuggestController } from 'vs/editor/contrib/suggest/suggestController'; +import { localize } from 'vs/nls'; + + +const suggestReplaceBackgroundColor = registerColor( + 'editor.suggestReplaceBackground', + { light: editorSelectionBackground, dark: editorSelectionBackground, hc: editorSelectionBackground }, + localize('suggestReplaceBackground', "Background color of text that suggest will replace.") +); + +const suggestReplaceBorderColor = registerColor( + 'editor.suggestReplaceBorder', + { light: null, dark: null, hc: editorSelectionHighlightBorder }, + localize('suggestReplaceBorder', "Border color of text that suggest will replace.") +); + +registerThemingParticipant((theme, collector) => { + const suggestReplaceBackground = theme.getColor(suggestReplaceBackgroundColor); + if (suggestReplaceBackground) { + collector.addRule(`.monaco-editor .suggestReplace { background-color: ${suggestReplaceBackground}; }`); + } + const suggestReplaceBorder = theme.getColor(suggestReplaceBorderColor); + if (suggestReplaceBorder) { + collector.addRule(`.monaco-editor .suggestReplace { border: 1px ${theme.type === 'hc' ? 'dotted' : 'solid'} ${suggestReplaceBorder}; }`); + } +}); + +export class SuggestRangeHighlighter { + + private readonly _disposables = new DisposableStore(); + + private _decorations: string[] = []; + private _hasWidgetListener: boolean = false; + + constructor(private readonly _controller: SuggestController) { + + this._disposables.add(_controller.model.onDidSuggest(e => { + if (!e.shy) { + const widget = this._controller.widget.getValue(); + const focused = widget.getFocusedItem(); + if (focused) { + this._highlight(focused.item); + } + + if (!this._hasWidgetListener) { + this._hasWidgetListener = true; + widget.onDidFocus(e => this._highlight(e.item), undefined, this._disposables); + } + } + })); + this._disposables.add(_controller.model.onDidCancel(() => { + this._reset(); + })); + } + + dispose(): void { + this._reset(); + this._disposables.dispose(); + } + + private _reset(): void { + this._decorations = this._controller.editor.deltaDecorations(this._decorations, []); + } + + private _highlight(item: CompletionItem) { + + const { overwriteOnAccept, highlightReplaceRange } = this._controller.editor.getOption(EditorOption.suggest); + + if (highlightReplaceRange) { + const info = this._controller.getOverwriteInfo(item, overwriteOnAccept); + const position = this._controller.editor.getPosition()!; + const range = new Range( + position.lineNumber, position.column - info.overwriteBefore, + position.lineNumber, position.column + info.overwriteAfter + ); + + this._decorations = this._controller.editor.deltaDecorations(this._decorations, [{ + range, + options: { + className: 'suggestReplace', + stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges + } + }]); + } + } +} diff --git a/src/vs/editor/contrib/suggest/test/completionModel.test.ts b/src/vs/editor/contrib/suggest/test/completionModel.test.ts index 0f4d43d9902..05eda1333a9 100644 --- a/src/vs/editor/contrib/suggest/test/completionModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/completionModel.test.ts @@ -35,6 +35,7 @@ suite('CompletionModel', function () { let defaultOptions = { overwriteOnAccept: false, + highlightReplaceRange: true, snippetsPreventQuickSuggestions: true, filterGraceful: true, localityBonus: false, diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 60982c5b34b..990de2aa73d 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3397,6 +3397,10 @@ declare namespace monaco.editor { * Overwrite word ends on accept. Default to false. */ overwriteOnAccept?: boolean; + /** + * Should the editor highlight what text suggest will replace. + */ + highlightReplaceRange?: boolean; /** * Enable graceful matching. Defaults to true. */