diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index 4e230dc8350..06d5da9c94d 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -710,7 +710,7 @@ export class SynchronizedInlineCompletionsCache extends Disposable { for (const c of this.completions) { const newRange = model.getDecorationRange(c.decorationId); if (!newRange) { - onUnexpectedError(new Error('Decoration has no range')); + // onUnexpectedError(new Error('Decoration has no range')); continue; } if (!c.synchronizedRange.equalsRange(newRange)) { diff --git a/src/vs/editor/contrib/interactive/browser/interactiveEditor.contribution.ts b/src/vs/editor/contrib/interactive/browser/interactiveEditor.contribution.ts new file mode 100644 index 00000000000..3e2347bbfb1 --- /dev/null +++ b/src/vs/editor/contrib/interactive/browser/interactiveEditor.contribution.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerAction2 } from 'vs/platform/actions/common/actions'; +import { EditorContributionInstantiation, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; +import { InteractiveEditorController } from 'vs/editor/contrib/interactive/browser/interactiveEditorWidget'; +import * as interactiveEditorActions from 'vs/editor/contrib/interactive/browser/interactiveEditorActions'; +import { IInteractiveEditorService } from 'vs/editor/contrib/interactive/common/interactiveEditor'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { InteractiveEditorServiceImpl } from 'vs/editor/contrib/interactive/common/interactiveEditorServiceImpl'; + +registerSingleton(IInteractiveEditorService, InteractiveEditorServiceImpl, InstantiationType.Delayed); + +registerEditorContribution(InteractiveEditorController.ID, InteractiveEditorController, EditorContributionInstantiation.Lazy); + +registerAction2(interactiveEditorActions.StartSessionAction); +registerAction2(interactiveEditorActions.ToggleHistory); +registerAction2(interactiveEditorActions.MakeRequestAction); +registerAction2(interactiveEditorActions.StopRequestAction); +registerAction2(interactiveEditorActions.AcceptWithPreviewInteractiveEditorAction); +registerAction2(interactiveEditorActions.TogglePreviewMode); +registerAction2(interactiveEditorActions.CancelSessionAction); +registerAction2(interactiveEditorActions.ArrowOutUpAction); +registerAction2(interactiveEditorActions.ArrowOutDownAction); +registerAction2(interactiveEditorActions.FocusInteractiveEditor); +registerAction2(interactiveEditorActions.PreviousFromHistory); +registerAction2(interactiveEditorActions.NextFromHistory); +registerAction2(interactiveEditorActions.UndoCommand); diff --git a/src/vs/editor/contrib/interactive/browser/interactiveEditor.css b/src/vs/editor/contrib/interactive/browser/interactiveEditor.css new file mode 100644 index 00000000000..8997efaeaf0 --- /dev/null +++ b/src/vs/editor/contrib/interactive/browser/interactiveEditor.css @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .interactive-editor { + z-index: 100; + color: inherit; + padding: 6px 0 6px 6px; +} + +/* body */ + +.monaco-editor .interactive-editor .body { + display: flex; +} + +.monaco-editor .interactive-editor .body .expando { + flex-grow: 0; + padding-top: 6px; + padding-right: 2px; + cursor: pointer; +} + +.monaco-editor .interactive-editor .body .history { + display: block; + margin: 0; + color: var(--vscode-descriptionForeground); + list-style-type: disc; + line-height: 20px; +} + +.monaco-editor .interactive-editor .body .history:not(:empty) { + margin: 0; + padding: 4px 0 0 12px; +} + +.monaco-editor .interactive-editor .body .history.hidden { + display: none; +} + +.monaco-editor .interactive-editor .body.preview { + padding: 4px 4px 0 4px; +} + +.monaco-editor .interactive-editor .body .content .input { + border-radius: 2px; + box-sizing: border-box; + padding: 2px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-settings-textInputBorder); + cursor: text; +} + +.monaco-editor .interactive-editor .monaco-editor-background { + background-color: var(--vscode-input-background); +} + +.monaco-editor .interactive-editor .body .content .input.synthetic-focus { + outline: 1px solid var(--vscode-focusBorder); +} + +.monaco-editor .interactive-editor .body .content .input .editor-placeholder { + position: absolute; + z-index: 1; + padding: 3px 0 0 2px; + color: var(--vscode-input-placeholderForeground); + font-family: var(--monaco-monospace-font); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.monaco-editor .interactive-editor .body .content .input .editor-placeholder.hidden { + display: none; +} + +.monaco-editor .interactive-editor .body .content .input .editor-container { + /* height: 24px; */ + vertical-align: middle; +} +.monaco-editor .interactive-editor .body .toolbar { + display: flex; + flex-direction: column; + align-self: start; + padding: 4px 8px 0 2px; +} + +.monaco-editor .interactive-editor .body .toolbar .actions-container { + display: flex; + flex-direction: row; + gap: 4px; +} + +/* progress bit */ + +.monaco-editor .interactive-editor .progress { + position: relative; + width: calc(100% - 18px); + left: 19px; +} + +/* UGLY - fighting against workbench styles */ +.monaco-workbench .part.editor > .content .monaco-editor .interactive-editor .progress .monaco-progress-container { + top: 0; +} + +/* decoration styles */ + + +.monaco-editor .interactive-editor-lines-deleted-range-inline { + text-decoration: line-through; + background-color: var(--vscode-diffEditor-removedTextBackground); + opacity: 0.6; +} +.monaco-editor .interactive-editor-lines-inserted-range { + background-color: var(--vscode-diffEditor-insertedTextBackground); +} + +.monaco-editor .interactive-editor-block { + border-radius: 4px; + border: 1px solid var(--vscode-widget-border); + box-shadow: 0 0 8px 1px var(--vscode-widget-shadow); +} diff --git a/src/vs/editor/contrib/interactive/browser/interactiveEditorActions.ts b/src/vs/editor/contrib/interactive/browser/interactiveEditorActions.ts new file mode 100644 index 00000000000..0ec58ab2455 --- /dev/null +++ b/src/vs/editor/contrib/interactive/browser/interactiveEditorActions.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { KeyChord, KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorAction2 } from 'vs/editor/browser/editorExtensions'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; +import { InteractiveEditorController } from 'vs/editor/contrib/interactive/browser/interactiveEditorWidget'; +import { CTX_INTERACTIVE_EDITOR_FOCUSED, CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST, CTX_INTERACTIVE_EDITOR_HAS_PROVIDER, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST, CTX_INTERACTIVE_EDITOR_EMPTY, CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION, CTX_INTERACTIVE_EDITOR_PREVIEW, CTX_INTERACTIVE_EDITOR_VISIBLE, MENU_INTERACTIVE_EDITOR_WIDGET, MENU_INTERACTIVE_EDITOR_WIDGET_LHS, CTX_INTERACTIVE_EDITOR_HISTORY_VISIBLE } from 'vs/editor/contrib/interactive/common/interactiveEditor'; +import { localize } from 'vs/nls'; +import { IAction2Options } from 'vs/platform/actions/common/actions'; +import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; + +export class StartSessionAction extends EditorAction2 { + + constructor() { + super({ + id: 'interactiveEditor.start', + title: { value: localize('run', 'Start Session'), original: 'Start Session' }, + category: AbstractInteractiveEditorAction.category, + f1: true, + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_HAS_PROVIDER, EditorContextKeys.writable, CTX_INTERACTIVE_EDITOR_VISIBLE.negate()), + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyI) + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ..._args: any[]) { + InteractiveEditorController.get(editor)?.run(); + } +} + +abstract class AbstractInteractiveEditorAction extends EditorAction2 { + + static readonly category = { value: localize('cat', 'Interactive Editor'), original: 'Interactive Editor' }; + + constructor(desc: IAction2Options) { + super({ + ...desc, + category: AbstractInteractiveEditorAction.category, + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_HAS_PROVIDER, desc.precondition) + }); + } + + override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ..._args: any[]) { + if (editor instanceof EmbeddedCodeEditorWidget) { + editor = editor.getParentEditor(); + } + const ctrl = InteractiveEditorController.get(editor); + if (!ctrl) { + return; + } + this.runInteractiveEditorCommand(accessor, ctrl, editor, ..._args); + } + + abstract runInteractiveEditorCommand(accessor: ServicesAccessor, ctrl: InteractiveEditorController, editor: ICodeEditor, ...args: any[]): void; +} + + +export class MakeRequestAction extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.accept', + title: localize('accept', 'Make Request'), + icon: Codicon.arrowUp, + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_VISIBLE, CTX_INTERACTIVE_EDITOR_EMPTY.negate()), + keybinding: { + when: CTX_INTERACTIVE_EDITOR_FOCUSED, + weight: KeybindingWeight.EditorCore + 7, + primary: KeyCode.Enter + }, + menu: { + id: MENU_INTERACTIVE_EDITOR_WIDGET, + group: 'main', + order: 1, + when: CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST.isEqualTo(false) + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.accept(); + } +} + +export class StopRequestAction extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.stop', + title: localize('stop', 'Stop Request'), + icon: Codicon.debugStop, + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_VISIBLE, CTX_INTERACTIVE_EDITOR_EMPTY.negate(), CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST), + menu: { + id: MENU_INTERACTIVE_EDITOR_WIDGET, + group: 'main', + order: 1, + when: CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST + }, + keybinding: { + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Escape + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.cancelCurrentRequest(); + } +} + +export class AcceptWithPreviewInteractiveEditorAction extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.acceptWithPreview', + title: localize('acceptPreview', 'Ask Question & Preview Reply'), + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_VISIBLE, CTX_INTERACTIVE_EDITOR_EMPTY.negate()), + keybinding: { + when: CTX_INTERACTIVE_EDITOR_FOCUSED, + weight: KeybindingWeight.EditorCore + 7, + primary: KeyMod.Shift + KeyCode.Enter + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.accept(true); + } +} + +export class TogglePreviewMode extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.togglePreview', + title: localize('togglePreview', 'Inline Preview'), + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_VISIBLE), + toggled: CTX_INTERACTIVE_EDITOR_PREVIEW, + menu: { + id: MENU_INTERACTIVE_EDITOR_WIDGET, + group: 'C', + order: 1 + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.togglePreview(); + } +} + +export class CancelSessionAction extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.cancel', + title: localize('cancel', 'Cancel'), + precondition: CTX_INTERACTIVE_EDITOR_VISIBLE, + keybinding: { + weight: KeybindingWeight.EditorContrib - 1, + primary: KeyCode.Escape + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.cancelSession(); + } +} + +export class ArrowOutUpAction extends AbstractInteractiveEditorAction { + constructor() { + super({ + id: 'interactiveEditor.arrowOutUp', + title: localize('arrowUp', 'Cursor Up'), + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_FOCUSED, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST), + keybinding: { + weight: KeybindingWeight.EditorCore, + primary: KeyCode.UpArrow + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.arrowOut(true); + } +} + +export class ArrowOutDownAction extends AbstractInteractiveEditorAction { + constructor() { + super({ + id: 'interactiveEditor.arrowOutDown', + title: localize('arrowDown', 'Cursor Down'), + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_FOCUSED, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST), + keybinding: { + weight: KeybindingWeight.EditorCore, + primary: KeyCode.DownArrow + } + }); + } + + runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.arrowOut(false); + } +} + +export class FocusInteractiveEditor extends EditorAction2 { + + constructor() { + super({ + id: 'interactiveEditor.focus', + title: localize('focus', 'Focus'), + category: AbstractInteractiveEditorAction.category, + precondition: ContextKeyExpr.and(EditorContextKeys.editorTextFocus, CTX_INTERACTIVE_EDITOR_VISIBLE, CTX_INTERACTIVE_EDITOR_FOCUSED.negate()), + keybinding: [{ + weight: KeybindingWeight.EditorCore + 10, // win against core_command + when: CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION.isEqualTo('above'), + primary: KeyCode.DownArrow, + }, { + weight: KeybindingWeight.EditorCore + 10, // win against core_command + when: CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION.isEqualTo('below'), + primary: KeyCode.UpArrow, + }] + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ..._args: any[]) { + InteractiveEditorController.get(editor)?.focus(); + } +} + +export class PreviousFromHistory extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.previousFromHistory', + title: localize('previousFromHistory', 'Previous From History'), + precondition: CTX_INTERACTIVE_EDITOR_FOCUSED, + keybinding: { + weight: KeybindingWeight.EditorCore + 10, // win against core_command + primary: KeyMod.CtrlCmd | KeyCode.UpArrow, + } + }); + } + + override runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.populateHistory(true); + } +} + +export class NextFromHistory extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.nextFromHistory', + title: localize('nextFromHistory', 'Next From History'), + precondition: CTX_INTERACTIVE_EDITOR_FOCUSED, + keybinding: { + weight: KeybindingWeight.EditorCore + 10, // win against core_command + primary: KeyMod.CtrlCmd | KeyCode.DownArrow, + } + }); + } + + override runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.populateHistory(false); + } +} + +export class UndoCommand extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.undo', + title: localize('undo', 'Undo'), + icon: Codicon.commentDiscussion, + precondition: ContextKeyExpr.and(CTX_INTERACTIVE_EDITOR_VISIBLE), + keybinding: { + weight: KeybindingWeight.EditorContrib + 10, + primary: KeyMod.CtrlCmd | KeyCode.KeyZ, + }, + menu: { + id: MENU_INTERACTIVE_EDITOR_WIDGET, + group: 'B', + order: 1 + } + }); + } + + override runInteractiveEditorCommand(_accessor: ServicesAccessor, _ctrl: InteractiveEditorController, editor: ICodeEditor, ..._args: any[]): void { + editor.getModel()?.undo(); + } +} + +export class ToggleHistory extends AbstractInteractiveEditorAction { + + constructor() { + super({ + id: 'interactiveEditor.toggleHistory', + title: localize('toggleHistory', 'Toggle History'), + icon: Codicon.chevronRight, + precondition: CTX_INTERACTIVE_EDITOR_VISIBLE, + toggled: { + condition: CTX_INTERACTIVE_EDITOR_HISTORY_VISIBLE, + icon: Codicon.chevronDown, + }, + menu: { + id: MENU_INTERACTIVE_EDITOR_WIDGET_LHS, + group: 'main', + order: 1 + } + }); + } + + override runInteractiveEditorCommand(_accessor: ServicesAccessor, ctrl: InteractiveEditorController, _editor: ICodeEditor, ..._args: any[]): void { + ctrl.toggleHistory(); + } +} diff --git a/src/vs/editor/contrib/interactive/browser/interactiveEditorWidget.ts b/src/vs/editor/contrib/interactive/browser/interactiveEditorWidget.ts new file mode 100644 index 00000000000..806e6f03e0f --- /dev/null +++ b/src/vs/editor/contrib/interactive/browser/interactiveEditorWidget.ts @@ -0,0 +1,744 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import 'vs/css!./interactiveEditor'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { EditorLayoutInfo, EditorOption } from 'vs/editor/common/config/editorOptions'; +import { IRange, Range } from 'vs/editor/common/core/range'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; +import { localize } from 'vs/nls'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ZoneWidget } from 'vs/editor/contrib/zoneWidget/browser/zoneWidget'; +import { assertType } from 'vs/base/common/types'; +import { IInteractiveEditorResponse, IInteractiveEditorService, CTX_INTERACTIVE_EDITOR_FOCUSED, CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST, CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST, CTX_INTERACTIVE_EDITOR_EMPTY, CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION, CTX_INTERACTIVE_EDITOR_PREVIEW, CTX_INTERACTIVE_EDITOR_VISIBLE, MENU_INTERACTIVE_EDITOR_WIDGET, CTX_INTERACTIVE_EDITOR_HISTORY_VISIBLE, MENU_INTERACTIVE_EDITOR_WIDGET_LHS } from 'vs/editor/contrib/interactive/common/interactiveEditor'; +import { EditOperation } from 'vs/editor/common/core/editOperation'; +import { Iterable } from 'vs/base/common/iterator'; +import { IModelDeltaDecoration, ITextModel, IValidEditOperation } from 'vs/editor/common/model'; +import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; +import { Dimension, addDisposableListener, getTotalHeight, getTotalWidth, h, reset } from 'vs/base/browser/dom'; +import { Emitter, Event } from 'vs/base/common/event'; +import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from 'vs/editor/browser/widget/codeEditorWidget'; +import { EditorExtensionsRegistry } from 'vs/editor/browser/editorExtensions'; +import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; +import { IModelService } from 'vs/editor/common/services/model'; +import { URI } from 'vs/base/common/uri'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; +import { GhostTextController } from 'vs/editor/contrib/inlineCompletions/browser/ghostTextController'; +import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { ProgressBar } from 'vs/base/browser/ui/progressbar/progressbar'; +import { SuggestController } from 'vs/editor/contrib/suggest/browser/suggestController'; +import { Position } from 'vs/editor/common/core/position'; +import { Selection } from 'vs/editor/common/core/selection'; +import { raceCancellationError } from 'vs/base/common/async'; +import { isCancellationError } from 'vs/base/common/errors'; +import { IEditorWorkerService } from 'vs/editor/common/services/editorWorker'; +import { ILogService } from 'vs/platform/log/common/log'; +import { isFalsyOrEmpty } from 'vs/base/common/arrays'; +import { StopWatch } from 'vs/base/common/stopwatch'; + + +class InteractiveEditorWidget { + + private static _noop = () => { }; + + private readonly _elements = h( + 'div.interactive-editor@root', + [ + h('div.body', [ + h('div.toolbar@lhsToolbar'), + h('div.content', [ + h('div.input@input', [ + h('div.editor-placeholder@placeholder'), + h('div.editor-container@editor'), + ]), + h('ol.history.hidden@history'), + ]), + h('div.toolbar@rhsToolbar'), + ]), + h('div.progress@progress') + ] + ); + + private readonly _store = new DisposableStore(); + + private readonly _inputEditor: ICodeEditor; + private readonly _inputModel: ITextModel; + private readonly _ctxInputEmpty: IContextKey; + private readonly _ctxHistoryVisible: IContextKey; + + private readonly _progressBar: ProgressBar; + + private readonly _onDidChangeHeight = new Emitter(); + readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + + private _isExpanded = false; + private _editorDim: Dimension | undefined; + + public acceptInput: (preview: boolean) => void = InteractiveEditorWidget._noop; + private _cancelInput: () => void = InteractiveEditorWidget._noop; + + constructor( + parentEditor: ICodeEditor | undefined, + @IModelService private readonly _modelService: IModelService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + + this._ctxHistoryVisible = CTX_INTERACTIVE_EDITOR_HISTORY_VISIBLE.bindTo(this._contextKeyService); + + // editor logic + const editorOptions: IEditorConstructionOptions = { + ariaLabel: localize('aria-label', "Interactive Editor Input"), + wordWrap: 'on', + overviewRulerLanes: 0, + glyphMargin: false, + lineNumbers: 'off', + folding: false, + selectOnLineNumbers: false, + hideCursorInOverviewRuler: true, + selectionHighlight: false, + scrollbar: { + useShadows: false, + vertical: 'hidden', + horizontal: 'auto', + // alwaysConsumeMouseWheel: false + }, + lineDecorationsWidth: 0, + overviewRulerBorder: false, + scrollBeyondLastLine: false, + renderLineHighlight: 'none', + fixedOverflowWidgets: true, + dragAndDrop: false, + revealHorizontalRightPadding: 5, + minimap: { enabled: false }, + guides: { indentation: false }, + cursorWidth: 2, + wrappingStrategy: 'advanced', + wrappingIndent: 'none', + padding: { top: 3, bottom: 2 }, + renderWhitespace: 'none', + dropIntoEditor: { enabled: true }, + + quickSuggestions: false, + suggest: { + showIcons: false, + showSnippets: false, + } + }; + + const codeEditorWidgetOptions: ICodeEditorWidgetOptions = { + isSimpleWidget: true, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + SnippetController2.ID, + GhostTextController.ID, + SuggestController.ID + ]) + }; + + this._inputEditor = parentEditor + ? this._instantiationService.createInstance(EmbeddedCodeEditorWidget, this._elements.editor, editorOptions, codeEditorWidgetOptions, parentEditor) + : this._instantiationService.createInstance(CodeEditorWidget, this._elements.editor, editorOptions, codeEditorWidgetOptions); + this._store.add(this._inputEditor); + + const uri = URI.from({ scheme: 'vscode', authority: 'interactive-editor', path: `/interactive-editor/model.txt` }); + this._inputModel = this._modelService.getModel(uri) ?? this._modelService.createModel('', null, uri); + this._inputEditor.setModel(this._inputModel); + + + + // show/hide placeholder depending on text model being empty + // content height + + const currentContentHeight = 0; + + this._ctxInputEmpty = CTX_INTERACTIVE_EDITOR_EMPTY.bindTo(this._contextKeyService); + const togglePlaceholder = () => { + const hasText = this._inputModel.getValueLength() > 0; + this._elements.placeholder.classList.toggle('hidden', hasText); + this._ctxInputEmpty.set(!hasText); + + const contentHeight = this._inputEditor.getContentHeight(); + if (contentHeight !== currentContentHeight && this._editorDim) { + this._editorDim = this._editorDim.with(undefined, contentHeight); + this._inputEditor.layout(this._editorDim); + this._onDidChangeHeight.fire(); + } + }; + this._store.add(this._inputModel.onDidChangeContent(togglePlaceholder)); + togglePlaceholder(); + + this._store.add(addDisposableListener(this._elements.placeholder, 'click', () => this._inputEditor.focus())); + + const lhsToolbar = this._instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.lhsToolbar, MENU_INTERACTIVE_EDITOR_WIDGET_LHS, { + telemetrySource: 'interactiveEditorWidget-toolbar-lhs', + toolbarOptions: { primaryGroup: 'main' } + }); + this._store.add(lhsToolbar); + + const rhsToolbar = this._instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.rhsToolbar, MENU_INTERACTIVE_EDITOR_WIDGET, { + telemetrySource: 'interactiveEditorWidget-toolbar-rhs', + toolbarOptions: { primaryGroup: 'main' } + }); + this._store.add(rhsToolbar); + + this._progressBar = new ProgressBar(this._elements.progress); + this._store.add(this._progressBar); + } + + dispose(): void { + this._store.dispose(); + this._ctxInputEmpty.reset(); + this._ctxHistoryVisible.reset(); + } + + getDomNode(): HTMLElement { + return this._elements.root; + } + + layout(dim: Dimension) { + + const innerEditorWidth = Math.min( + Number.MAX_SAFE_INTEGER, // TODO@jrieken define max width? + dim.width - (getTotalWidth(this._elements.lhsToolbar) + getTotalWidth(this._elements.rhsToolbar) + 12 /* L/R-padding */) + ); + const newDim = new Dimension(innerEditorWidth, this._inputEditor.getContentHeight()); + if (!this._editorDim || !Dimension.equals(this._editorDim, newDim)) { + this._editorDim = newDim; + this._inputEditor.layout(this._editorDim); + + this._elements.placeholder.style.width = `${innerEditorWidth - 4 /* input-padding*/}px`; + } + } + + getHeight(): number { + return this._inputEditor.getContentHeight() + getTotalHeight(this._elements.history); + } + + updateProgress(show: boolean) { + if (show) { + this._progressBar.infinite(); + } else { + this._progressBar.stop(); + } + } + + getInput(placeholder: string, value: string, token: CancellationToken): Promise<{ value: string; preview: boolean } | undefined> { + + this._elements.placeholder.innerText = placeholder; + this._elements.placeholder.style.fontSize = `${this._inputEditor.getOption(EditorOption.fontSize)}px`; + this._elements.placeholder.style.lineHeight = `${this._inputEditor.getOption(EditorOption.lineHeight)}px`; + this._inputModel.setValue(value); + + const disposeOnDone = new DisposableStore(); + + disposeOnDone.add(this._inputEditor.onDidLayoutChange(() => this._onDidChangeHeight.fire())); + + const ctxInnerCursorFirst = CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST.bindTo(this._contextKeyService); + const ctxInnerCursorLast = CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST.bindTo(this._contextKeyService); + const ctxInputEditorFocused = CTX_INTERACTIVE_EDITOR_FOCUSED.bindTo(this._contextKeyService); + + return new Promise<{ value: string; preview: boolean } | undefined>(resolve => { + + this._cancelInput = () => { + this.acceptInput = InteractiveEditorWidget._noop; + this._cancelInput = InteractiveEditorWidget._noop; + resolve(undefined); + return true; + }; + + this.acceptInput = (preview) => { + const newValue = this._inputEditor.getModel()!.getValue(); + if (newValue.trim().length === 0) { + // empty or whitespace only + this._cancelInput(); + return; + } + + this.acceptInput = InteractiveEditorWidget._noop; + this._cancelInput = InteractiveEditorWidget._noop; + resolve({ value: newValue, preview }); + + const entry = document.createElement('li'); + entry.classList.add('history-entry'); + entry.innerText = newValue; + + this._elements.history.insertBefore(entry, this._elements.history.firstChild); + this._onDidChangeHeight.fire(); + }; + + disposeOnDone.add(token.onCancellationRequested(() => this._cancelInput())); + + // CONTEXT KEYS + + // (1) inner cursor position (last/first line selected) + const updateInnerCursorFirstLast = () => { + if (!this._inputEditor.hasModel()) { + return; + } + const { lineNumber } = this._inputEditor.getPosition(); + ctxInnerCursorFirst.set(lineNumber === 1); + ctxInnerCursorLast.set(lineNumber === this._inputEditor.getModel().getLineCount()); + }; + disposeOnDone.add(this._inputEditor.onDidChangeCursorPosition(updateInnerCursorFirstLast)); + updateInnerCursorFirstLast(); + + // (2) input editor focused or not + const updateFocused = () => { + const hasFocus = this._inputEditor.hasWidgetFocus(); + ctxInputEditorFocused.set(hasFocus); + this._elements.input.classList.toggle('synthetic-focus', hasFocus); + }; + disposeOnDone.add(this._inputEditor.onDidFocusEditorWidget(updateFocused)); + disposeOnDone.add(this._inputEditor.onDidBlurEditorWidget(updateFocused)); + updateFocused(); + + this.focus(); + + }).finally(() => { + disposeOnDone.dispose(); + + ctxInnerCursorFirst.reset(); + ctxInnerCursorLast.reset(); + ctxInputEditorFocused.reset(); + }); + } + + populateInputField(value: string) { + this._inputModel.setValue(value.trim()); + this._inputEditor.setSelection(this._inputModel.getFullModelRange()); + } + + toggleHistory(): void { + this._isExpanded = !this._isExpanded; + this._elements.history.classList.toggle('hidden', !this._isExpanded); + this._ctxHistoryVisible.set(this._isExpanded); + this._onDidChangeHeight.fire(); + } + + reset() { + this._ctxInputEmpty.reset(); + + // empty history + this._isExpanded = false; + this._elements.history.classList.toggle('hidden', true); + this._ctxHistoryVisible.reset(); + reset(this._elements.history); + } + + focus() { + this._inputEditor.focus(); + } +} + +export class InteractiveEditorZoneWidget extends ZoneWidget { + + readonly widget: InteractiveEditorWidget; + + private readonly _ctxVisible: IContextKey; + private readonly _ctxCursorPosition: IContextKey<'above' | 'below' | ''>; + + constructor( + editor: ICodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor, { showFrame: false, showArrow: false, isAccessible: true, className: 'interactive-editor-widget', keepEditorSelection: true }); + + this._ctxVisible = CTX_INTERACTIVE_EDITOR_VISIBLE.bindTo(contextKeyService); + this._ctxCursorPosition = CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION.bindTo(contextKeyService); + + this._disposables.add(toDisposable(() => { + this._ctxVisible.reset(); + this._ctxCursorPosition.reset(); + })); + + this.widget = this._instaService.createInstance(InteractiveEditorWidget, this.editor); + this._disposables.add(this.widget.onDidChangeHeight(() => this._relayout())); + this._disposables.add(this.widget); + this.create(); + + + // todo@jrieken listen ONLY when showing + const updateCursorIsAboveContextKey = () => { + if (!this.position || !this.editor.hasModel()) { + this._ctxCursorPosition.reset(); + } else if (this.position.lineNumber === this.editor.getPosition().lineNumber) { + this._ctxCursorPosition.set('above'); + } else if (this.position.lineNumber + 1 === this.editor.getPosition().lineNumber) { + this._ctxCursorPosition.set('below'); + } else { + this._ctxCursorPosition.reset(); + } + }; + this._disposables.add(this.editor.onDidChangeCursorPosition(e => updateCursorIsAboveContextKey())); + this._disposables.add(this.editor.onDidFocusEditorText(e => updateCursorIsAboveContextKey())); + updateCursorIsAboveContextKey(); + } + + protected override _fillContainer(container: HTMLElement): void { + container.appendChild(this.widget.getDomNode()); + } + + protected override _getWidth(info: EditorLayoutInfo): number { + // TODO@jrieken + // makes the zone widget wider than wanted but this aligns + // it with wholeLine decorations that are added above + return info.width; + } + + private _dimension?: Dimension; + + protected override _onWidth(widthInPixel: number): void { + if (this._dimension) { + this._doLayout(this._dimension.height, widthInPixel); + } + } + + protected override _doLayout(heightInPixel: number, widthInPixel: number): void { + + const info = this.editor.getLayoutInfo(); + const spaceLeft = info.lineNumbersWidth + info.glyphMarginWidth + info.decorationsWidth; + const spaceRight = info.minimap.minimapWidth + info.verticalScrollbarWidth; + + const width = widthInPixel - (spaceLeft + spaceRight); + this._dimension = new Dimension(width, heightInPixel); + this.widget.getDomNode().style.marginLeft = `${spaceLeft}px`; + this.widget.getDomNode().style.marginRight = `${spaceRight}px`; + this.widget.layout(this._dimension); + } + + private _computeHeightInLines(): number { + const lineHeight = this.editor.getOption(EditorOption.lineHeight); + const contentHeightInLines = (this.widget.getHeight() / lineHeight); + return 2 + contentHeightInLines; + } + + protected override _relayout() { + super._relayout(this._computeHeightInLines()); + } + + async getInput(where: IRange, placeholder: string, value: string, token: CancellationToken): Promise<{ value: string; preview: boolean } | undefined> { + assertType(this.editor.hasModel()); + super.show(where, this._computeHeightInLines()); + this._ctxVisible.set(true); + + const task = this.widget.getInput(placeholder, value, token); + const result = await task; + return result; + } + + override hide(): void { + this._ctxVisible.reset(); + this._ctxCursorPosition.reset(); + this.widget.reset(); + super.hide(); + } + +} + +export class InteractiveEditorController implements IEditorContribution { + + static ID = 'interactiveEditor'; + + static get(editor: ICodeEditor) { + return editor.getContribution(InteractiveEditorController.ID); + } + + private static _decoBlock = ModelDecorationOptions.register({ + description: 'interactive-editor', + blockClassName: 'interactive-editor-block', + blockDoesNotCollapse: true, + blockPadding: [1, 0, 1, 4] + }); + + + private static _promptHistory: string[] = []; + private _historyOffset: number = -1; + + private readonly _store = new DisposableStore(); + private readonly _zone: InteractiveEditorZoneWidget; + private readonly _ctxShowPreview: IContextKey; + private readonly _ctxHasActiveRequest: IContextKey; + + private _ctsSession: CancellationTokenSource = new CancellationTokenSource(); + private _ctsRequest?: CancellationTokenSource; + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService instaService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IInteractiveEditorService private readonly _interactiveEditorService: IInteractiveEditorService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @ILogService private readonly _logService: ILogService, + ) { + this._zone = this._store.add(instaService.createInstance(InteractiveEditorZoneWidget, this._editor)); + this._ctxShowPreview = CTX_INTERACTIVE_EDITOR_PREVIEW.bindTo(contextKeyService); + this._ctxHasActiveRequest = CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST.bindTo(contextKeyService); + } + + dispose(): void { + this._store.dispose(); + this._ctsSession.dispose(true); + this._ctsSession.dispose(); + } + + getId(): string { + return InteractiveEditorController.ID; + } + + async run(): Promise { + + this._ctsSession.dispose(true); + + if (!this._editor.hasModel()) { + return; + } + + const provider = Iterable.first(this._interactiveEditorService.getAll()); + if (!provider) { + this._logService.trace('[IE] NO provider found'); + return; + } + + + this._ctsSession = new CancellationTokenSource(); + + const session = await provider.prepareInteractiveEditorSession(this._editor.getModel(), this._editor.getSelection(), this._ctsSession.token); + if (!session) { + this._logService.trace('[IE] NO session', provider.debugName); + return; + } + + this._logService.trace('[IE] NEW session', provider.debugName); + + const decoBackground = this._editor.createDecorationsCollection(); + const decoPreview = this._editor.createDecorationsCollection(); + + const decoWholeRange = this._editor.createDecorationsCollection(); + decoWholeRange.set([{ + range: this._editor.getSelection(), + options: { description: 'interactive-editor-marker' } + }]); + + let placeholder = session.placeholder ?? localize('placeholder1', "Ask me anything..."); + let value = ''; + + const listener = new DisposableStore(); + this._editor.onDidChangeModel(this._ctsSession.cancel, this._ctsSession, listener); + + // CANCEL if the document has changed outside the current range + this._editor.onDidChangeModelContent(e => { + + let cancel = false; + const wholeRange = decoWholeRange.getRange(0); + if (!wholeRange) { + cancel = true; + } else { + for (const change of e.changes) { + if (!Range.areIntersectingOrTouching(wholeRange, change.range)) { + cancel = true; + break; + } + } + } + + if (cancel) { + this._ctsSession.cancel(); + this._logService.trace('[IE] CANCEL because of model change OUTSIDE range'); + } + + }, undefined, listener); + + + + do { + + const wholeRange = decoWholeRange.getRange(0); + if (!wholeRange) { + // nuked whole file contents? + break; + } + + const newDecorations: IModelDeltaDecoration[] = [{ + range: wholeRange, + options: InteractiveEditorController._decoBlock + }]; + + decoBackground.set(newDecorations); + + this._historyOffset = -1; + const input = await this._zone.getInput(wholeRange.collapseToEnd(), placeholder, value, this._ctsSession.token); + + if (!input || !input.value) { + continue; + } + + this._ctsRequest?.dispose(true); + this._ctsRequest = new CancellationTokenSource(this._ctsSession.token); + + const sw = StopWatch.create(); + const task = provider.provideResponse( + session, + { + session, + prompt: input.value, + selection: this._editor.getSelection(), + wholeRange: wholeRange + }, + this._ctsRequest.token + ); + + let reply: IInteractiveEditorResponse | null | undefined; + try { + this._zone.widget.updateProgress(true); + this._ctxHasActiveRequest.set(true); + reply = await raceCancellationError(Promise.resolve(task), this._ctsRequest.token); + + } catch (e) { + if (!isCancellationError(e)) { + this._logService.error('[IE] ERROR during request', provider.debugName); + this._logService.error(e); + } + } finally { + this._ctxHasActiveRequest.set(false); + this._zone.widget.updateProgress(false); + } + + this._logService.trace('[IE] request took', sw.elapsed(), provider.debugName); + + if (this._ctsRequest.token.isCancellationRequested) { + value = input.value; + continue; + } + + if (!reply || isFalsyOrEmpty(reply.edits)) { + this._logService.trace('[IE] NO reply or edits', provider.debugName); + reply = { edits: [] }; + placeholder = localize('placeholder3', "Oops... Try something else..."); + continue; + } + + // make edits more minimal + const moreMinimalEdits = (await this._editorWorkerService.computeMoreMinimalEdits(this._editor.getModel().uri, reply.edits, true)) ?? reply.edits; + + // clear old preview + decoPreview.clear(); + + const undoEdits: IValidEditOperation[] = []; + this._editor.pushUndoStop(); + this._editor.executeEdits( + 'interactive-editor', + moreMinimalEdits.map(edit => { + // return EditOperation.replaceMove(Range.lift(edit.range), edit.text); ??? + return EditOperation.replace(Range.lift(edit.range), edit.text); + }), + _undoEdits => { + let last: Position | null = null; + for (const undoEdit of _undoEdits) { + undoEdits.push(undoEdit); + last = !last || last.isBefore(undoEdit.range.getEndPosition()) ? undoEdit.range.getEndPosition() : last; + } + return last && [Selection.fromPositions(last)]; + } + ); + this._editor.pushUndoStop(); + + if (input.preview) { + const decorations: IModelDeltaDecoration[] = []; + for (const edit of undoEdits) { + + let content = edit.text; + if (content.length > 12) { + content = content.substring(0, 12) + '…'; + } + decorations.push({ + range: edit.range, + options: { + description: 'interactive-editor-inline-diff', + className: 'interactive-editor-lines-inserted-range', + before: { + content, + inlineClassName: 'interactive-editor-lines-deleted-range-inline', + attachedData: edit + } + } + }); + } + decoPreview.set(decorations); + } + + if (!InteractiveEditorController._promptHistory.includes(input.value)) { + InteractiveEditorController._promptHistory.unshift(input.value); + } + placeholder = reply.placeholder ?? session.placeholder ?? localize('placeholder2', "You can ask me more..."); + + } while (!this._ctsSession.token.isCancellationRequested); + + // done, cleanup + decoWholeRange.clear(); + decoBackground.clear(); + decoPreview.clear(); + + listener.dispose(); + session.dispose?.(); + + this._zone.hide(); + this._editor.focus(); + + this._logService.trace('[IE] session DONE', provider.debugName); + } + + accept(preview: boolean = this._preview): void { + this._zone.widget.acceptInput(preview); + } + + private _preview: boolean = false; // TODO@jrieken persist this + + togglePreview(): void { + this._preview = !this._preview; + this._ctxShowPreview.set(this._preview); + } + + cancelCurrentRequest(): void { + this._ctsRequest?.cancel(); + } + + cancelSession() { + this._ctsSession.cancel(); + } + + arrowOut(up: boolean): void { + if (this._zone.position && this._editor.hasModel()) { + const { column } = this._editor.getPosition(); + const { lineNumber } = this._zone.position; + const newLine = up ? lineNumber : lineNumber + 1; + this._editor.setPosition({ lineNumber: newLine, column }); + this._editor.focus(); + } + } + + focus(): void { + this._zone.widget.focus(); + } + + populateHistory(up: boolean) { + const len = InteractiveEditorController._promptHistory.length; + if (len === 0) { + return; + } + const pos = (len + this._historyOffset + (up ? 1 : -1)) % len; + const entry = InteractiveEditorController._promptHistory[pos]; + this._zone.widget.populateInputField(entry); + this._historyOffset = pos; + } + + toggleHistory(): void { + this._zone.widget.toggleHistory(); + } +} diff --git a/src/vs/editor/contrib/interactive/common/interactiveEditor.ts b/src/vs/editor/contrib/interactive/common/interactiveEditor.ts new file mode 100644 index 00000000000..06a15d64ce5 --- /dev/null +++ b/src/vs/editor/contrib/interactive/common/interactiveEditor.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { IRange } from 'vs/editor/common/core/range'; +import { ISelection } from 'vs/editor/common/core/selection'; +import { ProviderResult, TextEdit } from 'vs/editor/common/languages'; +import { ITextModel } from 'vs/editor/common/model'; +import { localize } from 'vs/nls'; +import { MenuId } from 'vs/platform/actions/common/actions'; +import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export interface IInteractiveEditorSession { + id: number; + placeholder?: string; + dispose?(): void; +} + +export interface IInteractiveEditorRequest { + session: IInteractiveEditorSession; + prompt: string; + // model: ITextModel; + selection: ISelection; + wholeRange: IRange; +} + +export interface IInteractiveEditorResponse { + // item: IInputModeSession; + edits: TextEdit[]; // WorkspaceEdit? + placeholder?: string; +} + +export interface IInteractiveEditorSessionProvider { + + debugName: string; + + prepareInteractiveEditorSession(model: ITextModel, range: ISelection, token: CancellationToken): ProviderResult; + + provideResponse(item: IInteractiveEditorSession, request: IInteractiveEditorRequest, token: CancellationToken): ProviderResult; +} + +export const IInteractiveEditorService = createDecorator('IInteractiveEditorService'); + +export interface IInteractiveEditorService { + _serviceBrand: undefined; + add(provider: IInteractiveEditorSessionProvider): IDisposable; + getAll(): Iterable; +} + +export const MENU_INTERACTIVE_EDITOR_WIDGET_LHS = MenuId.for('interactiveEditorWidgetLhs'); +export const MENU_INTERACTIVE_EDITOR_WIDGET = MenuId.for('interactiveEditorWidgetRhs'); + +export const CTX_INTERACTIVE_EDITOR_HAS_PROVIDER = new RawContextKey('interactiveEditorHasProvider', false, localize('interactiveEditorHasProvider', "Whether a provider for interactive editors exists")); +export const CTX_INTERACTIVE_EDITOR_VISIBLE = new RawContextKey('interactiveEditorVisible', false, localize('interactiveEditorVisible', "Whether the interactive editor input is visible")); +export const CTX_INTERACTIVE_EDITOR_FOCUSED = new RawContextKey('interactiveEditorFocused', false, localize('interactiveEditorFocused', "Whether the interactive editor input is focused")); +export const CTX_INTERACTIVE_EDITOR_EMPTY = new RawContextKey('interactiveEditorEmpty', false, localize('interactiveEditorEmpty', "Whether the interactive editor input is empty")); +export const CTX_INTERACTIVE_EDITOR_PREVIEW = new RawContextKey('interactiveEditorPreview', false, localize('interactiveEditorPreview', "Whether the interactive editor input shows inline previews")); +export const CTX_INTERACTIVE_EDITOR_HISTORY_VISIBLE = new RawContextKey('interactiveEditorHistoryVisible', false, localize('interactiveEditorHistoryVisible', "Whether the interactive editor history is visible")); +export const CTX_INTERACTIVE_EDITOR_INNER_CURSOR_FIRST = new RawContextKey('interactiveEditorInnerCursorFirst', false, localize('interactiveEditorInnerCursorFirst', "Whether the cursor of the iteractive editor input is on the first line")); +export const CTX_INTERACTIVE_EDITOR_INNER_CURSOR_LAST = new RawContextKey('interactiveEditorInnerCursorLast', false, localize('interactiveEditorInnerCursorLast', "Whether the cursor of the iteractive editor input is on the last line")); +export const CTX_INTERACTIVE_EDITOR_OUTER_CURSOR_POSITION = new RawContextKey<'above' | 'below' | ''>('interactiveEditorOuterCursorPosition', '', localize('interactiveEditorOuterCursorPosition', "Whether the cursor of the outer editor is above or below the interactive editor input")); +export const CTX_INTERACTIVE_EDITOR_HAS_ACTIVE_REQUEST = new RawContextKey('interactiveEditorHasActiveRequest', false, localize('interactiveEditorHasActiveRequest', "Whether interactive editor has an active request")); diff --git a/src/vs/editor/contrib/interactive/common/interactiveEditorServiceImpl.ts b/src/vs/editor/contrib/interactive/common/interactiveEditorServiceImpl.ts new file mode 100644 index 00000000000..2b5c9a8cdfe --- /dev/null +++ b/src/vs/editor/contrib/interactive/common/interactiveEditorServiceImpl.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { LinkedList } from 'vs/base/common/linkedList'; +import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInteractiveEditorService, IInteractiveEditorSessionProvider, CTX_INTERACTIVE_EDITOR_HAS_PROVIDER } from './interactiveEditor'; + +export class InteractiveEditorServiceImpl implements IInteractiveEditorService { + + declare _serviceBrand: undefined; + + private readonly _entries = new LinkedList(); + + private readonly _ctxHasProvider: IContextKey; + + constructor(@IContextKeyService contextKeyService: IContextKeyService) { + this._ctxHasProvider = CTX_INTERACTIVE_EDITOR_HAS_PROVIDER.bindTo(contextKeyService); + } + + add(provider: IInteractiveEditorSessionProvider): IDisposable { + + const rm = this._entries.push(provider); + this._ctxHasProvider.set(true); + + return toDisposable(() => { + rm(); + this._ctxHasProvider.set(this._entries.size > 0); + }); + } + + getAll() { + return [...this._entries]; + } +} diff --git a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts index cf0ab63f807..d6195522780 100644 --- a/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts +++ b/src/vs/editor/contrib/zoneWidget/browser/zoneWidget.ts @@ -261,7 +261,7 @@ export abstract class ZoneWidget implements IHorizontalSashLayoutProvider { } } - private _getWidth(info: EditorLayoutInfo): number { + protected _getWidth(info: EditorLayoutInfo): number { return info.width - info.minimap.minimapWidth - info.verticalScrollbarWidth; } diff --git a/src/vs/editor/editor.all.ts b/src/vs/editor/editor.all.ts index 38a7a98a345..4d0773dc17f 100644 --- a/src/vs/editor/editor.all.ts +++ b/src/vs/editor/editor.all.ts @@ -57,6 +57,7 @@ import 'vs/editor/contrib/wordHighlighter/browser/wordHighlighter'; import 'vs/editor/contrib/wordOperations/browser/wordOperations'; import 'vs/editor/contrib/wordPartOperations/browser/wordPartOperations'; import 'vs/editor/contrib/readOnlyMessage/browser/contribution'; +import 'vs/editor/contrib/interactive/browser/interactiveEditor.contribution'; // Load up these strings even in VSCode, even if they are not used // in order to get them translated diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index deaa4b2cf62..c2f15fd6730 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -70,6 +70,7 @@ import './mainThreadNotebookKernels'; import './mainThreadNotebookDocumentsAndEditors'; import './mainThreadNotebookRenderers'; import './mainThreadInteractive'; +import './mainThreadInteractiveEditor'; import './mainThreadInteractiveSession'; import './mainThreadTask'; import './mainThreadLabelService'; diff --git a/src/vs/workbench/api/browser/mainThreadInteractiveEditor.ts b/src/vs/workbench/api/browser/mainThreadInteractiveEditor.ts new file mode 100644 index 00000000000..c88ad69bccb --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadInteractiveEditor.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap } from 'vs/base/common/lifecycle'; +import { IInteractiveEditorService } from 'vs/editor/contrib/interactive/common/interactiveEditor'; +import { ExtHostContext, ExtHostInteractiveEditorShape, MainContext, MainThreadInteractiveEditorShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; + +@extHostNamedCustomer(MainContext.MainThreadInteractiveEditor) +export class MainThreadInteractiveEditor implements MainThreadInteractiveEditorShape { + + private readonly _registrations = new DisposableMap(); + private readonly _proxy: ExtHostInteractiveEditorShape; + + constructor( + extHostContext: IExtHostContext, + @IInteractiveEditorService private readonly _interactiveEditorService: IInteractiveEditorService, + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostInteractiveEditor); + } + + dispose(): void { + this._registrations.dispose(); + } + + async $registerInteractiveEditorProvider(handle: number, debugName: string): Promise { + const unreg = this._interactiveEditorService.add({ + debugName, + prepareInteractiveEditorSession: async (model, range, token) => { + const session = await this._proxy.$prepareInteractiveSession(handle, model.uri, range, token); + if (!session) { + return undefined; + } + return { + ...session, + dispose: () => { + this._proxy.$releaseSession(handle, session.id); + } + }; + }, + provideResponse: (item, request, token) => { + return this._proxy.$provideResponse(handle, item, request, token); + } + }); + + this._registrations.set(handle, unreg); + } + + async $unregisterInteractiveEditorProvider(handle: number): Promise { + this._registrations.deleteAndDispose(handle); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 6026e2809fc..4cba8e9a4ba 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -98,6 +98,7 @@ import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessi import { ExtHostProfileContentHandlers } from 'vs/workbench/api/common/extHostProfileContentHandler'; import { ExtHostQuickDiff } from 'vs/workbench/api/common/extHostQuickDiff'; import { ExtHostInteractiveSession } from 'vs/workbench/api/common/extHostInteractiveSession'; +import { ExtHostInteractiveEditor } from 'vs/workbench/api/common/extHostInteractiveEditor'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -192,6 +193,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol)); const extHostProfileContentHandlers = rpcProtocol.set(ExtHostContext.ExtHostProfileContentHandlers, new ExtHostProfileContentHandlers(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostInteractive, new ExtHostInteractive(rpcProtocol, extHostNotebook, extHostDocumentsAndEditors, extHostCommands, extHostLogService)); + const extHostInteractiveEditor = rpcProtocol.set(ExtHostContext.ExtHostInteractiveEditor, new ExtHostInteractiveEditor(rpcProtocol, extHostDocuments, extHostLogService)); const extHostInteractiveSession = rpcProtocol.set(ExtHostContext.ExtHostInteractiveSession, new ExtHostInteractiveSession(rpcProtocol, extHostLogService)); // Check that no named customers are missing @@ -1216,6 +1218,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I // this needs to be updated whenever the API proposal changes _version: 1, + registerInteractiveEditorSessionProvider(provider: vscode.InteractiveEditorSessionProvider) { + checkProposedApiEnabled(extension, 'interactive'); + return extHostInteractiveEditor.registerProvider(extension, provider); + }, registerInteractiveSessionProvider(id: string, provider: vscode.InteractiveSessionProvider) { checkProposedApiEnabled(extension, 'interactive'); return extHostInteractiveSession.registerInteractiveSessionProvider(extension, id, provider); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b3933af7d0d..d7244510155 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -26,6 +26,7 @@ import * as languages from 'vs/editor/common/languages'; import { CharacterPair, CommentRule, EnterAction } from 'vs/editor/common/languages/languageConfiguration'; import { EndOfLineSequence } from 'vs/editor/common/model'; import { IModelChangedEvent } from 'vs/editor/common/model/mirrorTextModel'; +import { IInteractiveEditorResponse, IInteractiveEditorSession, IInteractiveEditorRequest } from 'vs/editor/contrib/interactive/common/interactiveEditor'; import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility'; import { ConfigurationTarget, IConfigurationChange, IConfigurationData, IConfigurationOverrides } from 'vs/platform/configuration/common/configuration'; import { ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; @@ -1068,6 +1069,17 @@ export interface MainThreadNotebookRenderersShape extends IDisposable { export interface MainThreadInteractiveShape extends IDisposable { } +export interface MainThreadInteractiveEditorShape extends IDisposable { + $registerInteractiveEditorProvider(handle: number, debugName: string): Promise; + $unregisterInteractiveEditorProvider(handle: number): Promise; +} + +export interface ExtHostInteractiveEditorShape { + $prepareInteractiveSession(handle: number, uri: UriComponents, range: ISelection, token: CancellationToken): Promise; + $provideResponse(handle: number, session: IInteractiveEditorSession, request: IInteractiveEditorRequest, token: CancellationToken): Promise; + $releaseSession(handle: number, sessionId: number): void; +} + export interface MainThreadUrlsShape extends IDisposable { $registerUriHandler(handle: number, extensionId: ExtensionIdentifier): Promise; $unregisterUriHandler(handle: number): Promise; @@ -2411,6 +2423,7 @@ export const MainContext = { MainThreadNotebookRenderers: createProxyIdentifier('MainThreadNotebookRenderers'), MainThreadInteractive: createProxyIdentifier('MainThreadInteractive'), MainThreadInteractiveSession: createProxyIdentifier('MainThreadInteractiveSession'), + MainThreadInteractiveEditor: createProxyIdentifier('MainThreadInteractiveEditor'), MainThreadTheming: createProxyIdentifier('MainThreadTheming'), MainThreadTunnelService: createProxyIdentifier('MainThreadTunnelService'), MainThreadTimeline: createProxyIdentifier('MainThreadTimeline'), @@ -2466,6 +2479,7 @@ export const ExtHostContext = { ExtHostNotebookKernels: createProxyIdentifier('ExtHostNotebookKernels'), ExtHostNotebookRenderers: createProxyIdentifier('ExtHostNotebookRenderers'), ExtHostInteractive: createProxyIdentifier('ExtHostInteractive'), + ExtHostInteractiveEditor: createProxyIdentifier('ExtHostInteractiveEditor'), ExtHostInteractiveSession: createProxyIdentifier('ExtHostInteractiveSession'), ExtHostTheming: createProxyIdentifier('ExtHostTheming'), ExtHostTunnelService: createProxyIdentifier('ExtHostTunnelService'), diff --git a/src/vs/workbench/api/common/extHostInteractiveEditor.ts b/src/vs/workbench/api/common/extHostInteractiveEditor.ts new file mode 100644 index 00000000000..f03cb728275 --- /dev/null +++ b/src/vs/workbench/api/common/extHostInteractiveEditor.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { toDisposable } from 'vs/base/common/lifecycle'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { ISelection } from 'vs/editor/common/core/selection'; +import { IInteractiveEditorResponse, IInteractiveEditorSession, IInteractiveEditorRequest } from 'vs/editor/contrib/interactive/common/interactiveEditor'; +import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ILogService } from 'vs/platform/log/common/log'; +import { ExtHostInteractiveEditorShape, IMainContext, MainContext, MainThreadInteractiveEditorShape } from 'vs/workbench/api/common/extHost.protocol'; +import { ExtHostDocuments } from 'vs/workbench/api/common/extHostDocuments'; +import * as typeConvert from 'vs/workbench/api/common/extHostTypeConverters'; +import type * as vscode from 'vscode'; + +class ProviderWrapper { + + private static _pool = 0; + + readonly handle: number = ProviderWrapper._pool++; + + constructor( + readonly extension: Readonly, + readonly provider: vscode.InteractiveEditorSessionProvider, + ) { } +} + +export class ExtHostInteractiveEditor implements ExtHostInteractiveEditorShape { + + private static _nextId = 0; + + private readonly _inputProvider = new Map(); + private readonly _inputSessions = new Map(); + private readonly _proxy: MainThreadInteractiveEditorShape; + + constructor( + mainContext: IMainContext, + private readonly _documents: ExtHostDocuments, + private readonly _logService: ILogService + ) { + this._proxy = mainContext.getProxy(MainContext.MainThreadInteractiveEditor); + } + + registerProvider(extension: Readonly, provider: vscode.InteractiveEditorSessionProvider): vscode.Disposable { + const wrapper = new ProviderWrapper(extension, provider); + this._inputProvider.set(wrapper.handle, wrapper); + this._proxy.$registerInteractiveEditorProvider(wrapper.handle, extension.identifier.value); + return toDisposable(() => { + this._proxy.$unregisterInteractiveEditorProvider(wrapper.handle); + this._inputProvider.delete(wrapper.handle); + }); + } + + async $prepareInteractiveSession(handle: number, uri: UriComponents, range: ISelection, token: CancellationToken): Promise { + const entry = this._inputProvider.get(handle); + if (!entry) { + this._logService.warn('CANNOT prepare session because the PROVIDER IS GONE'); + return undefined; + } + + const document = this._documents.getDocument(URI.revive(uri)); + const session = await entry.provider.prepareInteractiveEditorSession({ document, selection: typeConvert.Selection.to(range) }, token); + if (!session) { + return undefined; + } + + const id = ExtHostInteractiveEditor._nextId++; + this._inputSessions.set(id, session); + + return { id, placeholder: session.placeholder }; + } + + async $provideResponse(handle: number, item: IInteractiveEditorSession, request: IInteractiveEditorRequest, token: CancellationToken): Promise { + const entry = this._inputProvider.get(handle); + if (!entry) { + return undefined; + } + const session = this._inputSessions.get(item.id); + if (!session) { + return; + } + + const res = await entry.provider.provideInteractiveEditorResponse({ + session, + prompt: request.prompt, + selection: typeConvert.Selection.to(request.selection), + wholeRange: typeConvert.Range.to(request.wholeRange) + }, token); + + if (!res) { + return; + } + + return { + edits: res.edits.map(typeConvert.TextEdit.from), + placeholder: res.placeholder + }; + } + + $releaseSession(handle: number, sessionId: number) { + const session = this._inputSessions.get(sessionId); + const entry = this._inputProvider.get(handle); + if (session && entry) { + entry.provider.releaseInteractiveEditorSession?.(session); + } + this._inputSessions.delete(sessionId); + } + +} diff --git a/src/vscode-dts/vscode.proposed.interactive.d.ts b/src/vscode-dts/vscode.proposed.interactive.d.ts index 8f9c9255add..d7565caec61 100644 --- a/src/vscode-dts/vscode.proposed.interactive.d.ts +++ b/src/vscode-dts/vscode.proposed.interactive.d.ts @@ -31,11 +31,11 @@ declare module 'vscode' { action?: string; } - export interface InteractivEditorSessionProvider { + export interface InteractiveEditorSessionProvider { // Create a session. The lifetime of this session is the duration of the editing session with the input mode widget. prepareInteractiveEditorSession(context: TextDocumentContext, token: CancellationToken): ProviderResult; - provideInteractivEditorResponse(request: InteractiveEditorRequest, token: CancellationToken): ProviderResult; + provideInteractiveEditorResponse(request: InteractiveEditorRequest, token: CancellationToken): ProviderResult; // eslint-disable-next-line local/vscode-dts-provider-naming releaseInteractiveEditorSession?(session: InteractiveEditorSession): any; @@ -85,5 +85,7 @@ declare module 'vscode' { export function registerInteractiveSessionProvider(id: string, provider: InteractiveSessionProvider): Disposable; export function addInteractiveRequest(context: InteractiveSessionRequestArgs): void; + + export function registerInteractiveEditorSessionProvider(provider: InteractiveEditorSessionProvider): Disposable; } }