diff --git a/src/vs/editor/common/editorCommon.ts b/src/vs/editor/common/editorCommon.ts index 3c760e04c69..fd38dafbd88 100644 --- a/src/vs/editor/common/editorCommon.ts +++ b/src/vs/editor/common/editorCommon.ts @@ -639,6 +639,7 @@ export interface IContentDecorationRenderOptions { textDecoration?: string; color?: string | ThemeColor; backgroundColor?: string | ThemeColor; + opacity?: string; margin?: string; padding?: string; diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index e331f82272a..da4153ca2e1 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -688,7 +688,7 @@ export interface CompletionItemProvider { /** * @internal */ -export interface GhostText { +export interface InlineSuggestion { text: string; replaceRange?: IRange; } @@ -696,8 +696,15 @@ export interface GhostText { /** * @internal */ -export interface GhostTextProvider { - provideGhostText(model: model.ITextModel, position: Position, token: CancellationToken): ProviderResult; +export interface InlineSuggestions { + items: TItem[]; +} + +/** + * @internal + */ +export interface InlineSuggestionsProvider { + provideInlineSuggestions(model: model.ITextModel, position: Position, token: CancellationToken): ProviderResult; } export interface CodeAction { @@ -1788,7 +1795,7 @@ export const CompletionProviderRegistry = new LanguageFeatureRegistry(); +export const InlineSuggestionsProviderRegistry = new LanguageFeatureRegistry(); /** * @internal diff --git a/src/vs/editor/contrib/ghostText/ghostText.ts b/src/vs/editor/contrib/ghostText/ghostText.ts deleted file mode 100644 index 94ee9aa94fb..00000000000 --- a/src/vs/editor/contrib/ghostText/ghostText.ts +++ /dev/null @@ -1,155 +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 * as nls from 'vs/nls'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; -import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { EditorAction, registerEditorAction, registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; -import { ITextModel } from 'vs/editor/common/model'; -import { GhostTextProviderRegistry } from 'vs/editor/common/modes'; -import { Position } from 'vs/editor/common/core/position'; -import { Range } from 'vs/editor/common/core/range'; -import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; -import * as errors from 'vs/base/common/errors'; -import { GhostTextWidget, ValidGhostText } from 'vs/editor/contrib/ghostText/ghostTextWidget'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; - -class GhostTextController extends Disposable { - static ID = 'editor.contrib.ghostTextController'; - - public static get(editor: ICodeEditor): GhostTextController { - return editor.getContribution(GhostTextController.ID); - } - - private readonly _editor: ICodeEditor; - private readonly _widget: GhostTextWidget; - private _modelDisposable: DisposableStore; - private _ghostTextPromise: CancelablePromise | null; - - constructor( - editor: ICodeEditor, - @IInstantiationService instantiationService: IInstantiationService, - ) { - super(); - this._editor = editor; - this._widget = this._register(instantiationService.createInstance(GhostTextWidget, this._editor)); - this._modelDisposable = this._register(new DisposableStore()); - this._ghostTextPromise = null; - this._register(toDisposable(() => { - if (this._ghostTextPromise) { - this._ghostTextPromise.cancel(); - this._ghostTextPromise = null; - } - })); - - this._editor.onDidChangeModel(() => { - this._reinit(); - }); - GhostTextProviderRegistry.onDidChange(() => { - this._reinit(); - }); - this._reinit(); - - } - - private _reinit(): void { - this._modelDisposable.clear(); - if (!this._editor.hasModel()) { - return; - } - const model = this._editor.getModel(); - - this._modelDisposable.add(model.onDidChangeContent((e) => { - if (!GhostTextProviderRegistry.has(model)) { - return; - } - - this._trigger(); - })); - this._modelDisposable.add(toDisposable(() => { - this._widget.hide(); - })); - } - - private _trigger(): void { - if (!this._editor.hasModel()) { - return; - } - - const model = this._editor.getModel(); - const position = this._editor.getPosition(); - - if (this._ghostTextPromise) { - this._ghostTextPromise.cancel(); - this._ghostTextPromise = null; - } - - this._ghostTextPromise = createCancelablePromise(token => provideGhostText(model, position, token)); - - this._ghostTextPromise.then((result) => this._renderResult(result), errors.onUnexpectedError); - } - - private _renderResult(result: ValidGhostText | undefined): void { - if (!result) { - this._widget.hide(); - return; - } - this._widget.show(result); - } - - public trigger(): void { - this._trigger(); - } -} - -export class TriggerGhostTextAction extends EditorAction { - - constructor() { - super({ - id: 'editor.action.triggerGhostText', - label: nls.localize('triggerGhostTextAction', "Trigger Ghost Text"), - alias: 'Trigger Ghost Text', - precondition: EditorContextKeys.writable - }); - } - - public async run(accessor: ServicesAccessor | null, editor: ICodeEditor): Promise { - const controller = GhostTextController.get(editor); - if (controller) { - controller.trigger(); - } - } -} - -async function provideGhostText( - model: ITextModel, - position: Position, - token: CancellationToken = CancellationToken.None -): Promise { - - const word = model.getWordAtPosition(position); - const defaultReplaceRange = word ? new Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) : Range.fromPositions(position); - - const providers = GhostTextProviderRegistry.all(model); - const results = await Promise.all( - providers.map(provider => provider.provideGhostText(model, position, token)) - ); - - for (const result of results) { - if (result) { - return { - text: result.text, - replaceRange: result.replaceRange || defaultReplaceRange - }; - } - } - - return undefined; -} - -registerEditorContribution(GhostTextController.ID, GhostTextController); -registerEditorAction(TriggerGhostTextAction); diff --git a/src/vs/editor/contrib/ghostText/ghostTextWidget.ts b/src/vs/editor/contrib/inlineSuggestions/ghostTextWidget.ts similarity index 68% rename from src/vs/editor/contrib/ghostText/ghostTextWidget.ts rename to src/vs/editor/contrib/inlineSuggestions/ghostTextWidget.ts index 071b9a73ea5..09705694a94 100644 --- a/src/vs/editor/contrib/ghostText/ghostTextWidget.ts +++ b/src/vs/editor/contrib/inlineSuggestions/ghostTextWidget.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; -import { IRange, Range } from 'vs/editor/common/core/range'; +import { Range } from 'vs/editor/common/core/range'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; -import { GhostText } from 'vs/editor/common/modes'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import * as strings from 'vs/base/common/strings'; import { RenderLineInput, renderViewLine } from 'vs/editor/common/viewLayout/viewLineRenderer'; @@ -14,38 +13,39 @@ import { EditorFontLigatures, EditorOption } from 'vs/editor/common/config/edito import { createStringBuilder } from 'vs/editor/common/core/stringBuilder'; import { Configuration } from 'vs/editor/browser/config/configuration'; import { LineTokens } from 'vs/editor/common/core/lineTokens'; - -export interface ValidGhostText extends GhostText { - replaceRange: IRange; -} +import { Position } from 'vs/editor/common/core/position'; const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value }); -export class GhostTextWidget extends Disposable { +export interface GhostText { + text: string; + position: Position; +} +export class GhostTextWidget extends Disposable { private static instanceCount = 0; - private readonly _editor: ICodeEditor; private readonly _codeEditorDecorationTypeKey: string; - private _currentGhostText: ValidGhostText | null; - private _hasDecoration: boolean; - private _decorationIds: string[]; - private _viewZoneId: string | null; + private currentGhostText: GhostText | null; + private hasDecoration: boolean; + private decorationIds: string[]; + private viewZoneId: string | null; constructor( - editor: ICodeEditor, + private readonly editor: ICodeEditor, @ICodeEditorService private readonly _codeEditorService: ICodeEditorService ) { super(); - this._editor = editor; - this._codeEditorDecorationTypeKey = `0ghost-text-${++GhostTextWidget.instanceCount}`; - this._currentGhostText = null; - this._hasDecoration = false; - this._decorationIds = []; - this._viewZoneId = null; - this._register(this._editor.onDidChangeConfiguration((e) => { + // We add 0 to bring it before any other decoration. + this._codeEditorDecorationTypeKey = `0-ghost-text-${++GhostTextWidget.instanceCount}`; + this.currentGhostText = null; + this.hasDecoration = false; + this.decorationIds = []; + this.viewZoneId = null; + + this._register(this.editor.onDidChangeConfiguration((e) => { if ( e.hasChanged(EditorOption.disableMonospaceOptimizations) || e.hasChanged(EditorOption.stopRenderingLineAfter) @@ -62,66 +62,73 @@ export class GhostTextWidget extends Disposable { } private _removeInlineText(): void { - if (this._hasDecoration) { - this._hasDecoration = false; + if (this.hasDecoration) { + this.hasDecoration = false; this._codeEditorService.removeDecorationType(this._codeEditorDecorationTypeKey); } } public hide(): void { this._removeInlineText(); - this._editor.changeViewZones((changeAccessor) => { - if (this._viewZoneId) { - changeAccessor.removeZone(this._viewZoneId); - this._viewZoneId = null; + this.editor.changeViewZones((changeAccessor) => { + if (this.viewZoneId) { + changeAccessor.removeZone(this.viewZoneId); + this.viewZoneId = null; } }); - this._decorationIds = this._editor.deltaDecorations(this._decorationIds, []); - this._currentGhostText = null; + this.decorationIds = this.editor.deltaDecorations(this.decorationIds, []); + this.currentGhostText = null; } - public show(ghostText: ValidGhostText): void { - if (!this._editor.hasModel()) { + public show(ghostText: GhostText): void { + if (!this.editor.hasModel()) { return; } - this._currentGhostText = ghostText; + const model = this.editor.getModel(); + const maxColumn = model.getLineMaxColumn(ghostText.position.lineNumber); + if (ghostText.position.column !== maxColumn) { + console.warn('Can only show multiline ghost text at the end of a line'); + return; + } + this.currentGhostText = ghostText; this._render(); } private _render(): void { - if (!this._editor.hasModel() || !this._currentGhostText) { + if (!this.editor.hasModel() || !this.currentGhostText) { return; } - const model = this._editor.getModel(); + const model = this.editor.getModel(); const { tabSize } = model.getOptions(); - const ghostLines = strings.splitLines(this._currentGhostText.text); + const ghostLines = strings.splitLines(this.currentGhostText.text); this._removeInlineText(); this._codeEditorService.registerDecorationType(this._codeEditorDecorationTypeKey, { after: { - contentText: ghostLines[0] + contentText: ghostLines[0], + opacity: '0.2', } }); - this._hasDecoration = true; - const insertPosition = Range.lift(this._currentGhostText.replaceRange).getEndPosition(); - this._decorationIds = this._editor.deltaDecorations(this._decorationIds, [{ + this.hasDecoration = true; + const insertPosition = this.currentGhostText.position; + this.decorationIds = this.editor.deltaDecorations(this.decorationIds, [{ range: Range.fromPositions(insertPosition, insertPosition), options: this._codeEditorService.resolveDecorationOptions(this._codeEditorDecorationTypeKey, true) }]); - this._editor.changeViewZones((changeAccessor) => { - if (this._viewZoneId) { - changeAccessor.removeZone(this._viewZoneId); - this._viewZoneId = null; + this.editor.changeViewZones((changeAccessor) => { + if (this.viewZoneId) { + changeAccessor.removeZone(this.viewZoneId); + this.viewZoneId = null; } const remainingLines = ghostLines.slice(1); if (remainingLines.length > 0) { const domNode = document.createElement('div'); this._renderLines(domNode, tabSize, remainingLines); - this._viewZoneId = changeAccessor.addZone({ + this.viewZoneId = changeAccessor.addZone({ afterLineNumber: insertPosition.lineNumber, afterColumn: insertPosition.column, heightInLines: ghostLines.length - 1, @@ -132,7 +139,7 @@ export class GhostTextWidget extends Disposable { } private _renderLines(domNode: HTMLElement, tabSize: number, lines: string[]): void { - const opts = this._editor.getOptions(); + const opts = this.editor.getOptions(); const disableMonospaceOptimizations = opts.get(EditorOption.disableMonospaceOptimizations); const stopRenderingLineAfter = opts.get(EditorOption.stopRenderingLineAfter); const renderWhitespace = opts.get(EditorOption.renderWhitespace); @@ -142,6 +149,7 @@ export class GhostTextWidget extends Disposable { const lineHeight = opts.get(EditorOption.lineHeight); const sb = createStringBuilder(10000); + sb.appendASCIIString('
'); for (let i = 0, len = lines.length; i < len; i++) { const line = lines[i]; @@ -178,6 +186,7 @@ export class GhostTextWidget extends Disposable { sb.appendASCIIString('
'); } + sb.appendASCIIString(''); Configuration.applyFontInfoSlow(domNode, fontInfo); const html = sb.build(); diff --git a/src/vs/editor/contrib/inlineSuggestions/inlineSuggestionsController.ts b/src/vs/editor/contrib/inlineSuggestions/inlineSuggestionsController.ts new file mode 100644 index 00000000000..5ef6117d3ab --- /dev/null +++ b/src/vs/editor/contrib/inlineSuggestions/inlineSuggestionsController.ts @@ -0,0 +1,424 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from 'vs/nls'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IActiveCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction, registerEditorAction, registerEditorContribution, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { ITextModel } from 'vs/editor/common/model'; +import { InlineSuggestion, InlineSuggestions, InlineSuggestionsProviderRegistry } from 'vs/editor/common/modes'; +import { Position } from 'vs/editor/common/core/position'; +import { Range } from 'vs/editor/common/core/range'; +import { CancelablePromise, createCancelablePromise } from 'vs/base/common/async'; +import * as errors from 'vs/base/common/errors'; +import { GhostTextWidget } from 'vs/editor/contrib/inlineSuggestions/ghostTextWidget'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { Emitter } from 'vs/base/common/event'; +import { splitLines } from 'vs/base/common/strings'; +import { IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { KeyCode } from 'vs/base/common/keyCodes'; + +/* +TODO + +GhostTextProviderRegistry.onDidChange(() => { + this.trigger(); +}); +*/ + +class InlineSuggestionsController extends Disposable { + public static readonly inlineSuggestionVisible = new RawContextKey('inlineSuggestionVisible ', false, nls.localize('inlineSuggestionVisible ', "TODO")); + static ID = 'editor.contrib.inlineSuggestionsController'; + + public static get(editor: ICodeEditor): InlineSuggestionsController { + return editor.getContribution(InlineSuggestionsController.ID); + } + + private readonly widget: GhostTextWidget; + private readonly modelController = this._register(new MutableDisposable()); + + private readonly contextKeys: InlineSuggestionsContextKeys; + + constructor( + private readonly editor: ICodeEditor, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this.contextKeys = new InlineSuggestionsContextKeys(contextKeyService); + + this.widget = this._register(instantiationService.createInstance(GhostTextWidget, this.editor)); + + this.editor.onDidChangeModel(() => { + this.updateModelController(); + }); + this.updateModelController(); + } + + private updateModelController(): void { + this.modelController.value = this.editor.hasModel() + ? new InlineSuggestionsModelController(this.editor, this.widget, this.contextKeys) + : undefined; + } + + public trigger(): void { + if (this.modelController.value) { + this.modelController.value.trigger(); + } + } + + public commit(): void { + if (this.modelController.value) { + this.modelController.value.commit(); + } + } +} + +class InlineSuggestionsContextKeys { + public readonly inlineSuggestionVisible = InlineSuggestionsController.inlineSuggestionVisible.bindTo(this.contextKeyService); + + constructor(private readonly contextKeyService: IContextKeyService) { + } +} + +/** + * The model controller for a text editor with a specific text model. +*/ +export class InlineSuggestionsModelController extends Disposable { + private readonly model = this.editor.getModel(); + private readonly source = this._register(new InlineSuggestionsModel(this.model)); + private readonly completionSession = this._register(new MutableDisposable()); + + constructor( + private readonly editor: IActiveCodeEditor, + private readonly widget: GhostTextWidget, + private readonly contextKeys: InlineSuggestionsContextKeys, + ) { + super(); + + this._register(this.model.onDidChangeContent((e) => { + if (!InlineSuggestionsProviderRegistry.has(this.model)) { + return; + } + this.updateSession(); + })); + this._register(this.editor.onDidChangeCursorPosition((e) => { + if (!InlineSuggestionsProviderRegistry.has(this.model)) { + return; + } + this.updateSession(); + })); + + this._register(toDisposable(() => { + this.hide(); + })); + } + + private updateSession(): void { + const pos = this.editor.getPosition(); + + if (this.completionSession.value && this.completionSession.value.activeRange.containsPosition(pos)) { + return; + } + + // only trigger when cursor is at the end of a line + if (pos.column === this.model.getLineMaxColumn(pos.lineNumber)) { + this.triggerAt(pos); + } else { + this.hide(); + } + } + + private triggerAt(position: Position): void { + this.completionSession.value = new InlineSuggestionsSession(this.editor, this.widget, this.source, this.contextKeys, position); + } + + public hide(): void { + this.completionSession.clear(); + } + + public trigger(): void { + if (!this.completionSession.value) { + this.triggerAt(this.editor.getPosition()); + } + } + + public commit(): void { + if (this.completionSession.value) { + this.completionSession.value.commitCurrentSuggestion(); + } + } +} + +class InlineSuggestionsSession extends Disposable { + private readonly textModel = this.editor.getModel(); + + /* + private get position(): Position { + return this.editor.getPosition(); + //return new Position(this.lineNumber, this.model.getLineMaxColumn(this.lineNumber)); + }*/ + + constructor( + private readonly editor: IActiveCodeEditor, + private readonly widget: GhostTextWidget, + private readonly model: InlineSuggestionsModel, + private readonly contextKeys: InlineSuggestionsContextKeys, + private readonly triggerPosition: Position, + ) { + super(); + + this._register(this.textModel.onDidChangeContent((e) => { + this.model.update(this.editor.getPosition()); + this.update(); + })); + this._register(this.editor.onDidChangeCursorPosition((e) => { + this.model.update(this.editor.getPosition()); + this.update(); + })); + + this.model.onDidChange(() => { + this.update(); + }); + + this.model.update(this.editor.getPosition()); + this.update(); + } + + get currentSuggestion(): ValidatedInlineSuggestion | undefined { + const cursorPos = this.editor.getPosition(); + const suggestions = this.model.getInlineSuggestions(cursorPos); + const validatedSuggestions = suggestions.items + .map(s => validateSuggestion(s, this.textModel)) + .filter(s => s !== undefined); + const first = validatedSuggestions[0]; + + return first; + } + + get activeRange(): Range { + if (this.currentSuggestion) { + return this.currentSuggestion.suggestion.replaceRange; + } + return getDefaultRange(this.triggerPosition, this.textModel); + } + + private update() { + const suggestion = this.currentSuggestion; + const cursorPos = this.editor.getPosition(); + + this.contextKeys.inlineSuggestionVisible.set(!!suggestion); + + if (suggestion) { + this.widget.show({ + position: suggestion.suggestion.replaceRange.getStartPosition().delta(0, suggestion.committedSuggestionLength), + text: suggestion.suggestion.text.substr(suggestion.committedSuggestionLength), + }); + } else { + this.widget.hide(); + } + } + + public commitCurrentSuggestion(): void { + const s = this.currentSuggestion; + if (s) { + this.commit(s); + } + } + + public commit(suggestion: ValidatedInlineSuggestion): void { + this.textModel.applyEdits([{ + range: suggestion.suggestion.replaceRange, + text: suggestion.suggestion.text, + forceMoveMarkers: true, + }]); + } +} + +interface ValidatedInlineSuggestion { + suggestion: NormalizedInlineSuggestion; + lineNumber: number; + /** + * Indicates the length of the prefix of the suggestion that agrees with the text buffer. + */ + committedSuggestionLength: number; +} + +class InlineSuggestionsModel { + private updatePromise: CancelablePromise | undefined = undefined; + private readonly onDidChangeEventEmitter = new Emitter(); + private cachedList: NormalizedInlineSuggestions | undefined = undefined; + private cachedPosition: Position | undefined = undefined; + + public readonly onDidChange = this.onDidChangeEventEmitter.event; + + constructor(private readonly model: ITextModel) { + console.log(this.cachedPosition); + } + + public dispose(): void { + this.clearGhostTextPromise(); + } + + public getInlineSuggestions(position: Position): NormalizedInlineSuggestions { + if (this.cachedList && this.cachedPosition && position.lineNumber === this.cachedPosition.lineNumber) { + return this.cachedList; + } + return { + items: [] + }; + } + + public update(position: Position): void { + //this.cachedList = undefined; + //this.cachedPosition = undefined; + this._update(position); + } + + private _update(position: Position): void { + this.clearGhostTextPromise(); + this.updatePromise = createCancelablePromise(token => provideInlineSuggestions(position, this.model, token)); + this.updatePromise.then((result) => { + this.cachedList = result; + this.cachedPosition = position; + this.onDidChangeEventEmitter.fire(undefined); + }, errors.onUnexpectedError); + } + + private clearGhostTextPromise(): void { + if (this.updatePromise) { + this.updatePromise.cancel(); + this.updatePromise = undefined; + } + } +} + +function validateSuggestion(suggestion: NormalizedInlineSuggestion, model: ITextModel): ValidatedInlineSuggestion | undefined { + // Multiline replacements are not supported + if (suggestion.replaceRange.startLineNumber !== suggestion.replaceRange.endLineNumber) { + return undefined; + } + const lineNumber = suggestion.replaceRange.startLineNumber; + + const suggestedLines = splitLines(suggestion.text); + const firstSuggestedLine = suggestedLines[0]; + + const modelLine = model.getLineContent(lineNumber); + + const suggestionStartIdx = suggestion.replaceRange.startColumn - 1; + let committedSuggestionLength = 0; + while ( + committedSuggestionLength < firstSuggestedLine.length + && suggestionStartIdx + committedSuggestionLength < modelLine.length + && firstSuggestedLine[committedSuggestionLength] === modelLine[suggestionStartIdx + committedSuggestionLength]) { + committedSuggestionLength++; + } + + // If a suggestion wants to replace text, the suggestion may not replace that text with different text + //if (committedSuggestionLength !== suggestion.replaceRange.endColumn - suggestion.replaceRange.startColumn) { + // return undefined; + //} + + // For now, we don't support any left over text. An entire line suffix must be replaced. + //if (suggestion.replaceRange.endColumn !== model.getLineMaxColumn(lineNumber)) { + // return undefined; + //} + + return { + lineNumber, + committedSuggestionLength, + suggestion + }; +} + +export class TriggerGhostTextAction extends EditorAction { + constructor() { + super({ + id: 'editor.action.triggerInlineSuggestions', + label: nls.localize('triggerInlineSuggestionsAction', "Trigger Inline Suggestions"), + alias: 'Trigger Inline Suggestions', + precondition: EditorContextKeys.writable + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineSuggestionsController.get(editor); + if (controller) { + controller.trigger(); + } + } +} + +export class CommitInlineSuggestionAction extends EditorAction { + constructor() { + super({ + id: 'editor.action.commitInlineSuggestion', + label: nls.localize('commitInlineSuggestion', "Commit Inline Suggestion"), + alias: 'Commit Inline Suggestion', + precondition: InlineSuggestionsController.inlineSuggestionVisible, + kbOpts: { + weight: 100, + primary: KeyCode.Tab, + secondary: [KeyCode.RightArrow], + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineSuggestionsController.get(editor); + if (controller) { + controller.commit(); + } + } +} + +export interface NormalizedInlineSuggestion extends InlineSuggestion { + replaceRange: Range; +} + +export interface NormalizedInlineSuggestions extends InlineSuggestions { +} + +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)); +} + +async function provideInlineSuggestions( + position: Position, + model: ITextModel, + token: CancellationToken = CancellationToken.None +): Promise { + + const defaultReplaceRange = getDefaultRange(position, model); + + const providers = InlineSuggestionsProviderRegistry.all(model); + const results = await Promise.all( + providers.map(provider => provider.provideInlineSuggestions(model, position, token)) + ); + + const items = new Array(); + for (const result of results) { + if (result) { + items.push(...result.items.map(item => ({ + text: item.text, + replaceRange: item.replaceRange ? Range.lift(item.replaceRange) : defaultReplaceRange + }))); + } + } + + return { items }; +} + +registerEditorContribution(InlineSuggestionsController.ID, InlineSuggestionsController); +registerEditorAction(TriggerGhostTextAction); +registerEditorAction(CommitInlineSuggestionAction); diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index e0d961e6778..12429cdf3e1 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -24,7 +24,7 @@ import 'vs/editor/contrib/folding/folding'; import 'vs/editor/contrib/fontZoom/fontZoom'; import 'vs/editor/contrib/format/formatActions'; import 'vs/editor/contrib/documentSymbols/documentSymbols'; -import 'vs/editor/contrib/ghostText/ghostText'; +import 'vs/editor/contrib/inlineSuggestions/inlineSuggestionsController'; import 'vs/editor/contrib/gotoSymbol/goToCommands'; import 'vs/editor/contrib/gotoSymbol/link/goToDefinitionAtPosition'; import 'vs/editor/contrib/gotoError/gotoError'; diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index b369c180da1..5638d517ede 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -3283,21 +3283,33 @@ declare module 'vscode' { //#region https://github.com/microsoft/vscode/issues/124024 @hediet @alexdima - export class GhostText { - + export class InlineSuggestion { text: string; - replaceRange?: Range; constructor(text: string); } - export interface GhostTextProvider { - provideGhostTextItems(document: TextDocument, position: Position, token: CancellationToken): ProviderResult; + export class InlineSuggestions { + items: InlineSuggestion[]; + incomplete: boolean; + + constructor(items: InlineSuggestion[]); + } + + export interface InlineSuggestionsContext { + /** + * Communicates a preference on how many suggestions should be returned. + */ + preferredSuggestionCount: number; + } + + export interface InlineSuggestionsProvider { + provideInlineSuggestions(document: TextDocument, position: Position, token: CancellationToken, context: InlineSuggestionsContext): ProviderResult; } export namespace languages { - export function registerGhostTextProvider(selector: DocumentSelector, provider: GhostTextProvider): Disposable; + export function registerInlineSuggestionsProvider(selector: DocumentSelector, provider: InlineSuggestionsProvider): Disposable; } //#endregion diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 3c3c8704112..7121262018b 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -499,13 +499,13 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha this._registrations.set(handle, modes.CompletionProviderRegistry.register(selector, provider)); } - $registerGhostTextSupport(handle: number, selector: IDocumentFilterDto[]): void { - const provider: modes.GhostTextProvider = { - provideGhostText: async (model: ITextModel, position: EditorPosition, token: CancellationToken): Promise => { - return this._proxy.$provideGhostText(handle, model.uri, position, token); + $registerInlineSuggestionSupport(handle: number, selector: IDocumentFilterDto[]): void { + const provider: modes.InlineSuggestionsProvider = { + provideInlineSuggestions: async (model: ITextModel, position: EditorPosition, token: CancellationToken): Promise => { + return this._proxy.$provideInlineSuggestions(handle, model.uri, position, token); } }; - this._registrations.set(handle, modes.GhostTextProviderRegistry.register(selector, provider)); + this._registrations.set(handle, modes.InlineSuggestionsProviderRegistry.register(selector, provider)); } // --- parameter hints diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 68a9476384c..0a8a19ee970 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -480,9 +480,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerCompletionItemProvider(selector: vscode.DocumentSelector, provider: vscode.CompletionItemProvider, ...triggerCharacters: string[]): vscode.Disposable { return extHostLanguageFeatures.registerCompletionItemProvider(extension, checkSelector(selector), provider, triggerCharacters); }, - registerGhostTextProvider(selector: vscode.DocumentSelector, provider: vscode.GhostTextProvider): vscode.Disposable { + registerInlineSuggestionsProvider(selector: vscode.DocumentSelector, provider: vscode.InlineSuggestionsProvider): vscode.Disposable { checkProposedApiEnabled(extension); - return extHostLanguageFeatures.registerGhostTextProvider(extension, checkSelector(selector), provider); + return extHostLanguageFeatures.registerInlineSuggestionsProvider(extension, checkSelector(selector), provider); }, registerDocumentLinkProvider(selector: vscode.DocumentSelector, provider: vscode.DocumentLinkProvider): vscode.Disposable { return extHostLanguageFeatures.registerDocumentLinkProvider(extension, checkSelector(selector), provider); @@ -1181,7 +1181,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I FoldingRange: extHostTypes.FoldingRange, FoldingRangeKind: extHostTypes.FoldingRangeKind, FunctionBreakpoint: extHostTypes.FunctionBreakpoint, - GhostText: extHostTypes.GhostText, + InlineSuggestion: extHostTypes.InlineSuggestion, + InlineSuggestions: extHostTypes.InlineSuggestions, Hover: extHostTypes.Hover, IndentAction: languageConfiguration.IndentAction, Location: extHostTypes.Location, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ce25add0fec..8f6e77b0fe5 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -395,7 +395,7 @@ export interface MainThreadLanguageFeaturesShape extends IDisposable { $emitDocumentSemanticTokensEvent(eventHandle: number): void; $registerDocumentRangeSemanticTokensProvider(handle: number, selector: IDocumentFilterDto[], legend: modes.SemanticTokensLegend): void; $registerSuggestSupport(handle: number, selector: IDocumentFilterDto[], triggerCharacters: string[], supportsResolveDetails: boolean, displayName: string): void; - $registerGhostTextSupport(handle: number, selector: IDocumentFilterDto[]): void; + $registerInlineSuggestionSupport(handle: number, selector: IDocumentFilterDto[]): void; $registerSignatureHelpProvider(handle: number, selector: IDocumentFilterDto[], metadata: ISignatureHelpProviderMetadataDto): void; $registerInlayHintsProvider(handle: number, selector: IDocumentFilterDto[], eventHandle: number | undefined): void; $emitInlayHintsEvent(eventHandle: number, event?: any): void; @@ -1635,7 +1635,7 @@ export interface ExtHostLanguageFeaturesShape { $provideCompletionItems(handle: number, resource: UriComponents, position: IPosition, context: modes.CompletionContext, token: CancellationToken): Promise; $resolveCompletionItem(handle: number, id: ChainedCacheId, token: CancellationToken): Promise; $releaseCompletionItems(handle: number, id: number): void; - $provideGhostText(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; + $provideInlineSuggestions(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; $provideSignatureHelp(handle: number, resource: UriComponents, position: IPosition, context: modes.SignatureHelpContext, token: CancellationToken): Promise; $releaseSignatureHelp(handle: number, id: number): void; $provideInlayHints(handle: number, resource: UriComponents, range: IRange, token: CancellationToken): Promise diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 34bea6d81de..bbe65ca0123 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1038,22 +1038,25 @@ class SuggestAdapter { } } -class GhostTextAdapter { +class InlineSuggestionsAdapter { constructor( private readonly _documents: ExtHostDocuments, - private readonly _provider: vscode.GhostTextProvider, + private readonly _provider: vscode.InlineSuggestionsProvider, ) { } - async provideGhostText(resource: URI, position: IPosition, token: CancellationToken): Promise { + async provideInlineSuggestions(resource: URI, position: IPosition, token: CancellationToken): Promise { const doc = this._documents.getDocument(resource); const pos = typeConvert.Position.to(position); - // The default insert/replace ranges. - const defaultReplaceRange = doc.getWordRangeAtPosition(pos) || new Range(pos, pos); + // TODO + //const wordRange = doc.getWordRangeAtPosition(pos); + //const start = wordRange ? typeConvert.Position.from(wordRange.start) : position; + //const end = typeConvert.Position.from(doc.lineAt(pos).range.end); + //const defaultReplaceRange = new Range(start, end); - const result = await asPromise(() => this._provider.provideGhostTextItems(doc, pos, token)); + const result = await asPromise(() => this._provider.provideInlineSuggestions(doc, pos, token)); if (!result) { // undefined and null are valid results @@ -1067,8 +1070,10 @@ class GhostTextAdapter { } return { - text: result.text, - replaceRange: typeConvert.Range.from(result.replaceRange || defaultReplaceRange) + items: result.items.map(item => ({ + text: item.text, + replaceRange: item.replaceRange ? typeConvert.Range.from(item.replaceRange) : undefined, + })), }; } } @@ -1390,7 +1395,7 @@ type Adapter = DocumentSymbolAdapter | CodeLensAdapter | DefinitionAdapter | Hov | TypeDefinitionAdapter | ColorProviderAdapter | FoldingProviderAdapter | DeclarationAdapter | SelectionRangeAdapter | CallHierarchyAdapter | DocumentSemanticTokensAdapter | DocumentRangeSemanticTokensAdapter | EvaluatableExpressionAdapter | InlineValuesAdapter - | LinkedEditingRangeAdapter | InlayHintsAdapter | GhostTextAdapter; + | LinkedEditingRangeAdapter | InlayHintsAdapter | InlineSuggestionsAdapter; class AdapterData { constructor( @@ -1846,14 +1851,14 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF // --- ghost test - registerGhostTextProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.GhostTextProvider): vscode.Disposable { - const handle = this._addNewAdapter(new GhostTextAdapter(this._documents, provider), extension); - this._proxy.$registerGhostTextSupport(handle, this._transformDocumentSelector(selector)); + registerInlineSuggestionsProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, provider: vscode.InlineSuggestionsProvider): vscode.Disposable { + const handle = this._addNewAdapter(new InlineSuggestionsAdapter(this._documents, provider), extension); + this._proxy.$registerInlineSuggestionSupport(handle, this._transformDocumentSelector(selector)); return this._createDisposable(handle); } - $provideGhostText(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise { - return this._withAdapter(handle, GhostTextAdapter, adapter => adapter.provideGhostText(URI.revive(resource), position, token), undefined); + $provideInlineSuggestions(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise { + return this._withAdapter(handle, InlineSuggestionsAdapter, adapter => adapter.provideInlineSuggestions(URI.revive(resource), position, token), undefined); } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 352b41a4efa..a9b58ff2ae1 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -1548,7 +1548,7 @@ export class CompletionList { } @es5ClassCompat -export class GhostText implements vscode.GhostText { +export class InlineSuggestion implements vscode.InlineSuggestion { text: string; replaceRange?: Range; @@ -1558,6 +1558,15 @@ export class GhostText implements vscode.GhostText { } } +@es5ClassCompat +export class InlineSuggestions implements vscode.InlineSuggestions { + items: vscode.InlineSuggestion[]; + + constructor(items: vscode.InlineSuggestion[]) { + this.items = items; + } +} + export enum ViewColumn { Active = -1, Beside = -2,