Path: blob/main/src/vs/editor/contrib/snippet/browser/snippetController2.ts
5289 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';27import { IObservable, observableValue } from '../../../../base/common/observable.js';2829export interface ISnippetInsertOptions {30overwriteBefore: number;31overwriteAfter: number;32adjustWhitespace: boolean;33undoStopBefore: boolean;34undoStopAfter: boolean;35clipboardText: string | undefined;36overtypingCapturer: OvertypingCapturer | undefined;37reason?: TextModelEditSource;38}3940const _defaultOptions: ISnippetInsertOptions = {41overwriteBefore: 0,42overwriteAfter: 0,43undoStopBefore: true,44undoStopAfter: true,45adjustWhitespace: true,46clipboardText: undefined,47overtypingCapturer: undefined48};4950export class SnippetController2 implements IEditorContribution {5152public static readonly ID = 'snippetController2';5354static get(editor: ICodeEditor): SnippetController2 | null {55return editor.getContribution<SnippetController2>(SnippetController2.ID);56}5758static readonly InSnippetMode = new RawContextKey('inSnippetMode', false, localize('inSnippetMode', "Whether the editor in current in snippet mode"));59static readonly HasNextTabstop = new RawContextKey('hasNextTabstop', false, localize('hasNextTabstop', "Whether there is a next tab stop when in snippet mode"));60static readonly HasPrevTabstop = new RawContextKey('hasPrevTabstop', false, localize('hasPrevTabstop', "Whether there is a previous tab stop when in snippet mode"));6162private readonly _inSnippet: IContextKey<boolean>;63private readonly _inSnippetObservable = observableValue(this, false);64private readonly _hasNextTabstop: IContextKey<boolean>;65private readonly _hasPrevTabstop: IContextKey<boolean>;6667private _session?: SnippetSession;68private readonly _snippetListener = new DisposableStore();69private _modelVersionId: number = -1;70private _currentChoice?: Choice;7172private _choiceCompletions?: { provider: CompletionItemProvider; enable(): void; disable(): void };7374constructor(75private readonly _editor: ICodeEditor,76@ILogService private readonly _logService: ILogService,77@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,78@IContextKeyService contextKeyService: IContextKeyService,79@ILanguageConfigurationService private readonly _languageConfigurationService: ILanguageConfigurationService,80) {81this._inSnippet = SnippetController2.InSnippetMode.bindTo(contextKeyService);82this._hasNextTabstop = SnippetController2.HasNextTabstop.bindTo(contextKeyService);83this._hasPrevTabstop = SnippetController2.HasPrevTabstop.bindTo(contextKeyService);84}8586dispose(): void {87this._inSnippet.reset();88this._inSnippetObservable.set(false, undefined);89this._hasPrevTabstop.reset();90this._hasNextTabstop.reset();91this._session?.dispose();92this._snippetListener.dispose();93}9495apply(edits: ISnippetEdit[], opts?: Partial<ISnippetInsertOptions>) {96try {97this._doInsert(edits, typeof opts === 'undefined' ? _defaultOptions : { ..._defaultOptions, ...opts });9899} catch (e) {100this.cancel();101this._logService.error(e);102this._logService.error('snippet_error');103this._logService.error('insert_edits=', edits);104this._logService.error('existing_template=', this._session ? this._session._logInfo() : '<no_session>');105}106}107108insert(109template: string,110opts?: Partial<ISnippetInsertOptions>111): void {112// this is here to find out more about the yet-not-understood113// error that sometimes happens when we fail to inserted a nested114// snippet115try {116this._doInsert(template, typeof opts === 'undefined' ? _defaultOptions : { ..._defaultOptions, ...opts });117118} catch (e) {119this.cancel();120this._logService.error(e);121this._logService.error('snippet_error');122this._logService.error('insert_template=', template);123this._logService.error('existing_template=', this._session ? this._session._logInfo() : '<no_session>');124}125}126127private _doInsert(128template: string | ISnippetEdit[],129opts: ISnippetInsertOptions,130): void {131if (!this._editor.hasModel()) {132return;133}134135// don't listen while inserting the snippet136// as that is the inflight state causing cancelation137this._snippetListener.clear();138139if (opts.undoStopBefore) {140this._editor.getModel().pushStackElement();141}142143// don't merge144if (this._session && typeof template !== 'string') {145this.cancel();146}147148if (!this._session) {149this._modelVersionId = this._editor.getModel().getAlternativeVersionId();150this._session = new SnippetSession(this._editor, template, opts, this._languageConfigurationService);151this._session.insert(opts.reason);152} else {153assertType(typeof template === 'string');154this._session.merge(template, opts);155}156157if (opts.undoStopAfter) {158this._editor.getModel().pushStackElement();159}160161// regster completion item provider when there is any choice element162if (this._session?.hasChoice) {163const provider: CompletionItemProvider = {164_debugDisplayName: 'snippetChoiceCompletions',165provideCompletionItems: (model: ITextModel, position: Position) => {166if (!this._session || model !== this._editor.getModel() || !Position.equals(this._editor.getPosition(), position)) {167return undefined;168}169const { activeChoice } = this._session;170if (!activeChoice || activeChoice.choice.options.length === 0) {171return undefined;172}173174const word = model.getValueInRange(activeChoice.range);175const isAnyOfOptions = Boolean(activeChoice.choice.options.find(o => o.value === word));176const suggestions: CompletionItem[] = [];177for (let i = 0; i < activeChoice.choice.options.length; i++) {178const option = activeChoice.choice.options[i];179suggestions.push({180kind: CompletionItemKind.Value,181label: option.value,182insertText: option.value,183sortText: 'a'.repeat(i + 1),184range: activeChoice.range,185filterText: isAnyOfOptions ? `${word}_${option.value}` : undefined,186command: { id: 'jumpToNextSnippetPlaceholder', title: localize('next', 'Go to next placeholder...') }187});188}189return { suggestions };190}191};192193const model = this._editor.getModel();194195let registration: IDisposable | undefined;196let isRegistered = false;197const disable = () => {198registration?.dispose();199isRegistered = false;200};201202const enable = () => {203if (!isRegistered) {204registration = this._languageFeaturesService.completionProvider.register({205language: model.getLanguageId(),206pattern: model.uri.fsPath,207scheme: model.uri.scheme,208exclusive: true209}, provider);210this._snippetListener.add(registration);211isRegistered = true;212}213};214215this._choiceCompletions = { provider, enable, disable };216}217218this._updateState();219220this._snippetListener.add(this._editor.onDidChangeModelContent(e => e.isFlush && this.cancel()));221this._snippetListener.add(this._editor.onDidChangeModel(() => this.cancel()));222this._snippetListener.add(this._editor.onDidChangeCursorSelection(() => this._updateState()));223}224225private _updateState(): void {226if (!this._session || !this._editor.hasModel()) {227// canceled in the meanwhile228return;229}230231if (this._modelVersionId === this._editor.getModel().getAlternativeVersionId()) {232// undo until the 'before' state happened233// and makes use cancel snippet mode234return this.cancel();235}236237if (!this._session.hasPlaceholder) {238// don't listen for selection changes and don't239// update context keys when the snippet is plain text240return this.cancel();241}242243if (this._session.isAtLastPlaceholder || !this._session.isSelectionWithinPlaceholders()) {244this._editor.getModel().pushStackElement();245return this.cancel();246}247248this._inSnippet.set(true);249this._inSnippetObservable.set(true, undefined);250this._hasPrevTabstop.set(!this._session.isAtFirstPlaceholder);251this._hasNextTabstop.set(!this._session.isAtLastPlaceholder);252253this._handleChoice();254}255256private _handleChoice(): void {257if (!this._session || !this._editor.hasModel()) {258this._currentChoice = undefined;259return;260}261262const { activeChoice } = this._session;263if (!activeChoice || !this._choiceCompletions) {264this._choiceCompletions?.disable();265this._currentChoice = undefined;266return;267}268269if (this._currentChoice !== activeChoice.choice) {270this._currentChoice = activeChoice.choice;271272this._choiceCompletions.enable();273274// trigger suggest with the special choice completion provider275queueMicrotask(() => {276showSimpleSuggestions(this._editor, this._choiceCompletions!.provider);277});278}279}280281finish(): void {282while (this._inSnippet.get()) {283this.next();284}285}286287cancel(resetSelection: boolean = false): void {288this._inSnippet.reset();289this._inSnippetObservable.set(false, undefined);290this._hasPrevTabstop.reset();291this._hasNextTabstop.reset();292this._snippetListener.clear();293294this._currentChoice = undefined;295296this._session?.dispose();297this._session = undefined;298this._modelVersionId = -1;299if (resetSelection) {300// reset selection to the primary cursor when being asked301// for. this happens when explicitly cancelling snippet mode,302// e.g. when pressing ESC303this._editor.setSelections([this._editor.getSelection()!]);304}305}306307prev(): void {308this._session?.prev();309this._updateState();310}311312next(): void {313this._session?.next();314this._updateState();315}316317isInSnippet(): boolean {318return Boolean(this._inSnippet.get());319}320321get isInSnippetObservable(): IObservable<boolean> {322return this._inSnippetObservable;323}324325getSessionEnclosingRange(): Range | undefined {326if (this._session) {327return this._session.getEnclosingRange();328}329return undefined;330}331}332333334registerEditorContribution(SnippetController2.ID, SnippetController2, EditorContributionInstantiation.Lazy);335336const CommandCtor = EditorCommand.bindToContribution<SnippetController2>(SnippetController2.get);337338registerEditorCommand(new CommandCtor({339id: 'jumpToNextSnippetPlaceholder',340precondition: ContextKeyExpr.and(SnippetController2.InSnippetMode, SnippetController2.HasNextTabstop),341handler: ctrl => ctrl.next(),342kbOpts: {343weight: KeybindingWeight.EditorContrib + 30,344kbExpr: EditorContextKeys.textInputFocus,345primary: KeyCode.Tab346}347}));348registerEditorCommand(new CommandCtor({349id: 'jumpToPrevSnippetPlaceholder',350precondition: ContextKeyExpr.and(SnippetController2.InSnippetMode, SnippetController2.HasPrevTabstop),351handler: ctrl => ctrl.prev(),352kbOpts: {353weight: KeybindingWeight.EditorContrib + 30,354kbExpr: EditorContextKeys.textInputFocus,355primary: KeyMod.Shift | KeyCode.Tab356}357}));358registerEditorCommand(new CommandCtor({359id: 'leaveSnippet',360precondition: SnippetController2.InSnippetMode,361handler: ctrl => ctrl.cancel(true),362kbOpts: {363weight: KeybindingWeight.EditorContrib + 30,364kbExpr: EditorContextKeys.textInputFocus,365primary: KeyCode.Escape,366secondary: [KeyMod.Shift | KeyCode.Escape]367}368}));369370registerEditorCommand(new CommandCtor({371id: 'acceptSnippet',372precondition: SnippetController2.InSnippetMode,373handler: ctrl => ctrl.finish(),374// kbOpts: {375// weight: KeybindingWeight.EditorContrib + 30,376// kbExpr: EditorContextKeys.textFocus,377// primary: KeyCode.Enter,378// }379}));380381382