diff --git a/src/vs/base/common/observableImpl/base.ts b/src/vs/base/common/observableImpl/base.ts index 9d83083e49ba9..2f7f0a3dca8f1 100644 --- a/src/vs/base/common/observableImpl/base.ts +++ b/src/vs/base/common/observableImpl/base.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IDisposable } from 'vs/base/common/lifecycle'; import type { derived } from 'vs/base/common/observableImpl/derived'; import { getLogger } from 'vs/base/common/observableImpl/logging'; @@ -25,9 +26,9 @@ export interface IObservable { /** * Subscribes the reader to this observable and returns the current value of this observable. */ - read(reader: IReader): T; + read(reader: IReader | undefined): T; - map(fn: (value: T) => TNew): IObservable; + map(fn: (value: T, reader: IReader) => TNew): IObservable; readonly debugName: string; } @@ -100,19 +101,19 @@ export abstract class ConvenientObservable implements IObservable(fn: (value: T) => TNew): IObservable { + public map(fn: (value: T, reader: IReader) => TNew): IObservable { return _derived( () => { const name = getFunctionName(fn); return name !== undefined ? name : `${this.debugName} (mapped)`; }, - (reader) => fn(this.read(reader)) + (reader) => fn(this.read(reader), reader) ); } @@ -204,19 +205,19 @@ export class ObservableValue extends BaseObservable implements ISettableObservable { - private value: T; + protected _value: T; constructor(public readonly debugName: string, initialValue: T) { super(); - this.value = initialValue; + this._value = initialValue; } public get(): T { - return this.value; + return this._value; } public set(value: T, tx: ITransaction | undefined, change: TChange): void { - if (this.value === value) { + if (this._value === value) { return; } @@ -227,8 +228,8 @@ export class ObservableValue return; } - const oldValue = this.value; - this.value = value; + const oldValue = this._value; + this._setValue(value); getLogger()?.handleObservableChanged(this, { oldValue, newValue: value, change, didChange: true }); for (const observer of this.observers) { @@ -238,7 +239,30 @@ export class ObservableValue } override toString(): string { - return `${this.debugName}: ${this.value}`; + return `${this.debugName}: ${this._value}`; } + + protected _setValue(newValue: T): void { + this._value = newValue; + } +} + +export function disposableObservableValue(name: string, initialValue: T): ISettableObservable & IDisposable { + return new DisposableObservableValue(name, initialValue); } +export class DisposableObservableValue extends ObservableValue implements IDisposable { + protected override _setValue(newValue: T): void { + if (this._value === newValue) { + return; + } + if (this._value) { + this._value.dispose(); + } + this._value = newValue; + } + + public dispose(): void { + this._value?.dispose(); + } +} diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 3f1626af433a4..d9c10a656d282 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -621,19 +621,29 @@ export enum InlineCompletionTriggerKind { } export interface InlineCompletionContext { + /** * How the completion was triggered. */ readonly triggerKind: InlineCompletionTriggerKind; - readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined; } -export interface SelectedSuggestionInfo { - range: IRange; - text: string; - isSnippetText: boolean; - completionKind: CompletionItemKind; +export class SelectedSuggestionInfo { + constructor( + public readonly range: IRange, + public readonly text: string, + public readonly completionKind: CompletionItemKind, + public readonly isSnippetText: boolean, + ) { + } + + public equals(other: SelectedSuggestionInfo) { + return Range.lift(this.range).equalsRange(other.range) + && this.text === other.text + && this.completionKind === other.completionKind + && this.isSnippetText === other.isSnippetText; + } } export interface InlineCompletion { diff --git a/src/vs/editor/contrib/hover/browser/hover.ts b/src/vs/editor/contrib/hover/browser/hover.ts index acec580fa31eb..6d831dcec3421 100644 --- a/src/vs/editor/contrib/hover/browser/hover.ts +++ b/src/vs/editor/contrib/hover/browser/hover.ts @@ -28,7 +28,7 @@ import { HoverParticipantRegistry } from 'vs/editor/contrib/hover/browser/hoverT import { MarkdownHoverParticipant } from 'vs/editor/contrib/hover/browser/markdownHoverParticipant'; import { MarkerHoverParticipant } from 'vs/editor/contrib/hover/browser/markerHoverParticipant'; import 'vs/css!./hover'; -import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget'; +import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ResultKind } from 'vs/platform/keybinding/common/keybindingResolver'; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/consts.ts b/src/vs/editor/contrib/inlineCompletions/browser/commandIds.ts similarity index 100% rename from src/vs/editor/contrib/inlineCompletions/browser/consts.ts rename to src/vs/editor/contrib/inlineCompletions/browser/commandIds.ts diff --git a/src/vs/editor/contrib/inlineCompletions/browser/commands.ts b/src/vs/editor/contrib/inlineCompletions/browser/commands.ts new file mode 100644 index 0000000000000..fbc2a2d0d552a --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/commands.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { transaction } from 'vs/base/common/observable'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { showNextInlineSuggestionActionId, showPreviousInlineSuggestionActionId, inlineSuggestCommitId } from 'vs/editor/contrib/inlineCompletions/browser/commandIds'; +import { InlineCompletionContextKeys, InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; +import * as nls from 'vs/nls'; +import { MenuId, Action2 } from 'vs/platform/actions/common/actions'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; + +export class ShowNextInlineSuggestionAction extends EditorAction { + public static ID = showNextInlineSuggestionActionId; + constructor() { + super({ + id: ShowNextInlineSuggestionAction.ID, + label: nls.localize('action.inlineSuggest.showNext', "Show Next Inline Suggestion"), + alias: 'Show Next Inline Suggestion', + precondition: ContextKeyExpr.and(EditorContextKeys.writable, InlineCompletionContextKeys.inlineSuggestionVisible), + kbOpts: { + weight: 100, + primary: KeyMod.Alt | KeyCode.BracketRight, + }, + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.get(editor); + controller?.model.get()?.next(); + } +} + +export class ShowPreviousInlineSuggestionAction extends EditorAction { + public static ID = showPreviousInlineSuggestionActionId; + constructor() { + super({ + id: ShowPreviousInlineSuggestionAction.ID, + label: nls.localize('action.inlineSuggest.showPrevious', "Show Previous Inline Suggestion"), + alias: 'Show Previous Inline Suggestion', + precondition: ContextKeyExpr.and(EditorContextKeys.writable, InlineCompletionContextKeys.inlineSuggestionVisible), + kbOpts: { + weight: 100, + primary: KeyMod.Alt | KeyCode.BracketLeft, + }, + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.get(editor); + controller?.model.get()?.previous(); + } +} + +export class TriggerInlineSuggestionAction extends EditorAction { + constructor() { + super({ + id: 'editor.action.inlineSuggest.trigger', + label: nls.localize('action.inlineSuggest.trigger', "Trigger Inline Suggestion"), + alias: 'Trigger Inline Suggestion', + precondition: EditorContextKeys.writable + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.get(editor); + controller?.model.get()?.trigger(undefined); + } +} + +export class AcceptNextWordOfInlineCompletion extends EditorAction { + constructor() { + super({ + id: 'editor.action.inlineSuggest.acceptNextWord', + label: nls.localize('action.inlineSuggest.acceptNextWord', "Accept Next Word Of Inline Suggestion"), + alias: 'Accept Next Word Of Inline Suggestion', + precondition: ContextKeyExpr.and(EditorContextKeys.writable, InlineCompletionContextKeys.inlineSuggestionVisible), + kbOpts: { + weight: KeybindingWeight.EditorContrib + 1, + primary: KeyMod.CtrlCmd | KeyCode.RightArrow, + }, + menuOpts: [{ + menuId: MenuId.InlineSuggestionToolbar, + title: nls.localize('acceptWord', 'Accept Word'), + group: 'primary', + order: 2, + }], + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.get(editor); + controller?.model.get()?.acceptNextWord(controller.editor); + } +} + +export class AcceptInlineCompletion extends EditorAction { + constructor() { + super({ + id: inlineSuggestCommitId, + label: nls.localize('action.inlineSuggest.accept', "Accept Inline Suggestion"), + alias: 'Accept Inline Suggestion', + precondition: InlineCompletionContextKeys.inlineSuggestionVisible, + menuOpts: [{ + menuId: MenuId.InlineSuggestionToolbar, + title: nls.localize('accept', "Accept"), + group: 'primary', + order: 1, + }], + kbOpts: { + primary: KeyCode.Tab, + weight: 200, + kbExpr: ContextKeyExpr.and( + InlineCompletionContextKeys.inlineSuggestionVisible, + EditorContextKeys.tabMovesFocus.toNegated(), + InlineCompletionContextKeys.inlineSuggestionHasIndentationLessThanTabSize + ), + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.get(editor); + if (controller) { + controller.model.get()?.accept(controller.editor); + controller.editor.focus(); + } + } +} + +export class HideInlineCompletion extends EditorAction { + public static ID = 'editor.action.inlineSuggest.hide'; + + constructor() { + super({ + id: HideInlineCompletion.ID, + label: nls.localize('action.inlineSuggest.hide', "Hide Inline Suggestion"), + alias: 'Hide Inline Suggestion', + precondition: InlineCompletionContextKeys.inlineSuggestionVisible, + kbOpts: { + weight: 100, + primary: KeyCode.Escape, + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineCompletionsController.get(editor); + transaction(tx => { + controller?.model.get()?.stop(tx); + }); + } +} + +export class ToggleAlwaysShowInlineSuggestionToolbar extends Action2 { + public static ID = 'editor.action.inlineSuggest.toggleAlwaysShowToolbar'; + + constructor() { + super({ + id: ToggleAlwaysShowInlineSuggestionToolbar.ID, + title: nls.localize('action.inlineSuggest.alwaysShowToolbar', "Always Show Toolbar"), + f1: false, + precondition: undefined, + menu: [{ + id: MenuId.InlineSuggestionToolbar, + group: 'secondary', + order: 10, + }], + toggled: InlineCompletionContextKeys.alwaysShowInlineSuggestionToolbar, + }); + } + + public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { + const configService = accessor.get(IConfigurationService); + const currentValue = configService.getValue<'always' | 'onHover'>('editor.inlineSuggest.showToolbar'); + const newValue = currentValue === 'always' ? 'onHover' : 'always'; + configService.updateValue('editor.inlineSuggest.showToolbar', newValue); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.css b/src/vs/editor/contrib/inlineCompletions/browser/ghostText.css index d189a64ce7edf..ea7193a130d1c 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.css +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostText.css @@ -24,11 +24,7 @@ font-size: 0; } -.monaco-editor .ghost-text-decoration { - font-style: italic; -} - -.monaco-editor .suggest-preview-text { +.monaco-editor .ghost-text-decoration, .monaco-editor .suggest-preview-text .ghost-text { font-style: italic; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts index 69a700b284354..e4922230e3d9c 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostText.ts @@ -3,17 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable } from 'vs/base/common/lifecycle'; -import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorOption } from 'vs/editor/common/config/editorOptions'; -import { applyEdits } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { Range } from 'vs/editor/common/core/range'; +import { ColumnRange, applyEdits } from 'vs/editor/contrib/inlineCompletions/browser/utils'; export class GhostText { - public static equals(a: GhostText | undefined, b: GhostText | undefined): boolean { - return a === b || (!!a && !!b && a.equals(b)); - } - constructor( public readonly lineNumber: number, public readonly parts: GhostTextPart[], @@ -32,14 +25,12 @@ export class GhostText { */ render(documentText: string, debug: boolean = false): string { const l = this.lineNumber; - return applyEdits(documentText, - [ - ...this.parts.map(p => ({ - range: { startLineNumber: l, endLineNumber: l, startColumn: p.column, endColumn: p.column }, - text: debug ? `[${p.lines.join('\n')}]` : p.lines.join('\n') - })), - ] - ); + return applyEdits(documentText, [ + ...this.parts.map(p => ({ + range: { startLineNumber: l, endLineNumber: l, startColumn: p.column, endColumn: p.column }, + text: debug ? `[${p.lines.join('\n')}]` : p.lines.join('\n') + })), + ]); } renderForScreenReader(lineText: string): string { @@ -62,6 +53,10 @@ export class GhostText { isEmpty(): boolean { return this.parts.every(p => p.lines.length === 0); } + + get lineCount(): number { + return 1 + this.parts.reduce((r, p) => r + p.lines.length - 1, 0); + } } export class GhostTextPart { @@ -83,96 +78,43 @@ export class GhostTextPart { } export class GhostTextReplacement { - constructor( - readonly lineNumber: number, - readonly columnStart: number, - readonly length: number, - readonly newLines: readonly string[], - public readonly additionalReservedLineCount: number = 0, - ) { } public readonly parts: ReadonlyArray = [ new GhostTextPart( - this.columnStart + this.length, + this.columnRange.endColumnExclusive, this.newLines, false ), ]; + constructor( + readonly lineNumber: number, + readonly columnRange: ColumnRange, + readonly newLines: readonly string[], + public readonly additionalReservedLineCount: number = 0, + ) { } + renderForScreenReader(_lineText: string): string { return this.newLines.join('\n'); } render(documentText: string, debug: boolean = false): string { - const startLineNumber = this.lineNumber; - const endLineNumber = this.lineNumber; + const replaceRange = this.columnRange.toRange(this.lineNumber); if (debug) { - return applyEdits(documentText, - [ - { - range: { startLineNumber, endLineNumber, startColumn: this.columnStart, endColumn: this.columnStart }, - text: `(` - }, - { - range: { startLineNumber, endLineNumber, startColumn: this.columnStart + this.length, endColumn: this.columnStart + this.length }, - text: `)[${this.newLines.join('\n')}]` - } - ] - ); + return applyEdits(documentText, [ + { range: Range.fromPositions(replaceRange.getStartPosition()), text: `(` }, + { range: Range.fromPositions(replaceRange.getEndPosition()), text: `)[${this.newLines.join('\n')}]` } + ]); } else { - return applyEdits(documentText, - [ - { - range: { startLineNumber, endLineNumber, startColumn: this.columnStart, endColumn: this.columnStart + this.length }, - text: this.newLines.join('\n') - } - ] - ); + return applyEdits(documentText, [ + { range: replaceRange, text: this.newLines.join('\n') } + ]); } } -} - -export interface GhostTextWidgetModel { - readonly onDidChange: Event; - readonly ghostText: GhostText | GhostTextReplacement | undefined; - - setExpanded(expanded: boolean): void; - readonly expanded: boolean; - - readonly minReservedLineCount: number; -} - -export abstract class BaseGhostTextWidgetModel extends Disposable implements GhostTextWidgetModel { - public abstract readonly ghostText: GhostText | GhostTextReplacement | undefined; - - private _expanded: boolean | undefined = undefined; - - protected readonly onDidChangeEmitter = new Emitter(); - public readonly onDidChange = this.onDidChangeEmitter.event; - public abstract readonly minReservedLineCount: number; - - public get expanded() { - if (this._expanded === undefined) { - // TODO this should use a global hidden setting. - // See https://github.com/microsoft/vscode/issues/125037. - return true; - } - return this._expanded; - } - - constructor(protected readonly editor: IActiveCodeEditor) { - super(); - - this._register(editor.onDidChangeConfiguration((e) => { - if (e.hasChanged(EditorOption.suggest) && this._expanded === undefined) { - this.onDidChangeEmitter.fire(); - } - })); - } - - public setExpanded(expanded: boolean): void { - this._expanded = true; - this.onDidChangeEmitter.fire(); + get lineCount(): number { + return this.newLines.length; } } + +export type GhostTextOrReplacement = GhostText | GhostTextReplacement; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts deleted file mode 100644 index 7d5ecbfbb3403..0000000000000 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextController.ts +++ /dev/null @@ -1,437 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from 'vs/base/common/event'; -import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; -import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { firstNonWhitespaceIndex } from 'vs/base/common/strings'; -import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorAction, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; -import { EditorOption } from 'vs/editor/common/config/editorOptions'; -import { CursorColumns } from 'vs/editor/common/core/cursorColumns'; -import { Range } from 'vs/editor/common/core/range'; -import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; -import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { inlineSuggestCommitId, showNextInlineSuggestionActionId, showPreviousInlineSuggestionActionId } from 'vs/editor/contrib/inlineCompletions/browser/consts'; -import { GhostTextModel } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextModel'; -import { GhostTextWidget } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextWidget'; -import { InlineSuggestionHintsWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget'; -import * as nls from 'vs/nls'; -import { Action2, MenuId } from 'vs/platform/actions/common/actions'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { ContextKeyExpr, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; - -export class GhostTextController extends Disposable { - public static readonly inlineSuggestionVisible = new RawContextKey('inlineSuggestionVisible', false, nls.localize('inlineSuggestionVisible', "Whether an inline suggestion is visible")); - public static readonly inlineSuggestionHasIndentation = new RawContextKey('inlineSuggestionHasIndentation', false, nls.localize('inlineSuggestionHasIndentation', "Whether the inline suggestion starts with whitespace")); - public static readonly inlineSuggestionHasIndentationLessThanTabSize = new RawContextKey('inlineSuggestionHasIndentationLessThanTabSize', true, nls.localize('inlineSuggestionHasIndentationLessThanTabSize', "Whether the inline suggestion starts with whitespace that is less than what would be inserted by tab")); - /** - * Enables to use Ctrl+Left to undo partially accepted inline completions. - */ - public static readonly canUndoInlineSuggestion = new RawContextKey('canUndoInlineSuggestion', false, nls.localize('canUndoInlineSuggestion', "Whether undo would undo an inline suggestion")); - - public static readonly alwaysShowInlineSuggestionToolbar = new RawContextKey('alwaysShowInlineSuggestionToolbar', false, nls.localize('alwaysShowInlineSuggestionToolbar', "Whether the inline suggestion toolbar should always be visible")); - - static ID = 'editor.contrib.ghostTextController'; - - public static get(editor: ICodeEditor): GhostTextController | null { - return editor.getContribution(GhostTextController.ID); - } - - private triggeredExplicitly = false; - protected readonly activeController = this._register(new MutableDisposable()); - public get activeModel(): GhostTextModel | undefined { - return this.activeController.value?.model; - } - - private readonly activeModelDidChangeEmitter = this._register(new Emitter()); - public readonly onActiveModelDidChange = this.activeModelDidChangeEmitter.event; - - /** - * Tracks the first alternative version id until which only partial inline suggestions can be undone. - * Any other content change will invalidate this. - * This field is used to set the corresponding context key. - */ - private firstUndoableVersionId: number | undefined = undefined; - - public readonly alwaysShowInlineSuggestionToolbar = GhostTextController.alwaysShowInlineSuggestionToolbar.bindTo(this.contextKeyService); - - constructor( - public readonly editor: ICodeEditor, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - ) { - super(); - - this._register(this.editor.onDidChangeModelContent((e) => { - if (!e.isUndoing || this.firstUndoableVersionId && this.editor.getModel()!.getAlternativeVersionId() < this.firstUndoableVersionId) { - this.activeController.value?.contextKeys.canUndoInlineSuggestion.reset(); - this.firstUndoableVersionId = undefined; // Will be set again if this change was caused by an inline suggestion. - } - })); - - this._register(this.editor.onDidChangeCursorPosition((e) => { - if (e.reason === CursorChangeReason.Explicit) { - this.activeController.value?.contextKeys.canUndoInlineSuggestion.reset(); - this.firstUndoableVersionId = undefined; - } - })); - - this._register(this.editor.onDidChangeModel(() => { - this.update(); - })); - this._register(this.editor.onDidChangeConfiguration((e) => { - if (e.hasChanged(EditorOption.suggest) || e.hasChanged(EditorOption.inlineSuggest)) { - this.update(); - } - })); - this.update(); - } - - // Don't call this method when not necessary. It will recreate the activeController. - private update(): void { - const suggestOptions = this.editor.getOption(EditorOption.suggest); - const inlineSuggestOptions = this.editor.getOption(EditorOption.inlineSuggest); - - this.alwaysShowInlineSuggestionToolbar.set(inlineSuggestOptions.showToolbar === 'always'); - - const shouldCreate = this.editor.hasModel() && (suggestOptions.preview || inlineSuggestOptions.enabled || this.triggeredExplicitly); - - if (shouldCreate !== !!this.activeController.value) { - this.activeController.value = undefined; - // ActiveGhostTextController is only created if one of those settings is set or if the inline completions are triggered explicitly. - this.activeController.value = - shouldCreate ? this.instantiationService.createInstance( - ActiveGhostTextController, - this.editor as IActiveCodeEditor - ) - : undefined; - this.activeModelDidChangeEmitter.fire(); - } - } - - public shouldShowHoverAt(hoverRange: Range): boolean { - return this.activeModel?.shouldShowHoverAt(hoverRange) || false; - } - - public shouldShowHoverAtViewZone(viewZoneId: string): boolean { - return this.activeController.value?.widget?.shouldShowHoverAtViewZone(viewZoneId) || false; - } - - public trigger(): void { - this.triggeredExplicitly = true; - if (!this.activeController.value) { - this.update(); - } - this.activeModel?.triggerInlineCompletion(); - } - - public commitPartially(): void { - const nextVersion = this.firstUndoableVersionId; // Read this before committing, as it will be reset. - this.activeModel?.commitInlineCompletionPartially(); - this.activeController?.value?.contextKeys.canUndoInlineSuggestion.set(true); - // Don't override this field if the previous command already accepted some inline suggestion. - this.firstUndoableVersionId = nextVersion ?? this.editor.getModel()!.getAlternativeVersionId(); - } - - public commit(): void { - this.activeModel?.commitInlineCompletion(); - } - - public hide(): void { - this.activeModel?.hideInlineCompletion(); - } - - public showNextInlineCompletion(): void { - this.activeModel?.showNextInlineCompletion(); - } - - public showPreviousInlineCompletion(): void { - this.activeModel?.showPreviousInlineCompletion(); - } - - public async getInlineCompletionsCount(): Promise { - const result = await this.activeModel?.getInlineCompletionsCount(); - return result ?? 0; - } -} - -class GhostTextContextKeys { - public readonly inlineCompletionVisible = GhostTextController.inlineSuggestionVisible.bindTo(this.contextKeyService); - public readonly inlineCompletionSuggestsIndentation = GhostTextController.inlineSuggestionHasIndentation.bindTo(this.contextKeyService); - public readonly inlineCompletionSuggestsIndentationLessThanTabSize = GhostTextController.inlineSuggestionHasIndentationLessThanTabSize.bindTo(this.contextKeyService); - public readonly canUndoInlineSuggestion = GhostTextController.canUndoInlineSuggestion.bindTo(this.contextKeyService); - - constructor(private readonly contextKeyService: IContextKeyService) { - } -} - -/** - * The controller for a text editor with an initialized text model. - * Must be disposed as soon as the model detaches from the editor. -*/ -export class ActiveGhostTextController extends Disposable { - public readonly contextKeys = new GhostTextContextKeys(this.contextKeyService); - public readonly model = this._register(this.instantiationService.createInstance(GhostTextModel, this.editor)); - public readonly widget = this._register(this.instantiationService.createInstance(GhostTextWidget, this.editor, this.model)); - - public readonly hintsWidget = this._register(this.instantiationService.createInstance(InlineSuggestionHintsWidget, this.editor, this.model.inlineCompletionsModel)); - - constructor( - private readonly editor: IActiveCodeEditor, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - ) { - super(); - - this._register(toDisposable(() => { - this.contextKeys.inlineCompletionVisible.set(false); - this.contextKeys.inlineCompletionSuggestsIndentation.set(false); - this.contextKeys.inlineCompletionSuggestsIndentationLessThanTabSize.set(true); - })); - - this._register(this.model.onDidChange(() => { - this.updateContextKeys(); - })); - this.updateContextKeys(); - } - - private updateContextKeys(): void { - this.contextKeys.inlineCompletionVisible.set( - this.model.activeInlineCompletionsModel?.ghostText !== undefined - ); - - let startsWithIndentation = false; - let startsWithIndentationLessThanTabSize = true; - - const ghostText = this.model.inlineCompletionsModel.ghostText; - if (!!this.model.activeInlineCompletionsModel && ghostText && ghostText.parts.length > 0) { - const { column, lines } = ghostText.parts[0]; - - const firstLine = lines[0]; - - const indentationEndColumn = this.editor.getModel().getLineIndentColumn(ghostText.lineNumber); - const inIndentation = column <= indentationEndColumn; - - if (inIndentation) { - let firstNonWsIdx = firstNonWhitespaceIndex(firstLine); - if (firstNonWsIdx === -1) { - firstNonWsIdx = firstLine.length - 1; - } - startsWithIndentation = firstNonWsIdx > 0; - - const tabSize = this.editor.getModel().getOptions().tabSize; - const visibleColumnIndentation = CursorColumns.visibleColumnFromColumn(firstLine, firstNonWsIdx + 1, tabSize); - startsWithIndentationLessThanTabSize = visibleColumnIndentation < tabSize; - } - } - - this.contextKeys.inlineCompletionSuggestsIndentation.set(startsWithIndentation); - this.contextKeys.inlineCompletionSuggestsIndentationLessThanTabSize.set(startsWithIndentationLessThanTabSize); - } -} - - -export class ShowNextInlineSuggestionAction extends EditorAction { - public static ID = showNextInlineSuggestionActionId; - constructor() { - super({ - id: ShowNextInlineSuggestionAction.ID, - label: nls.localize('action.inlineSuggest.showNext', "Show Next Inline Suggestion"), - alias: 'Show Next Inline Suggestion', - precondition: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.inlineSuggestionVisible), - kbOpts: { - weight: 100, - primary: KeyMod.Alt | KeyCode.BracketRight, - }, - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - if (controller) { - controller.showNextInlineCompletion(); - } - } -} - -export class ShowPreviousInlineSuggestionAction extends EditorAction { - public static ID = showPreviousInlineSuggestionActionId; - constructor() { - super({ - id: ShowPreviousInlineSuggestionAction.ID, - label: nls.localize('action.inlineSuggest.showPrevious', "Show Previous Inline Suggestion"), - alias: 'Show Previous Inline Suggestion', - precondition: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.inlineSuggestionVisible), - kbOpts: { - weight: 100, - primary: KeyMod.Alt | KeyCode.BracketLeft, - }, - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - if (controller) { - controller.showPreviousInlineCompletion(); - } - } -} - -export class TriggerInlineSuggestionAction extends EditorAction { - constructor() { - super({ - id: 'editor.action.inlineSuggest.trigger', - label: nls.localize('action.inlineSuggest.trigger', "Trigger Inline Suggestion"), - alias: 'Trigger Inline Suggestion', - precondition: EditorContextKeys.writable - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - controller?.trigger(); - } -} - -export class AcceptNextWordOfInlineCompletion extends EditorAction { - constructor() { - super({ - id: 'editor.action.inlineSuggest.acceptNextWord', - label: nls.localize('action.inlineSuggest.acceptNextWord', "Accept Next Word Of Inline Suggestion"), - alias: 'Accept Next Word Of Inline Suggestion', - precondition: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.inlineSuggestionVisible), - kbOpts: { - weight: KeybindingWeight.EditorContrib + 1, - primary: KeyMod.CtrlCmd | KeyCode.RightArrow, - }, - menuOpts: [{ - menuId: MenuId.InlineSuggestionToolbar, - title: nls.localize('acceptWord', 'Accept Word'), - group: 'primary', - order: 2, - }], - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - if (controller) { - controller.commitPartially(); - } - } -} - -export class AcceptInlineCompletion extends EditorAction { - constructor() { - super({ - id: inlineSuggestCommitId, - label: nls.localize('action.inlineSuggest.accept', "Accept Inline Suggestion"), - alias: 'Accept Inline Suggestion', - precondition: GhostTextController.inlineSuggestionVisible, - menuOpts: [{ - menuId: MenuId.InlineSuggestionToolbar, - title: nls.localize('accept', "Accept"), - group: 'primary', - order: 1, - }], - kbOpts: { - primary: KeyCode.Tab, - weight: 200, - kbExpr: ContextKeyExpr.and( - GhostTextController.inlineSuggestionVisible, - EditorContextKeys.tabMovesFocus.toNegated(), - GhostTextController.inlineSuggestionHasIndentationLessThanTabSize - ), - } - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - if (controller) { - controller.commit(); - controller.editor.focus(); - } - } -} - -export class HideInlineCompletion extends EditorAction { - public static ID = 'editor.action.inlineSuggest.hide'; - - constructor() { - super({ - id: HideInlineCompletion.ID, - label: nls.localize('action.inlineSuggest.hide', "Hide Inline Suggestion"), - alias: 'Hide Inline Suggestion', - precondition: GhostTextController.inlineSuggestionVisible, - kbOpts: { - weight: 100, - primary: KeyCode.Escape, - } - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - if (controller) { - controller.hide(); - } - } -} - -export class ToggleAlwaysShowInlineSuggestionToolbar extends Action2 { - public static ID = 'editor.action.inlineSuggest.toggleAlwaysShowToolbar'; - - constructor() { - super({ - id: ToggleAlwaysShowInlineSuggestionToolbar.ID, - title: nls.localize('action.inlineSuggest.alwaysShowToolbar', "Always Show Toolbar"), - f1: false, - precondition: undefined, - menu: [{ - id: MenuId.InlineSuggestionToolbar, - group: 'secondary', - order: 10, - }], - toggled: GhostTextController.alwaysShowInlineSuggestionToolbar, - }); - } - - public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { - const configService = accessor.get(IConfigurationService); - const currentValue = configService.getValue<'always' | 'onHover'>('editor.inlineSuggest.showToolbar'); - const newValue = currentValue === 'always' ? 'onHover' : 'always'; - configService.updateValue('editor.inlineSuggest.showToolbar', newValue); - } -} - -export class UndoAcceptPart extends EditorAction { - constructor() { - super({ - id: 'editor.action.inlineSuggest.undo', - label: nls.localize('action.inlineSuggest.undo', "Undo Accept Word"), - alias: 'Undo Accept Word', - precondition: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.canUndoInlineSuggestion), - kbOpts: { - weight: KeybindingWeight.EditorContrib + 1, - primary: KeyMod.CtrlCmd | KeyCode.LeftArrow, - kbExpr: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.canUndoInlineSuggestion), - }, - menuOpts: [{ - menuId: MenuId.InlineSuggestionToolbar, - title: nls.localize('undoAcceptWord', 'Undo Accept Word'), - group: 'secondary', - order: 3, - }], - }); - } - - public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { - editor.getModel()?.undo(); - } -} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts deleted file mode 100644 index 1302ead9f656d..0000000000000 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts +++ /dev/null @@ -1,168 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Emitter } from 'vs/base/common/event'; -import { Disposable, IReference, MutableDisposable } from 'vs/base/common/lifecycle'; -import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { Position } from 'vs/editor/common/core/position'; -import { Range } from 'vs/editor/common/core/range'; -import { InlineCompletionTriggerKind } from 'vs/editor/common/languages'; -import { GhostText, GhostTextReplacement, GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; -import { InlineCompletionsModel, SynchronizedInlineCompletionsCache, TrackedInlineCompletions } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; -import { SuggestWidgetPreviewModel } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetPreviewModel'; -import { createDisposableRef } from 'vs/editor/contrib/inlineCompletions/browser/utils'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; - -export abstract class DelegatingModel extends Disposable implements GhostTextWidgetModel { - private readonly onDidChangeEmitter = new Emitter(); - public readonly onDidChange = this.onDidChangeEmitter.event; - - private hasCachedGhostText = false; - private cachedGhostText: GhostText | GhostTextReplacement | undefined; - - private readonly currentModelRef = this._register(new MutableDisposable>()); - protected get targetModel(): GhostTextWidgetModel | undefined { - return this.currentModelRef.value?.object; - } - - protected setTargetModel(model: GhostTextWidgetModel | undefined): void { - if (this.currentModelRef.value?.object === model) { - return; - } - this.currentModelRef.clear(); - this.currentModelRef.value = model ? createDisposableRef(model, model.onDidChange(() => { - this.hasCachedGhostText = false; - this.onDidChangeEmitter.fire(); - })) : undefined; - - this.hasCachedGhostText = false; - this.onDidChangeEmitter.fire(); - } - - public get ghostText(): GhostText | GhostTextReplacement | undefined { - if (!this.hasCachedGhostText) { - this.cachedGhostText = this.currentModelRef.value?.object?.ghostText; - this.hasCachedGhostText = true; - } - return this.cachedGhostText; - } - - public setExpanded(expanded: boolean): void { - this.targetModel?.setExpanded(expanded); - } - - public get expanded(): boolean { - return this.targetModel ? this.targetModel.expanded : false; - } - - public get minReservedLineCount(): number { - return this.targetModel ? this.targetModel.minReservedLineCount : 0; - } -} - -/** - * A ghost text model that is both driven by inline completions and the suggest widget. -*/ -export class GhostTextModel extends DelegatingModel implements GhostTextWidgetModel { - public readonly sharedCache = this._register(new SharedInlineCompletionCache()); - public readonly suggestWidgetAdapterModel = this._register(this.instantiationService.createInstance(SuggestWidgetPreviewModel, this.editor, this.sharedCache)); - public readonly inlineCompletionsModel = this._register(this.instantiationService.createInstance(InlineCompletionsModel, this.editor, this.sharedCache)); - - public get activeInlineCompletionsModel(): InlineCompletionsModel | undefined { - if (this.targetModel === this.inlineCompletionsModel) { - return this.inlineCompletionsModel; - } - return undefined; - } - - constructor( - private readonly editor: IActiveCodeEditor, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - - this._register(this.suggestWidgetAdapterModel.onDidChange(() => { - this.updateModel(); - })); - this.updateModel(); - } - - private updateModel(): void { - this.setTargetModel( - this.suggestWidgetAdapterModel.isActive - ? this.suggestWidgetAdapterModel - : this.inlineCompletionsModel - ); - this.inlineCompletionsModel.setActive(this.targetModel === this.inlineCompletionsModel); - } - - public shouldShowHoverAt(hoverRange: Range): boolean { - const ghostText = this.activeInlineCompletionsModel?.ghostText; - if (ghostText) { - return ghostText.parts.some(p => hoverRange.containsPosition(new Position(ghostText.lineNumber, p.column))); - } - return false; - } - - public triggerInlineCompletion(): void { - this.activeInlineCompletionsModel?.trigger(InlineCompletionTriggerKind.Explicit); - } - - public commitInlineCompletion(): void { - this.activeInlineCompletionsModel?.commitCurrentSuggestion(); - } - - public commitInlineCompletionPartially(): void { - this.activeInlineCompletionsModel?.commitCurrentSuggestionPartially(); - } - - public hideInlineCompletion(): void { - this.activeInlineCompletionsModel?.hide(); - } - - public showNextInlineCompletion(): void { - this.activeInlineCompletionsModel?.showNext(); - } - - public showPreviousInlineCompletion(): void { - this.activeInlineCompletionsModel?.showPrevious(); - } - - public async getInlineCompletionsCount(): Promise { - const result = await this.activeInlineCompletionsModel?.getInlineCompletionsCount(); - return result ?? 0; - } -} - -export class SharedInlineCompletionCache extends Disposable { - private readonly onDidChangeEmitter = new Emitter(); - public readonly onDidChange = this.onDidChangeEmitter.event; - - private readonly cache = this._register(new MutableDisposable()); - - public get value(): SynchronizedInlineCompletionsCache | undefined { - return this.cache.value; - } - - public setValue(editor: IActiveCodeEditor, - completionsSource: TrackedInlineCompletions, - triggerKind: InlineCompletionTriggerKind - ) { - this.cache.value = new SynchronizedInlineCompletionsCache( - completionsSource, - editor, - () => this.onDidChangeEmitter.fire(), - triggerKind - ); - } - - public clearAndLeak(): SynchronizedInlineCompletionsCache | undefined { - return this.cache.clearAndLeak(); - } - - public clear() { - this.cache.clear(); - } -} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts index 042a0322ac306..e6a6fd853251a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextWidget.ts @@ -3,89 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as dom from 'vs/base/browser/dom'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Event } from 'vs/base/common/event'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun, derived, observableFromEvent, observableSignalFromEvent, observableValue } from 'vs/base/common/observable'; import * as strings from 'vs/base/common/strings'; import 'vs/css!./ghostText'; import { applyFontInfo } from 'vs/editor/browser/config/domFontInfo'; -import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorFontLigatures, EditorOption, IComputedEditorOptions } from 'vs/editor/common/config/editorOptions'; -import { LineTokens } from 'vs/editor/common/tokens/lineTokens'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { StringBuilder } from 'vs/editor/common/core/stringBuilder'; -import { IModelDeltaDecoration, InjectedTextCursorStops, PositionAffinity } from 'vs/editor/common/model'; import { ILanguageIdCodec } from 'vs/editor/common/languages'; import { ILanguageService } from 'vs/editor/common/languages/language'; +import { IModelDeltaDecoration, ITextModel, InjectedTextCursorStops, PositionAffinity } from 'vs/editor/common/model'; +import { LineTokens } from 'vs/editor/common/tokens/lineTokens'; import { LineDecoration } from 'vs/editor/common/viewLayout/lineDecorations'; import { RenderLineInput, renderViewLine } from 'vs/editor/common/viewLayout/viewLineRenderer'; import { InlineDecorationType } from 'vs/editor/common/viewModel'; -import { GhostTextReplacement, GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { alert } from 'vs/base/browser/ui/aria/aria'; -import { AudioCue, IAudioCueService } from 'vs/platform/audioCues/browser/audioCueService'; +import { GhostText, GhostTextReplacement } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; +import { ColumnRange, applyObservableDecorations } from 'vs/editor/contrib/inlineCompletions/browser/utils'; -const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value }); +export interface IGhostTextWidgetModel { + readonly targetTextModel: IObservable; + readonly ghostText: IObservable; + readonly minReservedLineCount: IObservable; +} export class GhostTextWidget extends Disposable { - private disposed = false; - private readonly partsWidget = this._register(this.instantiationService.createInstance(DecorationsWidget, this.editor)); - private readonly additionalLinesWidget = this._register(new AdditionalLinesWidget(this.editor, this.languageService.languageIdCodec)); - private viewMoreContentWidget: ViewMoreLinesContentWidget | undefined = undefined; + private readonly isDisposed = observableValue('isDisposed', false); + private readonly currentTextModel = observableFromEvent(this.editor.onDidChangeModel, () => this.editor.getModel()); constructor( private readonly editor: ICodeEditor, - private readonly model: GhostTextWidgetModel, - @IInstantiationService private readonly instantiationService: IInstantiationService, + private readonly model: IGhostTextWidgetModel, @ILanguageService private readonly languageService: ILanguageService, - @IAudioCueService private readonly audioCueService: IAudioCueService ) { super(); - this._register(this.editor.onDidChangeConfiguration((e) => { - if ( - e.hasChanged(EditorOption.disableMonospaceOptimizations) - || e.hasChanged(EditorOption.stopRenderingLineAfter) - || e.hasChanged(EditorOption.renderWhitespace) - || e.hasChanged(EditorOption.renderControlCharacters) - || e.hasChanged(EditorOption.fontLigatures) - || e.hasChanged(EditorOption.fontInfo) - || e.hasChanged(EditorOption.lineHeight) - ) { - this.update(); - } - })); - - this._register(toDisposable(() => { - this.disposed = true; - this.update(); - - this.viewMoreContentWidget?.dispose(); - this.viewMoreContentWidget = undefined; - })); - - this._register(model.onDidChange(() => this.update(true))); - this.update(true); - } - - public shouldShowHoverAtViewZone(viewZoneId: string): boolean { - return (this.additionalLinesWidget.viewZoneId === viewZoneId); + this._register(toDisposable(() => { this.isDisposed.set(true, undefined); })); + this._register(applyObservableDecorations(this.editor, this.decorations)); } - private readonly replacementDecoration = this._register(new DisposableDecorations(this.editor)); - - private update(notifyUser?: boolean): void { - const ghostText = this.model.ghostText; - - if (!this.editor.hasModel() || !ghostText || this.disposed) { - this.partsWidget.clear(); - this.additionalLinesWidget.clear(); - this.replacementDecoration.clear(); - return; + private readonly uiState = derived('uiState', reader => { + if (this.isDisposed.read(reader)) { + return undefined; } + const textModel = this.currentTextModel.read(reader); + if (textModel !== this.model.targetTextModel.read(reader)) { + return undefined; + } + const ghostText = this.model.ghostText.read(reader); + if (!ghostText) { + return undefined; + } + + const replacedRange = ghostText instanceof GhostTextReplacement ? ghostText.columnRange : undefined; - const inlineTexts = new Array(); - const additionalLines = new Array(); + const inlineTexts: { column: number; text: string; preview: boolean }[] = []; + const additionalLines: LineData[] = []; function addToAdditionalLines(lines: readonly string[], className: string | undefined) { if (additionalLines.length > 0) { @@ -105,26 +81,7 @@ export class GhostTextWidget extends Disposable { } } - if (ghostText instanceof GhostTextReplacement) { - this.replacementDecoration.setDecorations([ - { - range: new Range( - ghostText.lineNumber, - ghostText.columnStart, - ghostText.lineNumber, - ghostText.columnStart + ghostText.length - ), - options: { - inlineClassName: 'inline-completion-text-to-replace', - description: 'GhostTextReplacement' - } - }, - ]); - } else { - this.replacementDecoration.setDecorations([]); - } - - const textBufferLine = this.editor.getModel().getLineContent(ghostText.lineNumber); + const textBufferLine = textModel.getLineContent(ghostText.lineNumber); let hiddenTextStartColumn: number | undefined = undefined; let lastIdx = 0; @@ -154,165 +111,116 @@ export class GhostTextWidget extends Disposable { addToAdditionalLines([textBufferLine.substring(lastIdx)], undefined); } - this.partsWidget.setParts(ghostText.lineNumber, inlineTexts, - hiddenTextStartColumn !== undefined ? { column: hiddenTextStartColumn, length: textBufferLine.length + 1 - hiddenTextStartColumn } : undefined); - this.additionalLinesWidget.updateLines(ghostText.lineNumber, additionalLines, ghostText.additionalReservedLineCount); - - if (notifyUser) { - this.audioCueService.playAudioCue(AudioCue.inlineSuggestion).then(() => { - if (this.editor.getOption(EditorOption.screenReaderAnnounceInlineSuggestion)) { - const lineText = this.editor.getModel()?.getLineContent(ghostText.lineNumber); - if (lineText) { - alert(ghostText.renderForScreenReader(lineText)); - } - } - }); - } - - if (0 < 0) { - // Not supported at the moment, condition is always false. - this.viewMoreContentWidget = this.renderViewMoreLines( - new Position(ghostText.lineNumber, this.editor.getModel()!.getLineMaxColumn(ghostText.lineNumber)), - '', 0 - ); - } else { - this.viewMoreContentWidget?.dispose(); - this.viewMoreContentWidget = undefined; - } - } - - private renderViewMoreLines(position: Position, firstLineText: string, remainingLinesLength: number): ViewMoreLinesContentWidget { - const fontInfo = this.editor.getOption(EditorOption.fontInfo); - const domNode = document.createElement('div'); - domNode.className = 'suggest-preview-additional-widget'; - applyFontInfo(domNode, fontInfo); - - const spacer = document.createElement('span'); - spacer.className = 'content-spacer'; - spacer.append(firstLineText); - domNode.append(spacer); - - const newline = document.createElement('span'); - newline.className = 'content-newline suggest-preview-text'; - newline.append('⏎ '); - domNode.append(newline); - - const disposableStore = new DisposableStore(); - - const button = document.createElement('div'); - button.className = 'button suggest-preview-text'; - button.append(`+${remainingLinesLength} lines…`); - - disposableStore.add(dom.addStandardDisposableListener(button, 'mousedown', (e) => { - this.model?.setExpanded(true); - e.preventDefault(); - this.editor.focus(); - })); - - domNode.append(button); - return new ViewMoreLinesContentWidget(this.editor, position, domNode, disposableStore); - } -} - -class DisposableDecorations { - private decorationIds: string[] = []; - - constructor(private readonly editor: ICodeEditor) { - } - - public setDecorations(decorations: IModelDeltaDecoration[]): void { - // Using change decorations ensures that we update the id's before some event handler is called. - this.editor.changeDecorations(accessor => { - this.decorationIds = accessor.deltaDecorations(this.decorationIds, decorations); - }); - } - - public clear(): void { - this.setDecorations([]); - } - - public dispose(): void { - this.clear(); - } -} - -interface HiddenText { - column: number; - length: number; -} - -interface InsertedInlineText { - column: number; - text: string; - preview: boolean; -} + const hiddenRange = hiddenTextStartColumn !== undefined ? new ColumnRange(hiddenTextStartColumn, textBufferLine.length + 1) : undefined; -class DecorationsWidget implements IDisposable { - private decorationIds: string[] = []; + return { + replacedRange, + inlineTexts, + additionalLines, + hiddenRange, + lineNumber: ghostText.lineNumber, + additionalReservedLineCount: ghostText.additionalReservedLineCount, + targetTextModel: textModel, + }; + }); - constructor( - private readonly editor: ICodeEditor - ) { - } + private readonly decorations = derived('decorations', reader => { + const uiState = this.uiState.read(reader); + if (!uiState) { + return []; + } - public dispose(): void { - this.clear(); - } + const decorations: IModelDeltaDecoration[] = []; - public clear(): void { - // Using change decorations ensures that we update the id's before some event handler is called. - this.editor.changeDecorations(accessor => { - this.decorationIds = accessor.deltaDecorations(this.decorationIds, []); - }); - } + if (uiState.replacedRange) { + decorations.push({ + range: uiState.replacedRange.toRange(uiState.lineNumber), + options: { inlineClassName: 'inline-completion-text-to-replace', description: 'GhostTextReplacement' } + }); + } - public setParts(lineNumber: number, parts: InsertedInlineText[], hiddenText?: HiddenText): void { - const textModel = this.editor.getModel(); - if (!textModel) { - return; + if (uiState.hiddenRange) { + decorations.push({ + range: uiState.hiddenRange.toRange(uiState.lineNumber), + options: { inlineClassName: 'ghost-text-hidden', description: 'ghost-text-hidden', } + }); } - const hiddenTextDecorations = new Array(); - if (hiddenText) { - hiddenTextDecorations.push({ - range: Range.fromPositions(new Position(lineNumber, hiddenText.column), new Position(lineNumber, hiddenText.column + hiddenText.length)), + for (const p of uiState.inlineTexts) { + decorations.push({ + range: Range.fromPositions(new Position(uiState.lineNumber, p.column)), options: { - inlineClassName: 'ghost-text-hidden', - description: 'ghost-text-hidden', + description: 'ghost-text', + after: { content: p.text, inlineClassName: p.preview ? 'ghost-text-decoration-preview' : 'ghost-text-decoration', cursorStops: InjectedTextCursorStops.Left }, + showIfCollapsed: true, } }); } - // Using change decorations ensures that we update the id's before some event handler is called. - this.editor.changeDecorations(accessor => { - this.decorationIds = accessor.deltaDecorations(this.decorationIds, parts.map(p => { - return ({ - range: Range.fromPositions(new Position(lineNumber, p.column)), - options: { - description: 'ghost-text', - after: { content: p.text, inlineClassName: p.preview ? 'ghost-text-decoration-preview' : 'ghost-text-decoration', cursorStops: InjectedTextCursorStops.Left }, - showIfCollapsed: true, - } - }); - }).concat(hiddenTextDecorations)); - }); + return decorations; + }); + + private readonly additionalLinesWidget = this._register( + new AdditionalLinesWidget( + this.editor, + this.languageService.languageIdCodec, + derived('lines', (reader) => { + const uiState = this.uiState.read(reader); + return uiState ? { + lineNumber: uiState.lineNumber, + additionalLines: uiState.additionalLines, + minReservedLineCount: uiState.additionalReservedLineCount, + targetTextModel: uiState.targetTextModel, + } : undefined; + }) + ) + ); + + public ownsViewZone(viewZoneId: string): boolean { + return this.additionalLinesWidget.viewZoneId === viewZoneId; } } -class AdditionalLinesWidget implements IDisposable { +class AdditionalLinesWidget extends Disposable { private _viewZoneId: string | undefined = undefined; public get viewZoneId(): string | undefined { return this._viewZoneId; } + private readonly editorOptionsChanged = observableSignalFromEvent('editorOptionChanged', Event.filter( + this.editor.onDidChangeConfiguration, + e => e.hasChanged(EditorOption.disableMonospaceOptimizations) + || e.hasChanged(EditorOption.stopRenderingLineAfter) + || e.hasChanged(EditorOption.renderWhitespace) + || e.hasChanged(EditorOption.renderControlCharacters) + || e.hasChanged(EditorOption.fontLigatures) + || e.hasChanged(EditorOption.fontInfo) + || e.hasChanged(EditorOption.lineHeight) + )); + constructor( private readonly editor: ICodeEditor, - private readonly languageIdCodec: ILanguageIdCodec - ) { } + private readonly languageIdCodec: ILanguageIdCodec, + private readonly lines: IObservable<{ targetTextModel: ITextModel; lineNumber: number; additionalLines: LineData[]; minReservedLineCount: number } | undefined> + ) { + super(); + + this._register(autorun('update view zone', reader => { + const lines = this.lines.read(reader); + this.editorOptionsChanged.read(reader); + + if (lines) { + this.updateLines(lines.lineNumber, lines.additionalLines, lines.minReservedLineCount); + } else { + this.clear(); + } + })); + } - public dispose(): void { + public override dispose(): void { + super.dispose(); this.clear(); } - public clear(): void { + private clear(): void { this.editor.changeViewZones((changeAccessor) => { if (this._viewZoneId) { changeAccessor.removeZone(this._viewZoneId); @@ -321,7 +229,7 @@ class AdditionalLinesWidget implements IDisposable { }); } - public updateLines(lineNumber: number, additionalLines: LineData[], minReservedLineCount: number): void { + private updateLines(lineNumber: number, additionalLines: LineData[], minReservedLineCount: number): void { const textModel = this.editor.getModel(); if (!textModel) { return; @@ -352,7 +260,7 @@ class AdditionalLinesWidget implements IDisposable { } interface LineData { - content: string; + content: string; // Must not contain a linebreak! decorations: LineDecoration[]; } @@ -413,37 +321,4 @@ function renderLines(domNode: HTMLElement, tabSize: number, lines: LineData[], o domNode.innerHTML = trustedhtml as string; } -class ViewMoreLinesContentWidget extends Disposable implements IContentWidget { - readonly allowEditorOverflow = false; - readonly suppressMouseDown = false; - - constructor( - private editor: ICodeEditor, - private position: Position, - private domNode: HTMLElement, - disposableStore: DisposableStore - ) { - super(); - this._register(disposableStore); - this._register(toDisposable(() => { - this.editor.removeContentWidget(this); - })); - this.editor.addContentWidget(this); - } - - getId(): string { - return 'editor.widget.viewMoreLinesWidget'; - } - - getDomNode(): HTMLElement { - return this.domNode; - } - - getPosition(): IContentWidgetPosition | null { - return { - position: this.position, - preference: [ContentWidgetPositionPreference.EXACT] - }; - } -} - +const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts b/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts similarity index 75% rename from src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts rename to src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts index d6eac9d12e975..af9a10cf59730 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/hoverParticipant.ts @@ -4,18 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; -import { Event } from 'vs/base/common/event'; import { MarkdownString } from 'vs/base/common/htmlContent'; -import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { autorun, constObservable } from 'vs/base/common/observable'; import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { Range } from 'vs/editor/common/core/range'; -import { Command } from 'vs/editor/common/languages'; import { ILanguageService } from 'vs/editor/common/languages/language'; import { IModelDecoration } from 'vs/editor/common/model'; import { HoverAnchor, HoverAnchorType, HoverForeignElementAnchor, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart } from 'vs/editor/contrib/hover/browser/hoverTypes'; -import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController'; -import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget'; +import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; +import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget'; import { MarkdownRenderer } from 'vs/editor/contrib/markdownRenderer/browser/markdownRenderer'; import * as nls from 'vs/nls'; import { IAccessibilityService } from 'vs/platform/accessibility/common/accessibility'; @@ -27,7 +26,7 @@ export class InlineCompletionsHover implements IHoverPart { constructor( public readonly owner: IEditorHoverParticipant, public readonly range: Range, - public readonly controller: GhostTextController + public readonly controller: InlineCompletionsController ) { } public isValidForHoverAnchor(anchor: HoverAnchor): boolean { @@ -37,31 +36,6 @@ export class InlineCompletionsHover implements IHoverPart { && this.range.endColumn >= anchor.range.endColumn ); } - - public requestExplicitContext(): void { - this.controller.activeModel?.activeInlineCompletionsModel?.completionSession.value?.ensureUpdateWithExplicitContext(); - } - - public getInlineCompletionsCount(): number | undefined { - const session = this.controller.activeModel?.activeInlineCompletionsModel?.completionSession.value; - if (!session?.hasBeenTriggeredExplicitly) { - return undefined; - } - return session?.getInlineCompletionsCountSync(); - } - - public getInlineCompletionIndex(): number | undefined { - return this.controller.activeModel?.activeInlineCompletionsModel?.completionSession.value?.currentlySelectedIndex; - } - - public onDidChange(handler: () => void): IDisposable { - const d = this.controller.activeModel?.activeInlineCompletionsModel?.onDidChange(handler); - return d || Disposable.None; - } - - public get commands(): Command[] { - return this.controller.activeModel?.activeInlineCompletionsModel?.completionSession.value?.commands || []; - } } export class InlineCompletionsHoverParticipant implements IEditorHoverParticipant { @@ -79,7 +53,7 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan } suggestHoverAnchor(mouseEvent: IEditorMouseEvent): HoverAnchor | null { - const controller = GhostTextController.get(this._editor); + const controller = InlineCompletionsController.get(this._editor); if (!controller) { return null; } @@ -113,7 +87,7 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan return []; } - const controller = GhostTextController.get(this._editor); + const controller = InlineCompletionsController.get(this._editor); if (controller && controller.shouldShowHoverAt(anchor.range)) { return [new InlineCompletionsHover(this, anchor.range, controller)]; } @@ -133,15 +107,18 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan this.renderScreenReaderText(context, part, disposableStore); } - const w = this._instantiationService.createInstance(InlineSuggestionHintsContentWidget, this._editor, false); + const model = part.controller.model.get()!; + + const w = this._instantiationService.createInstance(InlineSuggestionHintsContentWidget, this._editor, false, + constObservable(null), + model.currentInlineCompletionIndex, + model.inlineCompletionsCount, + model.currentInlineCompletion.map(v => v?.inlineCompletion.source.inlineCompletions.commands ?? []),); context.fragment.appendChild(w.getDomNode()); - w.update(null, part.getInlineCompletionIndex() || 0, part.getInlineCompletionsCount(), part.commands); - part.requestExplicitContext(); + model.triggerExplicitly(); - disposableStore.add(part.onDidChange(() => { - w.update(null, part.getInlineCompletionIndex() || 0, part.getInlineCompletionsCount(), part.commands); - })); + disposableStore.add(w); return disposableStore; } @@ -162,8 +139,8 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan hoverContentsElement.replaceChildren(renderedContents.element); }; - disposableStore.add(Event.runAndSubscribe(e => part.onDidChange(e), () => { - const ghostText = part.controller.activeModel?.inlineCompletionsModel?.ghostText; + disposableStore.add(autorun('update hover', (reader) => { + const ghostText = part.controller.model.read(reader)?.ghostText.read(reader); if (ghostText) { const lineText = this._editor.getModel()!.getLineContent(ghostText.lineNumber); render(ghostText.renderForScreenReader(lineText)); @@ -172,7 +149,6 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan } })); - context.fragment.appendChild(markdownHoverElement); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionToGhostText.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionToGhostText.ts deleted file mode 100644 index 505e5f4f4cf83..0000000000000 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionToGhostText.ts +++ /dev/null @@ -1,281 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IDiffChange, LcsDiff } from 'vs/base/common/diff/diff'; -import * as strings from 'vs/base/common/strings'; -import { Position } from 'vs/editor/common/core/position'; -import { Range } from 'vs/editor/common/core/range'; -import { ITextModel } from 'vs/editor/common/model'; -import { Command } from 'vs/editor/common/languages'; -import { GhostText, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; -import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; - -/** - * A normalized inline completion is an inline completion with a defined range. -*/ -export interface NormalizedInlineCompletion { - readonly filterText: string; - readonly command?: Command; - readonly range: Range; - readonly insertText: string; - readonly snippetInfo: - | { - snippet: string; - /* Could be different than the main range */ - range: Range; - } - | undefined; - - readonly additionalTextEdits: readonly ISingleEditOperation[]; -} - -/** - * Shrinks the range if the text has a suffix/prefix that agrees with the text buffer. - * E.g. text buffer: `ab[cdef]ghi`, [...] is the replace range, `cxyzf` is the new text. - * Then the minimized inline completion has range `abc[de]fghi` and text `xyz`. - */ -export function minimizeInlineCompletion(model: ITextModel, inlineCompletion: NormalizedInlineCompletion): NormalizedInlineCompletion; -export function minimizeInlineCompletion(model: ITextModel, inlineCompletion: NormalizedInlineCompletion | undefined): NormalizedInlineCompletion | undefined; -export function minimizeInlineCompletion(model: ITextModel, inlineCompletion: NormalizedInlineCompletion | undefined): NormalizedInlineCompletion | undefined { - if (!inlineCompletion) { - return inlineCompletion; - } - const valueToReplace = model.getValueInRange(inlineCompletion.range); - const commonPrefixLen = strings.commonPrefixLength(valueToReplace, inlineCompletion.insertText); - const startOffset = model.getOffsetAt(inlineCompletion.range.getStartPosition()) + commonPrefixLen; - const start = model.getPositionAt(startOffset); - - const remainingValueToReplace = valueToReplace.substr(commonPrefixLen); - const commonSuffixLen = strings.commonSuffixLength(remainingValueToReplace, inlineCompletion.insertText); - const end = model.getPositionAt(Math.max(startOffset, model.getOffsetAt(inlineCompletion.range.getEndPosition()) - commonSuffixLen)); - - return { - range: Range.fromPositions(start, end), - insertText: inlineCompletion.insertText.substr(commonPrefixLen, inlineCompletion.insertText.length - commonPrefixLen - commonSuffixLen), - snippetInfo: inlineCompletion.snippetInfo, - filterText: inlineCompletion.filterText, - additionalTextEdits: inlineCompletion.additionalTextEdits, - }; -} - -export function normalizedInlineCompletionsEquals(a: NormalizedInlineCompletion | undefined, b: NormalizedInlineCompletion | undefined): boolean { - if (a === b) { - return true; - } - if (!a || !b) { - return false; - } - return a.range.equalsRange(b.range) && a.insertText === b.insertText && a.command === b.command; -} - -/** - * @param previewSuffixLength Sets where to split `inlineCompletion.text`. - * If the text is `hello` and the suffix length is 2, the non-preview part is `hel` and the preview-part is `lo`. -*/ -export function inlineCompletionToGhostText( - inlineCompletion: NormalizedInlineCompletion, - textModel: ITextModel, - mode: 'prefix' | 'subword' | 'subwordSmart', - cursorPosition?: Position, - previewSuffixLength = 0 -): GhostText | undefined { - if (inlineCompletion.range.startLineNumber !== inlineCompletion.range.endLineNumber) { - // Only single line replacements are supported. - return undefined; - } - - const sourceLine = textModel.getLineContent(inlineCompletion.range.startLineNumber); - const sourceIndentationLength = strings.getLeadingWhitespace(sourceLine).length; - - const suggestionTouchesIndentation = inlineCompletion.range.startColumn - 1 <= sourceIndentationLength; - if (suggestionTouchesIndentation) { - // source: ··········[······abc] - // ^^^^^^^^^ inlineCompletion.range - // ^^^^^^^^^^ ^^^^^^ sourceIndentationLength - // ^^^^^^ replacedIndentation.length - // ^^^ rangeThatDoesNotReplaceIndentation - - // inlineCompletion.text: '··foo' - // ^^ suggestionAddedIndentationLength - - const suggestionAddedIndentationLength = strings.getLeadingWhitespace(inlineCompletion.insertText).length; - - const replacedIndentation = sourceLine.substring(inlineCompletion.range.startColumn - 1, sourceIndentationLength); - const rangeThatDoesNotReplaceIndentation = Range.fromPositions( - inlineCompletion.range.getStartPosition().delta(0, replacedIndentation.length), - inlineCompletion.range.getEndPosition() - ); - - const suggestionWithoutIndentationChange = - inlineCompletion.insertText.startsWith(replacedIndentation) - // Adds more indentation without changing existing indentation: We can add ghost text for this - ? inlineCompletion.insertText.substring(replacedIndentation.length) - // Changes or removes existing indentation. Only add ghost text for the non-indentation part. - : inlineCompletion.insertText.substring(suggestionAddedIndentationLength); - - inlineCompletion = { - range: rangeThatDoesNotReplaceIndentation, - insertText: suggestionWithoutIndentationChange, - command: inlineCompletion.command, - snippetInfo: undefined, - filterText: inlineCompletion.filterText, - additionalTextEdits: inlineCompletion.additionalTextEdits, - }; - } - - // This is a single line string - const valueToBeReplaced = textModel.getValueInRange(inlineCompletion.range); - - const changes = cachingDiff(valueToBeReplaced, inlineCompletion.insertText); - - if (!changes) { - // No ghost text in case the diff would be too slow to compute - return undefined; - } - - const lineNumber = inlineCompletion.range.startLineNumber; - - const parts = new Array(); - - if (mode === 'prefix') { - const filteredChanges = changes.filter(c => c.originalLength === 0); - if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) { - // Prefixes only have a single change. - return undefined; - } - } - - const previewStartInCompletionText = inlineCompletion.insertText.length - previewSuffixLength; - - for (const c of changes) { - const insertColumn = inlineCompletion.range.startColumn + c.originalStart + c.originalLength; - - if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === inlineCompletion.range.startLineNumber && insertColumn < cursorPosition.column) { - // No ghost text before cursor - return undefined; - } - - if (c.originalLength > 0) { - return undefined; - } - - if (c.modifiedLength === 0) { - continue; - } - - const modifiedEnd = c.modifiedStart + c.modifiedLength; - const nonPreviewTextEnd = Math.max(c.modifiedStart, Math.min(modifiedEnd, previewStartInCompletionText)); - const nonPreviewText = inlineCompletion.insertText.substring(c.modifiedStart, nonPreviewTextEnd); - const italicText = inlineCompletion.insertText.substring(nonPreviewTextEnd, Math.max(c.modifiedStart, modifiedEnd)); - - if (nonPreviewText.length > 0) { - const lines = strings.splitLines(nonPreviewText); - parts.push(new GhostTextPart(insertColumn, lines, false)); - } - if (italicText.length > 0) { - const lines = strings.splitLines(italicText); - parts.push(new GhostTextPart(insertColumn, lines, true)); - } - } - - return new GhostText(lineNumber, parts, 0); -} - -let lastRequest: { originalValue: string; newValue: string; changes: readonly IDiffChange[] | undefined } | undefined = undefined; -function cachingDiff(originalValue: string, newValue: string): readonly IDiffChange[] | undefined { - if (lastRequest?.originalValue === originalValue && lastRequest?.newValue === newValue) { - return lastRequest?.changes; - } else { - let changes = smartDiff(originalValue, newValue, true); - if (changes) { - const deletedChars = deletedCharacters(changes); - if (deletedChars > 0) { - // For performance reasons, don't compute diff if there is nothing to improve - const newChanges = smartDiff(originalValue, newValue, false); - if (newChanges && deletedCharacters(newChanges) < deletedChars) { - // Disabling smartness seems to be better here - changes = newChanges; - } - } - } - lastRequest = { - originalValue, - newValue, - changes - }; - return changes; - } -} - -function deletedCharacters(changes: readonly IDiffChange[]): number { - let sum = 0; - for (const c of changes) { - sum += c.originalLength; - } - return sum; -} - -/** - * When matching `if ()` with `if (f() = 1) { g(); }`, - * align it like this: `if ( )` - * Not like this: `if ( )` - * Also not like this: `if ( )`. - * - * The parenthesis are preprocessed to ensure that they match correctly. - */ -function smartDiff(originalValue: string, newValue: string, smartBracketMatching: boolean): (readonly IDiffChange[]) | undefined { - if (originalValue.length > 5000 || newValue.length > 5000) { - // We don't want to work on strings that are too big - return undefined; - } - - function getMaxCharCode(val: string): number { - let maxCharCode = 0; - for (let i = 0, len = val.length; i < len; i++) { - const charCode = val.charCodeAt(i); - if (charCode > maxCharCode) { - maxCharCode = charCode; - } - } - return maxCharCode; - } - - const maxCharCode = Math.max(getMaxCharCode(originalValue), getMaxCharCode(newValue)); - function getUniqueCharCode(id: number): number { - if (id < 0) { - throw new Error('unexpected'); - } - return maxCharCode + id + 1; - } - - function getElements(source: string): Int32Array { - let level = 0; - let group = 0; - const characters = new Int32Array(source.length); - for (let i = 0, len = source.length; i < len; i++) { - // TODO support more brackets - if (smartBracketMatching && source[i] === '(') { - const id = group * 100 + level; - characters[i] = getUniqueCharCode(2 * id); - level++; - } else if (smartBracketMatching && source[i] === ')') { - level = Math.max(level - 1, 0); - const id = group * 100 + level; - characters[i] = getUniqueCharCode(2 * id + 1); - if (level === 0) { - group++; - } - } else { - characters[i] = source.charCodeAt(i); - } - } - return characters; - } - - const elements1 = getElements(originalValue); - const elements2 = getElements(newValue); - - return new LcsDiff({ getElements: () => elements1 }, { getElements: () => elements2 }).ComputeDiff(false).changes; -} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.contribution.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts similarity index 65% rename from src/vs/editor/contrib/inlineCompletions/browser/ghostText.contribution.ts rename to src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts index 9dabcfaffb7dc..cf0668ce68706 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostText.contribution.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution.ts @@ -5,18 +5,19 @@ import { EditorContributionInstantiation, registerEditorAction, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; import { HoverParticipantRegistry } from 'vs/editor/contrib/hover/browser/hoverTypes'; -import { AcceptInlineCompletion, AcceptNextWordOfInlineCompletion, ToggleAlwaysShowInlineSuggestionToolbar, GhostTextController, HideInlineCompletion, ShowNextInlineSuggestionAction, ShowPreviousInlineSuggestionAction, TriggerInlineSuggestionAction, UndoAcceptPart } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController'; -import { InlineCompletionsHoverParticipant } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant'; +import { TriggerInlineSuggestionAction, ShowNextInlineSuggestionAction, ShowPreviousInlineSuggestionAction, AcceptNextWordOfInlineCompletion, AcceptInlineCompletion, HideInlineCompletion, ToggleAlwaysShowInlineSuggestionToolbar } from 'vs/editor/contrib/inlineCompletions/browser/commands'; +import { InlineCompletionsHoverParticipant } from 'vs/editor/contrib/inlineCompletions/browser/hoverParticipant'; +import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { registerAction2 } from 'vs/platform/actions/common/actions'; -registerEditorContribution(GhostTextController.ID, GhostTextController, EditorContributionInstantiation.Eventually); +registerEditorContribution(InlineCompletionsController.ID, InlineCompletionsController, EditorContributionInstantiation.Eventually); + registerEditorAction(TriggerInlineSuggestionAction); registerEditorAction(ShowNextInlineSuggestionAction); registerEditorAction(ShowPreviousInlineSuggestionAction); registerEditorAction(AcceptNextWordOfInlineCompletion); registerEditorAction(AcceptInlineCompletion); registerEditorAction(HideInlineCompletion); -registerEditorAction(UndoAcceptPart); registerAction2(ToggleAlwaysShowInlineSuggestionToolbar); HoverParticipantRegistry.register(InlineCompletionsHoverParticipant); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts new file mode 100644 index 0000000000000..deb2c1acd93e4 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { autorun, constObservable, observableFromEvent, observableValue } from 'vs/base/common/observable'; +import { IObservable, ITransaction, disposableObservableValue, transaction } from 'vs/base/common/observableImpl/base'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; +import { GhostTextWidget } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextWidget'; +import { InlineCompletionsModel, VersionIdChangeReason } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { SuggestWidgetAdaptor } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; +import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import * as nls from 'vs/nls'; +import { CursorColumns } from 'vs/editor/common/core/cursorColumns'; +import { firstNonWhitespaceIndex } from 'vs/base/common/strings'; +import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { CoreEditingCommands } from 'vs/editor/browser/coreCommands'; +import { inlineSuggestCommitId } from 'vs/editor/contrib/inlineCompletions/browser/commandIds'; +import { ILanguageFeatureDebounceService } from 'vs/editor/common/services/languageFeatureDebounce'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; + +export class InlineCompletionsController extends Disposable { + static ID = 'editor.contrib.inlineCompletionsController'; + + public static get(editor: ICodeEditor): InlineCompletionsController | null { + return editor.getContribution(InlineCompletionsController.ID); + } + + private readonly suggestWidgetAdaptor = this._register(new SuggestWidgetAdaptor( + this.editor, + () => this.model.get()?.currentInlineCompletion.get()?.toSingleTextEdit(), + (tx) => this.updateObservables(tx, VersionIdChangeReason.Other) + )); + + private readonly textModelVersionId = observableValue('textModelVersionId', -1); + private readonly cursorPosition = observableValue('cursorPosition', new Position(1, 1)); + public readonly model = disposableObservableValue('textModelVersionId', undefined); + + private ghostTextWidget = this._register(this.instantiationService.createInstance(GhostTextWidget, this.editor, { + ghostText: this.model.map((v, reader) => v?.ghostText.read(reader)), + minReservedLineCount: constObservable(0), + targetTextModel: this.model.map(v => v?.textModel), + })); + + private readonly _debounceValue = this.debounceService.for( + this.languageFeaturesService.inlineCompletionsProvider, + 'InlineCompletionsDebounce', + { min: 50, max: 50 } + ); + + constructor( + public readonly editor: ICodeEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ICommandService private readonly commandService: ICommandService, + @ILanguageFeatureDebounceService private readonly debounceService: ILanguageFeatureDebounceService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + ) { + super(); + + this._register(new InlineCompletionContextKeys(this.contextKeyService, this.model)); + + const enabled = observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.inlineSuggest).enabled); + + this._register(Event.runAndSubscribe(editor.onDidChangeModel, () => { + this.model.set(undefined, undefined); // This disposes the model (do this outside of the transaction to dispose autoruns) + transaction(tx => { + this.updateObservables(tx, VersionIdChangeReason.Other); + const textModel = editor.getModel(); + if (textModel) { + const model = instantiationService.createInstance( + InlineCompletionsModel, + textModel, + this.suggestWidgetAdaptor.selectedItem, + this.cursorPosition, + this.textModelVersionId, + this._debounceValue, + observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.suggest).preview), + observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.suggest).previewMode), + observableFromEvent(editor.onDidChangeConfiguration, () => editor.getOption(EditorOption.inlineSuggest).mode), + enabled + ); + this.model.set(model, tx); + } + }); + })); + + this._register(editor.onDidChangeModelContent((e) => transaction(tx => + this.updateObservables(tx, + e.isUndoing ? VersionIdChangeReason.Undo + : e.isRedoing ? VersionIdChangeReason.Redo + : this.model.get()?.isAcceptingPartialWord ? VersionIdChangeReason.AcceptWord + : VersionIdChangeReason.Other + ) + ))); + + this._register(editor.onDidChangeCursorPosition(e => transaction(tx => { + this.updateObservables(tx, VersionIdChangeReason.Other); + if (e.reason === CursorChangeReason.Explicit) { + this.model.get()?.stop(tx); + } + }))); + + this._register(editor.onDidType(() => transaction(tx => { + this.updateObservables(tx, VersionIdChangeReason.Other); + if (enabled.get()) { + this.model.get()?.trigger(tx); + } + }))); + + this._register( + this.commandService.onDidExecuteCommand((e) => { + // These commands don't trigger onDidType. + const commands = new Set([ + CoreEditingCommands.Tab.id, + CoreEditingCommands.DeleteLeft.id, + CoreEditingCommands.DeleteRight.id, + inlineSuggestCommitId, + 'acceptSelectedSuggestion', + ]); + if (commands.has(e.commandId) && editor.hasTextFocus()) { + transaction(tx => { + this.model.get()?.trigger(tx); + }); + } + }) + ); + + this._register(this.editor.onDidBlurEditorWidget(() => { + // This is a hidden setting very useful for debugging + if (this.configurationService.getValue('editor.inlineSuggest.keepOnBlur')) { + return; + } + if (InlineSuggestionHintsContentWidget.dropDownVisible) { + return; + } + transaction(tx => { + this.model.get()?.stop(tx); + }); + })); + + this._register(autorun('forceRenderingAbove', reader => { + const model = this.model.read(reader); + const ghostText = model?.ghostText.read(reader); + const selectedSuggestItem = this.suggestWidgetAdaptor.selectedItem.read(reader); + if (selectedSuggestItem) { + if (ghostText && ghostText.lineCount >= 2) { + this.suggestWidgetAdaptor.forceRenderingAbove(); + } + } else { + this.suggestWidgetAdaptor.stopForceRenderingAbove(); + } + })); + + this._register(toDisposable(() => { + this.suggestWidgetAdaptor.stopForceRenderingAbove(); + })); + } + + private updateObservables(tx: ITransaction, changeReason: VersionIdChangeReason): void { + const newModel = this.editor.getModel(); + this.textModelVersionId.set(newModel?.getVersionId() ?? -1, tx, changeReason); + this.cursorPosition.set(this.editor.getPosition() ?? new Position(1, 1), tx); + } + + shouldShowHoverAt(range: Range) { + const ghostText = this.model.get()?.ghostText.get(); + if (ghostText) { + return ghostText.parts.some(p => range.containsPosition(new Position(ghostText.lineNumber, p.column))); + } + return false; + } + + public shouldShowHoverAtViewZone(viewZoneId: string): boolean { + return this.ghostTextWidget.ownsViewZone(viewZoneId); + } +} + +export class InlineCompletionContextKeys extends Disposable { + public static readonly inlineSuggestionVisible = new RawContextKey('inlineSuggestionVisible', false, nls.localize('inlineSuggestionVisible', "Whether an inline suggestion is visible")); + public static readonly inlineSuggestionHasIndentation = new RawContextKey('inlineSuggestionHasIndentation', false, nls.localize('inlineSuggestionHasIndentation', "Whether the inline suggestion starts with whitespace")); + public static readonly inlineSuggestionHasIndentationLessThanTabSize = new RawContextKey('inlineSuggestionHasIndentationLessThanTabSize', true, nls.localize('inlineSuggestionHasIndentationLessThanTabSize', "Whether the inline suggestion starts with whitespace that is less than what would be inserted by tab")); + public static readonly alwaysShowInlineSuggestionToolbar = new RawContextKey('alwaysShowInlineSuggestionToolbar', false, nls.localize('alwaysShowInlineSuggestionToolbar', "Whether the inline suggestion toolbar should always be visible")); + + public readonly inlineCompletionVisible = InlineCompletionContextKeys.inlineSuggestionVisible.bindTo(this.contextKeyService); + public readonly inlineCompletionSuggestsIndentation = InlineCompletionContextKeys.inlineSuggestionHasIndentation.bindTo(this.contextKeyService); + public readonly inlineCompletionSuggestsIndentationLessThanTabSize = InlineCompletionContextKeys.inlineSuggestionHasIndentationLessThanTabSize.bindTo(this.contextKeyService); + + constructor( + private readonly contextKeyService: IContextKeyService, + private readonly model: IObservable, + ) { + super(); + + this._register(autorun('update context key: inlineCompletionVisible', (reader) => { + const model = this.model.read(reader); + const ghostText = model?.ghostText.read(reader); + const selectedSuggestItem = model?.selectedSuggestItem.read(reader); + this.inlineCompletionVisible.set(selectedSuggestItem === undefined && ghostText !== undefined); + })); + + this._register(autorun('update context key: inlineCompletionSuggestsIndentation, inlineCompletionSuggestsIndentationLessThanTabSize', (reader) => { + const model = this.model.read(reader); + + let startsWithIndentation = false; + let startsWithIndentationLessThanTabSize = true; + + const ghostText = model?.ghostText.read(reader); + if (!!model?.selectedSuggestItem && ghostText && ghostText.parts.length > 0) { + const { column, lines } = ghostText.parts[0]; + + const firstLine = lines[0]; + + const indentationEndColumn = model.textModel.getLineIndentColumn(ghostText.lineNumber); + const inIndentation = column <= indentationEndColumn; + + if (inIndentation) { + let firstNonWsIdx = firstNonWhitespaceIndex(firstLine); + if (firstNonWsIdx === -1) { + firstNonWsIdx = firstLine.length - 1; + } + startsWithIndentation = firstNonWsIdx > 0; + + const tabSize = model.textModel.getOptions().tabSize; + const visibleColumnIndentation = CursorColumns.visibleColumnFromColumn(firstLine, firstNonWsIdx + 1, tabSize); + startsWithIndentationLessThanTabSize = visibleColumnIndentation < tabSize; + } + } + + this.inlineCompletionSuggestsIndentation.set(startsWithIndentation); + this.inlineCompletionSuggestsIndentationLessThanTabSize.set(startsWithIndentationLessThanTabSize); + })); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.css b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.css similarity index 100% rename from src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.css rename to src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.css diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts similarity index 74% rename from src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.ts rename to src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts index 72a89f2a9705f..337793fbe518c 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsHintsWidget.ts @@ -11,15 +11,16 @@ import { equals } from 'vs/base/common/arrays'; import { RunOnceScheduler } from 'vs/base/common/async'; import { Codicon } from 'vs/base/common/codicons'; import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun, derived, observableFromEvent } from 'vs/base/common/observable'; import { OS } from 'vs/base/common/platform'; import { ThemeIcon } from 'vs/base/common/themables'; -import 'vs/css!./inlineSuggestionHintsWidget'; +import 'vs/css!./inlineCompletionsHintsWidget'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; -import { Command } from 'vs/editor/common/languages'; +import { Command, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; import { PositionAffinity } from 'vs/editor/common/model'; -import { showNextInlineSuggestionActionId, showPreviousInlineSuggestionActionId } from 'vs/editor/contrib/inlineCompletions/browser/consts'; +import { showPreviousInlineSuggestionActionId, showNextInlineSuggestionActionId } from 'vs/editor/contrib/inlineCompletions/browser/commandIds'; import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; import { localize } from 'vs/nls'; import { createAndFillInActionBarActions, MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; @@ -33,67 +34,57 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; -export class InlineSuggestionHintsWidget extends Disposable { - private readonly widget = this._register(this.instantiationService.createInstance(InlineSuggestionHintsContentWidget, this.editor, true)); +export class InlineCompletionsHintsWidget extends Disposable { + private readonly showToolbar = observableFromEvent(this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).showToolbar); private sessionPosition: Position | undefined = undefined; - private isDisposed = false; - constructor( - private readonly editor: ICodeEditor, - private readonly model: InlineCompletionsModel, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - - editor.addContentWidget(this.widget); - this._register(toDisposable(() => editor.removeContentWidget(this.widget))); - this._register(model.onDidChange(() => this.update())); - this._register(editor.onDidChangeConfiguration(() => this.update())); - this.update(); - } - - override dispose(): void { - this.isDisposed = true; - super.dispose(); - } - - private update(): void { - if (this.isDisposed) { - return; - } + private readonly position = derived('position', reader => { + const ghostText = this.model.ghostText.read(reader); - const options = this.editor.getOption(EditorOption.inlineSuggest); - if (options.showToolbar !== 'always' || !this.model.ghostText) { - this.widget.update(null, 0, undefined, []); + if (this.showToolbar.read(reader) !== 'always' || !ghostText) { this.sessionPosition = undefined; - return; - } - - if (!this.model.completionSession.value) { - return; + return null; } - if (!this.model.completionSession.value.hasBeenTriggeredExplicitly) { - this.model.completionSession.value.ensureUpdateWithExplicitContext(); - } - - const ghostText = this.model.ghostText; - const firstColumn = ghostText.parts[0].column; if (this.sessionPosition && this.sessionPosition.lineNumber !== ghostText.lineNumber) { this.sessionPosition = undefined; } const position = new Position(ghostText.lineNumber, Math.min(firstColumn, this.sessionPosition?.column ?? Number.MAX_SAFE_INTEGER)); - this.sessionPosition = position; + return position; + }); + + private readonly contentWidget = this._register(this.instantiationService.createInstance( + InlineSuggestionHintsContentWidget, + this.editor, + true, + this.position, + this.model.currentInlineCompletionIndex, + this.model.inlineCompletionsCount, + this.model.currentInlineCompletion.map(v => v?.inlineCompletion.source.inlineCompletions.commands ?? []), + )); - this.widget.update( - this.sessionPosition, - this.model.completionSession.value.currentlySelectedIndex, - this.model.completionSession.value.hasBeenTriggeredExplicitly ? this.model.completionSession.value.getInlineCompletionsCountSync() : undefined, - this.model.completionSession.value.commands, - ); + constructor( + private readonly editor: ICodeEditor, + private readonly model: InlineCompletionsModel, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + editor.addContentWidget(this.contentWidget); + this._register(toDisposable(() => editor.removeContentWidget(this.contentWidget))); + + this._register(autorun('request explicit', reader => { + const position = this.position.read(reader); + if (!position) { + return; + } + if (this.model.lastTriggerKind.read(reader) !== InlineCompletionTriggerKind.Explicit) { + this.model.triggerExplicitly(); + } + })); } } @@ -116,7 +107,6 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC h('div@toolBar'), ]) ]); - private position: Position | null = null; private createCommandAction(commandId: string, label: string, iconClassName: string): Action { const action = new Action( @@ -155,13 +145,16 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC this.previousAction.enabled = this.nextAction.enabled = false; }, 100)); - private lastCurrentSuggestionIdx = -1; - private lastSuggestionCount = -1; private lastCommands: Command[] = []; constructor( private readonly editor: ICodeEditor, private readonly withBorder: boolean, + private readonly _position: IObservable, + private readonly _currentSuggestionIdx: IObservable, + private readonly _suggestionCount: IObservable, + private readonly _extraCommands: IObservable, + @ICommandService private readonly _commandService: ICommandService, @IInstantiationService instantiationService: IInstantiationService, @IKeybindingService private readonly keybindingService: IKeybindingService, @@ -188,62 +181,65 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC this._register(this.toolBar.onDidChangeDropdownVisibility(e => { InlineSuggestionHintsContentWidget._dropDownVisible = e; })); - } - - public update(position: Position | null, currentSuggestionIdx: number, suggestionCount: number | undefined, extraCommands: Command[]): void { - if (this.position === position - && this.lastCurrentSuggestionIdx === currentSuggestionIdx - && this.lastSuggestionCount === suggestionCount - && equals(this.lastCommands, extraCommands)) { - // nothing to update - return; - } - - this.position = position; - this.lastCurrentSuggestionIdx = currentSuggestionIdx; - this.lastSuggestionCount = suggestionCount ?? -1; - this.lastCommands = extraCommands; - if (suggestionCount !== undefined && suggestionCount > 1) { - this.disableButtonsDebounced.cancel(); - this.previousAction.enabled = this.nextAction.enabled = true; - } else { - this.disableButtonsDebounced.schedule(); - } + this._register(autorun('update position', (reader) => { + this._position.read(reader); + this.editor.layoutContentWidget(this); + })); - if (suggestionCount !== undefined) { - this.clearAvailableSuggestionCountLabelDebounced.cancel(); - this.availableSuggestionCountAction.label = `${currentSuggestionIdx + 1}/${suggestionCount}`; - } else { - this.clearAvailableSuggestionCountLabelDebounced.schedule(); - } + this._register(autorun('counts', (reader) => { + const suggestionCount = this._suggestionCount.read(reader); + const currentSuggestionIdx = this._currentSuggestionIdx.read(reader); - this.editor.layoutContentWidget(this); + if (suggestionCount !== undefined) { + this.clearAvailableSuggestionCountLabelDebounced.cancel(); + this.availableSuggestionCountAction.label = `${currentSuggestionIdx + 1}/${suggestionCount}`; + } else { + this.clearAvailableSuggestionCountLabelDebounced.schedule(); + } - const extraActions = extraCommands.map(c => ({ - class: undefined, - id: c.id, - enabled: true, - tooltip: c.tooltip || '', - label: c.title, - run: (event) => { - return this._commandService.executeCommand(c.id); - }, + if (suggestionCount !== undefined && suggestionCount > 1) { + this.disableButtonsDebounced.cancel(); + this.previousAction.enabled = this.nextAction.enabled = true; + } else { + this.disableButtonsDebounced.schedule(); + } })); - for (const [_, group] of this.inlineCompletionsActionsMenus.getActions()) { - for (const action of group) { - if (action instanceof MenuItemAction) { - extraActions.push(action); + this._register(autorun('extra commands', (reader) => { + const extraCommands = this._extraCommands.read(reader); + if (equals(this.lastCommands, extraCommands)) { + // nothing to update + return; + } + + this.lastCommands = extraCommands; + + const extraActions = extraCommands.map(c => ({ + class: undefined, + id: c.id, + enabled: true, + tooltip: c.tooltip || '', + label: c.title, + run: (event) => { + return this._commandService.executeCommand(c.id); + }, + })); + + for (const [_, group] of this.inlineCompletionsActionsMenus.getActions()) { + for (const action of group) { + if (action instanceof MenuItemAction) { + extraActions.push(action); + } } } - } - if (extraActions.length > 0) { - extraActions.unshift(new Separator()); - } + if (extraActions.length > 0) { + extraActions.unshift(new Separator()); + } - this.toolBar.setAdditionalSecondaryActions(extraActions); + this.toolBar.setAdditionalSecondaryActions(extraActions); + })); } getId(): string { return this.id; } @@ -254,7 +250,7 @@ export class InlineSuggestionHintsContentWidget extends Disposable implements IC getPosition(): IContentWidgetPosition | null { return { - position: this.position, + position: this._position.get(), preference: [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW], positionAffinity: PositionAffinity.LeftOfInjectedText, }; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index 4f79ed9fd6ded..33922248d87db 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -3,534 +3,299 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { assertNever } from 'vs/base/common/assert'; -import { CancelablePromise, createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { onUnexpectedError, onUnexpectedExternalError } from 'vs/base/common/errors'; -import { Emitter } from 'vs/base/common/event'; -import { matchesSubString } from 'vs/base/common/filters'; -import { Disposable, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { CoreEditingCommands } from 'vs/editor/browser/coreCommands'; -import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { mapFind } from 'vs/base/common/arrays'; +import { BugIndicatingError, onUnexpectedExternalError } from 'vs/base/common/errors'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { IObservable, IReader, ITransaction, autorunHandleChanges, derived, observableSignal, observableValue, transaction } from 'vs/base/common/observable'; +import { isDefined } from 'vs/base/common/types'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { CursorChangeReason } from 'vs/editor/common/cursorEvents'; -import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; -import { Command, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; +import { InlineCompletionTriggerKind } from 'vs/editor/common/languages'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; -import { ITextModel } from 'vs/editor/common/model'; -import { fixBracketsInLine } from 'vs/editor/common/model/bracketPairsTextModelPart/fixBrackets'; -import { IFeatureDebounceInformation, ILanguageFeatureDebounceService } from 'vs/editor/common/services/languageFeatureDebounce'; -import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; -import { inlineSuggestCommitId } from 'vs/editor/contrib/inlineCompletions/browser/consts'; -import { BaseGhostTextWidgetModel, GhostText, GhostTextReplacement, GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; -import { SharedInlineCompletionCache } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextModel'; -import { inlineCompletionToGhostText, NormalizedInlineCompletion } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionToGhostText'; -import { InlineSuggestionHintsContentWidget } from 'vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget'; -import { getReadonlyEmptyArray } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; +import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; +import { GhostText } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; +import { addPositions, lengthOfText } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { InlineCompletionWithUpdatedRange, InlineCompletionsSource } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource'; +import { SuggestItemInfo } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; -import { SnippetParser, Text } from 'vs/editor/contrib/snippet/browser/snippetParser'; import { ICommandService } from 'vs/platform/commands/common/commands'; -import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -export class InlineCompletionsModel extends Disposable implements GhostTextWidgetModel { - protected readonly onDidChangeEmitter = new Emitter(); - public readonly onDidChange = this.onDidChangeEmitter.event; +export enum VersionIdChangeReason { + Undo, + Redo, + AcceptWord, + Other, +} - public readonly completionSession = this._register( - new MutableDisposable() - ); +export class InlineCompletionsModel extends Disposable { + private readonly _source = this._register(this._instantiationService.createInstance(InlineCompletionsSource, this.textModel, this._debounceValue)); + private readonly _isActive = observableValue('isActive', false); - private active: boolean = false; - private disposed = false; - private readonly debounceValue = this.debounceService.for( - this.languageFeaturesService.inlineCompletionsProvider, - 'InlineCompletionsDebounce', - { min: 50, max: 50 } - ); + private _isAcceptingPartialWord = false; + public get isAcceptingPartialWord() { return this._isAcceptingPartialWord; } constructor( - private readonly editor: IActiveCodeEditor, - private readonly cache: SharedInlineCompletionCache, - @ICommandService private readonly commandService: ICommandService, - @ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService, - @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - @ILanguageFeatureDebounceService private readonly debounceService: ILanguageFeatureDebounceService, - @IConfigurationService configurationService: IConfigurationService, + public readonly textModel: ITextModel, + public readonly selectedSuggestItem: IObservable, + public readonly cursorPosition: IObservable, + public readonly textModelVersionId: IObservable, + private readonly _debounceValue: IFeatureDebounceInformation, + private readonly _suggestPreviewEnabled: IObservable, + private readonly _suggestPreviewMode: IObservable<'prefix' | 'subword' | 'subwordSmart'>, + private readonly _inlineSuggestMode: IObservable<'prefix' | 'subword' | 'subwordSmart'>, + private readonly _enabled: IObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ICommandService private readonly _commandService: ICommandService, + @ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService, ) { super(); - this._register( - commandService.onDidExecuteCommand((e) => { - // These commands don't trigger onDidType. - const commands = new Set([ - CoreEditingCommands.Tab.id, - CoreEditingCommands.DeleteLeft.id, - CoreEditingCommands.DeleteRight.id, - inlineSuggestCommitId, - 'acceptSelectedSuggestion', - ]); - if (commands.has(e.commandId) && editor.hasTextFocus()) { - this.handleUserInput(); - } - }) - ); - - this._register( - this.editor.onDidType((e) => { - this.handleUserInput(); - }) - ); - - this._register( - this.editor.onDidChangeCursorPosition((e) => { - if (e.reason === CursorChangeReason.Explicit || - this.session && !this.session.isValid) { - this.hide(); - } - }) - ); - - this._register( - toDisposable(() => { - this.disposed = true; - }) - ); - - this._register( - this.editor.onDidBlurEditorWidget(() => { - // This is a hidden setting very useful for debugging - if (configurationService.getValue('editor.inlineSuggest.hideOnBlur')) { - return; - } - if (InlineSuggestionHintsContentWidget.dropDownVisible) { - return; + let preserveCurrentCompletion = true; + const preserveCurrentCompletionReasons = new Set([ + VersionIdChangeReason.Redo, + VersionIdChangeReason.Undo, + VersionIdChangeReason.AcceptWord, + ]); + this._register(autorunHandleChanges('update', { + handleChange: ctx => { + if (!(ctx.didChange(this.textModelVersionId) && preserveCurrentCompletionReasons.has(ctx.change))) { + preserveCurrentCompletion = false; } - this.hide(); - }) - ); - } - - private handleUserInput() { - if (this.session && !this.session.isValid) { - this.hide(); - } - setTimeout(() => { - if (this.disposed) { - return; + return true; } - // Wait for the cursor update that happens in the same iteration loop iteration - this.startSessionIfTriggered(); - }, 0); - } - - private get session(): InlineCompletionsSession | undefined { - return this.completionSession.value; - } - - public get ghostText(): GhostText | GhostTextReplacement | undefined { - return this.session?.ghostText; - } - - public get minReservedLineCount(): number { - return this.session ? this.session.minReservedLineCount : 0; - } - - public get expanded(): boolean { - return this.session ? this.session.expanded : false; - } - - public setExpanded(expanded: boolean): void { - this.session?.setExpanded(expanded); - } - - public setActive(active: boolean) { - this.active = active; - if (active) { - this.session?.scheduleAutomaticUpdate(); - } + }, (reader) => { + if ((this._enabled.read(reader) && this.selectedSuggestItem.read(reader)) || this._isActive.read(reader)) { + this._update(reader, InlineCompletionTriggerKind.Automatic, preserveCurrentCompletion); + } + preserveCurrentCompletion = true; + })); } - private startSessionIfTriggered(): void { - const suggestOptions = this.editor.getOption(EditorOption.inlineSuggest); - if (!suggestOptions.enabled) { - return; - } - - if (this.session && this.session.isValid) { - return; - } - - this.trigger(InlineCompletionTriggerKind.Automatic); - } + private async _update(reader: IReader | undefined, triggerKind: InlineCompletionTriggerKind, preserveCurrentCompletion: boolean = false): Promise { + const suggestItem = this.selectedSuggestItem.read(reader); + const cursorPosition = this.cursorPosition.read(reader); + this.textModelVersionId.read(reader); - public trigger(triggerKind: InlineCompletionTriggerKind): void { - if (this.completionSession.value) { - if (triggerKind === InlineCompletionTriggerKind.Explicit) { - void this.completionSession.value.ensureUpdateWithExplicitContext(); + const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.get(); + if (suggestWidgetInlineCompletions && !suggestItem) { + const inlineCompletions = this._source.inlineCompletions.get(); + if (inlineCompletions && suggestWidgetInlineCompletions.request.versionId > inlineCompletions.request.versionId) { + this._source.inlineCompletions.set(suggestWidgetInlineCompletions.clone(), undefined); } - return; - } - this.completionSession.value = new InlineCompletionsSession( - this.editor, - this.editor.getPosition(), - () => this.active, - this.commandService, - this.cache, - triggerKind, - this.languageConfigurationService, - this.languageFeaturesService.inlineCompletionsProvider, - this.debounceValue - ); - this.completionSession.value.takeOwnership( - this.completionSession.value.onDidChange(() => { - this.onDidChangeEmitter.fire(); - }) - ); - } - - public hide(): void { - if (this.completionSession.value) { - this.completionSession.clear(); - this.onDidChangeEmitter.fire(); + this._source.clearSuggestWidgetInlineCompletions(); } - } - - public commitCurrentSuggestion(): void { - // Don't dispose the session, so that after committing, more suggestions are shown. - this.session?.commitCurrentCompletion(); - } - - public commitCurrentSuggestionPartially(): void { - this.session?.commitCurrentCompletionNextWord(); - } - public showNext(): void { - this.session?.showNextInlineCompletion(); - } - - public showPrevious(): void { - this.session?.showPreviousInlineCompletion(); + await this._source.update( + cursorPosition, + { triggerKind, selectedSuggestionInfo: suggestItem?.toSelectedSuggestionInfo() }, + preserveCurrentCompletion ? this.currentInlineCompletion.get() : undefined + ); } - public async getInlineCompletionsCount(): Promise { - const result = await this.session?.getInlineCompletionsCount(); - return result ?? 0; + public trigger(tx?: ITransaction): void { + this._isActive.set(true, tx); } -} -export class InlineCompletionsSession extends BaseGhostTextWidgetModel { - public readonly minReservedLineCount = 0; - - private readonly updateOperation = this._register(new MutableDisposable()); - - private readonly updateSoon = this._register(new RunOnceScheduler(() => { - const triggerKind = this.initialTriggerKind; - // All subsequent triggers are automatic. - this.initialTriggerKind = InlineCompletionTriggerKind.Automatic; - return this.update(triggerKind); - }, 50)); - - constructor( - editor: IActiveCodeEditor, - private readonly triggerPosition: Position, - private readonly shouldUpdate: () => boolean, - private readonly commandService: ICommandService, - private readonly cache: SharedInlineCompletionCache, - private initialTriggerKind: InlineCompletionTriggerKind, - private readonly languageConfigurationService: ILanguageConfigurationService, - private readonly registry: LanguageFeatureRegistry, - private readonly debounce: IFeatureDebounceInformation, - ) { - super(editor); - - let lastCompletionItem: InlineCompletion | undefined = undefined; - this._register(this.onDidChange(() => { - const currentCompletion = this.currentCompletion; - if (currentCompletion && currentCompletion.sourceInlineCompletion !== lastCompletionItem) { - lastCompletionItem = currentCompletion.sourceInlineCompletion; - - const provider = currentCompletion.sourceProvider; - provider.handleItemDidShow?.(currentCompletion.sourceInlineCompletions, lastCompletionItem); - } - })); - - this._register(toDisposable(() => { - this.cache.clear(); - })); - - this._register(this.editor.onDidChangeCursorPosition((e) => { - if (e.reason === CursorChangeReason.Explicit) { - return; - } - // Ghost text depends on the cursor position - this.cache.value?.updateRanges(); - if (this.cache.value) { - this.updateFilteredInlineCompletions(); - this.onDidChangeEmitter.fire(); - } - })); - - this._register(this.editor.onDidChangeModelContent((e) => { - // Call this in case `onDidChangeModelContent` calls us first. - this.cache.value?.updateRanges(); - this.updateFilteredInlineCompletions(); - this.scheduleAutomaticUpdate(); - })); - - this._register(this.registry.onDidChange(() => { - this.updateSoon.schedule(this.debounce.get(this.editor.getModel())); - })); - - this.scheduleAutomaticUpdate(); - } - - private filteredCompletions: readonly CachedInlineCompletion[] = []; - - private updateFilteredInlineCompletions() { - if (!this.cache.value) { - this.filteredCompletions = []; + public stop(tx?: ITransaction): void { + if (!tx) { + transaction(tx => this.stop(tx)); return; } + this._isActive.set(false, tx); + this._source.clear(tx); + } - const model = this.editor.getModel(); - const cursorPosition = model.validatePosition(this.editor.getPosition()); - this.filteredCompletions = this.cache.value.completions.filter(c => { - const originalValue = model.getValueInRange(c.synchronizedRange).toLowerCase(); - const filterText = c.inlineCompletion.filterText.toLowerCase(); - - const indent = model.getLineIndentColumn(c.synchronizedRange.startLineNumber); - - - const cursorPosIndex = Math.max(0, cursorPosition.column - c.synchronizedRange.startColumn); - - let filterTextBefore = filterText.substring(0, cursorPosIndex); - let filterTextAfter = filterText.substring(cursorPosIndex); + private readonly _filteredInlineCompletionItems = derived('filteredInlineCompletionItems', (reader) => { + const c = this._source.inlineCompletions.read(reader); + if (!c) { return []; } - let originalValueBefore = originalValue.substring(0, cursorPosIndex); - let originalValueAfter = originalValue.substring(cursorPosIndex); + const versionId = this.textModelVersionId.read(reader); + const inlineCompletions = c.getInlineCompletions(versionId); - if (c.synchronizedRange.startColumn <= indent) { - // Remove indentation - originalValueBefore = originalValueBefore.trimStart(); - if (originalValueBefore.length === 0) { - originalValueAfter = originalValueAfter.trimStart(); - } - filterTextBefore = filterTextBefore.trimStart(); - if (filterTextBefore.length === 0) { - filterTextAfter = filterTextAfter.trimStart(); - } - } - - return filterTextBefore.startsWith(originalValueBefore) - && matchesSubString(originalValueAfter, filterTextAfter); - }); - } - - //#region Selection + const model = this.textModel; + const cursorPosition = model.validatePosition(this.cursorPosition.read(reader)); + const filteredCompletions = inlineCompletions.filter(c => c.isVisible(model, cursorPosition)); + return filteredCompletions; + }); // We use a semantic id to track the selection even if the cache changes. - private currentlySelectedCompletionId: string | undefined = undefined; + private _currentInlineCompletionId: string | undefined = undefined; + private readonly _selectedCompletionIdChanged = observableSignal('selectedCompletionIdChanged'); - public get currentlySelectedIndex(): number { - return this.fixAndGetIndexOfCurrentSelection(); - } + public readonly currentInlineCompletionIndex = derived('currentCachedCompletionIndex', (reader) => { + this._selectedCompletionIdChanged.read(reader); - private fixAndGetIndexOfCurrentSelection(): number { - if (!this.currentlySelectedCompletionId || !this.cache.value) { - return 0; - } - if (this.cache.value.completions.length === 0) { - // don't reset the selection in this case + const filteredCompletions = this._filteredInlineCompletionItems.read(reader); + if (!this._currentInlineCompletionId || filteredCompletions.length === 0) { return 0; } - const idx = this.filteredCompletions.findIndex(v => v.semanticId === this.currentlySelectedCompletionId); + const idx = filteredCompletions.findIndex(v => v.semanticId === this._currentInlineCompletionId); if (idx === -1) { // Reset the selection so that the selection does not jump back when it appears again - this.currentlySelectedCompletionId = undefined; + this._currentInlineCompletionId = undefined; return 0; } return idx; - } + }); - private get currentCachedCompletion(): CachedInlineCompletion | undefined { - if (!this.cache.value) { - return undefined; - } - return this.filteredCompletions[this.fixAndGetIndexOfCurrentSelection()]; - } + public readonly currentInlineCompletion = derived('currentCachedCompletion', (reader) => { + const filteredCompletions = this._filteredInlineCompletionItems.read(reader); + const idx = this.currentInlineCompletionIndex.read(reader); + return filteredCompletions[idx]; + }); - public async showNextInlineCompletion(): Promise { - await this.ensureUpdateWithExplicitContext(); + public readonly lastTriggerKind = this._source.inlineCompletions.map(v => v?.request.context.triggerKind); - const completions = this.filteredCompletions || []; - if (completions.length > 0) { - const newIdx = (this.fixAndGetIndexOfCurrentSelection() + 1) % completions.length; - this.currentlySelectedCompletionId = completions[newIdx].semanticId; + public readonly inlineCompletionsCount = derived('currentInlineCompletionsCount', reader => { + if (this.lastTriggerKind.read(reader) === InlineCompletionTriggerKind.Explicit) { + return this._filteredInlineCompletionItems.read(reader).length; } else { - this.currentlySelectedCompletionId = undefined; + return undefined; } - this.onDidChangeEmitter.fire(); - } + }); - public async showPreviousInlineCompletion(): Promise { - await this.ensureUpdateWithExplicitContext(); + public readonly ghostText = derived('ghostText', (reader) => { + const versionId = this.textModelVersionId.read(reader); + const model = this.textModel; - const completions = this.filteredCompletions || []; - if (completions.length > 0) { - const newIdx = (this.fixAndGetIndexOfCurrentSelection() + completions.length - 1) % completions.length; - this.currentlySelectedCompletionId = completions[newIdx].semanticId; - } else { - this.currentlySelectedCompletionId = undefined; - } - this.onDidChangeEmitter.fire(); - } + const suggestItem = this.selectedSuggestItem.read(reader); + if (suggestItem) { + const suggestWidgetInlineCompletions = this._source.suggestWidgetInlineCompletions.read(reader); + const candidateInlineCompletion = suggestWidgetInlineCompletions + ? suggestWidgetInlineCompletions.getInlineCompletions(versionId) + : [this.currentInlineCompletion.read(reader)].filter(isDefined); - public get hasBeenTriggeredExplicitly(): boolean { - return this.cache.value?.triggerKind === InlineCompletionTriggerKind.Explicit; - } + const suggestCompletion = suggestItem.toSingleTextEdit().removeCommonPrefix(model); - public async ensureUpdateWithExplicitContext(): Promise { - if (this.updateOperation.value) { - // Restart or wait for current update operation - if (this.updateOperation.value.triggerKind === InlineCompletionTriggerKind.Explicit) { - await this.updateOperation.value.promise; - } else { - await this.update(InlineCompletionTriggerKind.Explicit); + const augmentedCompletion = mapFind(candidateInlineCompletion, c => { + let r = c.toSingleTextEdit(); + r = r.removeCommonPrefix(model); + return r.augments(suggestCompletion) ? r : undefined; + }); + + const isSuggestionPreviewEnabled = this._suggestPreviewEnabled.read(reader); + if (!isSuggestionPreviewEnabled && !augmentedCompletion) { + return undefined; } - } else if (this.cache.value?.triggerKind !== InlineCompletionTriggerKind.Explicit) { - // Refresh cache - await this.update(InlineCompletionTriggerKind.Explicit); - } - } - public async getInlineCompletionsCount(): Promise { - await this.ensureUpdateWithExplicitContext(); - return this.getInlineCompletionsCountSync(); - } + const edit = augmentedCompletion ?? suggestCompletion; + const editPreviewLength = augmentedCompletion ? augmentedCompletion.text.length - suggestCompletion.text.length : 0; - public getInlineCompletionsCountSync(): number { - return this.filteredCompletions.length || 0; - } + const mode = this._suggestPreviewMode.read(reader); + const cursor = this.cursorPosition.read(reader); + const newGhostText = edit.computeGhostText(model, mode, cursor, editPreviewLength); - //#endregion + // Show an invisible ghost text to reserve space + return newGhostText ?? new GhostText(edit.range.endLineNumber, [], 0); + } else { + if (!this._isActive.read(reader)) { return undefined; } + const item = this.currentInlineCompletion.read(reader); + if (!item) { return undefined; } - public get ghostText(): GhostText | GhostTextReplacement | undefined { - const currentCompletion = this.currentCompletion; - if (!currentCompletion) { - return undefined; - } - const cursorPosition = this.editor.getPosition(); - if (currentCompletion.range.getEndPosition().isBefore(cursorPosition)) { - return undefined; + const replacement = item.toSingleTextEdit(); + const mode = this._inlineSuggestMode.read(reader); + const cursor = this.cursorPosition.read(reader); + return replacement.computeGhostText(model, mode, cursor); } + }); - const mode = this.editor.getOptions().get(EditorOption.inlineSuggest).mode; + public async next(): Promise { + await this.triggerExplicitly(); - const ghostText = inlineCompletionToGhostText(currentCompletion, this.editor.getModel(), mode, cursorPosition); - if (ghostText) { - if (ghostText.isEmpty()) { - return undefined; - } - return ghostText; + const completions = this._filteredInlineCompletionItems.get() || []; + if (completions.length > 0) { + const newIdx = (this.currentInlineCompletionIndex.get() + 1) % completions.length; + this._currentInlineCompletionId = completions[newIdx].semanticId; + } else { + this._currentInlineCompletionId = undefined; } - return new GhostTextReplacement( - currentCompletion.range.startLineNumber, - currentCompletion.range.startColumn, - currentCompletion.range.endColumn - currentCompletion.range.startColumn, - currentCompletion.insertText.split('\n'), - 0 - ); + this._selectedCompletionIdChanged.trigger(undefined); } - get currentCompletion(): TrackedInlineCompletion | undefined { - const completion = this.currentCachedCompletion; - if (!completion) { - return undefined; + public async previous(): Promise { + await this.triggerExplicitly(); + + const completions = this._filteredInlineCompletionItems.get() || []; + if (completions.length > 0) { + const newIdx = (this.currentInlineCompletionIndex.get() + completions.length - 1) % completions.length; + this._currentInlineCompletionId = completions[newIdx].semanticId; + } else { + this._currentInlineCompletionId = undefined; } - return completion.toLiveInlineCompletion(); + this._selectedCompletionIdChanged.trigger(undefined); } - get isValid(): boolean { - return this.editor.getPosition().lineNumber === this.triggerPosition.lineNumber; + public async triggerExplicitly(): Promise { + await this._update(undefined, InlineCompletionTriggerKind.Explicit); } - public scheduleAutomaticUpdate(): void { - // Since updateSoon debounces, starvation can happen. - // To prevent stale cache, we clear the current update operation. - this.updateOperation.clear(); - this.updateSoon.schedule(this.debounce.get(this.editor.getModel())); - } + public accept(editor: ICodeEditor): void { + if (editor.getModel() !== this.textModel) { + throw new BugIndicatingError(); + } - private async update(triggerKind: InlineCompletionTriggerKind): Promise { - if (!this.shouldUpdate()) { + const ghostText = this.ghostText.get(); + const completion = this.currentInlineCompletion.get()?.toInlineCompletion(); + if (!ghostText || !completion) { return; } - const position = this.editor.getPosition(); - - const startTime = new Date(); - - const promise = createCancelablePromise(async token => { - let result; - try { - result = await provideInlineCompletions(this.registry, position, - this.editor.getModel(), - { triggerKind, selectedSuggestionInfo: undefined }, - token, - this.languageConfigurationService - ); - - const endTime = new Date(); - if (this.editor.hasModel()) { - this.debounce.update(this.editor.getModel(), endTime.getTime() - startTime.getTime()); - } - - } catch (e) { - onUnexpectedError(e); - return; - } - - if (token.isCancellationRequested) { - return; - } - - this.cache.setValue( - this.editor, - result, - triggerKind + editor.pushUndoStop(); + if (completion.snippetInfo) { + editor.executeEdits( + 'inlineSuggestion.accept', + [ + EditOperation.replaceMove(completion.range, ''), + ...completion.additionalTextEdits + ] + ); + editor.setPosition(completion.snippetInfo.range.getStartPosition()); + SnippetController2.get(editor)?.insert(completion.snippetInfo.snippet, { undoStopBefore: false }); + } else { + editor.executeEdits( + 'inlineSuggestion.accept', + [ + EditOperation.replaceMove(completion.range, completion.insertText), + ...completion.additionalTextEdits + ] ); - this.updateFilteredInlineCompletions(); - this.onDidChangeEmitter.fire(); - }); - const operation = new UpdateOperation(promise, triggerKind); - this.updateOperation.value = operation; - await promise; - if (this.updateOperation.value === operation) { - this.updateOperation.clear(); } - } - public takeOwnership(disposable: IDisposable): void { - this._register(disposable); + if (completion.command) { + this._commandService + .executeCommand(completion.command.id, ...(completion.command.arguments || [])) + .finally(() => { + transaction(tx => { + this._source.clear(tx); + }); + }) + .then(undefined, onUnexpectedExternalError); + } else { + transaction(tx => { + this._source.clear(tx); + }); + } } - public commitCurrentCompletionNextWord(): void { - const ghostText = this.ghostText; - if (!ghostText) { - return; + public acceptNextWord(editor: ICodeEditor): void { + if (editor.getModel() !== this.textModel) { + throw new BugIndicatingError(); } - const completion = this.currentCompletion; - if (!completion) { + + const ghostText = this.ghostText.get(); + const completion = this.currentInlineCompletion.get()?.toInlineCompletion(); + if (!ghostText || !completion) { return; } if (completion.snippetInfo || completion.filterText !== completion.insertText) { // not in WYSIWYG mode, partial commit might change completion, thus it is not supported - this.commit(completion); + this.accept(editor); return; } @@ -540,9 +305,9 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { const firstPart = ghostText.parts[0]; const position = new Position(ghostText.lineNumber, firstPart.column); - const line = firstPart.lines[0]; - const langId = this.editor.getModel()!.getLanguageIdAtPosition(ghostText.lineNumber, 1); - const config = this.languageConfigurationService.getLanguageConfiguration(langId); + const line = firstPart.lines.join('\n'); + const langId = this.textModel.getLanguageIdAtPosition(ghostText.lineNumber, 1); + const config = this._languageConfigurationService.getLanguageConfiguration(langId); const wordRegExp = new RegExp(config.wordDefinition.source, config.wordDefinition.flags.replace('g', '')); const m1 = line.match(wordRegExp); @@ -557,391 +322,42 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { acceptUntilIndexExclusive = line.length; } - const wsRegExp = /\s/g; - let m2 = wsRegExp.exec(line); - if (m2 && m2.index === 0) { - m2 = wsRegExp.exec(line); - } + const wsRegExp = /\s+/g; + const m2 = wsRegExp.exec(line); if (m2 && m2.index !== undefined) { - if (m2.index < acceptUntilIndexExclusive) { - acceptUntilIndexExclusive = m2.index; + if (m2.index + m2[0].length < acceptUntilIndexExclusive) { + acceptUntilIndexExclusive = m2.index + m2[0].length; } } + if (acceptUntilIndexExclusive === line.length && ghostText.parts.length === 1) { + this.accept(editor); + return; + } + const partialText = line.substring(0, acceptUntilIndexExclusive); - this.editor.pushUndoStop(); - this.editor.executeEdits( - 'inlineSuggestion.accept', - [ + this._isAcceptingPartialWord = true; + try { + editor.pushUndoStop(); + editor.executeEdits('inlineSuggestion.accept', [ EditOperation.replace(Range.fromPositions(position), partialText), - ] - ); - this.editor.setPosition(position.delta(0, partialText.length)); - - if (completion.sourceProvider.handlePartialAccept) { - const acceptedRange = Range.fromPositions(completion.range.getStartPosition(), position.delta(0, acceptUntilIndexExclusive)); + ]); + const length = lengthOfText(partialText); + editor.setPosition(addPositions(position, length)); + } finally { + this._isAcceptingPartialWord = false; + } + if (completion.source.provider.handlePartialAccept) { + const acceptedRange = Range.fromPositions(completion.range.getStartPosition(), addPositions(position, lengthOfText(partialText))); // This assumes that the inline completion and the model use the same EOL style. - // This is not a problem at the moment, because partial acceptance only works for the first line of an - // inline completion. - const text = this.editor.getModel()!.getValueInRange(acceptedRange); - completion.sourceProvider.handlePartialAccept( - completion.sourceInlineCompletions, + const text = editor.getModel()!.getValueInRange(acceptedRange, EndOfLinePreference.LF); + completion.source.provider.handlePartialAccept( + completion.source.inlineCompletions, completion.sourceInlineCompletion, text.length, ); } } - - public commitCurrentCompletion(): void { - const ghostText = this.ghostText; - if (!ghostText) { - // No ghost text was shown for this completion. - // Thus, we don't want to commit anything. - return; - } - const completion = this.currentCompletion; - if (completion) { - this.commit(completion); - } - } - - public commit(completion: TrackedInlineCompletion): void { - // Mark the cache as stale, but don't dispose it yet, - // otherwise command args might get disposed. - const cache = this.cache.clearAndLeak(); - - this.editor.pushUndoStop(); - if (completion.snippetInfo) { - this.editor.executeEdits( - 'inlineSuggestion.accept', - [ - EditOperation.replaceMove(completion.range, ''), - ...completion.additionalTextEdits - ] - ); - this.editor.setPosition(completion.snippetInfo.range.getStartPosition()); - SnippetController2.get(this.editor)?.insert(completion.snippetInfo.snippet, { undoStopBefore: false }); - } else { - this.editor.executeEdits( - 'inlineSuggestion.accept', - [ - EditOperation.replaceMove(completion.range, completion.insertText), - ...completion.additionalTextEdits - ] - ); - } - - if (completion.command) { - this.commandService - .executeCommand(completion.command.id, ...(completion.command.arguments || [])) - .finally(() => { - cache?.dispose(); - }) - .then(undefined, onUnexpectedExternalError); - } else { - cache?.dispose(); - } - - this.onDidChangeEmitter.fire(); - } - - public get commands(): Command[] { - const lists = new Set(this.cache.value?.completions.map(c => c.inlineCompletion.sourceInlineCompletions) || []); - return [...lists].flatMap(l => l.commands || []); - } -} - -export class UpdateOperation implements IDisposable { - constructor(public readonly promise: CancelablePromise, public readonly triggerKind: InlineCompletionTriggerKind) { - } - - dispose() { - this.promise.cancel(); - } -} - -/** - * The cache keeps itself in sync with the editor. - * It also owns the completions result and disposes it when the cache is diposed. -*/ -export class SynchronizedInlineCompletionsCache extends Disposable { - public readonly completions: readonly CachedInlineCompletion[]; - private isDisposing = false; - - constructor( - completionsSource: TrackedInlineCompletions, - private readonly editor: IActiveCodeEditor, - private readonly onChange: () => void, - public readonly triggerKind: InlineCompletionTriggerKind, - ) { - super(); - - const decorationIds = editor.changeDecorations((changeAccessor) => { - return changeAccessor.deltaDecorations( - [], - completionsSource.items.map(i => ({ - range: i.range, - options: { - description: 'inline-completion-tracking-range' - }, - })) - ); - }); - - this._register(toDisposable(() => { - this.isDisposing = true; - editor.removeDecorations(decorationIds); - })); - - this.completions = completionsSource.items.map((c, idx) => new CachedInlineCompletion(c, decorationIds[idx])); - - this._register(editor.onDidChangeModelContent(() => { - this.updateRanges(); - })); - - this._register(completionsSource); - } - - public updateRanges(): void { - if (this.isDisposing) { - return; - } - - let hasChanged = false; - const model = this.editor.getModel(); - for (const c of this.completions) { - const newRange = model.getDecorationRange(c.decorationId); - if (!newRange) { - // onUnexpectedError(new Error('Decoration has no range')); - continue; - } - if (!c.synchronizedRange.equalsRange(newRange)) { - hasChanged = true; - c.synchronizedRange = newRange; - } - } - if (hasChanged) { - this.onChange(); - } - } -} - -class CachedInlineCompletion { - public readonly semanticId: string = JSON.stringify({ - text: this.inlineCompletion.insertText, - abbreviation: this.inlineCompletion.filterText, - startLine: this.inlineCompletion.range.startLineNumber, - startColumn: this.inlineCompletion.range.startColumn, - command: this.inlineCompletion.command - }); - - /** - * The range, synchronized with text model changes. - */ - public synchronizedRange: Range; - - constructor( - public readonly inlineCompletion: TrackedInlineCompletion, - public readonly decorationId: string, - ) { - this.synchronizedRange = inlineCompletion.range; - } - - public toLiveInlineCompletion(): TrackedInlineCompletion | undefined { - return { - insertText: this.inlineCompletion.insertText, - range: this.synchronizedRange, - command: this.inlineCompletion.command, - sourceProvider: this.inlineCompletion.sourceProvider, - sourceInlineCompletions: this.inlineCompletion.sourceInlineCompletions, - sourceInlineCompletion: this.inlineCompletion.sourceInlineCompletion, - snippetInfo: this.inlineCompletion.snippetInfo, - filterText: this.inlineCompletion.filterText, - additionalTextEdits: this.inlineCompletion.additionalTextEdits, - }; - } -} - -export async function provideInlineCompletions( - registry: LanguageFeatureRegistry, - position: Position, - model: ITextModel, - context: InlineCompletionContext, - token: CancellationToken = CancellationToken.None, - languageConfigurationService?: ILanguageConfigurationService -): Promise { - const defaultReplaceRange = getDefaultRange(position, model); - - const providers = registry.all(model); - const results = await Promise.all( - providers.map( - async provider => { - const completions = await Promise.resolve(provider.provideInlineCompletions(model, position, context, token)).catch(onUnexpectedExternalError); - return ({ - completions, - provider, - dispose: () => { - if (completions) { - provider.freeInlineCompletions(completions); - } - } - }); - } - ) - ); - - const itemsByHash = new Map(); - for (const result of results) { - const completions = result.completions; - if (!completions) { - continue; - } - - for (const item of completions.items) { - let range = item.range ? Range.lift(item.range) : defaultReplaceRange; - - if (range.startLineNumber !== range.endLineNumber) { - // Ignore invalid ranges. - continue; - } - - let insertText: string; - let snippetInfo: { - snippet: string; - /* Could be different than the main range */ - range: Range; - } - | undefined; - - if (typeof item.insertText === 'string') { - insertText = item.insertText; - - if (languageConfigurationService && item.completeBracketPairs) { - insertText = closeBrackets( - insertText, - range.getStartPosition(), - model, - languageConfigurationService - ); - - // Modify range depending on if brackets are added or removed - const diff = insertText.length - item.insertText.length; - if (diff !== 0) { - range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); - } - } - - snippetInfo = undefined; - } else if ('snippet' in item.insertText) { - const preBracketCompletionLength = item.insertText.snippet.length; - - if (languageConfigurationService && item.completeBracketPairs) { - item.insertText.snippet = closeBrackets( - item.insertText.snippet, - range.getStartPosition(), - model, - languageConfigurationService - ); - - // Modify range depending on if brackets are added or removed - const diff = item.insertText.snippet.length - preBracketCompletionLength; - if (diff !== 0) { - range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); - } - } - - const snippet = new SnippetParser().parse(item.insertText.snippet); - - if (snippet.children.length === 1 && snippet.children[0] instanceof Text) { - insertText = snippet.children[0].value; - snippetInfo = undefined; - } else { - insertText = snippet.toString(); - snippetInfo = { - snippet: item.insertText.snippet, - range: range - }; - } - } else { - assertNever(item.insertText); - } - - const trackedItem: TrackedInlineCompletion = ({ - insertText, - snippetInfo, - range, - command: item.command, - sourceProvider: result.provider, - sourceInlineCompletions: completions, - sourceInlineCompletion: item, - filterText: item.filterText || insertText, - additionalTextEdits: item.additionalTextEdits || getReadonlyEmptyArray() - }); - - itemsByHash.set(JSON.stringify({ insertText, range: item.range }), trackedItem); - } - } - - return { - items: [...itemsByHash.values()], - dispose: () => { - for (const result of results) { - result.dispose(); - } - }, - }; -} - -/** - * Contains no duplicated items and can be disposed. -*/ -export interface TrackedInlineCompletions { - readonly items: readonly TrackedInlineCompletion[]; - dispose(): void; -} - -/** - * A normalized inline completion that tracks which inline completion it has been constructed from. -*/ -export interface TrackedInlineCompletion extends NormalizedInlineCompletion { - sourceProvider: InlineCompletionsProvider; - - /** - * A reference to the original inline completion this inline completion has been constructed from. - * Used for event data to ensure referential equality. - */ - sourceInlineCompletion: InlineCompletion; - - /** - * A reference to the original inline completion list this inline completion has been constructed from. - * Used for event data to ensure referential equality. - */ - sourceInlineCompletions: InlineCompletions; -} - -function getDefaultRange(position: Position, model: ITextModel): Range { - const word = model.getWordAtPosition(position); - const maxColumn = model.getLineMaxColumn(position.lineNumber); - // By default, always replace up until the end of the current line. - // This default might be subject to change! - return word - ? new Range(position.lineNumber, word.startColumn, position.lineNumber, maxColumn) - : Range.fromPositions(position, position.with(undefined, maxColumn)); -} - -function closeBrackets(text: string, position: Position, model: ITextModel, languageConfigurationService: ILanguageConfigurationService): string { - const lineStart = model.getLineContent(position.lineNumber).substring(0, position.column - 1); - const newLine = lineStart + text; - - const newTokens = model.tokenization.tokenizeLineWithEdit(position, newLine.length - (position.column - 1), text); - const slicedTokens = newTokens?.sliceAndInflate(position.column - 1, newLine.length, 0); - if (!slicedTokens) { - return text; - } - - const newText = fixBracketsInLine(slicedTokens, languageConfigurationService); - - return newText; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts new file mode 100644 index 0000000000000..5d1ee09d3e79c --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsSource.ts @@ -0,0 +1,330 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { BugIndicatingError } from 'vs/base/common/errors'; +import { matchesSubString } from 'vs/base/common/filters'; +import { Disposable, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { ITransaction } from 'vs/base/common/observable'; +import { disposableObservableValue, transaction } from 'vs/base/common/observableImpl/base'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { InlineCompletionContext, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; +import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; +import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { InlineCompletionItem, InlineCompletionProviderResult, provideInlineCompletions } from 'vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions'; + +export class InlineCompletionsSource extends Disposable { + private readonly updateOperation = this._register(new MutableDisposable()); + + public readonly inlineCompletions = disposableObservableValue('inlineCompletions', undefined); + public readonly suggestWidgetInlineCompletions = disposableObservableValue('suggestWidgetInlineCompletions', undefined); + + + constructor( + private readonly textModel: ITextModel, + private readonly _debounceValue: IFeatureDebounceInformation, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService, + ) { + super(); + + this._register(this.textModel.onDidChangeContent(() => { + this.updateOperation.clear(); + })); + } + + public clear(tx: ITransaction): void { + this.updateOperation.clear(); + this.inlineCompletions.set(undefined, tx); + this.suggestWidgetInlineCompletions.set(undefined, tx); + } + + public clearSuggestWidgetInlineCompletions(): void { + if (this.updateOperation.value?.request.context.selectedSuggestionInfo) { + this.updateOperation.clear(); + } + this.suggestWidgetInlineCompletions.set(undefined, undefined); + } + + public update(position: Position, context: InlineCompletionContext, activeInlineCompletion: InlineCompletionWithUpdatedRange | undefined): Promise { + const request = new UpdateRequest(position, context, this.textModel.getVersionId()); + + const target = context.selectedSuggestionInfo ? this.suggestWidgetInlineCompletions : this.inlineCompletions; + + if (this.updateOperation.value?.request.satisfies(request)) { + return this.updateOperation.value.promise; + } else if (target.get()?.request.satisfies(request)) { + return Promise.resolve(true); + } + + const updateOngoing = !!this.updateOperation.value; + this.updateOperation.clear(); + + const source = new CancellationTokenSource(); + + const promise = (async () => { + const shouldDebounce = updateOngoing || context.triggerKind === InlineCompletionTriggerKind.Automatic; + if (shouldDebounce) { + // This debounces the operation + await wait(this._debounceValue.get(this.textModel)); + } + + if (source.token.isCancellationRequested || this.textModel.getVersionId() !== request.versionId) { + return false; + } + + const startTime = new Date(); + const updatedCompletions = await provideInlineCompletions( + this.languageFeaturesService.inlineCompletionsProvider, + position, + this.textModel, + context, + source.token, + this.languageConfigurationService + ); + + if (source.token.isCancellationRequested || this.textModel.getVersionId() !== request.versionId) { + return false; + } + + const endTime = new Date(); + this._debounceValue.update(this.textModel, endTime.getTime() - startTime.getTime()); + + const completions = new UpToDateInlineCompletions(updatedCompletions, request, this.textModel); + if ( + activeInlineCompletion && activeInlineCompletion.updatedRange.containsPosition(position) + && activeInlineCompletion.isVisible(this.textModel, position) + && !activeInlineCompletion.isSmallerThanOriginal() + && !updatedCompletions.has(activeInlineCompletion.toInlineCompletion()) + ) { + completions.prepend(activeInlineCompletion.inlineCompletion, activeInlineCompletion.updatedRange, true); + } + + transaction(tx => { + target.set(completions, tx); + }); + this.updateOperation.clear(); + + return true; + })(); + + const updateOperation = new UpdateOperation(request, source, promise); + this.updateOperation.value = updateOperation; + + return promise; + } +} + +function wait(ms: number, cancellationToken?: CancellationToken): Promise { + return new Promise(resolve => { + let d: IDisposable | undefined = undefined; + const handle = setTimeout(() => { + if (d) { d.dispose(); } + resolve(); + }, ms); + if (cancellationToken) { + d = cancellationToken.onCancellationRequested(() => { + clearTimeout(handle); + if (d) { d.dispose(); } + resolve(); + }); + } + }); +} + +class UpdateRequest { + constructor( + public readonly position: Position, + public readonly context: InlineCompletionContext, + public readonly versionId: number, + ) { + } + + public satisfies(other: UpdateRequest): boolean { + return this.position.equals(other.position) + && equals(this.context.selectedSuggestionInfo, other.context.selectedSuggestionInfo, (v1, v2) => v1.equals(v2)) + && (other.context.triggerKind === InlineCompletionTriggerKind.Automatic + || this.context.triggerKind === InlineCompletionTriggerKind.Explicit) + && this.versionId === other.versionId; + } +} + +function equals(v1: T | undefined, v2: T | undefined, equals: (v1: T, v2: T) => boolean): boolean { + if (!v1 || !v2) { + return v1 === v2; + } + return equals(v1, v2); +} + +class UpdateOperation implements IDisposable { + constructor( + public readonly request: UpdateRequest, + public readonly cancellationTokenSource: CancellationTokenSource, + public readonly promise: Promise, + ) { + } + + dispose() { + this.cancellationTokenSource.cancel(); + } +} + +export class UpToDateInlineCompletions implements IDisposable { + private lastVersionId: number = -1; + private readonly inlineCompletions: InlineCompletionWithUpdatedRange[]; + private refCount = 1; + private readonly prependedInlineCompletionItems: InlineCompletionItem[] = []; + + constructor( + private readonly inlineCompletionProviderResult: InlineCompletionProviderResult, + public readonly request: UpdateRequest, + private readonly textModel: ITextModel + ) { + const ids = textModel.deltaDecorations([], inlineCompletionProviderResult.completions.map(i => ({ + range: i.range, + options: { + description: 'inline-completion-tracking-range' + }, + }))); + + this.inlineCompletions = inlineCompletionProviderResult.completions.map( + (i, index) => new InlineCompletionWithUpdatedRange(i, ids[index]) + ); + } + + public prepend(inlineCompletion: InlineCompletionItem, range: Range, addRefToSource: boolean): void { + if (addRefToSource) { + inlineCompletion.source.addRef(); + } + + const id = this.textModel.deltaDecorations([], [{ + range, + options: { + description: 'inline-completion-tracking-range' + }, + }])[0]; + this.inlineCompletions.unshift(new InlineCompletionWithUpdatedRange(inlineCompletion, id, range)); + this.prependedInlineCompletionItems.push(inlineCompletion); + } + + public clone(): this { + this.refCount++; + return this; + } + + public dispose(): void { + this.refCount--; + if (this.refCount === 0) { + this.textModel.deltaDecorations(this.inlineCompletions.map(i => i.decorationId), []); + this.inlineCompletionProviderResult.dispose(); + for (const i of this.prependedInlineCompletionItems) { + i.source.removeRef(); + } + } + } + + /** + * The ranges of the inline completions are extended as the user typed. + */ + public getInlineCompletions(versionId: number): readonly InlineCompletionWithUpdatedRange[] { + if (versionId !== this.textModel.getVersionId()) { + throw new BugIndicatingError(); + } + if (this.textModel.getVersionId() !== this.lastVersionId) { + this.inlineCompletions.forEach(i => i.updateRange(this.textModel)); + this.lastVersionId = this.textModel.getVersionId(); + } + return this.inlineCompletions; + } +} + +export class InlineCompletionWithUpdatedRange { + public readonly semanticId = JSON.stringify([this.inlineCompletion.filterText, this.inlineCompletion.insertText, this.inlineCompletion.range.getStartPosition().toString()]); + private _updatedRange: Range; + public get updatedRange(): Range { return this._updatedRange; } + + constructor( + public readonly inlineCompletion: InlineCompletionItem, + public readonly decorationId: string, + initialRange?: Range, + ) { + this._updatedRange = initialRange ?? inlineCompletion.range; + } + + public updateRange(textModel: ITextModel): void { + const range = textModel.getDecorationRange(this.decorationId); + if (!range) { + throw new BugIndicatingError(); + } + this._updatedRange = range; + } + + public toInlineCompletion(): InlineCompletionItem { + return this.inlineCompletion.withRange(this.updatedRange); + } + + public toSingleTextEdit(): SingleTextEdit { + return new SingleTextEdit(this.updatedRange, this.inlineCompletion.insertText); + } + + public toFilterTextReplacement(): SingleTextEdit { + return new SingleTextEdit(this.updatedRange, this.inlineCompletion.filterText); + } + + public isVisible(model: ITextModel, cursorPosition: Position): boolean { + const minimizedReplacement = this.toFilterTextReplacement().removeCommonPrefix(model); + + if (!this.inlineCompletion.range.getStartPosition().equals(this.updatedRange.getStartPosition())) { + return false; + } + + if (cursorPosition.lineNumber !== minimizedReplacement.range.startLineNumber) { + return false; + } + + const originalValue = model.getValueInRange(minimizedReplacement.range, EndOfLinePreference.LF).toLowerCase(); + const filterText = this.inlineCompletion.filterText.toLowerCase(); + + const cursorPosIndex = Math.max(0, cursorPosition.column - minimizedReplacement.range.startColumn); + + let filterTextBefore = filterText.substring(0, cursorPosIndex); + let filterTextAfter = filterText.substring(cursorPosIndex); + + let originalValueBefore = originalValue.substring(0, cursorPosIndex); + let originalValueAfter = originalValue.substring(cursorPosIndex); + + const originalValueIndent = model.getLineIndentColumn(minimizedReplacement.range.startLineNumber); + if (this.updatedRange.startColumn <= originalValueIndent) { + // Remove indentation + originalValueBefore = originalValueBefore.trimStart(); + if (originalValueBefore.length === 0) { + originalValueAfter = originalValueAfter.trimStart(); + } + filterTextBefore = filterTextBefore.trimStart(); + if (filterTextBefore.length === 0) { + filterTextAfter = filterTextAfter.trimStart(); + } + } + + return filterTextBefore.startsWith(originalValueBefore) + && !!matchesSubString(originalValueAfter, filterTextAfter); + } + + public isSmallerThanOriginal(): boolean { + return length(this.updatedRange).isBefore(length(this.inlineCompletion.range)); + } +} + +function length(range: Range): Position { + if (range.startLineNumber === range.endLineNumber) { + return new Position(1, 1 + range.endColumn - range.startColumn); + } else { + return new Position(1 + range.endLineNumber - range.startLineNumber, range.endColumn); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts new file mode 100644 index 0000000000000..e4657ff4cbd72 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertNever } from 'vs/base/common/assert'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { onUnexpectedExternalError } from 'vs/base/common/errors'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { ISingleEditOperation } from 'vs/editor/common/core/editOperation'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { LanguageFeatureRegistry } from 'vs/editor/common/languageFeatureRegistry'; +import { Command, InlineCompletion, InlineCompletionContext, InlineCompletions, InlineCompletionsProvider } from 'vs/editor/common/languages'; +import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; +import { ITextModel } from 'vs/editor/common/model'; +import { fixBracketsInLine } from 'vs/editor/common/model/bracketPairsTextModelPart/fixBrackets'; +import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { getReadonlyEmptyArray } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { SnippetParser, Text } from 'vs/editor/contrib/snippet/browser/snippetParser'; + +export async function provideInlineCompletions( + registry: LanguageFeatureRegistry, + position: Position, + model: ITextModel, + context: InlineCompletionContext, + token: CancellationToken = CancellationToken.None, + languageConfigurationService?: ILanguageConfigurationService, +): Promise { + const providers = registry.all(model); + const providerResults = await Promise.all(providers.map(async provider => { + try { + const completions = await provider.provideInlineCompletions(model, position, context, token); + return ({ provider, completions }); + } catch (e) { + onUnexpectedExternalError(e); + } + return ({ provider, completions: undefined }); + })); + + const defaultReplaceRange = getDefaultRange(position, model); + + const itemsByHash = new Map(); + const lists: InlineCompletionList[] = []; + for (const result of providerResults) { + const completions = result.completions; + if (!completions) { + continue; + } + const list = new InlineCompletionList(completions, result.provider); + lists.push(list); + + for (const item of completions.items) { + const inlineCompletionItem = InlineCompletionItem.from( + item, + list, + defaultReplaceRange, + model, + languageConfigurationService + ); + itemsByHash.set(inlineCompletionItem.hash(), inlineCompletionItem); + } + } + + return new InlineCompletionProviderResult(Array.from(itemsByHash.values()), new Set(itemsByHash.keys()), lists); +} + +export class InlineCompletionProviderResult implements IDisposable { + + constructor( + /** + * Free of duplicates. + */ + public readonly completions: readonly InlineCompletionItem[], + private readonly hashs: Set, + private readonly providerResults: readonly InlineCompletionList[], + ) { } + + public has(item: InlineCompletionItem): boolean { + return this.hashs.has(item.hash()); + } + + dispose(): void { + for (const result of this.providerResults) { + result.removeRef(); + } + } +} + +/** + * A ref counted pointer to the computed `InlineCompletions` and the `InlineCompletionsProvider` that + * computed them. + */ +export class InlineCompletionList { + private refCount = 0; + constructor( + public readonly inlineCompletions: InlineCompletions, + public readonly provider: InlineCompletionsProvider, + ) { } + + addRef(): void { + this.refCount++; + } + + removeRef(): void { + this.refCount--; + if (this.refCount === 0) { + this.provider.freeInlineCompletions(this.inlineCompletions); + } + } +} + +export class InlineCompletionItem { + public static from( + inlineCompletion: InlineCompletion, + source: InlineCompletionList, + defaultReplaceRange: Range, + textModel: ITextModel, + languageConfigurationService: ILanguageConfigurationService | undefined, + ) { + let insertText: string; + let snippetInfo: SnippetInfo | undefined; + let range = inlineCompletion.range ? Range.lift(inlineCompletion.range) : defaultReplaceRange; + + if (typeof inlineCompletion.insertText === 'string') { + insertText = inlineCompletion.insertText; + + if (languageConfigurationService && inlineCompletion.completeBracketPairs) { + insertText = closeBrackets( + insertText, + range.getStartPosition(), + textModel, + languageConfigurationService + ); + + // Modify range depending on if brackets are added or removed + const diff = insertText.length - inlineCompletion.insertText.length; + if (diff !== 0) { + range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); + } + } + + snippetInfo = undefined; + } else if ('snippet' in inlineCompletion.insertText) { + const preBracketCompletionLength = inlineCompletion.insertText.snippet.length; + + if (languageConfigurationService && inlineCompletion.completeBracketPairs) { + inlineCompletion.insertText.snippet = closeBrackets( + inlineCompletion.insertText.snippet, + range.getStartPosition(), + textModel, + languageConfigurationService + ); + + // Modify range depending on if brackets are added or removed + const diff = inlineCompletion.insertText.snippet.length - preBracketCompletionLength; + if (diff !== 0) { + range = new Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn + diff); + } + } + + const snippet = new SnippetParser().parse(inlineCompletion.insertText.snippet); + + if (snippet.children.length === 1 && snippet.children[0] instanceof Text) { + insertText = snippet.children[0].value; + snippetInfo = undefined; + } else { + insertText = snippet.toString(); + snippetInfo = { + snippet: inlineCompletion.insertText.snippet, + range: range + }; + } + } else { + assertNever(inlineCompletion.insertText); + } + + return new InlineCompletionItem( + insertText, + inlineCompletion.command, + range, + insertText, + snippetInfo, + inlineCompletion.additionalTextEdits || getReadonlyEmptyArray(), + inlineCompletion, + source, + ); + } + + constructor( + readonly filterText: string, + readonly command: Command | undefined, + readonly range: Range, + readonly insertText: string, + readonly snippetInfo: SnippetInfo | undefined, + + readonly additionalTextEdits: readonly ISingleEditOperation[], + + + /** + * A reference to the original inline completion this inline completion has been constructed from. + * Used for event data to ensure referential equality. + */ + readonly sourceInlineCompletion: InlineCompletion, + + /** + * A reference to the original inline completion list this inline completion has been constructed from. + * Used for event data to ensure referential equality. + */ + readonly source: InlineCompletionList, + ) { + filterText = filterText.replace(/\r\n|\r/g, '\n'); + insertText = filterText.replace(/\r\n|\r/g, '\n'); + } + + public withRange(updatedRange: Range): InlineCompletionItem { + return new InlineCompletionItem( + this.filterText, + this.command, + updatedRange, + this.insertText, + this.snippetInfo, + this.additionalTextEdits, + this.sourceInlineCompletion, + this.source, + ); + } + + public hash(): string { + return JSON.stringify({ insertText: this.insertText, range: this.range.toString() }); + } + + public toSingleTextEdit(): SingleTextEdit { + return new SingleTextEdit(this.range, this.insertText); + } +} + +export interface SnippetInfo { + snippet: string; + /* Could be different than the main range */ + range: Range; +} + +function getDefaultRange(position: Position, model: ITextModel): Range { + const word = model.getWordAtPosition(position); + const maxColumn = model.getLineMaxColumn(position.lineNumber); + // By default, always replace up until the end of the current line. + // This default might be subject to change! + return word + ? new Range(position.lineNumber, word.startColumn, position.lineNumber, maxColumn) + : Range.fromPositions(position, position.with(undefined, maxColumn)); +} + +function closeBrackets(text: string, position: Position, model: ITextModel, languageConfigurationService: ILanguageConfigurationService): string { + const lineStart = model.getLineContent(position.lineNumber).substring(0, position.column - 1); + const newLine = lineStart + text; + + const newTokens = model.tokenization.tokenizeLineWithEdit(position, newLine.length - (position.column - 1), text); + const slicedTokens = newTokens?.sliceAndInflate(position.column - 1, newLine.length, 0); + if (!slicedTokens) { + return text; + } + + const newText = fixBracketsInLine(slicedTokens, languageConfigurationService); + + return newText; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts b/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts new file mode 100644 index 0000000000000..e869c22592587 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/singleTextEdit.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDiffChange, LcsDiff } from 'vs/base/common/diff/diff'; +import { commonPrefixLength, getLeadingWhitespace, splitLines } from 'vs/base/common/strings'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { EndOfLinePreference, ITextModel } from 'vs/editor/common/model'; +import { GhostText, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; +import { addPositions, lengthOfText } from 'vs/editor/contrib/inlineCompletions/browser/utils'; + +export class SingleTextEdit { + constructor( + public readonly range: Range, + public readonly text: string + ) { + } + + removeCommonPrefix(model: ITextModel): SingleTextEdit { + const valueToReplace = model.getValueInRange(this.range, EndOfLinePreference.LF); + const commonPrefixLen = commonPrefixLength(valueToReplace, this.text); + const start = addPositions(this.range.getStartPosition(), lengthOfText(valueToReplace.substring(0, commonPrefixLen))); + const text = this.text.substring(commonPrefixLen); + const range = Range.fromPositions(start, this.range.getEndPosition()); + return new SingleTextEdit(range, text); + } + + augments(base: SingleTextEdit): boolean { + // The augmented completion must replace the base range, but can replace even more + return this.text.startsWith(base.text) && rangeExtends(this.range, base.range); + } + + /** + * @param previewSuffixLength Sets where to split `inlineCompletion.text`. + * If the text is `hello` and the suffix length is 2, the non-preview part is `hel` and the preview-part is `lo`. + */ + computeGhostText( + textModel: ITextModel, + mode: 'prefix' | 'subword' | 'subwordSmart', + cursorPosition?: Position, + previewSuffixLength = 0 + ): GhostText | undefined { + let { range, text } = this; + + if (range.endLineNumber !== range.startLineNumber) { + // try to minimize + const textLines = text.split('\n'); + const actualText = textModel.getValueInRange(new Range(range.startLineNumber, range.startColumn, range.endLineNumber - 1, Number.MAX_SAFE_INTEGER), EndOfLinePreference.LF); + if (textLines.slice(0, range.endLineNumber - range.startLineNumber).join('\n') !== actualText) { + // first lines don't agree -> don't show ghost text + return undefined; + } + text = textLines.slice(range.endLineNumber - range.startLineNumber).join('\n'); + range = new Range(range.endLineNumber, 1, range.endLineNumber, range.endColumn); + } + + const sourceLine = textModel.getLineContent(range.startLineNumber); + const sourceIndentationLength = getLeadingWhitespace(sourceLine).length; + + const suggestionTouchesIndentation = range.startColumn - 1 <= sourceIndentationLength; + if (suggestionTouchesIndentation) { + // source: ··········[······abc] + // ^^^^^^^^^ inlineCompletion.range + // ^^^^^^^^^^ ^^^^^^ sourceIndentationLength + // ^^^^^^ replacedIndentation.length + // ^^^ rangeThatDoesNotReplaceIndentation + + // inlineCompletion.text: '··foo' + // ^^ suggestionAddedIndentationLength + + const suggestionAddedIndentationLength = getLeadingWhitespace(text).length; + + const replacedIndentation = sourceLine.substring(range.startColumn - 1, sourceIndentationLength); + const rangeThatDoesNotReplaceIndentation = Range.fromPositions( + range.getStartPosition().delta(0, replacedIndentation.length), + range.getEndPosition() + ); + + const suggestionWithoutIndentationChange = + text.startsWith(replacedIndentation) + // Adds more indentation without changing existing indentation: We can add ghost text for this + ? text.substring(replacedIndentation.length) + // Changes or removes existing indentation. Only add ghost text for the non-indentation part. + : text.substring(suggestionAddedIndentationLength); + + text = suggestionWithoutIndentationChange; + range = rangeThatDoesNotReplaceIndentation; + } + + // This is a single line string + const valueToBeReplaced = textModel.getValueInRange(range); + + const changes = cachingDiff(valueToBeReplaced, text); + + if (!changes) { + // No ghost text in case the diff would be too slow to compute + return undefined; + } + + const lineNumber = range.startLineNumber; + + const parts = new Array(); + + if (mode === 'prefix') { + const filteredChanges = changes.filter(c => c.originalLength === 0); + if (filteredChanges.length > 1 || filteredChanges.length === 1 && filteredChanges[0].originalStart !== valueToBeReplaced.length) { + // Prefixes only have a single change. + return undefined; + } + } + + const previewStartInCompletionText = text.length - previewSuffixLength; + + for (const c of changes) { + const insertColumn = range.startColumn + c.originalStart + c.originalLength; + + if (mode === 'subwordSmart' && cursorPosition && cursorPosition.lineNumber === range.startLineNumber && insertColumn < cursorPosition.column) { + // No ghost text before cursor + return undefined; + } + + if (c.originalLength > 0) { + return undefined; + } + + if (c.modifiedLength === 0) { + continue; + } + + const modifiedEnd = c.modifiedStart + c.modifiedLength; + const nonPreviewTextEnd = Math.max(c.modifiedStart, Math.min(modifiedEnd, previewStartInCompletionText)); + const nonPreviewText = text.substring(c.modifiedStart, nonPreviewTextEnd); + const italicText = text.substring(nonPreviewTextEnd, Math.max(c.modifiedStart, modifiedEnd)); + + if (nonPreviewText.length > 0) { + const lines = splitLines(nonPreviewText); + parts.push(new GhostTextPart(insertColumn, lines, false)); + } + if (italicText.length > 0) { + const lines = splitLines(italicText); + parts.push(new GhostTextPart(insertColumn, lines, true)); + } + } + + return new GhostText(lineNumber, parts, 0); + } +} + +function rangeExtends(extendingRange: Range, rangeToExtend: Range): boolean { + return rangeToExtend.getStartPosition().equals(extendingRange.getStartPosition()) + && rangeToExtend.getEndPosition().isBeforeOrEqual(extendingRange.getEndPosition()); +} + +let lastRequest: { originalValue: string; newValue: string; changes: readonly IDiffChange[] | undefined } | undefined = undefined; +function cachingDiff(originalValue: string, newValue: string): readonly IDiffChange[] | undefined { + if (lastRequest?.originalValue === originalValue && lastRequest?.newValue === newValue) { + return lastRequest?.changes; + } else { + let changes = smartDiff(originalValue, newValue, true); + if (changes) { + const deletedChars = deletedCharacters(changes); + if (deletedChars > 0) { + // For performance reasons, don't compute diff if there is nothing to improve + const newChanges = smartDiff(originalValue, newValue, false); + if (newChanges && deletedCharacters(newChanges) < deletedChars) { + // Disabling smartness seems to be better here + changes = newChanges; + } + } + } + lastRequest = { + originalValue, + newValue, + changes + }; + return changes; + } +} + +function deletedCharacters(changes: readonly IDiffChange[]): number { + let sum = 0; + for (const c of changes) { + sum += c.originalLength; + } + return sum; +} + +/** + * When matching `if ()` with `if (f() = 1) { g(); }`, + * align it like this: `if ( )` + * Not like this: `if ( )` + * Also not like this: `if ( )`. + * + * The parenthesis are preprocessed to ensure that they match correctly. + */ +function smartDiff(originalValue: string, newValue: string, smartBracketMatching: boolean): (readonly IDiffChange[]) | undefined { + if (originalValue.length > 5000 || newValue.length > 5000) { + // We don't want to work on strings that are too big + return undefined; + } + + function getMaxCharCode(val: string): number { + let maxCharCode = 0; + for (let i = 0, len = val.length; i < len; i++) { + const charCode = val.charCodeAt(i); + if (charCode > maxCharCode) { + maxCharCode = charCode; + } + } + return maxCharCode; + } + + const maxCharCode = Math.max(getMaxCharCode(originalValue), getMaxCharCode(newValue)); + function getUniqueCharCode(id: number): number { + if (id < 0) { + throw new Error('unexpected'); + } + return maxCharCode + id + 1; + } + + function getElements(source: string): Int32Array { + let level = 0; + let group = 0; + const characters = new Int32Array(source.length); + for (let i = 0, len = source.length; i < len; i++) { + // TODO support more brackets + if (smartBracketMatching && source[i] === '(') { + const id = group * 100 + level; + characters[i] = getUniqueCharCode(2 * id); + level++; + } else if (smartBracketMatching && source[i] === ')') { + level = Math.max(level - 1, 0); + const id = group * 100 + level; + characters[i] = getUniqueCharCode(2 * id + 1); + if (level === 0) { + group++; + } + } else { + characters[i] = source.charCodeAt(i); + } + } + return characters; + } + + const elements1 = getElements(originalValue); + const elements2 = getElements(newValue); + + return new LcsDiff({ getElements: () => elements1 }, { getElements: () => elements2 }).ComputeDiff(false).changes; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts index 0ff0c4d5726dd..b2e56cdea96f6 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider.ts @@ -3,54 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { compareBy, findMaxBy, numberComparator } from 'vs/base/common/arrays'; -import { Emitter, Event } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; -import { CompletionItemInsertTextRule, CompletionItemKind } from 'vs/editor/common/languages'; +import { CompletionItemInsertTextRule, CompletionItemKind, SelectedSuggestionInfo } from 'vs/editor/common/languages'; import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; import { SnippetSession } from 'vs/editor/contrib/snippet/browser/snippetSession'; import { CompletionItem } from 'vs/editor/contrib/suggest/browser/suggest'; import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; -import { minimizeInlineCompletion, NormalizedInlineCompletion, normalizedInlineCompletionsEquals } from './inlineCompletionToGhostText'; - -export interface SuggestWidgetState { - /** - * Represents the currently selected item in the suggest widget as inline completion, if possible. - */ - selectedItem: SuggestItemInfo | undefined; -} - -export interface SuggestItemInfo { - normalizedInlineCompletion: NormalizedInlineCompletion; - isSnippetText: boolean; - completionItemKind: CompletionItemKind; -} +import { IObservable, ITransaction, observableValue, transaction } from 'vs/base/common/observable'; +import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; +import { ITextModel } from 'vs/editor/common/model'; +import { compareBy, findMaxBy, numberComparator } from 'vs/base/common/arrays'; -export class SuggestWidgetInlineCompletionProvider extends Disposable { +export class SuggestWidgetAdaptor extends Disposable { private isSuggestWidgetVisible: boolean = false; private isShiftKeyPressed = false; private _isActive = false; private _currentSuggestItemInfo: SuggestItemInfo | undefined = undefined; - private readonly onDidChangeEmitter = new Emitter(); - public readonly onDidChange = this.onDidChangeEmitter.event; + private readonly _selectedItem = observableValue('suggestWidgetInlineCompletionProvider.selectedItem', undefined as SuggestItemInfo | undefined); - /** - * Returns undefined if the suggest widget is not active. - */ - get state(): SuggestWidgetState | undefined { - if (!this._isActive) { - return undefined; - } - return { selectedItem: this._currentSuggestItemInfo }; + public get selectedItem(): IObservable { + return this._selectedItem; } constructor( - private readonly editor: IActiveCodeEditor, - private readonly suggestControllerPreselector: () => NormalizedInlineCompletion | undefined + private readonly editor: ICodeEditor, + private readonly suggestControllerPreselector: () => SingleTextEdit | undefined, + private readonly checkModelVersion: (tx: ITransaction) => void, ) { super(); @@ -73,26 +56,28 @@ export class SuggestWidgetInlineCompletionProvider extends Disposable { this._register(suggestController.registerSelector({ priority: 100, select: (model, pos, suggestItems) => { + transaction(tx => this.checkModelVersion(tx)); + const textModel = this.editor.getModel(); - const normalizedItemToPreselect = minimizeInlineCompletion(textModel, this.suggestControllerPreselector()); - if (!normalizedItemToPreselect) { + if (!textModel) { + // Should not happen + return -1; + } + + const itemToPreselect = this.suggestControllerPreselector()?.removeCommonPrefix(textModel); + if (!itemToPreselect) { return -1; } const position = Position.lift(pos); const candidates = suggestItems .map((suggestItem, index) => { - const inlineSuggestItem = suggestionToSuggestItemInfo(suggestController, position, suggestItem, this.isShiftKeyPressed); - const normalizedSuggestItem = minimizeInlineCompletion(textModel, inlineSuggestItem?.normalizedInlineCompletion); - if (!normalizedSuggestItem) { - return undefined; - } - const valid = - rangeStartsWith(normalizedItemToPreselect.range, normalizedSuggestItem.range) && - normalizedItemToPreselect.insertText.startsWith(normalizedSuggestItem.insertText); - return { index, valid, prefixLength: normalizedSuggestItem.insertText.length, suggestItem }; + const suggestItemInfo = SuggestItemInfo.fromSuggestion(suggestController, textModel, position, suggestItem, this.isShiftKeyPressed); + const suggestItemTextEdit = suggestItemInfo.toSingleTextEdit().removeCommonPrefix(textModel); + const valid = itemToPreselect.augments(suggestItemTextEdit); + return { index, valid, prefixLength: suggestItemTextEdit.text.length, suggestItem }; }) - .filter(item => item && item.valid); + .filter(item => item && item.valid && item.prefixLength > 0); const result = findMaxBy( candidates, @@ -132,37 +117,36 @@ export class SuggestWidgetInlineCompletionProvider extends Disposable { private update(newActive: boolean): void { const newInlineCompletion = this.getSuggestItemInfo(); - let shouldFire = false; - if (!suggestItemInfoEquals(this._currentSuggestItemInfo, newInlineCompletion)) { - this._currentSuggestItemInfo = newInlineCompletion; - shouldFire = true; - } - if (this._isActive !== newActive) { + + if (this._isActive !== newActive || !suggestItemInfoEquals(this._currentSuggestItemInfo, newInlineCompletion)) { this._isActive = newActive; - shouldFire = true; - } - if (shouldFire) { - this.onDidChangeEmitter.fire(); + this._currentSuggestItemInfo = newInlineCompletion; + + transaction(tx => { + this.checkModelVersion(tx); + this._selectedItem.set(this._isActive ? this._currentSuggestItemInfo : undefined, tx); + }); } } private getSuggestItemInfo(): SuggestItemInfo | undefined { const suggestController = SuggestController.get(this.editor); - if (!suggestController) { - return undefined; - } - if (!this.isSuggestWidgetVisible) { + if (!suggestController || !this.isSuggestWidgetVisible) { return undefined; } + const focusedItem = suggestController.widget.value.getFocusedItem(); - if (!focusedItem) { + const position = this.editor.getPosition(); + const model = this.editor.getModel(); + + if (!focusedItem || !position || !model) { return undefined; } - // TODO: item.isResolved - return suggestionToSuggestItemInfo( + return SuggestItemInfo.fromSuggestion( suggestController, - this.editor.getPosition(), + model, + position, focusedItem.item, this.isShiftKeyPressed ); @@ -179,14 +163,56 @@ export class SuggestWidgetInlineCompletionProvider extends Disposable { } } -export function rangeStartsWith(rangeToTest: Range, prefix: Range): boolean { - return ( - prefix.startLineNumber === rangeToTest.startLineNumber && - prefix.startColumn === rangeToTest.startColumn && - (prefix.endLineNumber < rangeToTest.endLineNumber || - (prefix.endLineNumber === rangeToTest.endLineNumber && - prefix.endColumn <= rangeToTest.endColumn)) - ); +export class SuggestItemInfo { + public static fromSuggestion(suggestController: SuggestController, model: ITextModel, position: Position, item: CompletionItem, toggleMode: boolean): SuggestItemInfo { + let { insertText } = item.completion; + let isSnippetText = false; + if (item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet) { + const snippet = new SnippetParser().parse(insertText); + + if (snippet.children.length < 100) { + // Adjust whitespace is expensive. + SnippetSession.adjustWhitespace(model, position, true, snippet); + } + + insertText = snippet.toString(); + isSnippetText = true; + } + + const info = suggestController.getOverwriteInfo(item, toggleMode); + + return new SuggestItemInfo( + Range.fromPositions( + position.delta(0, -info.overwriteBefore), + position.delta(0, Math.max(info.overwriteAfter, 0)) + ), + insertText, + item.completion.kind, + isSnippetText, + ); + } + + private constructor( + public readonly range: Range, + public readonly insertText: string, + public readonly completionItemKind: CompletionItemKind, + public readonly isSnippetText: boolean, + ) { } + + public equals(other: SuggestItemInfo): boolean { + return this.range.equalsRange(other.range) + && this.insertText === other.insertText + && this.completionItemKind === other.completionItemKind + && this.isSnippetText === other.isSnippetText; + } + + public toSelectedSuggestionInfo(): SelectedSuggestionInfo { + return new SelectedSuggestionInfo(this.range, this.insertText, this.completionItemKind, this.isSnippetText); + } + + public toSingleTextEdit(): SingleTextEdit { + return new SingleTextEdit(this.range, this.insertText); + } } function suggestItemInfoEquals(a: SuggestItemInfo | undefined, b: SuggestItemInfo | undefined): boolean { @@ -196,59 +222,5 @@ function suggestItemInfoEquals(a: SuggestItemInfo | undefined, b: SuggestItemInf if (!a || !b) { return false; } - return a.completionItemKind === b.completionItemKind && - a.isSnippetText === b.isSnippetText && - normalizedInlineCompletionsEquals(a.normalizedInlineCompletion, b.normalizedInlineCompletion); -} - -function suggestionToSuggestItemInfo(suggestController: SuggestController, position: Position, item: CompletionItem, toggleMode: boolean): SuggestItemInfo | undefined { - // additionalTextEdits might not be resolved here, this could be problematic. - if (Array.isArray(item.completion.additionalTextEdits) && item.completion.additionalTextEdits.length > 0) { - // cannot represent additional text edits. TODO: Now we can. - return { - completionItemKind: item.completion.kind, - isSnippetText: false, - normalizedInlineCompletion: { - // Dummy element, so that space is reserved, but no text is shown - range: Range.fromPositions(position, position), - insertText: '', - filterText: '', - snippetInfo: undefined, - additionalTextEdits: [], - }, - }; - } - - let { insertText } = item.completion; - let isSnippetText = false; - if (item.completion.insertTextRules! & CompletionItemInsertTextRule.InsertAsSnippet) { - const snippet = new SnippetParser().parse(insertText); - const model = suggestController.editor.getModel()!; - - // Ignore snippets that are too large. - // Adjust whitespace is expensive for them. - if (snippet.children.length > 100) { - return undefined; - } - - SnippetSession.adjustWhitespace(model, position, true, snippet); - insertText = snippet.toString(); - isSnippetText = true; - } - - const info = suggestController.getOverwriteInfo(item, toggleMode); - return { - isSnippetText, - completionItemKind: item.completion.kind, - normalizedInlineCompletion: { - insertText: insertText, - filterText: insertText, - range: Range.fromPositions( - position.delta(0, -info.overwriteBefore), - position.delta(0, Math.max(info.overwriteAfter, 0)) - ), - snippetInfo: undefined, - additionalTextEdits: [], - } - }; + return a.equals(b); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetPreviewModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetPreviewModel.ts deleted file mode 100644 index 0de4fb2806a81..0000000000000 --- a/src/vs/editor/contrib/inlineCompletions/browser/suggestWidgetPreviewModel.ts +++ /dev/null @@ -1,198 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { createCancelablePromise, RunOnceScheduler } from 'vs/base/common/async'; -import { onUnexpectedError } from 'vs/base/common/errors'; -import { MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { IActiveCodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorOption } from 'vs/editor/common/config/editorOptions'; -import { Range } from 'vs/editor/common/core/range'; -import { CompletionItemKind, InlineCompletionTriggerKind, SelectedSuggestionInfo } from 'vs/editor/common/languages'; -import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; -import { SharedInlineCompletionCache } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextModel'; -import { BaseGhostTextWidgetModel, GhostText } from './ghostText'; -import { provideInlineCompletions, TrackedInlineCompletions, UpdateOperation } from './inlineCompletionsModel'; -import { inlineCompletionToGhostText, minimizeInlineCompletion, NormalizedInlineCompletion } from './inlineCompletionToGhostText'; -import { SuggestWidgetInlineCompletionProvider } from './suggestWidgetInlineCompletionProvider'; - -export class SuggestWidgetPreviewModel extends BaseGhostTextWidgetModel { - private readonly suggestionInlineCompletionSource = this._register( - new SuggestWidgetInlineCompletionProvider( - this.editor, - // Use the first cache item (if any) as preselection. - () => { - // We might get asked in a content change event before the cache has received that event. - this.cache.value?.updateRanges(); - return this.cache.value?.completions[0]?.toLiveInlineCompletion(); - } - ) - ); - private readonly updateOperation = this._register(new MutableDisposable()); - private readonly updateCacheSoon = this._register(new RunOnceScheduler(() => this.updateCache(), 50)); - - public override minReservedLineCount: number = 0; - - public get isActive(): boolean { - return this.suggestionInlineCompletionSource.state !== undefined; - } - - constructor( - editor: IActiveCodeEditor, - private readonly cache: SharedInlineCompletionCache, - @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, - ) { - super(editor); - - this._register(this.suggestionInlineCompletionSource.onDidChange(() => { - if (!this.editor.hasModel()) { - // onDidChange might be called when calling setModel on the editor, before we are disposed. - return; - } - - this.updateCacheSoon.schedule(); - - const suggestWidgetState = this.suggestionInlineCompletionSource.state; - if (!suggestWidgetState) { - this.minReservedLineCount = 0; - } - - const newGhostText = this.ghostText; - if (newGhostText) { - this.minReservedLineCount = Math.max(this.minReservedLineCount, sum(newGhostText.parts.map(p => p.lines.length - 1))); - } - - if (this.minReservedLineCount >= 1) { - this.suggestionInlineCompletionSource.forceRenderingAbove(); - } else { - this.suggestionInlineCompletionSource.stopForceRenderingAbove(); - } - this.onDidChangeEmitter.fire(); - })); - - this._register(this.cache.onDidChange(() => { - this.onDidChangeEmitter.fire(); - })); - - this._register(this.editor.onDidChangeCursorPosition((e) => { - this.minReservedLineCount = 0; - this.updateCacheSoon.schedule(); - this.onDidChangeEmitter.fire(); - })); - - this._register(toDisposable(() => this.suggestionInlineCompletionSource.stopForceRenderingAbove())); - } - - private isSuggestionPreviewEnabled(): boolean { - const suggestOptions = this.editor.getOption(EditorOption.suggest); - return suggestOptions.preview; - } - - private async updateCache() { - const state = this.suggestionInlineCompletionSource.state; - if (!state || !state.selectedItem) { - return; - } - - const info: SelectedSuggestionInfo = { - text: state.selectedItem.normalizedInlineCompletion.insertText, - range: state.selectedItem.normalizedInlineCompletion.range, - isSnippetText: state.selectedItem.isSnippetText, - completionKind: state.selectedItem.completionItemKind, - }; - - const position = this.editor.getPosition(); - - if ( - state.selectedItem.isSnippetText || - state.selectedItem.completionItemKind === CompletionItemKind.Snippet || - state.selectedItem.completionItemKind === CompletionItemKind.File || - state.selectedItem.completionItemKind === CompletionItemKind.Folder - ) { - // Don't ask providers for these types of suggestions. - this.cache.clear(); - return; - } - - const promise = createCancelablePromise(async token => { - let result: TrackedInlineCompletions; - try { - result = await provideInlineCompletions(this.languageFeaturesService.inlineCompletionsProvider, position, - this.editor.getModel(), - { triggerKind: InlineCompletionTriggerKind.Automatic, selectedSuggestionInfo: info }, - token - ); - } catch (e) { - onUnexpectedError(e); - return; - } - if (token.isCancellationRequested) { - result.dispose(); - return; - } - this.cache.setValue( - this.editor, - result, - InlineCompletionTriggerKind.Automatic - ); - this.onDidChangeEmitter.fire(); - }); - const operation = new UpdateOperation(promise, InlineCompletionTriggerKind.Automatic); - this.updateOperation.value = operation; - await promise; - if (this.updateOperation.value === operation) { - this.updateOperation.clear(); - } - } - - public override get ghostText(): GhostText | undefined { - const isSuggestionPreviewEnabled = this.isSuggestionPreviewEnabled(); - const model = this.editor.getModel(); - const augmentedCompletion = minimizeInlineCompletion(model, this.cache.value?.completions[0]?.toLiveInlineCompletion()); - - const suggestWidgetState = this.suggestionInlineCompletionSource.state; - const suggestInlineCompletion = minimizeInlineCompletion(model, suggestWidgetState?.selectedItem?.normalizedInlineCompletion); - - const isAugmentedCompletionValid = augmentedCompletion - && suggestInlineCompletion - // The intellisense completion must be a prefix of the augmented completion - && augmentedCompletion.insertText.startsWith(suggestInlineCompletion.insertText) - // The augmented completion must replace the intellisense completion range, but can replace even more - && rangeExtends(augmentedCompletion.range, suggestInlineCompletion.range); - - if (!isSuggestionPreviewEnabled && !isAugmentedCompletionValid) { - return undefined; - } - - // If the augmented completion is not valid and there is no suggest inline completion, we still show the augmented completion. - const finalCompletion = isAugmentedCompletionValid ? augmentedCompletion : (suggestInlineCompletion || augmentedCompletion); - - const inlineCompletionPreviewLength = isAugmentedCompletionValid ? finalCompletion!.insertText.length - suggestInlineCompletion.insertText.length : 0; - const newGhostText = this.toGhostText(finalCompletion, inlineCompletionPreviewLength); - - return newGhostText; - } - - private toGhostText(completion: NormalizedInlineCompletion | undefined, inlineCompletionPreviewLength: number): GhostText | undefined { - const mode = this.editor.getOptions().get(EditorOption.suggest).previewMode; - return completion - ? ( - inlineCompletionToGhostText(completion, this.editor.getModel(), mode, this.editor.getPosition(), inlineCompletionPreviewLength) || - // Show an invisible ghost text to reserve space - new GhostText(completion.range.endLineNumber, [], this.minReservedLineCount) - ) - : undefined; - } -} - -function sum(arr: number[]): number { - return arr.reduce((a, b) => a + b, 0); -} - -function rangeExtends(extendingRange: Range, rangeToExtend: Range): boolean { - return extendingRange.startLineNumber === rangeToExtend.startLineNumber && - extendingRange.startColumn === rangeToExtend.startColumn && - ((extendingRange.endLineNumber === rangeToExtend.endLineNumber && extendingRange.endColumn >= rangeToExtend.endColumn) - || extendingRange.endLineNumber > rangeToExtend.endLineNumber); -} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts index 04392dc4781f9..2215ecbe8001b 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/utils.ts @@ -3,16 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDisposable, IReference } from 'vs/base/common/lifecycle'; +import { BugIndicatingError } from 'vs/base/common/errors'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun } from 'vs/base/common/observable'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; - -export function createDisposableRef(object: T, disposable?: IDisposable): IReference { - return { - object, - dispose: () => disposable?.dispose(), - }; -} +import { IModelDeltaDecoration } from 'vs/editor/common/model'; export function applyEdits(text: string, edits: { range: IRange; text: string }[]): string { const transformer = new PositionOffsetTransformer(text); @@ -56,3 +53,55 @@ const array: ReadonlyArray = []; export function getReadonlyEmptyArray(): readonly T[] { return array; } + +export class ColumnRange { + constructor( + public readonly startColumn: number, + public readonly endColumnExclusive: number, + ) { + if (startColumn > endColumnExclusive) { + throw new BugIndicatingError(`startColumn ${startColumn} cannot be after endColumnExclusive ${endColumnExclusive}`); + } + } + + toRange(lineNumber: number): Range { + return new Range(lineNumber, this.startColumn, lineNumber, this.endColumnExclusive); + } +} + +export function applyObservableDecorations(editor: ICodeEditor, decorations: IObservable): IDisposable { + const d = new DisposableStore(); + let decorationIds: string[] = []; + d.add(autorun(`Apply decorations from ${decorations.debugName}`, reader => { + const d = decorations.read(reader); + editor.changeDecorations(a => { + decorationIds = a.deltaDecorations(decorationIds, d); + }); + })); + d.add({ + dispose: () => { + editor.changeDecorations(a => { + decorationIds = a.deltaDecorations(decorationIds, []); + }); + } + }); + return d; +} + +export function addPositions(pos1: Position, pos2: Position): Position { + return new Position(pos1.lineNumber + pos2.lineNumber - 1, pos2.lineNumber === 1 ? pos1.column + pos2.column - 1 : pos2.column); +} + +export function lengthOfText(text: string): Position { + let line = 1; + let column = 1; + for (const c of text) { + if (c === '\n') { + line++; + column = 1; + } else { + column++; + } + } + return new Position(line, column); +} diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts index ac2c4982aec1c..b4e28f9f4245a 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/inlineCompletionsProvider.test.ts @@ -9,17 +9,17 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { runWithFakedTimers } from 'vs/base/test/common/timeTravelScheduler'; import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; import { Range } from 'vs/editor/common/core/range'; -import { InlineCompletionsProvider, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; +import { InlineCompletionsProvider } from 'vs/editor/common/languages'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { LanguageFeaturesService } from 'vs/editor/common/services/languageFeaturesService'; import { ViewModel } from 'vs/editor/common/viewModel/viewModelImpl'; -import { SharedInlineCompletionCache } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextModel'; +import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { SingleTextEdit } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; import { GhostTextContext, MockInlineCompletionsProvider } from 'vs/editor/contrib/inlineCompletions/test/browser/utils'; import { ITestCodeEditor, TestCodeEditorInstantiationOptions, withAsyncTestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; import { createTextModel } from 'vs/editor/test/common/testTextModel'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; -import { inlineCompletionToGhostText } from '../../browser/inlineCompletionToGhostText'; suite('Inline Completions', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -35,11 +35,7 @@ suite('Inline Completions', () => { const options = ['prefix', 'subword'] as const; const result = {} as any; for (const option of options) { - result[option] = inlineCompletionToGhostText( - { insertText: suggestion, filterText: suggestion, snippetInfo: undefined, range, additionalTextEdits: [], }, - tempModel, - option - )?.render(cleanedText, true); + result[option] = new SingleTextEdit(range, suggestion).computeGhostText(tempModel, option)?.render(cleanedText, true); } tempModel.dispose(); @@ -80,8 +76,12 @@ suite('Inline Completions', () => { assert.deepStrictEqual(getOutput('bar[\tfoo]', 'foobar'), undefined); }); - test('Unsupported cases', () => { - assert.deepStrictEqual(getOutput('foo[\n]', '\n'), undefined); + test('Unsupported Case', () => { + assert.deepStrictEqual(getOutput('fo[o\n]', 'x\nbar'), undefined); + }); + + test('New Line', () => { + assert.deepStrictEqual(getOutput('fo[o\n]', 'o\nbar'), 'foo\n[bar]'); }); test('Multi Part Diffing', () => { @@ -115,8 +115,6 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider, inlineSuggest: { enabled: false } }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('foo'); await timeout(1000); @@ -132,11 +130,9 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('foo'); provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ @@ -152,7 +148,6 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider, inlineSuggest: { enabled: true } }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); context.keyboardType('foo'); provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); @@ -171,11 +166,9 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); context.keyboardType('foo'); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); provider.setReturnValue({ insertText: 'foobizz', range: new Range(1, 1, 1, 6) }); @@ -200,16 +193,14 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType(' '); provider.setReturnValue({ insertText: 'foo', range: new Range(1, 2, 1, 3) }); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['', ' [foo]']); - model.commitCurrentSuggestion(); + model.accept(editor); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,3)', text: ' ', triggerKind: 1, }, @@ -225,16 +216,14 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('\t\t'); provider.setReturnValue({ insertText: 'foo', range: new Range(1, 2, 1, 3) }); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['', '\t\t[foo]']); - model.commitCurrentSuggestion(); + model.accept(editor); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,3)', text: '\t\t', triggerKind: 1, }, @@ -250,16 +239,14 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('buzz '); provider.setReturnValue({ insertText: 'foo', range: new Range(1, 6, 1, 7) }); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); - assert.deepStrictEqual(context.getAndClearViewStates(), ['', 'buzz ']); + assert.deepStrictEqual(context.getAndClearViewStates(), ['']); - model.commitCurrentSuggestion(); + model.accept(editor); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,7)', text: 'buzz ', triggerKind: 1, }, @@ -275,11 +262,9 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('foo'); provider.setReturnValue({ insertText: 'foobar1', range: new Range(1, 1, 1, 4) }); - model.trigger(InlineCompletionTriggerKind.Automatic); + model.trigger(); await timeout(1000); assert.deepStrictEqual( @@ -293,27 +278,27 @@ suite('Inline Completions', () => { { insertText: 'foobuzz3', range: new Range(1, 1, 1, 4) } ]); - model.showNext(); + model.next(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bizz2]']); - model.showNext(); + model.next(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[buzz3]']); - model.showNext(); + model.next(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bar1]']); - model.showPrevious(); + model.previous(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[buzz3]']); - model.showPrevious(); + model.previous(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bizz2]']); - model.showPrevious(); + model.previous(); await timeout(1000); assert.deepStrictEqual(context.getAndClearViewStates(), ['foo[bar1]']); @@ -330,8 +315,7 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - model.trigger(InlineCompletionTriggerKind.Automatic); + model.trigger(); context.keyboardType('f'); await timeout(40); @@ -358,8 +342,6 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider, inlineSuggest: { enabled: true } }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('foo'); provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); @@ -387,11 +369,9 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); context.keyboardType('foo'); - model.trigger(InlineCompletionTriggerKind.Automatic); + model.trigger(); await timeout(1000); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,4)', text: 'foo', triggerKind: 0, } @@ -426,10 +406,9 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 4) }); context.keyboardType('foo'); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(100); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,4)', text: 'foo', triggerKind: 1, } @@ -455,13 +434,11 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('fooba'); provider.setReturnValue({ insertText: 'foobar', range: new Range(1, 1, 1, 6) }); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); assert.deepStrictEqual(provider.getAndClearCallHistory(), [ { position: '(1,6)', text: 'fooba', triggerKind: 1, } @@ -487,11 +464,10 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider, }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); context.keyboardType('h'); provider.setReturnValue({ insertText: 'helloworld', range: new Range(1, 1, 1, 2) }, 1000); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1030); context.keyboardType('ello'); @@ -513,9 +489,10 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider, inlineSuggest: { enabled: true } }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); context.keyboardType('hello\n'); context.cursorLeft(); + context.keyboardType('x'); + context.leftDelete(); provider.setReturnValue({ insertText: 'helloworld', range: new Range(1, 1, 1, 6) }, 1000); await timeout(2000); @@ -559,8 +536,6 @@ suite('Inline Completions', () => { await withAsyncTestCodeEditorAndInlineCompletionsModel('', { fakeClock: true, provider }, async ({ editor, editorViewModel, model, context }) => { - model.setActive(true); - context.keyboardType('buzz\nbaz'); provider.setReturnValue({ insertText: 'bazz', @@ -570,10 +545,10 @@ suite('Inline Completions', () => { text: 'bla' }], }); - model.trigger(InlineCompletionTriggerKind.Explicit); + model.triggerExplicitly(); await timeout(1000); - model.commitCurrentSuggestion(); + model.accept(editor); assert.deepStrictEqual(provider.getAndClearCallHistory(), ([{ position: "(2,4)", triggerKind: 1, text: "buzz\nbaz" }])); @@ -597,7 +572,6 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel( }, async () => { const disposableStore = new DisposableStore(); - try { if (options.provider) { const languageFeaturesService = new LanguageFeaturesService(); @@ -611,14 +585,15 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel( let result: T; await withAsyncTestCodeEditor(text, options, async (editor, editorViewModel, instantiationService) => { - const cache = disposableStore.add(new SharedInlineCompletionCache()); - const model = instantiationService.createInstance(InlineCompletionsModel, editor, cache); + const controller = instantiationService.createInstance(InlineCompletionsController, editor); + const model = controller.model.get()!; const context = new GhostTextContext(model, editor); try { result = await callback({ editor, editorViewModel, model, context }); } finally { context.dispose(); model.dispose(); + controller.dispose(); } }); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts index 632700ef5314a..8a5c555c9fd5d 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts @@ -34,7 +34,7 @@ import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace import { rangeStartsWith } from 'vs/editor/contrib/inlineCompletions/browser/suggestWidgetInlineCompletionProvider'; import { LanguageFeaturesService } from 'vs/editor/common/services/languageFeaturesService'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; -import { minimizeInlineCompletion } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionToGhostText'; +import { minimizeInlineCompletion } from 'vs/editor/contrib/inlineCompletions/browser/singleTextEdit'; suite('Suggest Widget Model', () => { test('rangeStartsWith', () => { diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 17eba2098c306..b25536d370303 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -10,8 +10,9 @@ import { CoreEditingCommands, CoreNavigationCommands } from 'vs/editor/browser/c import { Position } from 'vs/editor/common/core/position'; import { ITextModel } from 'vs/editor/common/model'; import { InlineCompletion, InlineCompletionContext, InlineCompletionsProvider } from 'vs/editor/common/languages'; -import { GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/browser/ghostText'; import { ITestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; +import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { autorun } from 'vs/base/common/observable'; export class MockInlineCompletionsProvider implements InlineCompletionsProvider { private returnValue: InlineCompletion[] = []; @@ -76,30 +77,23 @@ export class GhostTextContext extends Disposable { return this._currentPrettyViewState; } - constructor(private readonly model: GhostTextWidgetModel, private readonly editor: ITestCodeEditor) { + constructor(model: InlineCompletionsModel, private readonly editor: ITestCodeEditor) { super(); - this._register( - model.onDidChange(() => { - this.update(); - }) - ); - this.update(); - } - - private update(): void { - const ghostText = this.model?.ghostText; - let view: string | undefined; - if (ghostText) { - view = ghostText.render(this.editor.getValue(), true); - } else { - view = this.editor.getValue(); - } - - if (this._currentPrettyViewState !== view) { - this.prettyViewStates.push(view); - } - this._currentPrettyViewState = view; + this._register(autorun('update', reader => { + const ghostText = model.ghostText.read(reader); + let view: string | undefined; + if (ghostText) { + view = ghostText.render(this.editor.getValue(), true); + } else { + view = this.editor.getValue(); + } + + if (this._currentPrettyViewState !== view) { + this.prettyViewStates.push(view); + } + this._currentPrettyViewState = view; + })); } public getAndClearViewStates(): (string | undefined)[] { diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index 457a7b1f0084b..94842516dc169 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -26,7 +26,7 @@ import 'vs/editor/contrib/folding/browser/folding'; import 'vs/editor/contrib/fontZoom/browser/fontZoom'; import 'vs/editor/contrib/format/browser/formatActions'; import 'vs/editor/contrib/documentSymbols/browser/documentSymbols'; -import 'vs/editor/contrib/inlineCompletions/browser/ghostText.contribution'; +import 'vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution'; import 'vs/editor/contrib/inlineProgress/browser/inlineProgress'; import 'vs/editor/contrib/gotoSymbol/browser/goToCommands'; import 'vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition'; diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 79b3eed6aed78..2c049a56b94a0 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -6786,11 +6786,13 @@ declare namespace monaco.languages { readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined; } - export interface SelectedSuggestionInfo { - range: IRange; - text: string; - isSnippetText: boolean; - completionKind: CompletionItemKind; + export class SelectedSuggestionInfo { + readonly range: IRange; + readonly text: string; + readonly completionKind: CompletionItemKind; + readonly isSnippetText: boolean; + constructor(range: IRange, text: string, completionKind: CompletionItemKind, isSnippetText: boolean); + equals(other: SelectedSuggestionInfo): boolean; } export interface InlineCompletion { diff --git a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts index 6a3779bbf8f96..dd65ef7833efe 100644 --- a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts +++ b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts @@ -25,7 +25,7 @@ import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetCon import { IModelService } from 'vs/editor/common/services/model'; import { URI } from 'vs/base/common/uri'; import { EmbeddedCodeEditorWidget, EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; -import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController'; +import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; @@ -154,7 +154,7 @@ class InteractiveEditorWidget { isSimpleWidget: true, contributions: EditorExtensionsRegistry.getSomeEditorContributions([ SnippetController2.ID, - GhostTextController.ID, + InlineCompletionsController.ID, SuggestController.ID ]) }; diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 274aefe4cf4b1..a758b86d155d2 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -88,7 +88,7 @@ import { DropIntoEditorController } from 'vs/editor/contrib/dropIntoEditor/brows import { MessageController } from 'vs/editor/contrib/message/browser/messageController'; import { contrastBorder, registerColor } from 'vs/platform/theme/common/colorRegistry'; import { defaultButtonStyles, defaultCountBadgeStyles } from 'vs/platform/theme/browser/defaultStyles'; -import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController'; +import { InlineCompletionsController } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsController'; import { CodeActionController } from 'vs/editor/contrib/codeAction/browser/codeActionController'; import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService'; import { Schemas } from 'vs/base/common/network'; @@ -1964,7 +1964,7 @@ class SCMInputWidget { SelectionClipboardContributionID, SnippetController2.ID, SuggestController.ID, - GhostTextController.ID, + InlineCompletionsController.ID, CodeActionController.ID, ]) };