Repository: microsoft/vscode (TypeScript)
Date: 2021-05-05 20:20:48+00:00
Task type: Bug Fix

Issue:
Undo removes one of three spaces
1 Enter

`three   spaces`

2 Execute Edit Undo

Expected: `three`. Or perhaps `three   `
Observed: `three  ` i.e. one space removed, two not.

EDIT: Whoops, that formatting was mangled by at least two bugs on this web form. Here's the source:

![image](https://user-images.githubusercontent.com/6157073/73618558-7390f900-4620-11ea-9552-79fa171cef7d.png)

Undo removes one of three spaces
1 Enter

`three   spaces`

2 Execute Edit Undo

Expected: `three`. Or perhaps `three   `
Observed: `three  ` i.e. one space removed, two not.

EDIT: Whoops, that formatting was mangled by at least two bugs on this web form. Here's the source:

![image](https://user-images.githubusercontent.com/6157073/73618558-7390f900-4620-11ea-9552-79fa171cef7d.png)



Issue discussion:

Solution patch:
diff --git a/src/vs/editor/common/controller/cursorCommon.ts b/src/vs/editor/common/controller/cursorCommon.ts
--- a/src/vs/editor/common/controller/cursorCommon.ts
+++ b/src/vs/editor/common/controller/cursorCommon.ts
@@ -39,9 +39,11 @@ export const enum RevealTarget {
  */
 export const enum EditOperationType {
 	Other = 0,
-	Typing = 1,
 	DeletingLeft = 2,
-	DeletingRight = 3
+	DeletingRight = 3,
+	TypingOther = 4,
+	TypingFirstSpace = 5,
+	TypingConsecutiveSpace = 6,
 }
 
 export interface CharacterMap {
diff --git a/src/vs/editor/common/controller/cursorTypeOperations.ts b/src/vs/editor/common/controller/cursorTypeOperations.ts
--- a/src/vs/editor/common/controller/cursorTypeOperations.ts
+++ b/src/vs/editor/common/controller/cursorTypeOperations.ts
@@ -260,8 +260,8 @@ export class TypeOperations {
 
 	public static compositionType(prevEditOperationType: EditOperationType, config: CursorConfiguration, model: ITextModel, selections: Selection[], text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number): EditOperationResult {
 		const commands = selections.map(selection => this._compositionType(model, selection, text, replacePrevCharCnt, replaceNextCharCnt, positionDelta));
-		return new EditOperationResult(EditOperationType.Typing, commands, {
-			shouldPushStackElementBefore: (prevEditOperationType !== EditOperationType.Typing),
+		return new EditOperationResult(EditOperationType.TypingOther, commands, {
+			shouldPushStackElementBefore: shouldPushStackElementBetween(prevEditOperationType, EditOperationType.TypingOther),
 			shouldPushStackElementAfter: false
 		});
 	}
@@ -484,8 +484,8 @@ export class TypeOperations {
 			const typeSelection = new Range(position.lineNumber, position.column, position.lineNumber, position.column + 1);
 			commands[i] = new ReplaceCommand(typeSelection, ch);
 		}
-		return new EditOperationResult(EditOperationType.Typing, commands, {
-			shouldPushStackElementBefore: (prevEditOperationType !== EditOperationType.Typing),
+		return new EditOperationResult(EditOperationType.TypingOther, commands, {
+			shouldPushStackElementBefore: shouldPushStackElementBetween(prevEditOperationType, EditOperationType.TypingOther),
 			shouldPushStackElementAfter: false
 		});
 	}
@@ -636,7 +636,7 @@ export class TypeOperations {
 			const selection = selections[i];
 			commands[i] = new TypeWithAutoClosingCommand(selection, ch, insertOpenCharacter, autoClosingPairClose);
 		}
-		return new EditOperationResult(EditOperationType.Typing, commands, {
+		return new EditOperationResult(EditOperationType.TypingOther, commands, {
 			shouldPushStackElementBefore: true,
 			shouldPushStackElementAfter: false
 		});
@@ -762,7 +762,7 @@ export class TypeOperations {
 				let typeSelection = new Range(position.lineNumber, 1, position.lineNumber, position.column);
 
 				const command = new ReplaceCommand(typeSelection, typeText);
-				return new EditOperationResult(EditOperationType.Typing, [command], {
+				return new EditOperationResult(getTypingOperation(typeText, prevEditOperationType), [command], {
 					shouldPushStackElementBefore: false,
 					shouldPushStackElementAfter: true
 				});
@@ -803,7 +803,7 @@ export class TypeOperations {
 		if (this._isAutoClosingOvertype(config, model, selections, autoClosedCharacters, ch)) {
 			// Unfortunately, the close character is at this point "doubled", so we need to delete it...
 			const commands = selections.map(s => new ReplaceCommand(new Range(s.positionLineNumber, s.positionColumn, s.positionLineNumber, s.positionColumn + 1), '', false));
-			return new EditOperationResult(EditOperationType.Typing, commands, {
+			return new EditOperationResult(EditOperationType.TypingOther, commands, {
 				shouldPushStackElementBefore: true,
 				shouldPushStackElementAfter: false
 			});
@@ -824,7 +824,7 @@ export class TypeOperations {
 			for (let i = 0, len = selections.length; i < len; i++) {
 				commands[i] = TypeOperations._enter(config, model, false, selections[i]);
 			}
-			return new EditOperationResult(EditOperationType.Typing, commands, {
+			return new EditOperationResult(EditOperationType.TypingOther, commands, {
 				shouldPushStackElementBefore: true,
 				shouldPushStackElementAfter: false,
 			});
@@ -841,7 +841,7 @@ export class TypeOperations {
 				}
 			}
 			if (!autoIndentFails) {
-				return new EditOperationResult(EditOperationType.Typing, commands, {
+				return new EditOperationResult(EditOperationType.TypingOther, commands, {
 					shouldPushStackElementBefore: true,
 					shouldPushStackElementAfter: false,
 				});
@@ -877,12 +877,10 @@ export class TypeOperations {
 		for (let i = 0, len = selections.length; i < len; i++) {
 			commands[i] = new ReplaceCommand(selections[i], ch);
 		}
-		let shouldPushStackElementBefore = (prevEditOperationType !== EditOperationType.Typing);
-		if (ch === ' ') {
-			shouldPushStackElementBefore = true;
-		}
-		return new EditOperationResult(EditOperationType.Typing, commands, {
-			shouldPushStackElementBefore: shouldPushStackElementBefore,
+
+		const opType = getTypingOperation(ch, prevEditOperationType);
+		return new EditOperationResult(opType, commands, {
+			shouldPushStackElementBefore: shouldPushStackElementBetween(prevEditOperationType, opType),
 			shouldPushStackElementAfter: false
 		});
 	}
@@ -892,8 +890,9 @@ export class TypeOperations {
 		for (let i = 0, len = selections.length; i < len; i++) {
 			commands[i] = new ReplaceCommand(selections[i], str);
 		}
-		return new EditOperationResult(EditOperationType.Typing, commands, {
-			shouldPushStackElementBefore: (prevEditOperationType !== EditOperationType.Typing),
+		const opType = getTypingOperation(str, prevEditOperationType);
+		return new EditOperationResult(opType, commands, {
+			shouldPushStackElementBefore: shouldPushStackElementBetween(prevEditOperationType, opType),
 			shouldPushStackElementAfter: false
 		});
 	}
@@ -965,3 +964,40 @@ export class TypeWithAutoClosingCommand extends ReplaceCommandWithOffsetCursorSt
 		return super.computeCursorState(model, helper);
 	}
 }
+
+function getTypingOperation(typedText: string, previousTypingOperation: EditOperationType): EditOperationType {
+	if (typedText === ' ') {
+		return previousTypingOperation === EditOperationType.TypingFirstSpace
+			|| previousTypingOperation === EditOperationType.TypingConsecutiveSpace
+			? EditOperationType.TypingConsecutiveSpace
+			: EditOperationType.TypingFirstSpace;
+	}
+
+	return EditOperationType.TypingOther;
+}
+
+function shouldPushStackElementBetween(previousTypingOperation: EditOperationType, typingOperation: EditOperationType): boolean {
+	if (isTypingOperation(previousTypingOperation) && !isTypingOperation(typingOperation)) {
+		// Always set an undo stop before non-type operations
+		return true;
+	}
+	if (previousTypingOperation === EditOperationType.TypingFirstSpace) {
+		// `abc |d`: No undo stop
+		// `abc  |d`: Undo stop
+		return false;
+	}
+	// Insert undo stop between different operation types
+	return normalizeOperationType(previousTypingOperation) !== normalizeOperationType(typingOperation);
+}
+
+function normalizeOperationType(type: EditOperationType): EditOperationType | 'space' {
+	return (type === EditOperationType.TypingConsecutiveSpace || type === EditOperationType.TypingFirstSpace)
+		? 'space'
+		: type;
+}
+
+function isTypingOperation(type: EditOperationType): boolean {
+	return type === EditOperationType.TypingOther
+		|| type === EditOperationType.TypingFirstSpace
+		|| type === EditOperationType.TypingConsecutiveSpace;
+}


Test patch:
diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts
--- a/src/vs/editor/test/browser/controller/cursor.test.ts
+++ b/src/vs/editor/test/browser/controller/cursor.test.ts
@@ -6137,4 +6137,62 @@ suite('Undo stops', () => {
 			assert.strictEqual(model.getValue(), 'hello world\nhello world');
 		});
 	});
+
+	test('there is a single undo stop for consecutive whitespaces', () => {
+		let model = createTextModel(
+			[
+				''
+			].join('\n'),
+			{
+				insertSpaces: false,
+			}
+		);
+
+		withTestCodeEditor(null, { model: model }, (editor, viewModel) => {
+			viewModel.type('a', 'keyboard');
+			viewModel.type('b', 'keyboard');
+			viewModel.type(' ', 'keyboard');
+			viewModel.type(' ', 'keyboard');
+			viewModel.type('c', 'keyboard');
+			viewModel.type('d', 'keyboard');
+
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), 'ab  cd', 'assert1');
+
+			CoreEditingCommands.Undo.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), 'ab  ', 'assert2');
+
+			CoreEditingCommands.Undo.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), 'ab', 'assert3');
+
+			CoreEditingCommands.Undo.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '', 'assert4');
+		});
+	});
+
+	test('there is no undo stop after a single whitespace', () => {
+		let model = createTextModel(
+			[
+				''
+			].join('\n'),
+			{
+				insertSpaces: false,
+			}
+		);
+
+		withTestCodeEditor(null, { model: model }, (editor, viewModel) => {
+			viewModel.type('a', 'keyboard');
+			viewModel.type('b', 'keyboard');
+			viewModel.type(' ', 'keyboard');
+			viewModel.type('c', 'keyboard');
+			viewModel.type('d', 'keyboard');
+
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), 'ab cd', 'assert1');
+
+			CoreEditingCommands.Undo.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), 'ab', 'assert3');
+
+			CoreEditingCommands.Undo.runEditorCommand(null, editor, null);
+			assert.strictEqual(model.getValue(EndOfLinePreference.LF), '', 'assert4');
+		});
+	});
 });