From 52cc722dcd00b02e59edc4ea38cc694fc210d34e Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 26 May 2017 17:27:22 +0200 Subject: [PATCH] basic support for nested snippets, #24855 --- .../snippet/browser/snippetController2.ts | 124 +++++++++++++----- .../contrib/snippet/browser/snippetSession.ts | 74 +++++------ .../test/browser/snippetController2.test.ts | 24 +++- .../test/browser/snippetSession.test.ts | 15 --- 4 files changed, 142 insertions(+), 95 deletions(-) diff --git a/src/vs/editor/contrib/snippet/browser/snippetController2.ts b/src/vs/editor/contrib/snippet/browser/snippetController2.ts index c17c871a8233..c35e9766a499 100644 --- a/src/vs/editor/contrib/snippet/browser/snippetController2.ts +++ b/src/vs/editor/contrib/snippet/browser/snippetController2.ts @@ -13,6 +13,62 @@ import { SnippetSession } from './snippetSession'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; +class SnippetSessions { + + private _stack: SnippetSession[] = []; + + add(session: SnippetSession): number { + return this._stack.push(session); + } + + clear(): void { + dispose(this._stack); + this._stack.length = 0; + } + + get empty(): boolean { + return this._stack.length === 0; + } + + get hasPlaceholder(): boolean { + return this._stack.some(s => s.hasPlaceholder); + } + + get isAtFirstPlaceholder(): boolean { + // return !this.empty && this._stack[0].isAtFirstPlaceholder; + return this._stack.every(s => s.isAtFirstPlaceholder); + } + + get isAtFinalPlaceholder(): boolean { + // return !this.empty && this._stack[0].isAtFinalPlaceholder; + return this._stack.every(s => s.isAtFinalPlaceholder); + } + + get isSelectionWithinPlaceholders(): boolean { + return this._stack.some(s => s.isSelectionWithinPlaceholders()); + } + + prev(): void { + for (let i = this._stack.length - 1; i >= 0; i--) { + const snippet = this._stack[i]; + if (!snippet.isAtFirstPlaceholder) { + snippet.prev(); + break; + } + } + } + + next(): void { + for (let i = this._stack.length - 1; i >= 0; i--) { + const snippet = this._stack[i]; + if (!snippet.isAtFinalPlaceholder) { + snippet.next(); + break; + } + } + } +} + @commonEditorContribution export class SnippetController2 { @@ -28,7 +84,8 @@ export class SnippetController2 { private readonly _hasNextTabstop: IContextKey; private readonly _hasPrevTabstop: IContextKey; - private _snippet: SnippetSession; + // private _snippet: SnippetSession; + private _sessions = new SnippetSessions(); private _snippetListener: IDisposable[] = []; constructor( @@ -44,7 +101,7 @@ export class SnippetController2 { this._inSnippet.reset(); this._hasPrevTabstop.reset(); this._hasNextTabstop.reset(); - dispose(this._snippet); + this._sessions.clear(); } getId(): string { @@ -56,47 +113,49 @@ export class SnippetController2 { overwriteBefore: number = 0, overwriteAfter: number = 0, undoStopBefore: boolean = true, undoStopAfter: boolean = true ): void { + + // don't listen while inserting the snippet + // as that is the inflight state causing cancelation + this._snippetListener = dispose(this._snippetListener); + if (undoStopBefore) { this._editor.getModel().pushStackElement(); } - if (!this._snippet) { - // create a new session - this._snippet = new SnippetSession(this._editor); - this._snippet.insert(template, overwriteBefore, overwriteAfter); - this._snippetListener = [ - this._editor.onDidChangeModel(() => this.cancel()), - this._editor.onDidChangeCursorSelection(() => this._updateState()) - ]; - } else { - // only insert the snippet when a session - // is already active - this._snippet.insert(template, overwriteBefore, overwriteAfter); - } + + const snippet = new SnippetSession(this._editor); + snippet.insert(template, overwriteBefore, overwriteAfter); + this._sessions.add(snippet); + if (undoStopAfter) { this._editor.getModel().pushStackElement(); } + + this._snippetListener = [ + this._editor.onDidChangeModel(() => this.cancel()), + this._editor.onDidChangeCursorSelection(() => this._updateState()) + ]; this._updateState(); } private _updateState(): void { - if (!this._snippet) { + if (this._sessions.empty) { // canceled in the meanwhile return; } - if (!this._snippet.hasPlaceholder) { + if (!this._sessions.hasPlaceholder) { // don't listen for selection changes and don't // update context keys when the snippet is plain text return this.cancel(); } - if (this._snippet.isAtFinalPlaceholder || !this._snippet.isSelectionWithinPlaceholders()) { + if (this._sessions.isAtFinalPlaceholder || !this._sessions.isSelectionWithinPlaceholders) { return this.cancel(); } this._inSnippet.set(true); - this._hasPrevTabstop.set(!this._snippet.isAtFirstPlaceholder); - this._hasNextTabstop.set(!this._snippet.isAtFinalPlaceholder); + this._hasPrevTabstop.set(!this._sessions.isAtFirstPlaceholder); + this._hasNextTabstop.set(!this._sessions.isAtFinalPlaceholder); } finish(): void { @@ -106,28 +165,21 @@ export class SnippetController2 { } cancel(): void { - if (this._snippet) { - this._inSnippet.reset(); - this._hasPrevTabstop.reset(); - this._hasNextTabstop.reset(); - dispose(this._snippetListener); - dispose(this._snippet); - this._snippet = undefined; - } + this._inSnippet.reset(); + this._hasPrevTabstop.reset(); + this._hasNextTabstop.reset(); + this._sessions.clear(); + dispose(this._snippetListener); } prev(): void { - if (this._snippet) { - this._snippet.prev(); - this._updateState(); - } + this._sessions.prev(); + this._updateState(); } next(): void { - if (this._snippet) { - this._snippet.next(); - this._updateState(); - } + this._sessions.next(); + this._updateState(); } } diff --git a/src/vs/editor/contrib/snippet/browser/snippetSession.ts b/src/vs/editor/contrib/snippet/browser/snippetSession.ts index 9450bfa8ba35..51567e97c200 100644 --- a/src/vs/editor/contrib/snippet/browser/snippetSession.ts +++ b/src/vs/editor/contrib/snippet/browser/snippetSession.ts @@ -130,11 +130,13 @@ export class OneSnippet { } get isAtFirstPlaceholder() { - return this._placeholderGroupsIdx === 0; + return this._placeholderGroupsIdx === 0 || this._placeholderGroups.length === 0; } get isAtFinalPlaceholder() { - if (this._placeholderGroupsIdx < 0) { + if (this._placeholderGroups.length === 0) { + return true; + } else if (this._placeholderGroupsIdx < 0) { return false; } else { return this._placeholderGroups[this._placeholderGroupsIdx][0].isFinalTabstop; @@ -196,24 +198,35 @@ export class SnippetSession { return selection; } - static makeInsertEditsAndSnippets(editor: ICommonCodeEditor, template: string, overwriteBefore: number = 0, overwriteAfter: number = 0): { edits: IIdentifiedSingleEditOperation[], snippets: OneSnippet[] } { + private readonly _editor: ICommonCodeEditor; + private _snippets: OneSnippet[] = []; + + constructor(editor: ICommonCodeEditor) { + this._editor = editor; + } + + dispose(): void { + dispose(this._snippets); + } + + insert(template: string, overwriteBefore: number = 0, overwriteAfter: number = 0): void { + + const model = this._editor.getModel(); + const edits: IIdentifiedSingleEditOperation[] = []; let delta = 0; - let edits: IIdentifiedSingleEditOperation[] = []; - let snippets: OneSnippet[] = []; - let model = editor.getModel(); // know what text the overwrite[Before|After] extensions // of the primary curser have selected because only when // secondary selections extend to the same text we can grow them - let firstBeforeText = model.getValueInRange(SnippetSession.adjustSelection(model, editor.getSelection(), overwriteBefore, 0)); - let firstAfterText = model.getValueInRange(SnippetSession.adjustSelection(model, editor.getSelection(), 0, overwriteAfter)); + let firstBeforeText = model.getValueInRange(SnippetSession.adjustSelection(model, this._editor.getSelection(), overwriteBefore, 0)); + let firstAfterText = model.getValueInRange(SnippetSession.adjustSelection(model, this._editor.getSelection(), 0, overwriteAfter)); // sort selections by their start position but remeber // the original index. that allows you to create correct // offset-based selection logic without changing the // primary selection - const indexedSelection = editor.getSelections() + const indexedSelection = this._editor.getSelections() .map((selection, idx) => ({ selection, idx })) .sort((a, b) => Range.compareRangesUsingStarts(a.selection, b.selection)); @@ -248,46 +261,18 @@ export class SnippetSession { // that ensures the primiary cursor stays primary despite not being // the one with lowest start position edits[idx] = EditOperation.replaceMove(snippetSelection, snippet.text); - snippets[idx] = new OneSnippet(editor, snippet, offset); - } - - return { edits, snippets }; - } - - private readonly _editor: ICommonCodeEditor; - private _snippets: OneSnippet[]; - - constructor(editor: ICommonCodeEditor) { - this._editor = editor; - } - - dispose(): void { - dispose(this._snippets); - } - - insert(template: string, overwriteBefore: number = 0, overwriteAfter: number = 0): void { - - const model = this._editor.getModel(); - const { edits, snippets } = SnippetSession.makeInsertEditsAndSnippets( - this._editor, template, overwriteBefore, overwriteAfter - ); - - let isNestedInsert = true; - if (!this._snippets) { - // keep snippets around - this._snippets = snippets; - isNestedInsert = false; + this._snippets[idx] = new OneSnippet(this._editor, snippet, offset); } // make insert edit and start with first selections - const newSelections = model.pushEditOperations(this._editor.getSelections(), edits, undoEdits => { - if (!isNestedInsert && this._snippets[0].hasPlaceholder) { + + this._editor.setSelections(model.pushEditOperations(this._editor.getSelections(), edits, undoEdits => { + if (this._snippets[0].hasPlaceholder) { return this._move(true); } else { return undoEdits.map(edit => Selection.fromPositions(edit.range.getEndPosition())); } - }); - this._editor.setSelections(newSelections); + })); } next(): void { @@ -322,6 +307,11 @@ export class SnippetSession { } isSelectionWithinPlaceholders(): boolean { + + if (!this.hasPlaceholder) { + return false; + } + const selections = this._editor.getSelections(); if (selections.length < this._snippets.length) { // this means we started snippet mode with N diff --git a/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts b/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts index b862a3dba923..ce84d8796d83 100644 --- a/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts +++ b/src/vs/editor/contrib/snippet/test/browser/snippetController2.test.ts @@ -148,17 +148,37 @@ suite('SnippetController2', function () { // assertContextKeys(contextKeys, false, false, false); }); - test('insert, insert nested', function () { + test('insert, nested snippet', function () { + const ctrl = new SnippetController2(editor, contextKeys); + ctrl.insert('${1:foobar}$0'); + assertContextKeys(contextKeys, true, false, true); + assertSelections(editor, new Selection(1, 1, 1, 7), new Selection(2, 5, 2, 11)); + + ctrl.insert('farboo$1$0'); + assertSelections(editor, new Selection(1, 7, 1, 7), new Selection(2, 11, 2, 11)); + assertContextKeys(contextKeys, true, false, true); + + ctrl.next(); + assertSelections(editor, new Selection(1, 7, 1, 7), new Selection(2, 11, 2, 11)); + assertContextKeys(contextKeys, true, true, true); + + ctrl.next(); + assertSelections(editor, new Selection(1, 7, 1, 7), new Selection(2, 11, 2, 11)); + assertContextKeys(contextKeys, false, false, false); + }); + + test('insert, nested plain text', function () { const ctrl = new SnippetController2(editor, contextKeys); ctrl.insert('${1:foobar}$0'); assertContextKeys(contextKeys, true, false, true); assertSelections(editor, new Selection(1, 1, 1, 7), new Selection(2, 5, 2, 11)); ctrl.insert('farboo'); - assertContextKeys(contextKeys, true, false, true); assertSelections(editor, new Selection(1, 7, 1, 7), new Selection(2, 11, 2, 11)); + assertContextKeys(contextKeys, true, false, true); ctrl.next(); + assertSelections(editor, new Selection(1, 7, 1, 7), new Selection(2, 11, 2, 11)); assertContextKeys(contextKeys, false, false, false); }); }); diff --git a/src/vs/editor/contrib/snippet/test/browser/snippetSession.test.ts b/src/vs/editor/contrib/snippet/test/browser/snippetSession.test.ts index 8d5af1eef2e0..ce807bedad76 100644 --- a/src/vs/editor/contrib/snippet/test/browser/snippetSession.test.ts +++ b/src/vs/editor/contrib/snippet/test/browser/snippetSession.test.ts @@ -403,20 +403,5 @@ suite('SnippetSession', function () { assert.equal(model.getValue(), '@line=1function foo() {\n @line=2console.log(a);\n}'); assertSelections(editor, new Selection(1, 8, 1, 8), new Selection(2, 12, 2, 12)); }); - - test('snippet, insert-nested', function () { - const session = new SnippetSession(editor); - session.insert('foo$1foo$0'); - - assertSelections(editor, new Selection(1, 4, 1, 4), new Selection(2, 8, 2, 8)); - session.insert('bar'); - assert.ok(session.isSelectionWithinPlaceholders()); - assertSelections(editor, new Selection(1, 7, 1, 7), new Selection(2, 11, 2, 11)); - - session.next(); - assertSelections(editor, new Selection(1, 10, 1, 10), new Selection(2, 14, 2, 14)); - assert.ok(session.isAtFinalPlaceholder); - }); - });