From 65fd2f120285916eac40574384ad4056757aac05 Mon Sep 17 00:00:00 2001 From: AnupamKumar-1 Date: Wed, 2 Sep 2026 15:31:40 +0530 Subject: [PATCH] fix(chat): preserve #file reference when editing text before it --- .../attachments/chatDynamicVariables.ts | 33 +++++++- .../browser/attachments/chatVariables.test.ts | 80 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts index 78675ef803efd..cfc2830435bdf 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts @@ -102,13 +102,23 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC change.rangeOffset <= data.rangeOffset && change.rangeOffset + change.rangeLength >= data.rangeOffset + data.text.length ); - const preservedRange = replacement && this.findReferenceRangeInReplacement(model, e.changes, replacement, data); + + const touchesReference = e.changes.some(change => + change.rangeOffset < data.rangeOffset + data.text.length + && change.rangeOffset + change.rangeLength > data.rangeOffset + ); + + const preservedRange = replacement + ? this.findReferenceRangeInReplacement(model, e.changes, replacement, data) + : !touchesReference + ? this.findReferenceRangeForNonOverlappingEdit(model, e.changes, data) + : undefined; if (preservedRange) { didChange = true; return { ...ref, range: preservedRange }; } - if (!replacement) { + if (!replacement && touchesReference) { this.widget.inputEditor.executeEdits(this.id, [{ range: newRange, text: '', @@ -176,6 +186,25 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC return model.getValueInRange(range) === data.text ? range : undefined; } + private findReferenceRangeForNonOverlappingEdit( + model: ITextModel, + changes: readonly IModelContentChange[], + data: { text: string; rangeOffset: number } + ): Range | undefined { + if (!data.text) { + return undefined; + } + + const precedingChangesDelta = changes.reduce((delta, change) => + change.rangeOffset < data.rangeOffset ? delta + change.text.length - change.rangeLength : delta, 0); + const startOffset = data.rangeOffset + precedingChangesDelta; + const range = Range.fromPositions( + model.getPositionAt(startOffset), + model.getPositionAt(startOffset + data.text.length) + ); + return model.getValueInRange(range) === data.text ? range : undefined; + } + getInputState(contrib: Record): void { contrib[ChatDynamicVariableModel.ID] = [...this._variables]; } diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts index 04e89f2a439f1..dea81d8c3206b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts @@ -435,4 +435,84 @@ suite('ChatDynamicVariableModel', () => { hasImageDecorationHover: false, }); }); + + test('recovers a reference range after a delete strictly before it', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example '); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [{ + range: new Range(1, 8, 1, 9), + text: '', + }]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables.map(variable => variable.range), + }, { + text: 'explain#sym:example ', + variables: [new Range(1, 8, 1, 20)], + }); + }); + + test('recovers a reference range after a replace strictly before it', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example '); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [{ + range: new Range(1, 1, 1, 8), + text: 'describe', + }]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables.map(variable => variable.range), + }, { + text: 'describe #sym:example ', + variables: [new Range(1, 10, 1, 22)], + }); + }); + + test('removes the reference when a delete touches its first character', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example '); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [{ + range: new Range(1, 9, 1, 10), + text: '', + }]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables, + }, { + text: 'explain ', + variables: [], + }); + }); + + test('recovers a reference range across edits before and after it', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example please'); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [ + { range: new Range(1, 1, 1, 8), text: 'describe' }, + { range: new Range(1, 22, 1, 28), text: 'now' }, + ]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables.map(variable => variable.range), + }, { + text: 'describe #sym:example now', + variables: [new Range(1, 10, 1, 22)], + }); + }); });