Repository: microsoft/vscode (TypeScript) — Feature

Issue:
First class support for inline text (move away from ::after)
related #125324, #125123


Discussion:

Resolving patch:
diff --git a/src/vs/editor/common/viewModel/splitLinesCollection.ts b/src/vs/editor/common/viewModel/splitLinesCollection.ts
--- a/src/vs/editor/common/viewModel/splitLinesCollection.ts
+++ b/src/vs/editor/common/viewModel/splitLinesCollection.ts
@@ -1246,7 +1246,7 @@ export class SplitLine implements ISplitLine {
 
 		let r: string;
 		if (this._lineBreakData.injectionOffsets !== null) {
-			const injectedTexts = this._lineBreakData.injectionOffsets.map((offset, idx) => new LineInjectedText(0, 0, offset, this._lineBreakData.injectionOptions![idx], 0));
+			const injectedTexts = this._lineBreakData.injectionOffsets.map((offset, idx) => new LineInjectedText(0, 0, offset + 1, this._lineBreakData.injectionOptions![idx], 0));
 			r = LineInjectedText.applyInjectedText(model.getLineContent(modelLineNumber), injectedTexts).substring(startOffset, endOffset);
 		} else {
 			r = model.getValueInRange({
diff --git a/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts b/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts
--- a/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts
+++ b/src/vs/editor/contrib/inlineCompletions/ghostTextWidget.ts
@@ -25,20 +25,21 @@ import { GhostTextWidgetModel } from 'vs/editor/contrib/inlineCompletions/ghostT
 import { IModelDeltaDecoration } from 'vs/editor/common/model';
 import { LineDecoration } from 'vs/editor/common/viewLayout/lineDecorations';
 import { InlineDecorationType } from 'vs/editor/common/viewModel/viewModel';
+import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
+import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
 
 const ttPolicy = window.trustedTypes?.createPolicy('editorGhostText', { createHTML: value => value });
 
 export class GhostTextWidget extends Disposable {
 	private disposed = false;
-	private readonly partsWidget = this._register(new DecorationsWidget(this.editor, this.codeEditorService, this.themeService));
+	private readonly partsWidget = this._register(this.instantiationService.createInstance(DecorationsWidget, this.editor));
 	private readonly additionalLinesWidget = this._register(new AdditionalLinesWidget(this.editor));
 	private viewMoreContentWidget: ViewMoreLinesContentWidget | undefined = undefined;
 
 	constructor(
 		private readonly editor: ICodeEditor,
 		private readonly model: GhostTextWidgetModel,
-		@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
-		@IThemeService private readonly themeService: IThemeService,
+		@IInstantiationService private readonly instantiationService: IInstantiationService,
 	) {
 		super();
 
@@ -200,7 +201,8 @@ class DecorationsWidget implements IDisposable {
 	constructor(
 		private readonly editor: ICodeEditor,
 		@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
-		@IThemeService private readonly themeService: IThemeService
+		@IThemeService private readonly themeService: IThemeService,
+		@IContextKeyService private readonly contextKeyService: IContextKeyService
 	) {
 	}
 
@@ -254,6 +256,9 @@ class DecorationsWidget implements IDisposable {
 			});
 		}
 
+		const key = this.contextKeyService.getContextKeyValue('config.editor.useInjectedText');
+		const shouldUseInjectedText = key === undefined ? true : !!key;
+
 		this.decorationIds = this.editor.deltaDecorations(this.decorationIds, parts.map<IModelDeltaDecoration>(p => {
 			currentLinePrefix += line.substring(lastIndex, p.column - 1);
 			lastIndex = p.column - 1;
@@ -273,7 +278,10 @@ class DecorationsWidget implements IDisposable {
 
 			return ({
 				range: Range.fromPositions(new Position(lineNumber, p.column)),
-				options: {
+				options: shouldUseInjectedText ? {
+					description: 'ghost-text',
+					after: { content: contentText, inlineClassName: 'ghost-text-decoration' }
+				} : {
 					...decorationType.resolve()
 				}
 			});
@@ -486,7 +494,7 @@ registerThemingParticipant((theme, collector) => {
 		const opacity = String(foreground.rgba.a);
 		const color = Color.Format.CSS.format(opaque(foreground))!;
 
-		// We need to override the only used token type .mtk1
+		collector.addRule(`.monaco-editor .ghost-text-decoration { opacity: ${opacity}; color: ${color}; }`);
 		collector.addRule(`.monaco-editor .suggest-preview-text .ghost-text { opacity: ${opacity}; color: ${color}; }`);
 	}
 
diff --git a/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts
--- a/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts
+++ b/src/vs/editor/contrib/inlineCompletions/inlineCompletionsModel.ts
@@ -19,7 +19,7 @@ import { ICommandService } from 'vs/platform/commands/common/commands';
 import { EditorOption } from 'vs/editor/common/config/editorOptions';
 import { RedoCommand, UndoCommand } from 'vs/editor/browser/editorExtensions';
 import { CoreEditingCommands } from 'vs/editor/browser/controller/coreCommands';
-import { IDiffChange, stringDiff } from 'vs/base/common/diff/diff';
+import { IDiffChange, LcsDiff } from 'vs/base/common/diff/diff';
 import { GhostTextWidgetModel, GhostText, BaseGhostTextWidgetModel, GhostTextPart } from 'vs/editor/contrib/inlineCompletions/ghostText';
 
 export class InlineCompletionsModel extends Disposable implements GhostTextWidgetModel {
@@ -540,12 +540,12 @@ export function inlineCompletionToGhostText(inlineCompletion: NormalizedInlineCo
 	return new GhostText(lineNumber, parts, 0);
 }
 
-let lastRequest: { originalValue: string, newValue: string, changes: IDiffChange[] } | undefined = undefined;
+let lastRequest: { originalValue: string, newValue: string, changes: readonly IDiffChange[] } | undefined = undefined;
 function cachingDiff(originalValue: string, newValue: string): readonly IDiffChange[] {
 	if (lastRequest?.originalValue === originalValue && lastRequest?.newValue === newValue) {
 		return lastRequest?.changes;
 	} else {
-		const changes = stringDiff(originalValue, newValue, false);
+		const changes = smartDiff(originalValue, newValue);
 		lastRequest = {
 			originalValue,
 			newValue,
@@ -555,6 +555,63 @@ function cachingDiff(originalValue: string, newValue: string): readonly IDiffCha
 	}
 }
 
+/**
+ * When matching `if ()` with `if (f() = 1) { g(); }`,
+ * align it like this:        `if (       )`
+ * Not like this:			  `if (  )`
+ * Also not like this:		  `if (             )`.
+ *
+ * The parenthesis are preprocessed to ensure that they match correctly.
+ */
+function smartDiff(originalValue: string, newValue: string): readonly IDiffChange[] {
+	function getMaxCharCode(val: string): number {
+		let maxCharCode = 0;
+		for (let i = 0, len = val.length; i < len; i++) {
+			const charCode = val.charCodeAt(i);
+			if (charCode > maxCharCode) {
+				maxCharCode = charCode;
+			}
+		}
+		return maxCharCode;
+	}
+	const maxCharCode = Math.max(getMaxCharCode(originalValue), getMaxCharCode(newValue));
+	function getUniqueCharCode(id: number): number {
+		if (id < 0) {
+			throw new Error('unexpected');
+		}
+		return maxCharCode + id + 1;
+	}
+
+	function getElements(source: string): Int32Array {
+		let level = 0;
+		let group = 0;
+		const characters = new Int32Array(source.length);
+		for (let i = 0, len = source.length; i < len; i++) {
+			const id = group * 100 + level;
+
+			// TODO support more brackets
+			if (source[i] === '(') {
+				characters[i] = getUniqueCharCode(2 * id);
+				level++;
+			} else if (source[i] === ')') {
+				characters[i] = getUniqueCharCode(2 * id + 1);
+				if (level === 1) {
+					group++;
+				}
+				level = Math.max(level - 1, 0);
+			} else {
+				characters[i] = source.charCodeAt(i);
+			}
+		}
+		return characters;
+	}
+
+	const elements1 = getElements(originalValue);
+	const elements2 = getElements(newValue);
+
+	return new LcsDiff({ getElements: () => elements1 }, { getElements: () => elements2 }).ComputeDiff(false).changes;
+}
+
 export interface LiveInlineCompletion extends NormalizedInlineCompletion {
 	sourceProvider: InlineCompletionsProvider;
 	sourceInlineCompletion: InlineCompletion;


Test patch:
diff --git a/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts b/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts
--- a/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts
+++ b/src/vs/editor/contrib/inlineCompletions/test/inlineCompletionsProvider.test.ts
@@ -75,6 +75,12 @@ suite('Inline Completions', () => {
 		test('Multi Part Diffing', () => {
 			assert.deepStrictEqual(getOutput('foo[()]', '(x);'), { prefix: undefined, subword: 'foo([x])[;]' });
 			assert.deepStrictEqual(getOutput('[\tfoo]', '\t\tfoobar'), { prefix: undefined, subword: '\t[\t]foo[bar]' });
+			assert.deepStrictEqual(getOutput('[(y ===)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ===[ 1])[ { f(); }]' });
+			assert.deepStrictEqual(getOutput('[(y ==)]', '(y === 1) { f(); }'), { prefix: undefined, subword: '(y ==[= 1])[ { f(); }]' });
+		});
+
+		test('Multi Part Diffing 1', () => {
+			assert.deepStrictEqual(getOutput('[if () ()]', 'if (1 == f()) ()'), { prefix: undefined, subword: 'if ([1 == f()]) ()' });
 		});
 	});
 
diff --git a/src/vs/editor/test/common/viewModel/splitLinesCollection.test.ts b/src/vs/editor/test/common/viewModel/splitLinesCollection.test.ts
--- a/src/vs/editor/test/common/viewModel/splitLinesCollection.test.ts
+++ b/src/vs/editor/test/common/viewModel/splitLinesCollection.test.ts
@@ -408,6 +408,10 @@ suite('SplitLinesCollection', () => {
 
 	function assertAllMinimapLinesRenderingData(splitLinesCollection: SplitLinesCollection, all: ITestMinimapLineRenderingData[]): void {
 		let lineCount = all.length;
+		for (let line = 1; line <= lineCount; line++) {
+			assert.strictEqual(splitLinesCollection.getViewLineData(line).content, splitLinesCollection.getViewLineContent(line));
+		}
+
 		for (let start = 1; start <= lineCount; start++) {
 			for (let end = start; end <= lineCount; end++) {
 				let count = end - start + 1;
@@ -419,6 +423,7 @@ suite('SplitLinesCollection', () => {
 						expected[i] = (needed[i] ? all[start - 1 + i] : null);
 					}
 					let actual = splitLinesCollection.getViewLinesData(start, end, needed);
+
 					assertMinimapLinesRenderingData(actual, expected);
 					// Comment out next line to test all possible combinations
 					break;