Path: blob/main/src/vs/editor/contrib/snippet/browser/snippetController2.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';6import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';7import { assertType } from '../../../../base/common/types.js';8import { ICodeEditor } from '../../../browser/editorBrowser.js';9import { EditorCommand, EditorContributionInstantiation, registerEditorCommand, registerEditorContribution } from '../../../browser/editorExtensions.js';10import { Position } from '../../../common/core/position.js';11import { Range } from '../../../common/core/range.js';12import { IEditorContribution } from '../../../common/editorCommon.js';13import { EditorContextKeys } from '../../../common/editorContextKeys.js';14import { CompletionItem, CompletionItemKind, CompletionItemProvider } from '../../../common/languages.js';15import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js';16import { ITextModel } from '../../../common/model.js';17import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';18import { Choice } from './snippetParser.js';19import { showSimpleSuggestions } from '../../suggest/browser/suggest.js';20import { OvertypingCapturer } from '../../suggest/browser/suggestOvertypingCapturer.js';21import { localize } from '../../../../nls.js';22import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';23import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';24import { ILogService } from '../../../../platform/log/common/log.js';25import { ISnippetEdit, SnippetSession } from './snippetSession.js';26import { TextModelEditSource } from '../../../common/textModelEditSource.js';2728export interface ISnippetInsertOptions {29overwriteBefore: number;30overwriteAfter: number;31adjustWhitespace: boolean;32undoStopBefore: boolean;33undoStopAfter: boolean;34clipboardText: string | undefined;35overtypingCapturer: OvertypingCapturer | undefined;36reason?: TextModelEditSource;37}3839const _defaultOptions: ISnippetInsertOptions = {40overwriteBefore: 0,41overwriteAfter: 0,42undoStopBefore: true,43undoStopAfter: true,44adjustWhitespace: true,45clipboardText: undefined,46overtypingCapturer: undefined47};4849export class SnippetController2 implements IEditorContribution {5051public static readonly ID = 'snippetController2';5253static get(editor: ICodeEditor): SnippetController2 | null {54return editor.getContribution<SnippetController2>(SnippetController2.ID);55}5657static readonly InSnippetMode = new RawContextKey('inSnippetMode', false, localize('inSnippetMode', "Whether the editor in current in snippet mode"));58static readonly HasNextTabstop = new RawContextKey('hasNextTabstop', false, localize('hasNextTabstop', "Whether there is a next tab stop when in snippet mode"));59static readonly HasPrevTabstop = new RawContextKey('hasPrevTabstop', false, localize('hasPrevTabstop', "Whether there is a previous tab stop when in snippet mode"));6061private readonly _inSnippet: IContextKey<boolean>;62private readonly _hasNextTabstop: IContextKey<boolean>;63private readonly _hasPrevTabstop: IContextKey<boolean>;6465private _session?: SnippetSession;66private readonly _snippetListener = new DisposableStore();67private _modelVersionId: number = -1;68private _currentChoice?: Choice;6970private _choiceCompletions?: { provider: CompletionItemProvider; enable(): void; disable(): void };7172constructor(73private readonly _editor: ICodeEditor,74@ILogService private readonly _logService: ILogService,75@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,76@IContextKeyService contextKeyService: IContextKeyService,77@ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService,78) {79this._inSnippet = SnippetController2.InSnippetMode.bindTo(contextKeyService);80this._hasNextTabstop = SnippetController2.HasNextTabstop.bindTo(contextKeyService);81this._hasPrevTabstop = SnippetController2.HasPrevTabstop.bindTo(contextKeyService);82}8384dispose(): void {85this._inSnippet.reset();86this._hasPrevTabstop.reset();87this._hasNextTabstop.reset();88this._session?.dispose();89this._snippetListener.dispose();90}9192apply(edits: ISnippetEdit[], opts?: Partial<ISnippetInsertOptions>) {93try {94this._doInsert(edits, typeof opts === 'undefined' ? _defaultOptions : { ..._defaultOptions, ...opts });9596} catch (e) {97this.cancel();98this._logService.error(e);99this._logService.error('snippet_error');100this._logService.error('insert_edits=', edits);101this._logService.error('existing_template=', this._session ? this._session._logInfo() : '<no_session>');102}103}104105insert(106template: string,107opts?: Partial<ISnippetInsertOptions>108): void {109// this is here to find out more about the yet-not-understood110// error that sometimes happens when we fail to inserted a nested111// snippet112try {113this._doInsert(template, typeof opts === 'undefined' ? _defaultOptions : { ..._defaultOptions, ...opts });114115} catch (e) {116this.cancel();117this._logService.error(e);118this._logService.error('snippet_error');119this._logService.error('insert_template=', template);120this._logService.error('existing_template=', this._session ? this._session._logInfo() : '<no_session>');121}122}123124private _doInsert(125template: string | ISnippetEdit[],126opts: ISnippetInsertOptions,127): void {128if (!this._editor.hasModel()) {129return;130}131132// don't listen while inserting the snippet133// as that is the inflight state causing cancelation134this._snippetListener.clear();135136if (opts.undoStopBefore) {137this._editor.getModel().pushStackElement();138}139140// don't merge141if (this._session && typeof template !== 'string') {142this.cancel();143}144145if (!this._session) {146this._modelVersionId = this._editor.getModel().getAlternativeVersionId();147this._session = new SnippetSession(this._editor, template, opts, this._languageConfigurationService);148this._session.insert(opts.reason);149} else {150assertType(typeof template === 'string');151this._session.merge(template, opts);152}153154if (opts.undoStopAfter) {155this._editor.getModel().pushStackElement();156}157158// regster completion item provider when there is any choice element159if (this._session?.hasChoice) {160const provider: CompletionItemProvider = {161_debugDisplayName: 'snippetChoiceCompletions',162provideCompletionItems: (model: ITextModel, position: Position) => {163if (!this._session || model !== this._editor.getModel() || !Position.equals(this._editor.getPosition(), position)) {164return undefined;165}166const { activeChoice } = this._session;167if (!activeChoice || activeChoice.choice.options.length === 0) {168return undefined;169}170171const word = model.getValueInRange(activeChoice.range);172const isAnyOfOptions = Boolean(activeChoice.choice.options.find(o => o.value === word));173const suggestions: CompletionItem[] = [];174for (let i = 0; i < activeChoice.choice.options.length; i++) {175const option = activeChoice.choice.options[i];176suggestions.push({177kind: CompletionItemKind.Value,178label: option.value,179insertText: option.value,180sortText: 'a'.repeat(i + 1),181range: activeChoice.range,182filterText: isAnyOfOptions ? `${word}_${option.value}` : undefined,183command: { id: 'jumpToNextSnippetPlaceholder', title: localize('next', 'Go to next placeholder...') }184});185}186return { suggestions };187}188};189190const model = this._editor.getModel();191192let registration: IDisposable | undefined;193let isRegistered = false;194const disable = () => {195registration?.dispose();196isRegistered = false;197};198199const enable = () => {200if (!isRegistered) {201registration = this._languageFeaturesService.completionProvider.register({202language: model.getLanguageId(),203pattern: model.uri.fsPath,204scheme: model.uri.scheme,205exclusive: true206}, provider);207this._snippetListener.add(registration);208isRegistered = true;209}210};211212this._choiceCompletions = { provider, enable, disable };213}214215this._updateState();216217this._snippetListener.add(this._editor.onDidChangeModelContent(e => e.isFlush && this.cancel()));218this._snippetListener.add(this._editor.onDidChangeModel(() => this.cancel()));219this._snippetListener.add(this._editor.onDidChangeCursorSelection(() => this._updateState()));220}221222private _updateState(): void {223if (!this._session || !this._editor.hasModel()) {224// canceled in the meanwhile225return;226}227228if (this._modelVersionId === this._editor.getModel().getAlternativeVersionId()) {229// undo until the 'before' state happened230// and makes use cancel snippet mode231return this.cancel();232}233234if (!this._session.hasPlaceholder) {235// don't listen for selection changes and don't236// update context keys when the snippet is plain text237return this.cancel();238}239240if (this._session.isAtLastPlaceholder || !this._session.isSelectionWithinPlaceholders()) {241this._editor.getModel().pushStackElement();242return this.cancel();243}244245this._inSnippet.set(true);246this._hasPrevTabstop.set(!this._session.isAtFirstPlaceholder);247this._hasNextTabstop.set(!this._session.isAtLastPlaceholder);248249this._handleChoice();250}251252private _handleChoice(): void {253if (!this._session || !this._editor.hasModel()) {254this._currentChoice = undefined;255return;256}257258const { activeChoice } = this._session;259if (!activeChoice || !this._choiceCompletions) {260this._choiceCompletions?.disable();261this._currentChoice = undefined;262return;263}264265if (this._currentChoice !== activeChoice.choice) {266this._currentChoice = activeChoice.choice;267268this._choiceCompletions.enable();269270// trigger suggest with the special choice completion provider271queueMicrotask(() => {272showSimpleSuggestions(this._editor, this._choiceCompletions!.provider);273});274}275}276277finish(): void {278while (this._inSnippet.get()) {279this.next();280}281}282283cancel(resetSelection: boolean = false): void {284this._inSnippet.reset();285this._hasPrevTabstop.reset();286this._hasNextTabstop.reset();287this._snippetListener.clear();288289this._currentChoice = undefined;290291this._session?.dispose();292this._session = undefined;293this._modelVersionId = -1;294if (resetSelection) {295// reset selection to the primary cursor when being asked296// for. this happens when explicitly cancelling snippet mode,297// e.g. when pressing ESC298this._editor.setSelections([this._editor.getSelection()!]);299}300}301302prev(): void {303this._session?.prev();304this._updateState();305}306307next(): void {308this._session?.next();309this._updateState();310}311312isInSnippet(): boolean {313return Boolean(this._inSnippet.get());314}315316getSessionEnclosingRange(): Range | undefined {317if (this._session) {318return this._session.getEnclosingRange();319}320return undefined;321}322}323324325registerEditorContribution(SnippetController2.ID, SnippetController2, EditorContributionInstantiation.Lazy);326327const CommandCtor = EditorCommand.bindToContribution<SnippetController2>(SnippetController2.get);328329registerEditorCommand(new CommandCtor({330id: 'jumpToNextSnippetPlaceholder',331precondition: ContextKeyExpr.and(SnippetController2.InSnippetMode, SnippetController2.HasNextTabstop),332handler: ctrl => ctrl.next(),333kbOpts: {334weight: KeybindingWeight.EditorContrib + 30,335kbExpr: EditorContextKeys.textInputFocus,336primary: KeyCode.Tab337}338}));339registerEditorCommand(new CommandCtor({340id: 'jumpToPrevSnippetPlaceholder',341precondition: ContextKeyExpr.and(SnippetController2.InSnippetMode, SnippetController2.HasPrevTabstop),342handler: ctrl => ctrl.prev(),343kbOpts: {344weight: KeybindingWeight.EditorContrib + 30,345kbExpr: EditorContextKeys.textInputFocus,346primary: KeyMod.Shift | KeyCode.Tab347}348}));349registerEditorCommand(new CommandCtor({350id: 'leaveSnippet',351precondition: SnippetController2.InSnippetMode,352handler: ctrl => ctrl.cancel(true),353kbOpts: {354weight: KeybindingWeight.EditorContrib + 30,355kbExpr: EditorContextKeys.textInputFocus,356primary: KeyCode.Escape,357secondary: [KeyMod.Shift | KeyCode.Escape]358}359}));360361registerEditorCommand(new CommandCtor({362id: 'acceptSnippet',363precondition: SnippetController2.InSnippetMode,364handler: ctrl => ctrl.finish(),365// kbOpts: {366// weight: KeybindingWeight.EditorContrib + 30,367// kbExpr: EditorContextKeys.textFocus,368// primary: KeyCode.Enter,369// }370}));371372373