From 309351259c318dc63ed8ead955a22dc82dc55e58 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 20 Jun 2024 22:05:42 +0200 Subject: [PATCH] Implements experimental inline edits Signed-off-by: Henning Dieterichs --- .../lib/stylelint/vscode-known-variables.json | 4 +- src/vs/base/browser/dom.ts | 101 +++++ src/vs/editor/common/languages.ts | 11 + .../browser/provideInlineCompletions.ts | 15 +- .../contrib/inlineEdits/browser/commands.ts | 185 ++++++++ .../contrib/inlineEdits/browser/consts.ts | 16 + .../browser/inlineEdits.contribution.ts | 19 + .../browser/inlineEditsController.ts | 97 +++++ .../inlineEdits/browser/inlineEditsModel.ts | 289 +++++++++++++ .../inlineEdits/browser/inlineEditsWidget.css | 49 +++ .../inlineEdits/browser/inlineEditsWidget.ts | 400 ++++++++++++++++++ src/vs/editor/editor.all.ts | 1 + src/vs/platform/actions/common/actions.ts | 1 + .../api/browser/mainThreadLanguageFeatures.ts | 3 + .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostLanguageFeatures.ts | 86 +++- ...e.proposed.inlineCompletionsAdditions.d.ts | 6 + 17 files changed, 1277 insertions(+), 7 deletions(-) create mode 100644 src/vs/editor/contrib/inlineEdits/browser/commands.ts create mode 100644 src/vs/editor/contrib/inlineEdits/browser/consts.ts create mode 100644 src/vs/editor/contrib/inlineEdits/browser/inlineEdits.contribution.ts create mode 100644 src/vs/editor/contrib/inlineEdits/browser/inlineEditsController.ts create mode 100644 src/vs/editor/contrib/inlineEdits/browser/inlineEditsModel.ts create mode 100644 src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.css create mode 100644 src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.ts diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 9921547a998..6d729ef5ea4 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -94,6 +94,7 @@ "--vscode-debugTokenExpression-name", "--vscode-debugTokenExpression-number", "--vscode-debugTokenExpression-string", + "--vscode-debugTokenExpression-type", "--vscode-debugTokenExpression-value", "--vscode-debugToolBar-background", "--vscode-debugToolBar-border", @@ -852,6 +853,7 @@ "--z-index-notebook-scrollbar", "--z-index-run-button-container", "--zoom-factor", - "--test-bar-width" + "--test-bar-width", + "--widget-color" ] } diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index c258342ad6f..66d30c3aca3 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2373,6 +2373,107 @@ export function h(tag: string, ...args: [] | [attributes: { $: string } & Partia return result; } +export function svgElem + (tag: TTag): + TagToRecord extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem + (tag: TTag, children: [...T]): + (ArrayToObj & TagToRecord) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem + (tag: TTag, attributes: Partial>>): + TagToRecord extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem + (tag: TTag, attributes: Partial>>, children: [...T]): + (ArrayToObj & TagToRecord) extends infer Y ? { [TKey in keyof Y]: Y[TKey] } : never; + +export function svgElem(tag: string, ...args: [] | [attributes: { $: string } & Partial> | Record, children?: any[]] | [children: any[]]): Record { + let attributes: { $?: string } & Partial>; + let children: (Record | HTMLElement)[] | undefined; + + if (Array.isArray(args[0])) { + attributes = {}; + children = args[0]; + } else { + attributes = args[0] as any || {}; + children = args[1]; + } + + const match = H_REGEX.exec(tag); + + if (!match || !match.groups) { + throw new Error('Bad use of h'); + } + + const tagName = match.groups['tag'] || 'div'; + const el = document.createElementNS('http://www.w3.org/2000/svg', tagName) as any as HTMLElement; + + if (match.groups['id']) { + el.id = match.groups['id']; + } + + const classNames = []; + if (match.groups['class']) { + for (const className of match.groups['class'].split('.')) { + if (className !== '') { + classNames.push(className); + } + } + } + if (attributes.className !== undefined) { + for (const className of attributes.className.split('.')) { + if (className !== '') { + classNames.push(className); + } + } + } + if (classNames.length > 0) { + el.className = classNames.join(' '); + } + + const result: Record = {}; + + if (match.groups['name']) { + result[match.groups['name']] = el; + } + + if (children) { + for (const c of children) { + if (isHTMLElement(c)) { + el.appendChild(c); + } else if (typeof c === 'string') { + el.append(c); + } else if ('root' in c) { + Object.assign(result, c); + el.appendChild(c.root); + } + } + } + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'className') { + continue; + } else if (key === 'style') { + for (const [cssKey, cssValue] of Object.entries(value)) { + el.style.setProperty( + camelCaseToHyphenCase(cssKey), + typeof cssValue === 'number' ? cssValue + 'px' : '' + cssValue + ); + } + } else if (key === 'tabIndex') { + el.tabIndex = value; + } else { + el.setAttribute(camelCaseToHyphenCase(key), value.toString()); + } + } + + result['root'] = el; + + return result; +} + function camelCaseToHyphenCase(str: string) { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 96e743a2dca..1d5c2901178 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -687,6 +687,11 @@ export interface InlineCompletionContext { */ readonly triggerKind: InlineCompletionTriggerKind; readonly selectedSuggestionInfo: SelectedSuggestionInfo | undefined; + /** + * @experimental + * @internal + */ + readonly userPrompt?: string | undefined; } export class SelectedSuggestionInfo { @@ -765,6 +770,12 @@ export type InlineCompletionProviderGroupId = string; export interface InlineCompletionsProvider { provideInlineCompletions(model: model.ITextModel, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult; + /** + * @experimental + * @internal + */ + provideInlineEdits?(model: model.ITextModel, range: Range, context: InlineCompletionContext, token: CancellationToken): ProviderResult; + /** * Will be called when an item is shown. * @param updatedInsertText Is useful to understand bracket completion. diff --git a/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts b/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts index 28052040c32..25ccf4cd125 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions.ts @@ -18,19 +18,19 @@ import { ILanguageConfigurationService } from 'vs/editor/common/languages/langua import { ITextModel } from 'vs/editor/common/model'; import { fixBracketsInLine } from 'vs/editor/common/model/bracketPairsTextModelPart/fixBrackets'; import { SingleTextEdit } from 'vs/editor/common/core/textEdit'; -import { getReadonlyEmptyArray } from 'vs/editor/contrib/inlineCompletions/browser/utils'; +import { getReadonlyEmptyArray } from './utils'; import { SnippetParser, Text } from 'vs/editor/contrib/snippet/browser/snippetParser'; export async function provideInlineCompletions( registry: LanguageFeatureRegistry, - position: Position, + positionOrRange: Position | Range, model: ITextModel, context: InlineCompletionContext, token: CancellationToken = CancellationToken.None, languageConfigurationService?: ILanguageConfigurationService, ): Promise { // Important: Don't use position after the await calls, as the model could have been changed in the meantime! - const defaultReplaceRange = getDefaultRange(position, model); + const defaultReplaceRange = positionOrRange instanceof Position ? getDefaultRange(positionOrRange, model) : positionOrRange; const providers = registry.all(model); const multiMap = new SetMap>(); @@ -100,8 +100,13 @@ export async function provideInlineCompletions( } try { - const completions = await provider.provideInlineCompletions(model, position, context, token); - return completions; + if (positionOrRange instanceof Position) { + const completions = await provider.provideInlineCompletions(model, positionOrRange, context, token); + return completions; + } else { + const completions = await provider.provideInlineEdits?.(model, positionOrRange, context, token); + return completions; + } } catch (e) { onUnexpectedExternalError(e); return undefined; diff --git a/src/vs/editor/contrib/inlineEdits/browser/commands.ts b/src/vs/editor/contrib/inlineEdits/browser/commands.ts new file mode 100644 index 00000000000..c5ce0e90296 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/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 { Codicon } from 'vs/base/common/codicons'; +import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { transaction } from 'vs/base/common/observable'; +import { asyncTransaction } from 'vs/base/common/observableInternal/base'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction, ServicesAccessor } from 'vs/editor/browser/editorExtensions'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { inlineEditAcceptId, inlineEditVisible, showNextInlineEditActionId, showPreviousInlineEditActionId } from 'vs/editor/contrib/inlineEdits/browser/consts'; +import { InlineEditsController } from 'vs/editor/contrib/inlineEdits/browser/inlineEditsController'; +import * as nls from 'vs/nls'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; + + +function labelAndAlias(str: nls.ILocalizedString): { label: string, alias: string } { + return { + label: str.value, + alias: str.original, + }; +} + +export class ShowNextInlineEditAction extends EditorAction { + public static ID = showNextInlineEditActionId; + constructor() { + super({ + id: ShowNextInlineEditAction.ID, + ...labelAndAlias(nls.localize2('action.inlineEdits.showNext', "Show Next Inline Edit")), + precondition: ContextKeyExpr.and(EditorContextKeys.writable, inlineEditVisible), + kbOpts: { + weight: 100, + primary: KeyMod.Alt | KeyCode.BracketRight, + }, + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditsController.get(editor); + controller?.model.get()?.next(); + } +} + +export class ShowPreviousInlineEditAction extends EditorAction { + public static ID = showPreviousInlineEditActionId; + constructor() { + super({ + id: ShowPreviousInlineEditAction.ID, + ...labelAndAlias(nls.localize2('action.inlineEdits.showPrevious', "Show Previous Inline Edit")), + precondition: ContextKeyExpr.and(EditorContextKeys.writable, inlineEditVisible), + kbOpts: { + weight: 100, + primary: KeyMod.Alt | KeyCode.BracketLeft, + }, + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditsController.get(editor); + controller?.model.get()?.previous(); + } +} + +export class TriggerInlineEditAction extends EditorAction { + constructor() { + super({ + id: 'editor.action.inlineEdits.trigger', + ...labelAndAlias(nls.localize2('action.inlineEdits.trigger', "Trigger Inline Edit")), + precondition: EditorContextKeys.writable + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditsController.get(editor); + await asyncTransaction(async tx => { + /** @description triggerExplicitly from command */ + await controller?.model.get()?.triggerExplicitly(tx); + }); + } +} + +export class AcceptInlineEdit extends EditorAction { + constructor() { + super({ + id: inlineEditAcceptId, + ...labelAndAlias(nls.localize2('action.inlineEdits.accept', "Accept Inline Edit")), + precondition: inlineEditVisible, + menuOpts: { + menuId: MenuId.InlineEditsActions, + title: nls.localize('inlineEditsActions', "Accept Inline Edit"), + group: 'primary', + order: 1, + icon: Codicon.check, + }, + kbOpts: { + primary: KeyMod.CtrlCmd | KeyCode.Space, + weight: 20000, + kbExpr: inlineEditVisible, + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + if (editor instanceof EmbeddedCodeEditorWidget) { + editor = editor.getParentEditor(); + } + const controller = InlineEditsController.get(editor); + if (controller) { + controller.model.get()?.accept(controller.editor); + controller.editor.focus(); + } + } +} + +/* +TODO@hediet +export class PinInlineEdit extends EditorAction { + constructor() { + super({ + id: 'editor.action.inlineEdits.pin', + ...labelAndAlias(nls.localize2('action.inlineEdits.pin', "Pin Inline Edit")), + precondition: undefined, + kbOpts: { + primary: KeyMod.Shift | KeyCode.Space, + weight: 20000, + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditsController.get(editor); + if (controller) { + controller.model.get()?.togglePin(); + } + } +} + +MenuRegistry.appendMenuItem(MenuId.InlineEditsActions, { + command: { + id: 'editor.action.inlineEdits.pin', + title: nls.localize('Pin', "Pin"), + icon: Codicon.pin, + }, + group: 'primary', + order: 1, + when: isPinnedContextKey.negate(), +}); + +MenuRegistry.appendMenuItem(MenuId.InlineEditsActions, { + command: { + id: 'editor.action.inlineEdits.unpin', + title: nls.localize('Unpin', "Unpin"), + icon: Codicon.pinned, + }, + group: 'primary', + order: 1, + when: isPinnedContextKey, +});*/ + +export class HideInlineEdit extends EditorAction { + public static ID = 'editor.action.inlineEdits.hide'; + + constructor() { + super({ + id: HideInlineEdit.ID, + ...labelAndAlias(nls.localize2('action.inlineEdits.hide', "Hide Inline Edit")), + precondition: inlineEditVisible, + kbOpts: { + weight: 100, + primary: KeyCode.Escape, + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = InlineEditsController.get(editor); + transaction(tx => { + controller?.model.get()?.stop(tx); + }); + } +} diff --git a/src/vs/editor/contrib/inlineEdits/browser/consts.ts b/src/vs/editor/contrib/inlineEdits/browser/consts.ts new file mode 100644 index 00000000000..9ad19e98a76 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/browser/consts.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; + +export const inlineEditAcceptId = 'editor.action.inlineEdits.accept'; + +export const showPreviousInlineEditActionId = 'editor.action.inlineEdits.showPrevious'; + +export const showNextInlineEditActionId = 'editor.action.inlineEdits.showNext'; + +export const inlineEditVisible = new RawContextKey('inlineEditsVisible', false, localize('inlineEditsVisible', "Whether an inline edit is visible")); +export const isPinnedContextKey = new RawContextKey('inlineEditsIsPinned', false, localize('isPinned', "Whether an inline edit is visible")); diff --git a/src/vs/editor/contrib/inlineEdits/browser/inlineEdits.contribution.ts b/src/vs/editor/contrib/inlineEdits/browser/inlineEdits.contribution.ts new file mode 100644 index 00000000000..ae8b7182a89 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/browser/inlineEdits.contribution.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorContributionInstantiation, registerEditorAction, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; +import { + TriggerInlineEditAction, ShowNextInlineEditAction, ShowPreviousInlineEditAction, + AcceptInlineEdit, HideInlineEdit, +} from 'vs/editor/contrib/inlineEdits/browser/commands'; +import { InlineEditsController } from 'vs/editor/contrib/inlineEdits/browser/inlineEditsController'; + +registerEditorContribution(InlineEditsController.ID, InlineEditsController, EditorContributionInstantiation.Eventually); + +registerEditorAction(TriggerInlineEditAction); +registerEditorAction(ShowNextInlineEditAction); +registerEditorAction(ShowPreviousInlineEditAction); +registerEditorAction(AcceptInlineEdit); +registerEditorAction(HideInlineEdit); diff --git a/src/vs/editor/contrib/inlineEdits/browser/inlineEditsController.ts b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsController.ts new file mode 100644 index 00000000000..5d0afd8ca0e --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsController.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { derived, derivedObservableWithCache, IReader, ISettableObservable, observableValue } from 'vs/base/common/observable'; +import { derivedDisposable, derivedWithSetter } from 'vs/base/common/observableInternal/derived'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { observableCodeEditor } from 'vs/editor/browser/observableCodeEditor'; +import { readHotReloadableExport } from 'vs/editor/browser/widget/diffEditor/utils'; +import { Selection } from 'vs/editor/common/core/selection'; +import { ILanguageFeatureDebounceService } from 'vs/editor/common/services/languageFeatureDebounce'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { inlineEditVisible, isPinnedContextKey } from 'vs/editor/contrib/inlineEdits/browser/consts'; +import { InlineEditsModel } from 'vs/editor/contrib/inlineEdits/browser/inlineEditsModel'; +import { InlineEditsWidget } from 'vs/editor/contrib/inlineEdits/browser/inlineEditsWidget'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { bindContextKey, observableConfigValue } from 'vs/platform/observable/common/platformObservableUtils'; + +export class InlineEditsController extends Disposable { + static ID = 'editor.contrib.inlineEditsController'; + + public static get(editor: ICodeEditor): InlineEditsController | null { + return editor.getContribution(InlineEditsController.ID); + } + + private readonly _enabled = observableConfigValue('editor.inlineEdits.enabled', false, this._configurationService); + private readonly _editorObs = observableCodeEditor(this.editor); + private readonly _selection = derived(this, reader => this._editorObs.cursorSelection.read(reader) ?? new Selection(1, 1, 1, 1)); + + private readonly _debounceValue = this._debounceService.for( + this._languageFeaturesService.inlineCompletionsProvider, + 'InlineEditsDebounce', + { min: 50, max: 50 } + ); + + public readonly model = derivedDisposable(this, reader => { + if (!this._enabled.read(reader)) { + return undefined; + } + if (this._editorObs.isReadonly.read(reader)) { return undefined; } + const textModel = this._editorObs.model.read(reader); + if (!textModel) { return undefined; } + + const model: InlineEditsModel = this._instantiationService.createInstance( + readHotReloadableExport(InlineEditsModel, reader), + textModel, + this._editorObs.versionId, + this._selection, + this._debounceValue, + ); + + return model; + }); + + private readonly _hadInlineEdit = derivedObservableWithCache(this, (reader, lastValue) => lastValue || this.model.read(reader)?.inlineEdit.read(reader) !== undefined); + + protected readonly _widget = derivedDisposable(this, reader => { + if (!this._hadInlineEdit.read(reader)) { return undefined; } + + return this._instantiationService.createInstance( + readHotReloadableExport(InlineEditsWidget, reader), + this.editor, + this.model.map((m, reader) => m?.inlineEdit.read(reader)), + flattenSettableObservable((reader) => this.model.read(reader)?.userPrompt ?? observableValue('empty', '')), + ); + }); + + constructor( + public readonly editor: ICodeEditor, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ILanguageFeatureDebounceService private readonly _debounceService: ILanguageFeatureDebounceService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + + this._register(bindContextKey(inlineEditVisible, this._contextKeyService, r => !!this.model.read(r)?.inlineEdit.read(r))); + this._register(bindContextKey(isPinnedContextKey, this._contextKeyService, r => !!this.model.read(r)?.isPinned.read(r))); + + this.model.recomputeInitiallyAndOnChange(this._store); + this._widget.recomputeInitiallyAndOnChange(this._store); + } +} + +function flattenSettableObservable(fn: (reader: IReader | undefined) => ISettableObservable): ISettableObservable { + return derivedWithSetter(undefined, reader => { + const obs = fn(reader); + return obs.read(reader); + }, (value, tx) => { + fn(undefined).set(value, tx); + }); +} diff --git a/src/vs/editor/contrib/inlineEdits/browser/inlineEditsModel.ts b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsModel.ts new file mode 100644 index 00000000000..812818c85b1 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsModel.ts @@ -0,0 +1,289 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from 'vs/base/common/async'; +import { CancellationToken, cancelOnDispose } from 'vs/base/common/cancellation'; +import { itemsEquals, structuralEquals } from 'vs/base/common/equals'; +import { BugIndicatingError } from 'vs/base/common/errors'; +import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, ISettableObservable, ITransaction, ObservablePromise, derived, derivedHandleChanges, derivedOpts, disposableObservableValue, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction } from 'vs/base/common/observable'; +import { derivedDisposable } from 'vs/base/common/observableInternal/derived'; +import { URI } from 'vs/base/common/uri'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { IDiffProviderFactoryService } from 'vs/editor/browser/widget/diffEditor/diffProviderFactoryService'; +import { LineRange } from 'vs/editor/common/core/lineRange'; +import { Range } from 'vs/editor/common/core/range'; +import { Selection } from 'vs/editor/common/core/selection'; +import { Command, InlineCompletionContext, InlineCompletionTriggerKind } from 'vs/editor/common/languages'; +import { ITextModel } from 'vs/editor/common/model'; +import { IFeatureDebounceInformation } from 'vs/editor/common/services/languageFeatureDebounce'; +import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; +import { IModelService } from 'vs/editor/common/services/model'; +import { IModelContentChangedEvent } from 'vs/editor/common/textModelEvents'; +import { InlineCompletionItem, InlineCompletionProviderResult, provideInlineCompletions } from 'vs/editor/contrib/inlineCompletions/browser/provideInlineCompletions'; +import { InlineEdit } from 'vs/editor/contrib/inlineEdits/browser/inlineEditsWidget'; + +export class InlineEditsModel extends Disposable { + private static _modelId = 0; + private static _createUniqueUri(): URI { + return URI.from({ scheme: 'inline-edits', path: new Date().toString() + String(InlineEditsModel._modelId++) }); + } + + private readonly _forceUpdateExplicitlySignal = observableSignal(this); + + // We use a semantic id to keep the same inline completion selected even if the provider reorders the completions. + private readonly _selectedInlineCompletionId = observableValue(this, undefined); + + private readonly _isActive = observableValue(this, false); + + private readonly _originalModel = derivedDisposable(() => this._modelService.createModel('', null, InlineEditsModel._createUniqueUri())).keepObserved(this._store); + private readonly _modifiedModel = derivedDisposable(() => this._modelService.createModel('', null, InlineEditsModel._createUniqueUri())).keepObserved(this._store); + + private readonly _pinnedRange = new TrackedRange(this.textModel, this._textModelVersionId); + + public readonly isPinned = this._pinnedRange.range.map(range => !!range); + + public readonly userPrompt: ISettableObservable = observableValue(this, undefined); + + constructor( + public readonly textModel: ITextModel, + public readonly _textModelVersionId: IObservable, + private readonly _selection: IObservable, + protected readonly _debounceValue: IFeatureDebounceInformation, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IDiffProviderFactoryService private readonly _diffProviderFactoryService: IDiffProviderFactoryService, + @IModelService private readonly _modelService: IModelService, + ) { + super(); + + this._register(recomputeInitiallyAndOnChange(this._fetchInlineEditsPromise)); + } + + public readonly inlineEdit = derived(this, reader => { + return this._inlineEdit.read(reader)?.promiseResult.read(reader)?.data; + }); + + public readonly _inlineEdit = derived | undefined>(this, reader => { + const edit = this.selectedInlineEdit.read(reader); + if (!edit) { return undefined; } + const range = edit.inlineCompletion.range; + if (edit.inlineCompletion.insertText.trim() === '') { + return undefined; + } + + let newLines = edit.inlineCompletion.insertText.split(/\r\n|\r|\n/); + + function removeIndentation(lines: string[]): string[] { + const indentation = lines[0].match(/^\s*/)?.[0] ?? ''; + return lines.map(l => l.replace(new RegExp('^' + indentation), '')); + } + newLines = removeIndentation(newLines); + + const existing = this.textModel.getValueInRange(range); + let existingLines = existing.split(/\r\n|\r|\n/); + existingLines = removeIndentation(existingLines); + this._originalModel.get().setValue(existingLines.join('\n')); + this._modifiedModel.get().setValue(newLines.join('\n')); + + const d = this._diffProviderFactoryService.createDiffProvider({ diffAlgorithm: 'advanced' }); + return ObservablePromise.fromFn(async () => { + const result = await d.computeDiff(this._originalModel.get(), this._modifiedModel.get(), { + computeMoves: false, + ignoreTrimWhitespace: false, + maxComputationTimeMs: 1000, + }, CancellationToken.None); + + if (result.identical) { + return undefined; + } + + return new InlineEdit(LineRange.fromRangeInclusive(range), removeIndentation(newLines), result.changes); + }); + }); + + private readonly _fetchStore = this._register(new DisposableStore()); + + private readonly _inlineEditsFetchResult = disposableObservableValue(this, undefined); + private readonly _inlineEdits = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + return this._inlineEditsFetchResult.read(reader)?.completions.map(c => new InlineEditData(c)) ?? []; + }); + + private readonly _fetchInlineEditsPromise = derivedHandleChanges({ + owner: this, + createEmptyChangeSummary: () => ({ + inlineCompletionTriggerKind: InlineCompletionTriggerKind.Automatic + }), + handleChange: (ctx, changeSummary) => { + /** @description fetch inline completions */ + if (ctx.didChange(this._forceUpdateExplicitlySignal)) { + changeSummary.inlineCompletionTriggerKind = InlineCompletionTriggerKind.Explicit; + } + return true; + }, + }, async (reader, changeSummary) => { + this._fetchStore.clear(); + this._forceUpdateExplicitlySignal.read(reader); + /*if (!this._isActive.read(reader)) { + return undefined; + }*/ + this._textModelVersionId.read(reader); + + function mapValue(value: T, fn: (value: T) => TOut): TOut { + return fn(value); + } + + const selection = this._pinnedRange.range.read(reader) ?? mapValue(this._selection.read(reader), v => v.isEmpty() ? undefined : v); + if (!selection) { + this._inlineEditsFetchResult.set(undefined, undefined); + this.userPrompt.set(undefined, undefined); + return undefined; + } + const context: InlineCompletionContext = { + triggerKind: changeSummary.inlineCompletionTriggerKind, + selectedSuggestionInfo: undefined, + userPrompt: this.userPrompt.read(reader), + }; + + const token = cancelOnDispose(this._fetchStore); + await timeout(200, token); + const result = await provideInlineCompletions(this.languageFeaturesService.inlineCompletionsProvider, selection, this.textModel, context, token); + if (token.isCancellationRequested) { + return; + } + + this._inlineEditsFetchResult.set(result, undefined); + }); + + public async trigger(tx?: ITransaction): Promise { + this._isActive.set(true, tx); + await this._fetchInlineEditsPromise.get(); + } + + public async triggerExplicitly(tx?: ITransaction): Promise { + subtransaction(tx, tx => { + this._isActive.set(true, tx); + this._forceUpdateExplicitlySignal.trigger(tx); + }); + await this._fetchInlineEditsPromise.get(); + } + + public stop(tx?: ITransaction): void { + subtransaction(tx, tx => { + this.userPrompt.set(undefined, tx); + this._isActive.set(false, tx); + this._inlineEditsFetchResult.set(undefined, tx); + this._pinnedRange.setRange(undefined, tx); + //this._source.clear(tx); + }); + } + + private readonly _filteredInlineEditItems = derivedOpts({ owner: this, equalsFn: itemsEquals() }, reader => { + return this._inlineEdits.read(reader); + }); + + public readonly selectedInlineCompletionIndex = derived(this, (reader) => { + const selectedInlineCompletionId = this._selectedInlineCompletionId.read(reader); + const filteredCompletions = this._filteredInlineEditItems.read(reader); + const idx = this._selectedInlineCompletionId === undefined ? -1 + : filteredCompletions.findIndex(v => v.semanticId === selectedInlineCompletionId); + if (idx === -1) { + // Reset the selection so that the selection does not jump back when it appears again + this._selectedInlineCompletionId.set(undefined, undefined); + return 0; + } + return idx; + }); + + public readonly selectedInlineEdit = derived(this, (reader) => { + const filteredCompletions = this._filteredInlineEditItems.read(reader); + const idx = this.selectedInlineCompletionIndex.read(reader); + return filteredCompletions[idx]; + }); + + public readonly activeCommands = derivedOpts({ owner: this, equalsFn: itemsEquals() }, + r => this.selectedInlineEdit.read(r)?.inlineCompletion.source.inlineCompletions.commands ?? [] + ); + + private async _deltaSelectedInlineCompletionIndex(delta: 1 | -1): Promise { + await this.triggerExplicitly(); + + const completions = this._filteredInlineEditItems.get() || []; + if (completions.length > 0) { + const newIdx = (this.selectedInlineCompletionIndex.get() + delta + completions.length) % completions.length; + this._selectedInlineCompletionId.set(completions[newIdx].semanticId, undefined); + } else { + this._selectedInlineCompletionId.set(undefined, undefined); + } + } + + public async next(): Promise { + await this._deltaSelectedInlineCompletionIndex(1); + } + + public async previous(): Promise { + await this._deltaSelectedInlineCompletionIndex(-1); + } + + public togglePin(): void { + if (this.isPinned.get()) { + this._pinnedRange.setRange(undefined, undefined); + } else { + this._pinnedRange.setRange(this._selection.get(), undefined); + } + } + + public async accept(editor: ICodeEditor): Promise { + if (editor.getModel() !== this.textModel) { + throw new BugIndicatingError(); + } + const edit = this.selectedInlineEdit.get(); + if (!edit) { + return; + } + + editor.pushUndoStop(); + editor.executeEdits( + 'inlineSuggestion.accept', + [ + edit.inlineCompletion.toSingleTextEdit().toSingleEditOperation() + ] + ); + this.stop(); + } +} + +class InlineEditData { + public readonly semanticId = this.inlineCompletion.hash(); + + constructor(public readonly inlineCompletion: InlineCompletionItem) { + + } +} + +class TrackedRange extends Disposable { + private readonly _decorations = observableValue(this, []); + + constructor( + private readonly _textModel: ITextModel, + private readonly _versionId: IObservable, + ) { + super(); + this._register(toDisposable(() => { + this._textModel.deltaDecorations(this._decorations.get(), []); + })); + } + + setRange(range: Range | undefined, tx: ITransaction | undefined): void { + this._decorations.set(this._textModel.deltaDecorations(this._decorations.get(), range ? [{ range, options: { description: 'trackedRange' } }] : []), tx); + } + + public readonly range = derived(this, reader => { + this._versionId.read(reader); + const deco = this._decorations.read(reader)[0]; + if (!deco) { return null; } + + return this._textModel.getDecorationRange(deco) ?? null; + }); +} diff --git a/src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.css b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.css new file mode 100644 index 00000000000..68910c883a6 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.css @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor div.inline-edits-widget { + --widget-color: var(--vscode-notifications-background); + + .promptEditor .monaco-editor { + --vscode-editor-placeholder-foreground: var(--vscode-editorGhostText-foreground); + } + + .toolbar, .promptEditor { + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + &:hover, &.focused { + .toolbar, .promptEditor { + opacity: 1; + } + } + + .preview .monaco-editor { + + .mtk1 { + /*color: rgba(215, 215, 215, 0.452);*/ + color: var(--vscode-editorGhostText-foreground); + } + .view-overlays .current-line-exact { + border: none; + } + + .current-line-margin { + border: none; + } + + --vscode-editor-background: var(--widget-color); + } + + svg { + .gradient-start { + stop-color: var(--vscode-editor-background); + } + + .gradient-stop { + stop-color: var(--widget-color); + } + } +} diff --git a/src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.ts b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.ts new file mode 100644 index 00000000000..0a1498915d4 --- /dev/null +++ b/src/vs/editor/contrib/inlineEdits/browser/inlineEditsWidget.ts @@ -0,0 +1,400 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { h, svgElem } from 'vs/base/browser/dom'; +import { DEFAULT_FONT_FAMILY } from 'vs/base/browser/fonts'; +import { Disposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { autorun, constObservable, derived, IObservable, ISettableObservable } from 'vs/base/common/observable'; +import { derivedWithSetter } from 'vs/base/common/observableInternal/derived'; +import 'vs/css!./inlineEditsWidget'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; +import { observableCodeEditor } from 'vs/editor/browser/observableCodeEditor'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; +import { diffAddDecoration, diffAddDecorationEmpty, diffDeleteDecoration, diffDeleteDecorationEmpty, diffLineAddDecorationBackgroundWithIndicator, diffLineDeleteDecorationBackgroundWithIndicator, diffWholeLineAddDecoration, diffWholeLineDeleteDecoration } from 'vs/editor/browser/widget/diffEditor/registrations.contribution'; +import { appendRemoveOnDispose, applyStyle } from 'vs/editor/browser/widget/diffEditor/utils'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { LineRange } from 'vs/editor/common/core/lineRange'; +import { DetailedLineRangeMapping } from 'vs/editor/common/diff/rangeMapping'; +import { PLAINTEXT_LANGUAGE_ID } from 'vs/editor/common/languages/modesRegistry'; +import { IModelDeltaDecoration } from 'vs/editor/common/model'; +import { TextModel } from 'vs/editor/common/model/textModel'; +import { ContextMenuController } from 'vs/editor/contrib/contextmenu/browser/contextmenu'; +import { PlaceholderTextContribution } from 'vs/editor/contrib/placeholderText/browser/placeholderText.contribution'; +import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; +import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; + +export class InlineEdit { + constructor( + public readonly range: LineRange, + public readonly newLines: string[], + public readonly changes: readonly DetailedLineRangeMapping[], + ) { + + } +} + +export class InlineEditsWidget extends Disposable { + private readonly _editorObs = observableCodeEditor(this._editor); + + private readonly _elements = h('div.inline-edits-widget', { + style: { + position: 'absolute', + overflow: 'visible', + top: '0px', + left: '0px', + }, + }, [ + h('div@editorContainer', { style: { position: 'absolute', top: '0px', left: '0px', width: '500px', height: '500px', } }, [ + h('div.toolbar@toolbar', { style: { position: 'absolute', top: '-25px', left: '0px' } }), + h('div.promptEditor@promptEditor', { style: { position: 'absolute', top: '-25px', left: '80px', width: '300px', height: '22px' } }), + h('div.preview@editor', { style: { position: 'absolute', top: '0px', left: '0px' } }), + ]), + svgElem('svg', { style: { overflow: 'visible', pointerEvents: 'none' }, }, [ + svgElem('defs', [ + svgElem('linearGradient', { + id: 'Gradient2', + x1: '0', + y1: '0', + x2: '1', + y2: '0', + }, [ + /*svgElem('stop', { offset: '0%', class: 'gradient-start', }), + svgElem('stop', { offset: '0%', class: 'gradient-start', }), + svgElem('stop', { offset: '20%', class: 'gradient-stop', }),*/ + svgElem('stop', { offset: '0%', class: 'gradient-stop', }), + svgElem('stop', { offset: '100%', class: 'gradient-stop', }), + ]), + ]), + svgElem('path@path', { + d: '', + fill: 'url(#Gradient2)', + }), + ]), + ]); + + protected readonly _toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.toolbar, MenuId.InlineEditsActions, { + toolbarOptions: { + primaryGroup: g => g.startsWith('primary'), + }, + })); + private readonly _previewTextModel = this._register(this._instantiationService.createInstance( + TextModel, + '', + PLAINTEXT_LANGUAGE_ID, + TextModel.DEFAULT_CREATION_OPTIONS, + null + )); + + private readonly _setText = derived(reader => { + const edit = this._edit.read(reader); + if (!edit) { return; } + this._previewTextModel.setValue(edit.newLines.join('\n')); + }).recomputeInitiallyAndOnChange(this._store); + + + private readonly _promptTextModel = this._register(this._instantiationService.createInstance( + TextModel, + '', + PLAINTEXT_LANGUAGE_ID, + TextModel.DEFAULT_CREATION_OPTIONS, + null + )); + private readonly _promptEditor = this._register(this._instantiationService.createInstance( + EmbeddedCodeEditorWidget, + this._elements.promptEditor, + { + glyphMargin: false, + lineNumbers: 'off', + minimap: { enabled: false }, + guides: { + indentation: false, + bracketPairs: false, + bracketPairsHorizontal: false, + highlightActiveIndentation: false, + }, + folding: false, + selectOnLineNumbers: false, + selectionHighlight: false, + columnSelection: false, + overviewRulerBorder: false, + overviewRulerLanes: 0, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + placeholder: 'Describe the change you want...', + fontFamily: DEFAULT_FONT_FAMILY, + }, + { + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + SuggestController.ID, + PlaceholderTextContribution.ID, + ContextMenuController.ID, + ]), + isSimpleWidget: true + }, + this._editor + )); + + private readonly _previewEditor = this._register(this._instantiationService.createInstance( + EmbeddedCodeEditorWidget, + this._elements.editor, + { + glyphMargin: false, + lineNumbers: 'off', + minimap: { enabled: false }, + guides: { + indentation: false, + bracketPairs: false, + bracketPairsHorizontal: false, + highlightActiveIndentation: false, + }, + folding: false, + selectOnLineNumbers: false, + selectionHighlight: false, + columnSelection: false, + overviewRulerBorder: false, + overviewRulerLanes: 0, + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + }, + { contributions: [], }, + this._editor + )); + + private readonly _previewEditorObs = observableCodeEditor(this._previewEditor); + + private readonly _decorations = derived(this, (reader) => { + this._setText.read(reader); + const diff = this._edit.read(reader)?.changes; + if (!diff) { return []; } + + const originalDecorations: IModelDeltaDecoration[] = []; + const modifiedDecorations: IModelDeltaDecoration[] = []; + + if (diff.length === 1 && diff[0].innerChanges![0].modifiedRange.equalsRange(this._previewTextModel.getFullModelRange())) { + return []; + } + + for (const m of diff) { + if (!m.original.isEmpty) { + originalDecorations.push({ range: m.original.toInclusiveRange()!, options: diffLineDeleteDecorationBackgroundWithIndicator }); + } + if (!m.modified.isEmpty) { + modifiedDecorations.push({ range: m.modified.toInclusiveRange()!, options: diffLineAddDecorationBackgroundWithIndicator }); + } + + if (m.modified.isEmpty || m.original.isEmpty) { + if (!m.original.isEmpty) { + originalDecorations.push({ range: m.original.toInclusiveRange()!, options: diffWholeLineDeleteDecoration }); + } + if (!m.modified.isEmpty) { + modifiedDecorations.push({ range: m.modified.toInclusiveRange()!, options: diffWholeLineAddDecoration }); + } + } else { + for (const i of m.innerChanges || []) { + // Don't show empty markers outside the line range + if (m.original.contains(i.originalRange.startLineNumber)) { + originalDecorations.push({ range: i.originalRange, options: i.originalRange.isEmpty() ? diffDeleteDecorationEmpty : diffDeleteDecoration }); + } + if (m.modified.contains(i.modifiedRange.startLineNumber)) { + modifiedDecorations.push({ range: i.modifiedRange, options: i.modifiedRange.isEmpty() ? diffAddDecorationEmpty : diffAddDecoration }); + } + } + } + } + + return modifiedDecorations; + }); + + private readonly _layout1 = derived(this, reader => { + const model = this._editor.getModel()!; + const inlineEdit = this._edit.read(reader); + if (!inlineEdit) { return null; } + + const range = inlineEdit.range; + + let maxLeft = 0; + for (let i = range.startLineNumber; i < range.endLineNumberExclusive; i++) { + const column = model.getLineMaxColumn(i); + const left = this._editor.getOffsetForColumn(i, column); + maxLeft = Math.max(maxLeft, left); + } + + const layoutInfo = this._editor.getLayoutInfo(); + const contentLeft = layoutInfo.contentLeft; + + return { left: contentLeft + maxLeft }; + }); + + private readonly _layout = derived(this, (reader) => { + const inlineEdit = this._edit.read(reader); + if (!inlineEdit) { return null; } + + const range = inlineEdit.range; + + const scrollLeft = this._editorObs.scrollLeft.read(reader); + + const left = this._layout1.read(reader)!.left + 20 - scrollLeft; + + const selectionTop = this._editor.getTopForLineNumber(range.startLineNumber) - this._editorObs.scrollTop.read(reader); + const selectionBottom = this._editor.getTopForLineNumber(range.endLineNumberExclusive) - this._editorObs.scrollTop.read(reader); + + const topCode = new Point(left, selectionTop); + const bottomCode = new Point(left, selectionBottom); + const codeHeight = selectionBottom - selectionTop; + + const codeEditDist = 50; + const editHeight = this._editor.getOption(EditorOption.lineHeight) * inlineEdit.newLines.length; + const difference = codeHeight - editHeight; + const topEdit = new Point(left + codeEditDist, selectionTop + (difference / 2)); + const bottomEdit = new Point(left + codeEditDist, selectionBottom - (difference / 2)); + + return { + topCode, + bottomCode, + codeHeight, + topEdit, + bottomEdit, + editHeight, + }; + }); + + constructor( + private readonly _editor: ICodeEditor, + private readonly _edit: IObservable, + private readonly _userPrompt: ISettableObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + const visible = derived(this, reader => this._edit.read(reader) !== undefined || this._userPrompt.read(reader) !== undefined); + this._register(applyStyle(this._elements.root, { + display: derived(this, reader => visible.read(reader) ? 'block' : 'none') + })); + + this._register(appendRemoveOnDispose(this._editor.getDomNode()!, this._elements.root)); + + this._register(observableCodeEditor(_editor).createOverlayWidget({ + domNode: this._elements.root, + position: constObservable(null), + allowEditorOverflow: false, + minContentWidthInPx: derived(reader => { + const x = this._layout1.read(reader)?.left; + if (x === undefined) { return 0; } + const width = this._previewEditorObs.contentWidth.read(reader); + return x + width; + }), + })); + + this._previewEditor.setModel(this._previewTextModel); + + this._register(this._previewEditorObs.setDecorations(this._decorations)); + + this._register(autorun(reader => { + const layoutInfo = this._layout.read(reader); + if (!layoutInfo) { return; } + + const { topCode, bottomCode, topEdit, bottomEdit, editHeight } = layoutInfo; + + const straightWidthCode = 10; + const straightWidthEdit = 0; + const bezierDist = 40; + + const path = new PathBuilder() + .moveTo(topCode) + .lineTo(topCode.deltaX(straightWidthCode)) + .curveTo( + topCode.deltaX(straightWidthCode + bezierDist), + topEdit.deltaX(-bezierDist - straightWidthEdit), + topEdit.deltaX(-straightWidthEdit), + ) + .lineTo(topEdit) + .lineTo(bottomEdit) + .lineTo(bottomEdit.deltaX(-straightWidthEdit)) + .curveTo( + bottomEdit.deltaX(-bezierDist - straightWidthEdit), + bottomCode.deltaX(straightWidthCode + bezierDist), + bottomCode.deltaX(straightWidthCode), + ) + .lineTo(bottomCode) + .build(); + + + this._elements.path.setAttribute('d', path); + + this._elements.editorContainer.style.top = `${topEdit.y}px`; + this._elements.editorContainer.style.left = `${topEdit.x}px`; + this._elements.editorContainer.style.height = `${editHeight}px`; + + const width = this._previewEditorObs.contentWidth.read(reader); + this._previewEditor.layout({ height: editHeight, width }); + })); + + this._promptEditor.setModel(this._promptTextModel); + this._promptEditor.layout(); + this._register(createTwoWaySync(mapSettableObservable(this._userPrompt, v => v ?? '', v => v), observableCodeEditor(this._promptEditor).value)); + + this._register(autorun(reader => { + const isFocused = observableCodeEditor(this._promptEditor).isFocused.read(reader); + this._elements.root.classList.toggle('focused', isFocused); + })); + } +} + +function mapSettableObservable(obs: ISettableObservable, fn1: (value: T) => T1, fn2: (value: T1) => T): ISettableObservable { + return derivedWithSetter(undefined, reader => fn1(obs.read(reader)), (value, tx) => obs.set(fn2(value), tx)); +} + +class Point { + constructor( + public readonly x: number, + public readonly y: number, + ) { } + + public add(other: Point): Point { + return new Point(this.x + other.x, this.y + other.y); + } + + public deltaX(delta: number): Point { + return new Point(this.x + delta, this.y); + } +} + +class PathBuilder { + private _data: string = ''; + + public moveTo(point: Point): this { + this._data += `M ${point.x} ${point.y} `; + return this; + } + + public lineTo(point: Point): this { + this._data += `L ${point.x} ${point.y} `; + return this; + } + + public curveTo(cp1: Point, cp2: Point, to: Point): this { + this._data += `C ${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${to.x} ${to.y} `; + return this; + } + + public build(): string { + return this._data; + } +} + +function createTwoWaySync(main: ISettableObservable, target: ISettableObservable): IDisposable { + const store = new DisposableStore(); + store.add(autorun(reader => { + const value = main.read(reader); + target.set(value, undefined); + })); + store.add(autorun(reader => { + const value = target.read(reader); + main.set(value, undefined); + })); + return store; +} diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index ed1e6badc50..e40c7056aa7 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -42,6 +42,7 @@ import 'vs/editor/contrib/links/browser/links'; import 'vs/editor/contrib/longLinesHelper/browser/longLinesHelper'; import 'vs/editor/contrib/multicursor/browser/multicursor'; import 'vs/editor/contrib/inlineEdit/browser/inlineEdit.contribution'; +import 'vs/editor/contrib/inlineEdits/browser/inlineEdits.contribution'; import 'vs/editor/contrib/parameterHints/browser/parameterHints'; import 'vs/editor/contrib/placeholderText/browser/placeholderText.contribution'; import 'vs/editor/contrib/rename/browser/rename'; diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index c1d80dd2c50..d85af48eacf 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -211,6 +211,7 @@ export class MenuId { static readonly TerminalStickyScrollContext = new MenuId('TerminalStickyScrollContext'); static readonly WebviewContext = new MenuId('WebviewContext'); static readonly InlineCompletionsActions = new MenuId('InlineCompletionsActions'); + static readonly InlineEditsActions = new MenuId('InlineEditsActions'); static readonly InlineEditActions = new MenuId('InlineEditActions'); static readonly NewFile = new MenuId('NewFile'); static readonly MergeInput1Toolbar = new MenuId('MergeToolbar1Toolbar'); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 41ae1ab857c..4aa61aeab45 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -612,6 +612,9 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread provideInlineCompletions: async (model: ITextModel, position: EditorPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise => { return this._proxy.$provideInlineCompletions(handle, model.uri, position, context, token); }, + provideInlineEdits: async (model: ITextModel, range: EditorRange, context: languages.InlineCompletionContext, token: CancellationToken): Promise => { + return this._proxy.$provideInlineEdits(handle, model.uri, range, context, token); + }, handleItemDidShow: async (completions: IdentifiableInlineCompletions, item: IdentifiableInlineCompletion, updatedInsertText: string): Promise => { if (supportsHandleEvents) { await this._proxy.$handleInlineCompletionDidShow(handle, completions.pid, item.idx, updatedInsertText); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 793267c4a7c..2543db5dcba 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2190,6 +2190,7 @@ export interface ExtHostLanguageFeaturesShape { $resolveCompletionItem(handle: number, id: ChainedCacheId, token: CancellationToken): Promise; $releaseCompletionItems(handle: number, id: number): void; $provideInlineCompletions(handle: number, resource: UriComponents, position: IPosition, context: languages.InlineCompletionContext, token: CancellationToken): Promise; + $provideInlineEdits(handle: number, resource: UriComponents, range: IRange, context: languages.InlineCompletionContext, token: CancellationToken): Promise; $handleInlineCompletionDidShow(handle: number, pid: number, idx: number, updatedInsertText: string): void; $handleInlineCompletionPartialAccept(handle: number, pid: number, idx: number, acceptedCharacters: number, info: languages.PartialAcceptInfo): void; $freeInlineCompletionsList(handle: number, pid: number): void; diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index a4c8f96ca20..0706a87cedb 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -33,7 +33,7 @@ import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; import { ExtHostTelemetry, IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry'; import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; import { CodeActionKind, CompletionList, Disposable, DocumentDropOrPasteEditKind, DocumentSymbol, InlineCompletionTriggerKind, InlineEditTriggerKind, InternalDataTransferItem, Location, NewSymbolNameTriggerKind, Range, SemanticTokens, SemanticTokensEdit, SemanticTokensEdits, SnippetString, SymbolInformation, SyntaxTokenType } from 'vs/workbench/api/common/extHostTypes'; -import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { checkProposedApiEnabled, isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import type * as vscode from 'vscode'; import { Cache } from './cache'; import * as extHostProtocol from './extHost.protocol'; @@ -1287,6 +1287,10 @@ class InlineCompletionAdapterBase { return undefined; } + async provideInlineEdits(resource: URI, range: IRange, context: languages.InlineCompletionContext, token: CancellationToken): Promise { + return undefined; + } + disposeCompletions(pid: number): void { } handleDidShowCompletionItem(pid: number, idx: number, updatedInsertText: string): void { } @@ -1392,6 +1396,82 @@ class InlineCompletionAdapter extends InlineCompletionAdapterBase { }; } + override async provideInlineEdits(resource: URI, range: IRange, context: languages.InlineCompletionContext, token: CancellationToken): Promise { + if (!this._provider.provideInlineEdits) { + return undefined; + } + checkProposedApiEnabled(this._extension, 'inlineCompletionsAdditions'); + + const doc = this._documents.getDocument(resource); + const r = typeConvert.Range.to(range); + + const result = await this._provider.provideInlineEdits(doc, r, { + selectedCompletionInfo: + context.selectedSuggestionInfo + ? { + range: typeConvert.Range.to(context.selectedSuggestionInfo.range), + text: context.selectedSuggestionInfo.text + } + : undefined, + triggerKind: this.languageTriggerKindToVSCodeTriggerKind[context.triggerKind], + userPrompt: context.userPrompt, + }, token); + + if (!result) { + // undefined and null are valid results + return undefined; + } + + if (token.isCancellationRequested) { + // cancelled -> return without further ado, esp no caching + // of results as they will leak + return undefined; + } + + const normalizedResult = Array.isArray(result) ? result : result.items; + const commands = this._isAdditionsProposedApiEnabled ? Array.isArray(result) ? [] : result.commands || [] : []; + const enableForwardStability = this._isAdditionsProposedApiEnabled && !Array.isArray(result) ? result.enableForwardStability : undefined; + + let disposableStore: DisposableStore | undefined = undefined; + const pid = this._references.createReferenceId({ + dispose() { + disposableStore?.dispose(); + }, + items: normalizedResult + }); + + return { + pid, + items: normalizedResult.map((item, idx) => { + let command: languages.Command | undefined = undefined; + if (item.command) { + if (!disposableStore) { + disposableStore = new DisposableStore(); + } + command = this._commands.toInternal(item.command, disposableStore); + } + + const insertText = item.insertText; + return ({ + insertText: typeof insertText === 'string' ? insertText : { snippet: insertText.value }, + filterText: item.filterText, + range: item.range ? typeConvert.Range.from(item.range) : undefined, + command, + idx: idx, + completeBracketPairs: this._isAdditionsProposedApiEnabled ? item.completeBracketPairs : false, + }); + }), + commands: commands.map(c => { + if (!disposableStore) { + disposableStore = new DisposableStore(); + } + return this._commands.toInternal(c, disposableStore); + }), + suppressSuggestions: false, + enableForwardStability, + }; + } + override disposeCompletions(pid: number) { const data = this._references.disposeReferenceId(pid); data?.dispose(); @@ -2581,6 +2661,10 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF return this._withAdapter(handle, InlineCompletionAdapterBase, adapter => adapter.provideInlineCompletions(URI.revive(resource), position, context, token), undefined, token); } + $provideInlineEdits(handle: number, resource: UriComponents, range: IRange, context: languages.InlineCompletionContext, token: CancellationToken): Promise { + return this._withAdapter(handle, InlineCompletionAdapterBase, adapter => adapter.provideInlineEdits(URI.revive(resource), range, context, token), undefined, token); + } + $handleInlineCompletionDidShow(handle: number, pid: number, idx: number, updatedInsertText: string): void { this._withAdapter(handle, InlineCompletionAdapterBase, async adapter => { adapter.handleDidShowCompletionItem(pid, idx, updatedInsertText); diff --git a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts index 2715014a0a8..eccc51b5380 100644 --- a/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.inlineCompletionsAdditions.d.ts @@ -60,6 +60,12 @@ declare module 'vscode' { */ // eslint-disable-next-line local/vscode-dts-provider-naming handleDidPartiallyAcceptCompletionItem?(completionItem: InlineCompletionItem, info: PartialAcceptInfo): void; + + provideInlineEdits?(document: TextDocument, range: Range, context: InlineCompletionContext, token: CancellationToken): ProviderResult; + } + + export interface InlineCompletionContext { + readonly userPrompt?: string; } export interface PartialAcceptInfo {