Change:
Issue Type: <bBug</bLike what the heading says, inserting a code snippet anywhere in the code file causes an autoscroll. This is a slight inconvenience, as while inserting snippets, viewing code above it is desirable, causing us to rescroll into view. So adding an option to revert to the functionality in the stable release would be preferred VS Code version: Code - Insiders 1.46.0-insider (, 2020-05-07T16:36:39.681Z) OS version: Linux x64 5.4.38-1-MANJARO <details<summarySystem Info</summary </details<details<summaryExtensions (4)</summaryExtensionVersion ------ cpptools0.27.1 Go0.14.1 material-icon-theme4.1.0 material-theme3.5.0 (1 theme extensions excluded) </details<!-- generated by issue reporter --
Yeah - that's a regression from a recent change to keep the position stable when additional edits are inserted...
is the item that caused this regression.
fixed via
Yep, it works fine now. Thanks a lot!
This bug has been fixed in to the latest release of ! you can help us out by commenting if things are now working as expected. If things still don't seem right, please ensure you're on version of Insiders (today's or later - you can use in the command pallette to check), and leave a comment letting us know what isn't working as expected. Happy Coding!
/verified

Discussion:
// create the suggestion resolver if (typeof provider.resolveCompletionItem !== 'function') { this._resolveCache = Promise.resolve(); <ins> this._isResolved = true; </ins> } } // resolving get isResolved() { <del> return Boolean(this._resolveCache); </del> <ins> return Boolean(this._isResolved); </ins> } private _resolveCache?: Promise<void>; <ins> private _isResolved?: boolean; </ins> async resolve(token: CancellationToken) { if (!this._resolveCache) { const sub = token.onCancellationRequested(() => { this._resolveCache = undefined; <ins> this._isResolved = false; </ins> }); this._resolveCache = Promise.resolve(this.provider.resolveCompletionItem!(this.completion, token)).then(value => { Object.assign(this.completion, value); <ins> this._isResolved = true; </ins> sub.dispose(); }, err => { if (isPromiseCanceledError(err)) { // the IPC queue will reject the request with the // cancellation error -> reset cached this._resolveCache = undefined; <ins> this._isResolved = false; </ins> } }); }

import { EditorOption } from 'vs/editor/common/config/editorOptions'; import * as platform from 'vs/base/common/platform'; import { MenuRegistry } from 'vs/platform/actions/common/actions'; <ins> import { CancellationTokenSource } from 'vs/base/common/cancellation'; </ins> // sticky suggest widget which doesn't disappear on focus out and such let _sticky = false;

const modelVersionNow = model.getAlternativeVersionId(); const { item } = event; <ins> // const tasks: Promise<any>[] = []; const cts = new CancellationTokenSource(); </ins> // pushing undo stops *before* additional text edits and // *after* the main edit if (!(flags & InsertFlags.NoBeforeUndoStop)) {

// keep item in memory this._memoryService.memorize(model, this.editor.getPosition(), item); <del> const scrollState = StableEditorScrollState.capture(this.editor); </del> if (Array.isArray(item.completion.additionalTextEdits)) { <del> this.editor.executeEdits('suggestController.additionalTextEdits', item.completion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text))); </del> <ins> // sync additional edits const scrollState = StableEditorScrollState.capture(this.editor); this.editor.executeEdits( 'suggestController.additionalTextEdits.sync', item.completion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text)) ); scrollState.restoreRelativeVerticalPositionOfCursor(this.editor); } else if (!item.isResolved) { // async additional edits let position: IPosition | undefined; const docListener = model.onDidChangeContent(e => { if (e.isFlush) { cts.cancel(); docListener.dispose(); return; } for (let change of e.changes) { const thisPosition = Range.getEndPosition(change.range); if (!position || Position.isBefore(thisPosition, position)) { position = thisPosition; } } }); let oldFlags = flags; flags |= InsertFlags.NoAfterUndoStop; let didType = false; let typeListener = this.editor.onWillType(() => { typeListener.dispose(); didType = true; if (!(oldFlags & InsertFlags.NoAfterUndoStop)) { this.editor.pushUndoStop(); } }); tasks.push(item.resolve(cts.token).then(() => { if (!item.completion.additionalTextEdits || cts.token.isCancellationRequested) { return; } if (position && item.completion.additionalTextEdits.some(edit => Position.isBefore(position!, Range.getStartPosition(edit.range)))) { return; } if (didType) { this.editor.pushUndoStop(); } this.editor.executeEdits( 'suggestController.additionalTextEdits.async', item.completion.additionalTextEdits.map(edit => EditOperation.replace(Range.lift(edit.range), edit.text)) ); if (didType || !(oldFlags & InsertFlags.NoAfterUndoStop)) { this.editor.pushUndoStop(); } }).finally(() => { docListener.dispose(); typeListener.dispose(); })); </ins> } let { insertText } = item.completion;

adjustWhitespace: !(item.completion.insertTextRules! & CompletionItemInsertTextRule.KeepWhitespace) }); <del> scrollState.restoreRelativeVerticalPositionOfCursor(this.editor); </del> if (!(flags & InsertFlags.NoAfterUndoStop)) { this.editor.pushUndoStop(); }

