diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 9cc2c31c4c6..db41d13d007 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1818,8 +1818,23 @@ export function h(tag: string, ...args: [] | [attributes: { $: string } & Partia el.id = match.groups['id']; } + const classNames = []; if (match.groups['class']) { - el.className = match.groups['class'].replace(/\./g, ' ').trim(); + 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 = {}; @@ -1842,7 +1857,9 @@ export function h(tag: string, ...args: [] | [attributes: { $: string } & Partia } for (const [key, value] of Object.entries(attributes)) { - if (key === 'style') { + if (key === 'className') { + continue; + } else if (key === 'style') { for (const [cssKey, cssValue] of Object.entries(value)) { el.style.setProperty( camelCaseToHyphenCase(cssKey), diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index c261c3de8b0..0c4867f7faa 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -3784,6 +3784,9 @@ export interface IInlineSuggestOptions { * Defaults to `prefix`. */ mode?: 'prefix' | 'subword' | 'subwordSmart'; + + useExperimentalHints?: boolean; + hideHints?: boolean; } /** @@ -3798,7 +3801,9 @@ class InlineEditorSuggest extends BaseEditorOption('inlineSuggestionVisible', false, nls.localize('inlineSuggestionVisible', "Whether an inline suggestion is visible")); @@ -140,9 +144,9 @@ export class GhostTextController extends Disposable { this.activeModel?.showPreviousInlineCompletion(); } - public async hasMultipleInlineCompletions(): Promise { - const result = await this.activeModel?.hasMultipleInlineCompletions(); - return result !== undefined ? result : false; + public async getInlineCompletionsCount(): Promise { + const result = await this.activeModel?.getInlineCompletionsCount(); + return result ?? 0; } } @@ -165,6 +169,8 @@ export class ActiveGhostTextController extends Disposable { public readonly model = this._register(this.instantiationService.createInstance(GhostTextModel, this.editor)); public readonly widget = this._register(this.instantiationService.createInstance(GhostTextWidget, this.editor, this.model)); + public readonly hintsWidget = this._register(this.instantiationService.createInstance(InlineSuggestionHintsWidget, this.editor, this.model.inlineCompletionsModel)); + constructor( private readonly editor: IActiveCodeEditor, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -221,7 +227,7 @@ export class ActiveGhostTextController extends Disposable { export class ShowNextInlineSuggestionAction extends EditorAction { - public static ID = 'editor.action.inlineSuggest.showNext'; + public static ID = showNextInlineSuggestionActionId; constructor() { super({ id: ShowNextInlineSuggestionAction.ID, @@ -245,7 +251,7 @@ export class ShowNextInlineSuggestionAction extends EditorAction { } export class ShowPreviousInlineSuggestionAction extends EditorAction { - public static ID = 'editor.action.inlineSuggest.showPrevious'; + public static ID = showPreviousInlineSuggestionActionId; constructor() { super({ id: ShowPreviousInlineSuggestionAction.ID, @@ -294,7 +300,13 @@ export class AcceptNextWordOfInlineCompletion extends EditorAction { kbOpts: { weight: KeybindingWeight.EditorContrib + 1, primary: KeyMod.CtrlCmd | KeyCode.RightArrow, - } + }, + menuOpts: [{ + menuId: MenuId.InlineSuggestionToolbar, + title: nls.localize('acceptPart', 'Accept Part'), + group: 'primary', + order: 2, + }], }); } @@ -306,9 +318,110 @@ export class AcceptNextWordOfInlineCompletion extends EditorAction { } } -KeybindingsRegistry.registerKeybindingRule({ - id: 'undo', - weight: KeybindingWeight.EditorContrib + 1, - primary: KeyMod.CtrlCmd | KeyCode.LeftArrow, - when: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.canUndoInlineSuggestion), -}); +export class AcceptInlineCompletion extends EditorAction { + constructor() { + super({ + id: inlineSuggestCommitId, + label: nls.localize('action.inlineSuggest.acceptNextWord', "Accept Next Word Of Inline Suggestion"), + alias: 'Accept Next Word Of Inline Suggestion', + precondition: GhostTextController.inlineSuggestionVisible, + menuOpts: [{ + menuId: MenuId.InlineSuggestionToolbar, + title: nls.localize('accept', "Accept"), + group: 'primary', + order: 1, + }], + kbOpts: { + primary: KeyCode.Tab, + weight: 200, + kbExpr: ContextKeyExpr.and( + GhostTextController.inlineSuggestionVisible, + EditorContextKeys.tabMovesFocus.toNegated(), + GhostTextController.inlineSuggestionHasIndentationLessThanTabSize + ), + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = GhostTextController.get(editor); + if (controller) { + controller.commit(); + controller.editor.focus(); + } + } +} + +export class HideInlineCompletion extends EditorAction { + public static ID = 'editor.action.inlineSuggest.hide'; + + constructor() { + super({ + id: HideInlineCompletion.ID, + label: nls.localize('action.inlineSuggest.acceptNextWord', "Accept Next Word Of Inline Suggestion"), + alias: 'Accept Next Word Of Inline Suggestion', + precondition: GhostTextController.inlineSuggestionVisible, + kbOpts: { + weight: 100, + primary: KeyCode.Escape, + } + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + const controller = GhostTextController.get(editor); + if (controller) { + controller.hide(); + } + } +} + +export class DisableSuggestionHints extends EditorAction { + public static ID = 'editor.action.inlineSuggest.disableHints'; + + constructor() { + super({ + id: DisableSuggestionHints.ID, + label: nls.localize('action.inlineSuggest.disableHints', "Disable suggestion hints"), + alias: 'Disable suggestion hints', + precondition: undefined, + menuOpts: [{ + menuId: MenuId.InlineSuggestionToolbar, + title: nls.localize('action.inlineSuggest.disableHints', "Disable suggestion hints"), + group: 'secondary', + order: 10, + }], + }); + } + + public async run(accessor: ServicesAccessor, editor: ICodeEditor): Promise { + const configService = accessor.get(IConfigurationService); + configService.updateValue('editor.inlineSuggest.hideHints', true); + } +} + +export class UndoAcceptPart extends EditorAction { + constructor() { + super({ + id: 'editor.action.inlineSuggest.undo', + label: nls.localize('action.inlineSuggest.undo', "Undo Accept Part"), + alias: 'Undo Accept Part', + precondition: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.canUndoInlineSuggestion), + kbOpts: { + weight: KeybindingWeight.EditorContrib + 1, + primary: KeyMod.CtrlCmd | KeyCode.LeftArrow, + kbExpr: ContextKeyExpr.and(EditorContextKeys.writable, GhostTextController.canUndoInlineSuggestion), + }, + menuOpts: [{ + menuId: MenuId.InlineSuggestionToolbar, + title: 'Undo Accept Part', + group: 'secondary', + order: 3, + }], + }); + } + + public async run(accessor: ServicesAccessor | undefined, editor: ICodeEditor): Promise { + editor.getModel()?.undo(); + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts index 40da94aa796..9627ef629bb 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextHoverParticipant.ts @@ -21,6 +21,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { inlineSuggestCommitId } from 'vs/editor/contrib/inlineCompletions/browser/consts'; import { Command } from 'vs/editor/common/languages'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; export class InlineCompletionsHover implements IHoverPart { constructor( @@ -37,8 +38,8 @@ export class InlineCompletionsHover implements IHoverPart { ); } - public hasMultipleSuggestions(): Promise { - return this.controller.hasMultipleInlineCompletions(); + public getInlineCompletionsCount(): Promise { + return this.controller.getInlineCompletionsCount(); } public get commands(): Command[] { @@ -58,13 +59,18 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan @ILanguageService private readonly _languageService: ILanguageService, @IOpenerService private readonly _openerService: IOpenerService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - ) { } + ) { + } suggestHoverAnchor(mouseEvent: IEditorMouseEvent): HoverAnchor | null { const controller = GhostTextController.get(this._editor); if (!controller) { return null; } + if (this._editor.getOption(EditorOption.inlineSuggest).useExperimentalHints) { + return null; + } + const target = mouseEvent.target; if (target.type === MouseTargetType.CONTENT_VIEW_ZONE) { // handle the case where the mouse is over the view zone @@ -131,9 +137,9 @@ export class InlineCompletionsHoverParticipant implements IEditorHoverParticipan for (const action of actions) { action.setEnabled(false); } - part.hasMultipleSuggestions().then(hasMore => { + part.getInlineCompletionsCount().then(count => { for (const action of actions) { - action.setEnabled(hasMore); + action.setEnabled(count > 1); } }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts index 8c9aea7bcae..1302ead9f65 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/ghostTextModel.ts @@ -130,9 +130,9 @@ export class GhostTextModel extends DelegatingModel implements GhostTextWidgetMo this.activeInlineCompletionsModel?.showPrevious(); } - public async hasMultipleInlineCompletions(): Promise { - const result = await this.activeInlineCompletionsModel?.hasMultipleInlineCompletions(); - return result !== undefined ? result : false; + public async getInlineCompletionsCount(): Promise { + const result = await this.activeInlineCompletionsModel?.getInlineCompletionsCount(); + return result ?? 0; } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts index a43d70e6cc5..24dd7ab7f59 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel.ts @@ -209,9 +209,9 @@ export class InlineCompletionsModel extends Disposable implements GhostTextWidge this.session?.showPreviousInlineCompletion(); } - public async hasMultipleInlineCompletions(): Promise { - const result = await this.session?.hasMultipleInlineCompletions(); - return result !== undefined ? result : false; + public async getInlineCompletionsCount(): Promise { + const result = await this.session?.getInlineCompletionsCount(); + return result ?? 0; } } @@ -328,6 +328,10 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { // We use a semantic id to track the selection even if the cache changes. private currentlySelectedCompletionId: string | undefined = undefined; + public get currentlySelectedIndex(): number { + return this.fixAndGetIndexOfCurrentSelection(); + } + private fixAndGetIndexOfCurrentSelection(): number { if (!this.currentlySelectedCompletionId || !this.cache.value) { return 0; @@ -379,6 +383,10 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { this.onDidChangeEmitter.fire(); } + public get hasBeenTriggeredExplicitly(): boolean { + return this.cache.value?.triggerKind === InlineCompletionTriggerKind.Explicit; + } + public async ensureUpdateWithExplicitContext(): Promise { if (this.updateOperation.value) { // Restart or wait for current update operation @@ -393,9 +401,13 @@ export class InlineCompletionsSession extends BaseGhostTextWidgetModel { } } - public async hasMultipleInlineCompletions(): Promise { + public async getInlineCompletionsCount(): Promise { await this.ensureUpdateWithExplicitContext(); - return (this.cache.value?.completions.length || 0) > 1; + return this.getInlineCompletionsCountSync(); + } + + public getInlineCompletionsCountSync(): number { + return this.filteredCompletions.length || 0; } //#endregion diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.css b/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.css new file mode 100644 index 00000000000..76c7682fa7c --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.css @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .inlineSuggestionsHints { + z-index: 39; + color: var(--vscode-editor-hoverForeground); + background-color: var(--vscode-editorHoverWidget-background); + border: 1px solid var(--vscode-editorHoverWidget-border); +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.ts b/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.ts new file mode 100644 index 00000000000..969739a7ab3 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/inlineSuggestionHintsWidget.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { h } from 'vs/base/browser/dom'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { Action } from 'vs/base/common/actions'; +import { Codicon } from 'vs/base/common/codicons'; +import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; +import { ThemeIcon } from 'vs/base/common/themables'; +import 'vs/css!./inlineSuggestionHintsWidget'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { Position } from 'vs/editor/common/core/position'; +import { PositionAffinity } from 'vs/editor/common/model'; +import { showNextInlineSuggestionActionId, showPreviousInlineSuggestionActionId } from 'vs/editor/contrib/inlineCompletions/browser/consts'; +import { InlineCompletionsModel } from 'vs/editor/contrib/inlineCompletions/browser/inlineCompletionsModel'; +import { localize } from 'vs/nls'; +import { MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar'; +import { MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; + +export class InlineSuggestionHintsWidget extends Disposable { + private readonly widget = this._register(this.instantiationService.createInstance(InlineSuggestionHintsContentWidget, this.editor)); + + private sessionPosition: Position | undefined = undefined; + + constructor( + private readonly editor: ICodeEditor, + private readonly model: InlineCompletionsModel, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + + editor.addContentWidget(this.widget); + this._register(toDisposable(() => editor.removeContentWidget(this.widget))); + this._register(model.onDidChange(() => this.update())); + this._register(editor.onDidChangeConfiguration(() => this.update())); + this.update(); + } + + private update(): void { + const options = this.editor.getOption(EditorOption.inlineSuggest); + if (!options.useExperimentalHints || options.hideHints || !this.model.ghostText) { + this.widget.update(null, 0, undefined); + this.sessionPosition = undefined; + return; + } + + if (!this.model.completionSession.value) { + return; + } + + if (!this.model.completionSession.value.hasBeenTriggeredExplicitly) { + this.model.completionSession.value.ensureUpdateWithExplicitContext(); + } + + const ghostText = this.model.ghostText; + + const firstColumn = ghostText.parts[0].column; + if (this.sessionPosition && this.sessionPosition.lineNumber !== ghostText.lineNumber) { + this.sessionPosition = undefined; + } + + const position = new Position(ghostText.lineNumber, Math.min(firstColumn, this.sessionPosition?.column ?? Number.MAX_SAFE_INTEGER)); + this.sessionPosition = position; + + this.widget.update( + this.sessionPosition, + this.model.completionSession.value.currentlySelectedIndex, + this.model.completionSession.value.hasBeenTriggeredExplicitly ? this.model.completionSession.value.getInlineCompletionsCountSync() : undefined, + ); + } +} + +const inlineSuggestionHintsNextIcon = registerIcon('inline-suggestion-hints-next', Codicon.chevronRight, localize('parameterHintsNextIcon', 'Icon for show next parameter hint.')); +const inlineSuggestionHintsPreviousIcon = registerIcon('inline-suggestion-hints-previous', Codicon.chevronLeft, localize('parameterHintsPreviousIcon', 'Icon for show previous parameter hint.')); + +class InlineSuggestionHintsContentWidget extends Disposable implements IContentWidget { + private static id = 0; + + private readonly id = `InlineSuggestionHintsContentWidget${InlineSuggestionHintsContentWidget.id++}`; + public readonly allowEditorOverflow = true; + public readonly suppressMouseDown = false; + + private readonly nodes = h('div.inlineSuggestionsHints', [ + h('div', { style: { display: 'flex' } }, [ + h('div@actionBar'), + h('div@toolBar'), + ]) + ]); + private position: Position | null = null; + + private createCommandAction(commandId: string, label: string, iconClassName: string): Action { + const action = new Action( + commandId, + label, + iconClassName, + true, + () => this.commandService.executeCommand(commandId), + ); + const kb = this.keybindingService.lookupKeybinding(commandId, this._contextKeyService); + let tooltip = label; + if (kb) { + tooltip = localize({ key: 'content', comment: ['A label', 'A keybinding'] }, '{0} ({1})', label, kb.getLabel()); + } + action.tooltip = tooltip; + return action; + } + + private readonly previousAction = this.createCommandAction(showPreviousInlineSuggestionActionId, localize('previous', 'Previous'), ThemeIcon.asClassName(inlineSuggestionHintsPreviousIcon)); + private readonly availableSuggestionCountAction = new Action('inlineSuggestionHints.availableSuggestionCount', '', undefined, false); + private readonly nextAction = this.createCommandAction(showNextInlineSuggestionActionId, localize('next', 'Next'), ThemeIcon.asClassName(inlineSuggestionHintsNextIcon)); + + constructor( + private readonly editor: ICodeEditor, + @ICommandService private readonly commandService: ICommandService, + @IInstantiationService instantiationService: IInstantiationService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + ) { + super(); + + const actionBar = this._register(new ActionBar(this.nodes.actionBar)); + + actionBar.push(this.previousAction, { icon: true, label: false }); + actionBar.push(this.availableSuggestionCountAction); + actionBar.push(this.nextAction, { icon: true, label: false }); + + this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this.nodes.toolBar, MenuId.InlineSuggestionToolbar, { + menuOptions: { renderShortTitle: true }, + toolbarOptions: { primaryGroup: 'primary' }, + actionViewItemProvider: (action, options) => { + return action instanceof MenuItemAction ? instantiationService.createInstance(StatusBarViewItem, action, undefined) : undefined; + }, + })); + } + + public update(position: Position | null, currentSuggestionIdx: number, suggestionCount: number | undefined): void { + this.position = position; + + this.previousAction.enabled = this.nextAction.enabled = suggestionCount !== undefined && suggestionCount > 1; + this.availableSuggestionCountAction.label = suggestionCount !== undefined ? `${currentSuggestionIdx + 1}/${suggestionCount}` : '1/?'; + + this.editor.layoutContentWidget(this); + } + + getId(): string { return this.id; } + + getDomNode(): HTMLElement { + return this.nodes.root; + } + + getPosition(): IContentWidgetPosition | null { + return { + position: this.position, + preference: [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW], + positionAffinity: PositionAffinity.LeftOfInjectedText, + }; + } +} + +class StatusBarViewItem extends MenuEntryActionViewItem { + protected override updateLabel() { + const kb = this._keybindingService.lookupKeybinding(this._action.id, this._contextKeyService); + if (!kb) { + return super.updateLabel(); + } + if (this.label) { + this.label.textContent = localize({ key: 'content', comment: ['A label', 'A keybinding'] }, '{0} ({1})', this._action.label, kb.getLabel()); + } + } +} diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 004ad4d1d52..93148fe4411 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4325,6 +4325,8 @@ declare namespace monaco.editor { * Defaults to `prefix`. */ mode?: 'prefix' | 'subword' | 'subwordSmart'; + useExperimentalHints?: boolean; + hideHints?: boolean; } export interface IBracketPairColorizationOptions { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index a7723fc0d2f..d09fe3f2907 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -175,6 +175,7 @@ export class MenuId { static readonly MergeInput2Toolbar = new MenuId('MergeToolbar2Toolbar'); static readonly MergeBaseToolbar = new MenuId('MergeBaseToolbar'); static readonly MergeInputResultToolbar = new MenuId('MergeToolbarResultToolbar'); + static readonly InlineSuggestionToolbar = new MenuId('InlineSuggestionToolbar'); /** * Create or reuse a `MenuId` with the given identifier