if (!item.completion.command) { // done this.model.cancel(); <del> this.model.clear(); </del> } else if (item.completion.command.id === TriggerSuggestAction.id) { // retigger

} else { // exec command, done <del> this._commandService.executeCommand(item.completion.command.id, ...(item.completion.command.arguments ? [...item.completion.command.arguments] : [])) .catch(onUnexpectedError) .finally(() => this.model.clear()); // <- clear only now, keep commands alive </del> <ins> tasks.push(this._commandService.executeCommand(item.completion.command.id, ...(item.completion.command.arguments ? [...item.completion.command.arguments] : [])).catch(onUnexpectedError)); </ins> this.model.cancel(); } if (flags & InsertFlags.KeepAlternativeSuggestions) { this._alternatives.value.set(event, next => { <ins> // cancel resolving of additional edits cts.cancel(); </ins> // this is not so pretty. when inserting the 'next' // suggestion we undo until we are at the state at // which we were before inserting the previous suggestion...

} this._alertCompletionItem(item); <ins> // clear only now - after all tasks are done Promise.all(tasks).finally(() => { this.model.clear(); cts.dispose(); }); </ins> } getOverwriteInfo(item: CompletionItem, toggleMode: boolean): { overwriteBefore: number, overwriteAfter: number } {

import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2'; import { IMenuService, IMenu } from 'vs/platform/actions/common/actions'; import { createTextModel } from 'vs/editor/test/common/editorTestUtils'; <ins> import { Range } from 'vs/editor/common/core/range'; import { timeout } from 'vs/base/common/async'; </ins> suite('SuggestController', function () {

assert.equal(editor.getValue(), '    let name = foo'); }); <ins> test('use additionalTextEdits sync when possible', async function () { disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos), additionalTextEdits: [{ text: 'I came sync', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }] }] }; }, async resolveCompletionItem(item) { return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'I came synchellonhallohello'); }); test('resolve additionalTextEdits async when needed', async function () { let resolveCallCount = 0; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await timeout(10); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }]; return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hellonhallohello'); assert.equal(resolveCallCount, 1); // additional edits happened after a litte wait await timeout(20); assert.equal(editor.getValue(), 'I came latehellonhallohello'); // single undo stop editor.getModel()?.undo(); assert.equal(editor.getValue(), 'hellonhallo'); }); test('resolve additionalTextEdits async when needed (typing)', async function () { let resolveCallCount = 0; let resolve: Function = () => { }; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await new Promise(_resolve => resolve = _resolve); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } }]; return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hellonhallohello'); assert.equal(resolveCallCount, 1); // additional edits happened after a litte wait assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 11, 2, 11))); editor.trigger('test', 'type', { text: 'TYPING' }); assert.equal(editor.getValue(), 'hellonhallohelloTYPING'); resolve(); await timeout(10); assert.equal(editor.getValue(), 'I came latehellonhallohelloTYPING'); assert.ok(editor.getSelection()?.equalsSelection(new Selection(2, 17, 2, 17))); }); // additional edit come late and are AFTER the selection -> cancel test('resolve additionalTextEdits async when needed (simple conflict)', async function () { let resolveCallCount = 0; let resolve: Function = () => { }; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await new Promise(_resolve => resolve = _resolve); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 6, endLineNumber: 1, endColumn: 6 } }]; return item; } })); editor.setValue(''); editor.setSelection(new Selection(1, 1, 1, 1)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hello'); assert.equal(resolveCallCount, 1); resolve(); await timeout(10); assert.equal(editor.getValue(), 'hello'); }); // additional edit come late and are AFTER the position at which the user typed -> cancelled test('resolve additionalTextEdits async when needed (conflict)', async function () { let resolveCallCount = 0; let resolve: Function = () => { }; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { resolveCallCount += 1; await new Promise(_resolve => resolve = _resolve); item.additionalTextEdits = [{ text: 'I came late', range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 } }]; return item; } })); editor.setValue('hellonhallo'); editor.setSelection(new Selection(2, 6, 2, 6)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(false, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'hellonhallohello'); assert.equal(resolveCallCount, 1); // additional edits happened after a litte wait editor.setSelection(new Selection(1, 1, 1, 1)); editor.trigger('test', 'type', { text: 'TYPING' }); assert.equal(editor.getValue(), 'TYPINGhellonhallohello'); resolve(); await timeout(10); assert.equal(editor.getValue(), 'TYPINGhellonhallohello'); assert.ok(editor.getSelection()?.equalsSelection(new Selection(1, 7, 1, 7))); }); test('resolve additionalTextEdits async when needed (cancel)', async function () { let resolve: Function[] = []; disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { provideCompletionItems(doc, pos) { return { suggestions: [{ kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hello', range: Range.fromPositions(pos) }, { kind: CompletionItemKind.Snippet, label: 'let', insertText: 'hallo', range: Range.fromPositions(pos) }] }; }, async resolveCompletionItem(item) { await new Promise(_resolve => resolve.push(_resolve)); item.additionalTextEdits = [{ text: 'additionalTextEdits', range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 } }]; return item; } })); editor.setValue('abc'); editor.setSelection(new Selection(1, 1, 1, 1)); // trigger let p1 = Event.toPromise(controller.model.onDidSuggest); controller.triggerSuggest(); await p1; // let p2 = Event.toPromise(controller.model.onDidCancel); controller.acceptSelectedSuggestion(true, false); await p2; // insertText happens sync! assert.equal(editor.getValue(), 'helloabc'); // next controller.acceptNextSuggestion(); // resolve additional edits (MUST be cancelled) resolve.forEach(fn => fn); resolve.length = 0; await timeout(10); // next suggestion used assert.equal(editor.getValue(), 'halloabc'); }); </ins> });