diff --git a/vscode/src/autoedits/prompt-utils.test.ts b/vscode/src/autoedits/prompt-utils.test.ts
index 0f93109acd7e..70b85c80aa4e 100644
--- a/vscode/src/autoedits/prompt-utils.test.ts
+++ b/vscode/src/autoedits/prompt-utils.test.ts
@@ -384,6 +384,7 @@ line 64
+
Now, continue where I left off and finish my change by rewriting "code_to_rewrite":
`
expect(prompt.toString()).toEqual(expectedPrompt)
diff --git a/vscode/src/autoedits/prompt-utils.ts b/vscode/src/autoedits/prompt-utils.ts
index 4287e2c40c0a..2ac25287af81 100644
--- a/vscode/src/autoedits/prompt-utils.ts
+++ b/vscode/src/autoedits/prompt-utils.ts
@@ -77,6 +77,11 @@ interface CurrentFileContext {
range: vscode.Range
}
+interface RecentEditPromptComponents {
+ longTermDiff: PromptString
+ shortTermDiff: PromptString
+}
+
// Helper function to get prompt in some format
export function getBaseUserPrompt(
docContext: DocumentContext,
@@ -107,10 +112,8 @@ export function getBaseUserPrompt(
getRecentlyViewedSnippetsPrompt
)
- const recentEditsPrompt = getPromptForTheContextSource(
- contextItemMapping.get(RetrieverIdentifier.RecentEditsRetriever) || [],
- RECENT_EDITS_INSTRUCTION,
- getRecentEditsPrompt
+ const recentEditsPromptComponents = getRecentEditsPromptComponents(
+ contextItemMapping.get(RetrieverIdentifier.RecentEditsRetriever) || []
)
const lintErrorsPrompt = getPromptForTheContextSource(
@@ -134,10 +137,11 @@ export function getBaseUserPrompt(
${jaccardSimilarityPrompt}
${recentViewsPrompt}
${CURRENT_FILE_INSTRUCTION}${fileWithMarkerPrompt}
-${recentEditsPrompt}
+${recentEditsPromptComponents.longTermDiff}
${lintErrorsPrompt}
${recentCopyPrompt}
${areaPrompt}
+${recentEditsPromptComponents.shortTermDiff}
${FINAL_USER_PROMPT}
`
autoeditsLogger.logDebug('AutoEdits', 'Prompt\n', finalPrompt)
@@ -323,24 +327,61 @@ ${RECENT_COPY_TAG_CLOSE}
`
}
-export function getRecentEditsPrompt(contextItems: AutocompleteContextSnippet[]): PromptString {
+export function getRecentEditsPromptComponents(
+ contextItems: AutocompleteContextSnippet[]
+): RecentEditPromptComponents {
const recentEdits = getContextItemsForIdentifier(
contextItems,
RetrieverIdentifier.RecentEditsRetriever
)
recentEdits.reverse()
- if (recentEdits.length === 0) {
+ let shortTermDiff: PromptString = ps``
+ let longTermDiff: PromptString = ps``
+ if (recentEdits.length > 0) {
+ shortTermDiff = getRecentEditPrompt([recentEdits.at(-1)!])
+ }
+ if (recentEdits.length > 1) {
+ const longTermDiffPrompt = getRecentEditPromptLongTermDiffComponent(recentEdits.slice(0, -1))
+ longTermDiff = ps`${RECENT_EDITS_INSTRUCTION}
+${longTermDiffPrompt}
+`
+ }
+ return {
+ shortTermDiff,
+ longTermDiff,
+ }
+}
+
+function getRecentEditPromptLongTermDiffComponent(context: AutocompleteContextSnippet[]): PromptString {
+ if (context.length === 0) {
return ps``
}
- const recentEditsPrompts = recentEdits.map(item =>
- getContextPromptWithPath(
+ const prompts = context.map(item =>
+ getContextPromptForDiffWithPath(
PromptString.fromDisplayPath(item.uri),
PromptString.fromAutocompleteContextSnippet(item).content
)
)
- const recentEditsPrompt = PromptString.join(recentEditsPrompts, ps`\n`)
- return ps`${RECENT_EDITS_TAG_OPEN}
-${recentEditsPrompt}
+ return ps`
+${RECENT_EDITS_TAG_OPEN}
+${PromptString.join(prompts, ps`\n`)}
+${RECENT_EDITS_TAG_CLOSE}
+`
+}
+
+function getRecentEditPrompt(contextItems: AutocompleteContextSnippet[]): PromptString {
+ if (contextItems.length === 0) {
+ return ps``
+ }
+ const prompts = contextItems.map(item =>
+ getContextPromptForDiffWithPath(
+ PromptString.fromDisplayPath(item.uri),
+ PromptString.fromAutocompleteContextSnippet(item).content
+ )
+ )
+ return ps`
+${RECENT_EDITS_TAG_OPEN}
+${PromptString.join(prompts, ps`\n`)}
${RECENT_EDITS_TAG_CLOSE}
`
}
@@ -455,3 +496,7 @@ function getContextItemsForIdentifier(
function getContextPromptWithPath(filePath: PromptString, content: PromptString): PromptString {
return ps`(\`${filePath}\`)\n\n${content}\n`
}
+
+function getContextPromptForDiffWithPath(filePath: PromptString, content: PromptString): PromptString {
+ return ps`${filePath}\n${content}`
+}
diff --git a/vscode/src/completions/analytics-logger.ts b/vscode/src/completions/analytics-logger.ts
index 8c7b1974481c..c96925210d4d 100644
--- a/vscode/src/completions/analytics-logger.ts
+++ b/vscode/src/completions/analytics-logger.ts
@@ -803,6 +803,7 @@ function suggestionDocumentDiffTracker(
const documentText = document.getText(trackingRange)
const persistenceTimeoutList = [
+ 10 * 1000, // 10 seconds
20 * 1000, // 20 seconds
60 * 1000, // 60 seconds
120 * 1000, // 120 seconds
diff --git a/vscode/src/completions/context/context-data-logging.ts b/vscode/src/completions/context/context-data-logging.ts
index 23cb9119c1cc..3655d2c6d649 100644
--- a/vscode/src/completions/context/context-data-logging.ts
+++ b/vscode/src/completions/context/context-data-logging.ts
@@ -13,14 +13,15 @@ import type { RetrievedContextResults } from './completions-context-ranker'
import { JaccardSimilarityRetriever } from './retrievers/jaccard-similarity/jaccard-similarity-retriever'
import { DiagnosticsRetriever } from './retrievers/recent-user-actions/diagnostics-retriever'
import { RecentCopyRetriever } from './retrievers/recent-user-actions/recent-copy'
-import { RecentEditsRetrieverDiffStrategyIdentifier } from './retrievers/recent-user-actions/recent-edits-diff-helpers/recent-edits-diff-strategy'
+import { LineLevelDiffStrategy } from './retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff'
+import { TwoStageUnifiedDiffStrategy } from './retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff'
import { RecentEditsRetriever } from './retrievers/recent-user-actions/recent-edits-retriever'
import { RecentViewPortRetriever } from './retrievers/recent-user-actions/recent-view-port'
import { RetrieverIdentifier } from './utils'
interface RetrieverConfig {
identifier: RetrieverIdentifier
- maxSnippets: number
+ maxSnippets?: number
}
export class ContextRetrieverDataCollection implements vscode.Disposable {
@@ -31,7 +32,7 @@ export class ContextRetrieverDataCollection implements vscode.Disposable {
private gitMetadataInstance = GitHubDotComRepoMetadata.getInstance()
private readonly retrieverConfigs: RetrieverConfig[] = [
- { identifier: RetrieverIdentifier.RecentEditsRetriever, maxSnippets: 15 },
+ { identifier: RetrieverIdentifier.RecentEditsRetriever },
{ identifier: RetrieverIdentifier.DiagnosticsRetriever, maxSnippets: 15 },
{ identifier: RetrieverIdentifier.RecentViewPortRetriever, maxSnippets: 10 },
]
@@ -105,8 +106,11 @@ export class ContextRetrieverDataCollection implements vscode.Disposable {
case RetrieverIdentifier.RecentEditsRetriever:
return new RecentEditsRetriever({
maxAgeMs: 10 * 60 * 1000,
- diffStrategyIdentifier:
- RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiffWithLineNumbers,
+ diffStrategyList: [
+ new TwoStageUnifiedDiffStrategy(),
+ new LineLevelDiffStrategy({ shouldGroupNonOverlappingLines: true }),
+ new LineLevelDiffStrategy({ shouldGroupNonOverlappingLines: false }),
+ ],
})
case RetrieverIdentifier.DiagnosticsRetriever:
return new DiagnosticsRetriever({
diff --git a/vscode/src/completions/context/context-strategy.ts b/vscode/src/completions/context/context-strategy.ts
index 67e8c433ec3a..b07d1b7e4817 100644
--- a/vscode/src/completions/context/context-strategy.ts
+++ b/vscode/src/completions/context/context-strategy.ts
@@ -11,7 +11,8 @@ import { JaccardSimilarityRetriever } from './retrievers/jaccard-similarity/jacc
import { LspLightRetriever } from './retrievers/lsp-light/lsp-light-retriever'
import { DiagnosticsRetriever } from './retrievers/recent-user-actions/diagnostics-retriever'
import { RecentCopyRetriever } from './retrievers/recent-user-actions/recent-copy'
-import { RecentEditsRetrieverDiffStrategyIdentifier } from './retrievers/recent-user-actions/recent-edits-diff-helpers/recent-edits-diff-strategy'
+import { TwoStageUnifiedDiffStrategy } from './retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff'
+import { UnifiedDiffStrategy } from './retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff'
import { RecentEditsRetriever } from './retrievers/recent-user-actions/recent-edits-retriever'
import { RecentViewPortRetriever } from './retrievers/recent-user-actions/recent-view-port'
import { loadTscRetriever } from './retrievers/tsc/load-tsc-retriever'
@@ -55,8 +56,9 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory {
this.allLocalRetrievers = [
new RecentEditsRetriever({
maxAgeMs: 60 * 1000,
- diffStrategyIdentifier:
- RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff,
+ diffStrategyList: [
+ new UnifiedDiffStrategy({ addLineNumbers: false }),
+ ],
}),
]
break
@@ -64,8 +66,9 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory {
this.allLocalRetrievers = [
new RecentEditsRetriever({
maxAgeMs: 60 * 1000,
- diffStrategyIdentifier:
- RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff,
+ diffStrategyList: [
+ new UnifiedDiffStrategy({ addLineNumbers: false }),
+ ],
}),
]
break
@@ -73,8 +76,9 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory {
this.allLocalRetrievers = [
new RecentEditsRetriever({
maxAgeMs: 60 * 5 * 1000,
- diffStrategyIdentifier:
- RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff,
+ diffStrategyList: [
+ new UnifiedDiffStrategy({ addLineNumbers: false }),
+ ],
}),
]
break
@@ -82,8 +86,9 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory {
this.allLocalRetrievers = [
new RecentEditsRetriever({
maxAgeMs: 60 * 1000,
- diffStrategyIdentifier:
- RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff,
+ diffStrategyList: [
+ new UnifiedDiffStrategy({ addLineNumbers: false }),
+ ],
}),
new JaccardSimilarityRetriever(),
]
@@ -127,8 +132,7 @@ export class DefaultContextStrategyFactory implements ContextStrategyFactory {
this.allLocalRetrievers = [
new RecentEditsRetriever({
maxAgeMs: 10 * 60 * 1000,
- diffStrategyIdentifier:
- RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiffWithLineNumbers,
+ diffStrategyList: [new TwoStageUnifiedDiffStrategy()],
}),
new DiagnosticsRetriever({
contextLines: 0,
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/auotedit-short-term-diff.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/auotedit-short-term-diff.test.ts
deleted file mode 100644
index 1ab858071d14..000000000000
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/auotedit-short-term-diff.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { Uri } from 'vscode'
-import { range } from '../../../../../testutils/textDocument'
-import { AutoeditWithShortTermDiffStrategy } from './auotedit-short-term-diff'
-import type { TextDocumentChange } from './recent-edits-diff-strategy'
-
-describe('AutoeditWithShortTermDiffStrategy', () => {
- const strategy = new AutoeditWithShortTermDiffStrategy()
- const mockUri = Uri.parse('file:///test.txt')
-
- const createChange = (timestamp: number, oldText: string, text: string) => ({
- timestamp,
- change: {
- range: range(0, 0, 0, 0),
- text,
- rangeLength: oldText.length,
- rangeOffset: 0,
- },
- })
-
- it('should divide changes into short-term and long-term windows', () => {
- const now = Date.now()
- const initialContent = 'initial content'
- const changes: TextDocumentChange[] = [
- createChange(now - 10000, initialContent, 'change 1'),
- createChange(now - 2000, 'change 1', 'change 2'),
- ]
-
- const hunks = strategy.getDiffHunks({
- uri: mockUri,
- oldContent: initialContent,
- changes,
- })
-
- expect(hunks).toHaveLength(2)
- expect(hunks[0].diff.toString()).toMatchInlineSnapshot(`
- "1-| initial content
- 1+| change 1"
- `)
- expect(hunks[1].diff.toString()).toMatchInlineSnapshot(`
- "1-| change 1
- 1+| change 2"
- `)
- })
-})
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/auotedit-short-term-diff.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/auotedit-short-term-diff.ts
deleted file mode 100644
index 49190044f92e..000000000000
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/auotedit-short-term-diff.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type * as vscode from 'vscode'
-import type {
- DiffCalculationInput,
- DiffHunk,
- RecentEditsRetrieverDiffStrategy,
- TextDocumentChange,
-} from './recent-edits-diff-strategy'
-import { applyTextDocumentChanges, computeDiffWithLineNumbers } from './utils'
-
-/**
- * Generates a single unified diff patch that combines all changes
- * made to a document into one consolidated view.
- */
-export class AutoeditWithShortTermDiffStrategy implements RecentEditsRetrieverDiffStrategy {
- private shortTermDiffWindowMs = 5 * 1000 // 5 seconds
- private longTermContextLines = 3
- private shortTermContextLines = 0
-
- public getDiffHunks(input: DiffCalculationInput): DiffHunk[] {
- const [shortTermChanges, longTermChanges] = this.divideChangesIntoWindows(input.changes)
- const [shortTermHunks, shortTermNewContent] = this.getDiffHunksForChanges(
- input.uri,
- input.oldContent,
- shortTermChanges,
- this.shortTermContextLines
- )
- const [longTermHunks, _] = this.getDiffHunksForChanges(
- input.uri,
- shortTermNewContent,
- longTermChanges,
- this.longTermContextLines
- )
- return [shortTermHunks, longTermHunks]
- }
-
- private getDiffHunksForChanges(
- uri: vscode.Uri,
- oldContent: string,
- changes: TextDocumentChange[],
- numContextLines: number
- ): [DiffHunk, string] {
- const newContent = applyTextDocumentChanges(
- oldContent,
- changes.map(c => c.change)
- )
- const gitDiff = computeDiffWithLineNumbers(uri, oldContent, newContent, numContextLines)
- const diffHunk = {
- diff: gitDiff,
- latestEditTimestamp: Math.max(...changes.map(c => c.timestamp)),
- }
- return [diffHunk, newContent]
- }
-
- private divideChangesIntoWindows(
- changes: TextDocumentChange[]
- ): [TextDocumentChange[], TextDocumentChange[]] {
- // Divide the changes into 2 different windows, where the second window is the short term changes under 5 seconds
- const now = Date.now()
- const index = changes.findIndex(c => now - c.timestamp < this.shortTermDiffWindowMs)
- const shortTermChanges = changes.slice(0, index)
- const longTermChanges = changes.slice(index)
- return [shortTermChanges, longTermChanges]
- }
-}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/base.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/base.ts
new file mode 100644
index 000000000000..e69ad9a165f5
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/base.ts
@@ -0,0 +1,33 @@
+import type { PromptString } from '@sourcegraph/cody-shared'
+import type * as vscode from 'vscode'
+
+export interface RecentEditsRetrieverDiffStrategy {
+ getDiffHunks(input: DiffCalculationInput): DiffHunk[]
+ getDiffStrategyName(): string
+}
+
+export interface TextDocumentChange {
+ timestamp: number
+ change: vscode.TextDocumentContentChangeEvent
+ // The range in the document where the text was inserted.
+ insertedRange: vscode.Range
+}
+
+export interface DiffCalculationInput {
+ uri: vscode.Uri
+ oldContent: string
+ changes: TextDocumentChange[]
+}
+
+export interface DiffHunk {
+ uri: vscode.Uri
+ latestEditTimestamp: number
+ diff: PromptString
+}
+
+export interface UnifiedPatchResponse {
+ uri: vscode.Uri
+ newContent: string
+ diff: PromptString
+ latestEditTimestamp: number
+}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/helper.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/helper.test.ts
new file mode 100644
index 000000000000..b9e0ec834ba0
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/helper.test.ts
@@ -0,0 +1,280 @@
+import dedent from 'dedent'
+import { describe, expect, it } from 'vitest'
+import { getPositionAt, parseTextAndGenerateChangeEvents } from './helper'
+import { processContinousChangesForText } from './helper'
+import { applyTextDocumentChanges } from './utils'
+
+describe('parseTextAndGenerateChangeEvents', () => {
+ const testChanges = (params: {
+ text: string
+ expectedOriginalString: string
+ expectedChanges: string[]
+ }) => {
+ const { text, expectedOriginalString, expectedChanges } = params
+ const { originalText, changeEvents } = parseTextAndGenerateChangeEvents(text)
+ expect(originalText).to.equal(expectedOriginalString)
+ expect(changeEvents.length).to.equal(expectedChanges.length)
+ for (let i = 0; i < changeEvents.length; i++) {
+ const changes = changeEvents.slice(0, i + 1)
+ const newContent = applyTextDocumentChanges(originalText, changes)
+ expect(newContent).to.equal(
+ expectedChanges[i],
+ `Failed at index ${i}. Expected "${expectedChanges[i]}" but got "${newContent}"`
+ )
+ }
+ }
+
+ it('should handle insert markers correctly', () => {
+ const text = 'This is a test string.'
+ const expectedOriginalString = 'This is a string.'
+ const expectedChanges = ['This is a test string.']
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle delete markers correctly', () => {
+ const text = 'This is a sample string.'
+ const expectedOriginalString = 'This is a sample string.'
+ const expectedChanges = ['This is a string.']
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle replace markers correctly', () => {
+ const text = 'Please replaceswap this word.'
+ const expectedOriginalString = 'Please replace this word.'
+ const expectedChanges = ['Please swap this word.']
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle multiple markers correctly', () => {
+ const text = 'start and middle unnecessary text swap change end.'
+ const expectedOriginalString = 'start middle unnecessary text swap end.'
+ const expectedChanges = [
+ 'start and middle unnecessary text swap end.',
+ 'start and middle text swap end.',
+ 'start and middle text change end.',
+ ]
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle text without markers correctly', () => {
+ const text = 'This is plain text.'
+ const expectedOriginalString = 'This is plain text.'
+ const expectedChanges: string[] = []
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should ignore unmatched markers', () => {
+ const inputText = 'Unmatched markers insert text without closing.'
+ const { originalText, changeEvents } = parseTextAndGenerateChangeEvents(inputText)
+
+ expect(originalText).to.equal('Unmatched markers insert text without closing.')
+ expect(changeEvents).to.have.lengthOf(0)
+ })
+
+ it('should handle complex multi-line text with mixed markers', () => {
+ const text = dedent`
+ First line inserted text
+ Second line to be deleted
+ Third line oldThird line new
+ Fourth line addition
+ Fifth line addition
+ Sixth line to delete
+ End of text.
+ `
+ const expectedOriginalString = dedent`
+ First line
+ Second line to be deleted
+ Third line old
+ Sixth line to delete
+ End of text.
+ `
+ const expectedChanges = [
+ dedent`
+ First line inserted text
+ Second line to be deleted
+ Third line old
+ Sixth line to delete
+ End of text.
+ `,
+ dedent`
+ First line inserted text
+ Second line
+ Third line old
+ Sixth line to delete
+ End of text.
+ `,
+ dedent`
+ First line inserted text
+ Second line
+ Third line new
+ Sixth line to delete
+ End of text.
+ `,
+ dedent`
+ First line inserted text
+ Second line
+ Third line new
+ Fourth line addition
+ Fifth line addition
+ Sixth line to delete
+ End of text.
+ `,
+ dedent`
+ First line inserted text
+ Second line
+ Third line new
+ Fourth line addition
+ Fifth line addition
+ End of text.
+ `,
+ ]
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle continuous insert markers correctly', () => {
+ const text = 'Hello World!'
+ const expectedOriginalString = 'Hello !'
+ const expectedChanges = ['Hello W!', 'Hello Wo!', 'Hello Wor!', 'Hello Worl!', 'Hello World!']
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle continuous delete markers correctly', () => {
+ const text = 'Delete this text.'
+ const expectedOriginalString = 'Delete this text.'
+ const expectedChanges = [
+ 'Deletethis text.',
+ 'Deletehis text.',
+ 'Deleteis text.',
+ 'Deletes text.',
+ 'Delete text.',
+ ]
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+
+ it('should handle multiple continuous markers correctly', () => {
+ const text = 'Hi, this is a sample text.'
+ const expectedOriginalString = ', this is a sample text.'
+ const expectedChanges = [
+ 'H, this is a sample text.',
+ 'Hi, this is a sample text.',
+ 'Hi, this is a ample text.',
+ 'Hi, this is a mple text.',
+ 'Hi, this is a ple text.',
+ 'Hi, this is a le text.',
+ 'Hi, this is a e text.',
+ 'Hi, this is a text.',
+ ]
+ testChanges({ text, expectedOriginalString, expectedChanges })
+ })
+})
+
+describe('processContinousChangesForText', () => {
+ it('should convert tags into individual tags for each character', () => {
+ const input = 'Hello World!'
+ const expectedOutput = 'Hello World!'
+ expect(processContinousChangesForText(input)).toBe(expectedOutput)
+ })
+
+ it('should convert tags into individual tags for each character', () => {
+ const input = 'Delete this text.'
+ const expectedOutput = 'Delete this text.'
+ expect(processContinousChangesForText(input)).toBe(expectedOutput)
+ })
+
+ it('should handle multiple and tags in the input', () => {
+ const input = 'Hello and Goodbye!'
+ const expectedOutput =
+ 'Hello and Goodbye!'
+ expect(processContinousChangesForText(input)).toBe(expectedOutput)
+ })
+
+ it('should return the same text if there are no or tags', () => {
+ const input = 'No changes here.'
+ expect(processContinousChangesForText(input)).toBe(input)
+ })
+
+ it('should handle empty and tags gracefully', () => {
+ const input = 'Empty and tags.'
+ const expectedOutput = 'Empty and tags.'
+ expect(processContinousChangesForText(input)).toBe(expectedOutput)
+ })
+
+ it('should handle special characters within and tags', () => {
+ const input = 'Special chars: !@# and 123.'
+ const expectedOutput =
+ 'Special chars: !@# and 123.'
+ expect(processContinousChangesForText(input)).toBe(expectedOutput)
+ })
+
+ it('should handle consecutive and tags without text in between', () => {
+ const input = 'ABC123'
+ const expectedOutput = 'ABC123'
+ expect(processContinousChangesForText(input)).toBe(expectedOutput)
+ })
+})
+
+describe('getPositionAt', () => {
+ it('should return position at offset 0', () => {
+ const content = 'Hello, world!'
+ const offset = 0
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(0)
+ expect(position.character).to.equal(0)
+ })
+
+ it('should return correct position in single-line content', () => {
+ const content = 'Hello, world!'
+ const offset = 7
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(0)
+ expect(position.character).to.equal(7)
+ })
+
+ it('should return correct position at the end of content', () => {
+ const content = 'Hello, world!'
+ const offset = content.length
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(0)
+ expect(position.character).to.equal(content.length)
+ })
+
+ it('should return correct position in multi-line content', () => {
+ const content = 'Line 1\nLine 2\nLine 3'
+ const offset = content.indexOf('Line 2')
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(1)
+ expect(position.character).to.equal(0)
+ })
+
+ it('should handle offsets at line breaks', () => {
+ const content = 'Line 1\nLine 2\nLine 3'
+ const offset = content.indexOf('\n') + 1 // Position after the first line break
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(1)
+ expect(position.character).to.equal(0)
+ })
+
+ it('should return correct position for offsets within lines', () => {
+ const content = 'Line 1\nLine 2\nLine 3'
+ const offset = content.indexOf('2')
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(1)
+ expect(position.character).to.equal(5)
+ })
+
+ it('should handle empty content', () => {
+ const content = ''
+ const offset = 0
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(0)
+ expect(position.character).to.equal(0)
+ })
+
+ it('should handle content with carriage returns correctly', () => {
+ const content = 'Line 1\r\nLine 2\r\nLine 3'
+ const offset = content.indexOf('Line 3')
+ const position = getPositionAt(content, offset)
+ expect(position.line).to.equal(2)
+ expect(position.character).to.equal(0)
+ })
+})
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/helper.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/helper.ts
new file mode 100644
index 000000000000..4a0e5a09fc78
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/helper.ts
@@ -0,0 +1,172 @@
+import * as vscode from 'vscode'
+import { createGitDiff } from '../../../../../../../lib/shared/src/editor/create-git-diff'
+import { getPositionAfterTextInsertion } from '../../../../text-processing/utils'
+import type { TextDocumentChange } from './base'
+import { type TextDocumentChangeGroup, applyTextDocumentChanges } from './utils'
+
+export function getTextDocumentChangesForText(text: string): {
+ originalText: string
+ changes: TextDocumentChange[]
+} {
+ const { originalText, changeEvents } = parseTextAndGenerateChangeEvents(text)
+ const documentChanges: TextDocumentChange[] = []
+ for (const change of changeEvents) {
+ const insertedRange = new vscode.Range(
+ change.range.start,
+ getPositionAfterTextInsertion(change.range.start, change.text)
+ )
+ documentChanges.push({
+ timestamp: Date.now(),
+ change: change,
+ insertedRange,
+ })
+ }
+ return { originalText, changes: documentChanges }
+}
+
+export function getDiffsForContentChanges(
+ oldContent: string,
+ groupedChanges: TextDocumentChangeGroup[]
+): string[] {
+ const diffList: string[] = []
+ let currentContent = oldContent
+ for (const changeGroup of groupedChanges) {
+ const newContent = applyTextDocumentChanges(
+ currentContent,
+ changeGroup.changes.map(change => change.change)
+ )
+ const diff = createGitDiff('test.ts', currentContent, newContent)
+ diffList.push(diff)
+ currentContent = newContent
+ }
+ return diffList
+}
+
+/**
+ * The function is used by the test classes to simulate the text changes in a document text.
+ * Parses the input text containing markers and generates the corresponding
+ * TextDocumentContentChangeEvent events. It also returns the original text
+ * after processing the markers.
+ *
+ * Markers:
+ * - `text`: Insert `text` at the position of ``.
+ * - `text`: Delete `text` starting from the position of ``.
+ * - `text1text2`: Replace `text1` with `text2` starting from the position of ``.
+ * - `text`: Creates a seperate insert change for each character in `text`.
+ * - `text`: Creates a seperate delete change for each character in `text`.
+ *
+ * @param text The input text containing markers.
+ * @returns An object containing the original text and the array of change events.
+ */
+export function parseTextAndGenerateChangeEvents(text: string): {
+ originalText: string
+ changeEvents: vscode.TextDocumentContentChangeEvent[]
+} {
+ text = processContinousChangesForText(text)
+
+ const changeEvents: vscode.TextDocumentContentChangeEvent[] = []
+ let originalText = ''
+ let currentText = ''
+ let currentOffset = 0
+ const regex = /(.*?)<\/I>|(.*?)<\/D>|(.*?)(.*?)<\/R>/gs
+ let match: RegExpExecArray | null
+
+ match = regex.exec(text)
+ while (match !== null) {
+ const [fullMatch, insertText, deleteText, replaceText1, replaceText2] = match
+ const matchIndex = match.index
+
+ const textBeforeMarker = text.substring(currentOffset, matchIndex)
+ originalText += textBeforeMarker
+ currentText += textBeforeMarker
+
+ const position = getPositionAt(currentText, currentText.length)
+
+ if (insertText !== undefined) {
+ changeEvents.push({
+ range: new vscode.Range(position, position),
+ rangeOffset: currentText.length,
+ rangeLength: 0,
+ text: insertText,
+ })
+ currentText += insertText
+ } else if (deleteText !== undefined) {
+ const deleteEndPosition = getPositionAfterTextInsertion(position, deleteText)
+ changeEvents.push({
+ range: new vscode.Range(position, deleteEndPosition),
+ rangeOffset: currentText.length,
+ rangeLength: deleteText.length,
+ text: '',
+ })
+ originalText += deleteText
+ } else if (replaceText1 !== undefined && replaceText2 !== undefined) {
+ const replaceEndPosition = getPositionAfterTextInsertion(position, replaceText1)
+ changeEvents.push({
+ range: new vscode.Range(position, replaceEndPosition),
+ rangeOffset: currentText.length,
+ rangeLength: replaceText1.length,
+ text: replaceText2,
+ })
+ currentText += replaceText2
+ originalText += replaceText1
+ }
+ currentOffset = matchIndex + fullMatch.length
+ match = regex.exec(text)
+ }
+ const remainingText = text.substring(currentOffset)
+ originalText += remainingText
+ currentText += remainingText
+ return { originalText, changeEvents }
+}
+
+/**
+ * Processes continuous changes in text by converting continuous insertion and deletion markers
+ * into individual character markers.
+ *
+ * @param text The input text containing and markers
+ * @returns The processed text with individual and markers for each character
+ */
+export function processContinousChangesForText(text: string): string {
+ // Replace ... with individual ... markers for each character
+ text = text.replace(/(.*?)<\/IC>/gs, (_, content) => {
+ return content
+ .split('')
+ .map((char: string) => `${char}`)
+ .join('')
+ })
+
+ // Replace ... with individual ... markers for each character
+ text = text.replace(/(.*?)<\/DC>/gs, (_, content) => {
+ return content
+ .split('')
+ .map((char: string) => `${char}`)
+ .join('')
+ })
+
+ return text
+}
+
+/**
+ * Calculates the Position in the text at the given offset.
+ *
+ * @param text The text content.
+ * @param offset The offset in the text.
+ * @returns The Position corresponding to the offset.
+ */
+// Helper function to convert an offset to a Position (line and character)
+export function getPositionAt(content: string, offset: number): vscode.Position {
+ let line = 0
+ let character = 0
+ let i = 0
+ while (i < offset) {
+ if (content[i] === '\n') {
+ line++
+ character = 0
+ } else {
+ character++
+ }
+ i++
+ }
+
+ return new vscode.Position(line, character)
+}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff.test.ts
new file mode 100644
index 000000000000..1d06ae63cb6b
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff.test.ts
@@ -0,0 +1,108 @@
+import dedent from 'dedent'
+import { describe, expect, it } from 'vitest'
+import * as vscode from 'vscode'
+import { getTextDocumentChangesForText } from './helper'
+import { LineLevelDiffStrategy } from './line-level-diff'
+
+const processComputedDiff = (text: string): string => {
+ const lines = text.split('\n')
+ const updatedText = lines.filter(line => !line.includes('\\ No newline at end of file')).join('\n')
+ return updatedText
+}
+
+describe('LineLevelDiffStrategy', () => {
+ describe('with non-overlapping lines grouping enabled', () => {
+ const strategy = new LineLevelDiffStrategy({ shouldGroupNonOverlappingLines: true })
+
+ it('handles multiple line changes with grouping', () => {
+ const text = dedent`
+ letconst x = 5;
+ console.log('break');
+ letconst y = 10;
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(2)
+ expect(processComputedDiff(diffs[1].diff.toString())).toMatchInlineSnapshot(`
+ "1-| let x = 5;
+ 1+| const x = 5;
+ 2 | console.log('break');
+ 3 | let y = 10;"
+ `)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "1 | const x = 5;
+ 2 | console.log('break');
+ 3-| let y = 10;
+ 3+| const y = 10;"
+ `)
+ })
+
+ it('handles single line change', () => {
+ const text = dedent`
+ const x = 5;
+ varlet y = 10;
+ console.log('test');
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(1)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "1 | const x = 5;
+ 2-| var y = 10;
+ 2+| let y = 10;
+ 3 | console.log('test');"
+ `)
+ })
+ })
+
+ describe('with non-overlapping lines grouping disabled', () => {
+ const strategy = new LineLevelDiffStrategy({ shouldGroupNonOverlappingLines: false })
+
+ it('handles multiple separate changes without grouping', () => {
+ const text = dedent`
+ letconst x = 5;
+ console.log('break');
+ letconst y = 10;
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(2)
+ expect(processComputedDiff(diffs[1].diff.toString())).toMatchInlineSnapshot(`
+ "1-| let x = 5;
+ 1+| const x = 5;
+ 2 | console.log('break');
+ 3 | let y = 10;"
+ `)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "1 | const x = 5;
+ 2 | console.log('break');
+ 3-| let y = 10;
+ 3+| const y = 10;"
+ `)
+ })
+ })
+
+ it('returns correct strategy name', () => {
+ const strategyWithGrouping = new LineLevelDiffStrategy({ shouldGroupNonOverlappingLines: true })
+ expect(strategyWithGrouping.getDiffStrategyName()).toBe('line-level-diff-non-overlap-lines-true')
+
+ const strategyWithoutGrouping = new LineLevelDiffStrategy({
+ shouldGroupNonOverlappingLines: false,
+ })
+ expect(strategyWithoutGrouping.getDiffStrategyName()).toBe(
+ 'line-level-diff-non-overlap-lines-false'
+ )
+ })
+})
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff.ts
new file mode 100644
index 000000000000..61f23d7a79f0
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/line-level-diff.ts
@@ -0,0 +1,75 @@
+import type * as vscode from 'vscode'
+import type { DiffCalculationInput, DiffHunk, RecentEditsRetrieverDiffStrategy } from './base'
+import { groupNonOverlappingChangeGroups, groupOverlappingDocumentChanges } from './utils'
+import {
+ type TextDocumentChangeGroup,
+ divideGroupedChangesIntoShortTermAndLongTerm,
+ getDiffHunkFromUnifiedPatch,
+ getUnifiedDiffHunkFromTextDocumentChange,
+} from './utils'
+
+interface StrategyOptions {
+ shouldGroupNonOverlappingLines: boolean
+}
+
+export class LineLevelDiffStrategy implements RecentEditsRetrieverDiffStrategy {
+ private contextLines = 3
+ private shouldGroupNonOverlappingLines: boolean
+
+ constructor(options: StrategyOptions) {
+ this.shouldGroupNonOverlappingLines = options.shouldGroupNonOverlappingLines
+ }
+
+ public getDiffHunks(input: DiffCalculationInput): DiffHunk[] {
+ const groupedChanges = this.getLineLevelChanges(input)
+ const diffHunks = this.getDiffHunksForGroupedChanges({
+ uri: input.uri,
+ oldContent: input.oldContent,
+ groupedChanges,
+ contextLines: this.contextLines,
+ addLineNumbersForDiff: true,
+ }).filter(diffHunk => diffHunk.diff.toString() !== '')
+ diffHunks.reverse()
+ return diffHunks
+ }
+
+ private getDiffHunksForGroupedChanges(params: {
+ uri: vscode.Uri
+ oldContent: string
+ groupedChanges: TextDocumentChangeGroup[]
+ contextLines: number
+ addLineNumbersForDiff: boolean
+ }): DiffHunk[] {
+ let currentContent = params.oldContent
+ const diffHunks: DiffHunk[] = []
+ for (const groupedChange of params.groupedChanges) {
+ const patch = getUnifiedDiffHunkFromTextDocumentChange({
+ uri: params.uri,
+ oldContent: currentContent,
+ changes: groupedChange.changes,
+ addLineNumbersForDiff: params.addLineNumbersForDiff,
+ contextLines: params.contextLines,
+ })
+ const hunk = getDiffHunkFromUnifiedPatch(patch)
+ diffHunks.push(hunk)
+ currentContent = patch.newContent
+ }
+ return diffHunks
+ }
+
+ private getLineLevelChanges(input: DiffCalculationInput): TextDocumentChangeGroup[] {
+ const changes = groupOverlappingDocumentChanges(input.changes)
+ if (!this.shouldGroupNonOverlappingLines) {
+ return changes
+ }
+ let { shortTermChanges, longTermChanges } = divideGroupedChangesIntoShortTermAndLongTerm(changes)
+ longTermChanges = groupNonOverlappingChangeGroups(longTermChanges)
+ return [...longTermChanges, ...shortTermChanges]
+ }
+
+ public getDiffStrategyName(): string {
+ return `line-level-diff-${
+ this.shouldGroupNonOverlappingLines ? 'non-overlap-lines-true' : 'non-overlap-lines-false'
+ }`
+ }
+}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/recent-edits-diff-strategy.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/recent-edits-diff-strategy.ts
deleted file mode 100644
index fc3d70c92a96..000000000000
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/recent-edits-diff-strategy.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { PromptString } from '@sourcegraph/cody-shared'
-import type * as vscode from 'vscode'
-import { AutoeditWithShortTermDiffStrategy } from './auotedit-short-term-diff'
-import { UnifiedDiffStrategy } from './unified-diff'
-
-/**
- * Identifiers for the different diff strategies.
- */
-export enum RecentEditsRetrieverDiffStrategyIdentifier {
- /**
- * Unified diff strategy that shows changes in a single patch.
- */
- UnifiedDiff = 'unified-diff',
- /**
- * Unified diff strategy that shows changes in a single patch.
- */
- UnifiedDiffWithLineNumbers = 'unified-diff-with-line-numbers',
- /**
- * Diff Strategy to use a seperate short term diff used by `auto-edits`.
- */
- AutoeditWithShortTermDiff = 'autoedit-with-short-term-diff',
-}
-
-/**
- * Creates a new instance of a diff strategy based on the provided identifier.
- * @param identifier The identifier of the diff strategy to create.
- * @returns A new instance of the diff strategy.
- */
-export function createDiffStrategy(
- identifier: RecentEditsRetrieverDiffStrategyIdentifier
-): RecentEditsRetrieverDiffStrategy {
- switch (identifier) {
- case RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff:
- return new UnifiedDiffStrategy({ addLineNumbers: false })
- case RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiffWithLineNumbers:
- return new UnifiedDiffStrategy({ addLineNumbers: true })
- case RecentEditsRetrieverDiffStrategyIdentifier.AutoeditWithShortTermDiff:
- return new AutoeditWithShortTermDiffStrategy()
- default:
- throw new Error(`Unknown diff strategy identifier: ${identifier}`)
- }
-}
-
-export interface RecentEditsRetrieverDiffStrategy {
- getDiffHunks(input: DiffCalculationInput): DiffHunk[]
-}
-
-export interface TextDocumentChange {
- timestamp: number
- change: vscode.TextDocumentContentChangeEvent
-}
-
-export interface DiffCalculationInput {
- uri: vscode.Uri
- oldContent: string
- changes: TextDocumentChange[]
-}
-
-export interface DiffHunk {
- latestEditTimestamp: number
- diff: PromptString
-}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff.test.ts
new file mode 100644
index 000000000000..410599b3726a
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff.test.ts
@@ -0,0 +1,125 @@
+import dedent from 'dedent'
+import { describe, expect, it } from 'vitest'
+import * as vscode from 'vscode'
+import { getTextDocumentChangesForText } from './helper'
+import { TwoStageUnifiedDiffStrategy } from './two-stage-unified-diff'
+
+const processComputedDiff = (text: string): string => {
+ const lines = text.split('\n')
+ const updatedText = lines.filter(line => !line.includes('\\ No newline at end of file')).join('\n')
+ return updatedText
+}
+
+describe('AutoeditWithShortTermDiffStrategy', () => {
+ const strategy = new TwoStageUnifiedDiffStrategy()
+
+ it('handles multiple changes across different lines', () => {
+ const text = dedent`
+ letconst x = 5;
+ varlet y = 10;
+ console.log('break');
+ letconst z = 5;
+ console.log(x +x * y);
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(2)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "5-| console.log(x + y);
+ 5+| console.log(x * y);"
+ `)
+ expect(processComputedDiff(diffs[1].diff.toString())).toMatchInlineSnapshot(`
+ "1-| let x = 5;
+ 2-| var y = 10;
+ 1+| const x = 5;
+ 2+| let y = 10;
+ 3 | console.log('break');
+ 4-| let z = 5;
+ 4+| const z = 5;
+ 5 | console.log(x + y);"
+ `)
+ })
+
+ it('handles case with no changes', () => {
+ const text = dedent`
+ const x = 5;
+ let y = 10;
+ console.log('break');
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(0)
+ })
+
+ it('handles single change', () => {
+ const text = dedent`
+ const x = 5;
+ varlet y = 10;
+ console.log('break');
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(1)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "2-| var y = 10;
+ 2+| let y = 10;"
+ `)
+ })
+
+ it('handles changes at file boundaries', () => {
+ const text = dedent`
+ // First line added\nconst x = 5;
+ let y = 10;
+ console.log('break');\nfinal line removed
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(2)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "4-| console.log('break');
+ 5-| final line removed
+ 4+| console.log('break');"
+ `)
+ expect(processComputedDiff(diffs[1].diff.toString())).toMatchInlineSnapshot(`
+ "1+| // First line added
+ 2 | const x = 5;
+ 3 | let y = 10;
+ 4 | console.log('break');"
+ `)
+ })
+
+ it('handles multiple adjacent changes', () => {
+ const text = dedent`
+ const x = 5;
+ varlet y = 1020;
+ console.log('break');
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const diffs = strategy.getDiffHunks({
+ uri: vscode.Uri.parse('file://test.ts'),
+ oldContent: originalText,
+ changes,
+ })
+ expect(diffs.length).toBe(1)
+ expect(processComputedDiff(diffs[0].diff.toString())).toMatchInlineSnapshot(`
+ "2-| var y = 10;
+ 2+| let y = 20;"
+ `)
+ })
+})
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff.ts
new file mode 100644
index 000000000000..495d49239426
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/two-stage-unified-diff.ts
@@ -0,0 +1,46 @@
+import type { DiffCalculationInput, DiffHunk, RecentEditsRetrieverDiffStrategy } from './base'
+import { groupOverlappingDocumentChanges } from './utils'
+import {
+ divideGroupedChangesIntoShortTermAndLongTerm,
+ getDiffHunkFromUnifiedPatch,
+ getUnifiedDiffHunkFromTextDocumentChange,
+} from './utils'
+
+/**
+ * Generates a single unified diff patch that combines all changes
+ * made to a document into one consolidated view.
+ */
+export class TwoStageUnifiedDiffStrategy implements RecentEditsRetrieverDiffStrategy {
+ private longTermContextLines = 3
+ private shortTermContextLines = 0
+
+ public getDiffHunks(input: DiffCalculationInput): DiffHunk[] {
+ const rawChanges = groupOverlappingDocumentChanges(input.changes)
+ const { shortTermChanges, longTermChanges } =
+ divideGroupedChangesIntoShortTermAndLongTerm(rawChanges)
+
+ const longTermPatch = getUnifiedDiffHunkFromTextDocumentChange({
+ uri: input.uri,
+ oldContent: input.oldContent,
+ changes: longTermChanges.flatMap(c => c.changes),
+ addLineNumbersForDiff: true,
+ contextLines: this.longTermContextLines,
+ })
+ const shortTermPatch = getUnifiedDiffHunkFromTextDocumentChange({
+ uri: input.uri,
+ oldContent: longTermPatch.newContent,
+ changes: shortTermChanges.flatMap(c => c.changes),
+ addLineNumbersForDiff: true,
+ contextLines: this.shortTermContextLines,
+ })
+ const diffs = [
+ getDiffHunkFromUnifiedPatch(shortTermPatch),
+ getDiffHunkFromUnifiedPatch(longTermPatch),
+ ].filter(diff => diff.diff.length > 0)
+ return diffs
+ }
+
+ public getDiffStrategyName(): string {
+ return 'two-stage-unified-diff-strategy'
+ }
+}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff.ts
index eb57e638f9bd..1f5ab77caea7 100644
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff.ts
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff.ts
@@ -1,10 +1,5 @@
-import { PromptString } from '@sourcegraph/cody-shared'
-import type {
- DiffCalculationInput,
- DiffHunk,
- RecentEditsRetrieverDiffStrategy,
-} from './recent-edits-diff-strategy'
-import { applyTextDocumentChanges, computeDiffWithLineNumbers } from './utils'
+import type { DiffCalculationInput, DiffHunk, RecentEditsRetrieverDiffStrategy } from './base'
+import { getUnifiedDiffHunkFromTextDocumentChange } from './utils'
interface UnifiedDiffStrategyOptions {
addLineNumbers: boolean
@@ -23,28 +18,19 @@ export class UnifiedDiffStrategy implements RecentEditsRetrieverDiffStrategy {
}
public getDiffHunks(input: DiffCalculationInput): DiffHunk[] {
- const newContent = applyTextDocumentChanges(
- input.oldContent,
- input.changes.map(c => c.change)
- )
- const diff = this.getDiffForUnifiedStrategy(input, newContent)
- return [
- {
- diff,
- latestEditTimestamp: Math.max(...input.changes.map(c => c.timestamp)),
- },
- ]
+ const diffHunk = getUnifiedDiffHunkFromTextDocumentChange({
+ uri: input.uri,
+ oldContent: input.oldContent,
+ changes: input.changes,
+ addLineNumbersForDiff: this.addLineNumbers,
+ contextLines: this.numContextLines,
+ })
+ return diffHunk ? [diffHunk] : []
}
- private getDiffForUnifiedStrategy(input: DiffCalculationInput, newContent: string): PromptString {
- if (this.addLineNumbers) {
- return computeDiffWithLineNumbers(
- input.uri,
- input.oldContent,
- newContent,
- this.numContextLines
- )
- }
- return PromptString.fromGitDiff(input.uri, input.oldContent, newContent)
+ public getDiffStrategyName(): string {
+ return `unified-diff-strategy-${
+ this.addLineNumbers ? 'with-line-numbers' : 'without-line-numbers'
+ }`
}
}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.test.ts
new file mode 100644
index 000000000000..bc2a138e2b80
--- /dev/null
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.test.ts
@@ -0,0 +1,419 @@
+import { PromptString } from '@sourcegraph/cody-shared'
+import dedent from 'dedent'
+import { describe, expect, it } from 'vitest'
+import type * as vscode from 'vscode'
+import { getDiffsForContentChanges, getTextDocumentChangesForText } from './helper'
+import {
+ applyTextDocumentChanges,
+ computeDiffWithLineNumbers,
+ groupConsecutiveItemsByPredicate,
+ groupNonOverlappingChangeGroups,
+ groupOverlappingDocumentChanges,
+} from './utils'
+
+const processComputedDiff = (text: string) => {
+ const lines = text.split('\n')
+ const updatedText = lines.filter(line => !line.includes('\\ No newline at end of file')).join('\n')
+ return updatedText.split('\n').slice(3).join('\n')
+}
+
+describe('groupChangesForLines', () => {
+ it('handles multiple deletions across different lines', () => {
+ const text = dedent`
+ const a = 5;
+ console.log('test');
+ const data = 5;
+ function test() {
+ return true;
+ }
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const result = groupOverlappingDocumentChanges(changes)
+ expect(result.length).toBe(2)
+ const diffs = getDiffsForContentChanges(originalText, result)
+ expect(processComputedDiff(diffs[0])).toMatchInlineSnapshot(`
+ " const a = 5;
+ -console.log('test');
+ const data = 5;
+ function test() {
+ return true;
+ }
+ "
+ `)
+ expect(processComputedDiff(diffs[1])).toMatchInlineSnapshot(`
+ " const a = 5;
+ const data = 5;
+ -function test() {
+ - return true;
+ -}
+ "
+ `)
+ const combinedChanges = groupNonOverlappingChangeGroups(result)
+ expect(combinedChanges.length).toBe(1)
+ const combinedDiffs = getDiffsForContentChanges(originalText, combinedChanges)
+ expect(processComputedDiff(combinedDiffs[0])).toMatchInlineSnapshot(`
+ " const a = 5;
+ -console.log('test');
+ const data = 5;
+ -function test() {
+ - return true;
+ -}
+ "
+ `)
+ })
+
+ it('handles interleaved insertions and deletions', () => {
+ const text = dedent`
+ letconst x = 5;
+ varlet y = 10;
+ console.log(x +x * y);
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const result = groupOverlappingDocumentChanges(changes)
+ expect(result.length).toBe(3)
+ const diffs = getDiffsForContentChanges(originalText, result)
+ expect(processComputedDiff(diffs[0])).toMatchInlineSnapshot(`
+ "-let x = 5;
+ +const x = 5;
+ var y = 10;
+ console.log(x + y);
+ "
+ `)
+ })
+
+ it('handles overlapping multi-line changes', () => {
+ const text = dedent`
+ function test() {
+ const x = 5;
+ if (true) {
+ console.log(x);
+ }
+ }
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const result = groupOverlappingDocumentChanges(changes)
+ expect(result.length).toBe(2)
+ const combinedChanges = groupNonOverlappingChangeGroups(result)
+ expect(combinedChanges.length).toBe(1)
+ const combinedDiffs = getDiffsForContentChanges(originalText, combinedChanges)
+ expect(processComputedDiff(combinedDiffs[0])).toMatchInlineSnapshot(`
+ " function test() {
+ -
+ -
+ + const x = 5;
+ + if (true) {
+ + console.log(x);
+ + }
+ }
+ "
+ `)
+ })
+
+ it('seperate line changes for non-continous changes on different lines', () => {
+ const text = dedent`
+ console.log('Hello, world!');
+ data = 'check'
+ const a = 5;
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const result = groupOverlappingDocumentChanges(changes)
+ expect(result.length).toBe(3)
+ const diffs = getDiffsForContentChanges(originalText, result)
+ expect(processComputedDiff(diffs[0])).toMatchInlineSnapshot(`
+ "-console.
+ +console.log('Hello, world!');
+ data =
+ const
+ "
+ `)
+ expect(processComputedDiff(diffs[1])).toMatchInlineSnapshot(`
+ " console.log('Hello, world!');
+ -data =
+ +data = 'check'
+ const
+ "
+ `)
+ expect(processComputedDiff(diffs[2])).toMatchInlineSnapshot(`
+ " console.log('Hello, world!');
+ data = 'check'
+ -const
+ +const a = 5;
+ "
+ `)
+ const combinedChanges = groupNonOverlappingChangeGroups(result)
+ expect(combinedChanges.length).toBe(1)
+ const combinedDiffs = getDiffsForContentChanges(originalText, combinedChanges)
+ expect(processComputedDiff(combinedDiffs[0])).toMatchInlineSnapshot(`
+ "-console.
+ -data =
+ -const
+ +console.log('Hello, world!');
+ +data = 'check'
+ +const a = 5;
+ "
+ `)
+ })
+
+ it('same line changes with non-continous character typing', () => {
+ const text = dedent`
+ console.log('Hello, world!');
+ console.log('done')
+ const a = 5;
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const result = groupOverlappingDocumentChanges(changes)
+ expect(result.length).toBe(1)
+ const diffs = getDiffsForContentChanges(originalText, result)
+ expect(processComputedDiff(diffs[0])).toMatchInlineSnapshot(`
+ "-console.log
+ +console.log('Hello, world!');
+ +console.log('done')
+ +const a = 5;
+ "
+ `)
+ })
+
+ it('continous character typing by the user', () => {
+ const text = dedent`
+ console.log('Hello, world!');
+ console.log('done')
+ `
+ const { originalText, changes } = getTextDocumentChangesForText(text)
+ const result = groupOverlappingDocumentChanges(changes)
+ expect(result.length).toBe(1)
+ const diffs = getDiffsForContentChanges(originalText, result)
+ expect(processComputedDiff(diffs[0])).toMatchInlineSnapshot(`
+ "-console.
+ +console.log('Hello, world!');
+ +console.log('done')
+ "
+ `)
+ })
+})
+
+describe('applyTextDocumentChanges', () => {
+ const createChange = (
+ offset: number,
+ length: number,
+ text: string
+ ): vscode.TextDocumentContentChangeEvent =>
+ ({
+ rangeOffset: offset,
+ rangeLength: length,
+ text,
+ }) as vscode.TextDocumentContentChangeEvent
+
+ it('should insert text at the beginning', () => {
+ const content = 'world'
+ const changes = [createChange(0, 0, 'Hello ')]
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hello world')
+ })
+
+ it('should insert text in the middle', () => {
+ const content = 'Hello world'
+ const changes = [createChange(5, 0, ' beautiful')]
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hello beautiful world')
+ })
+
+ it('should replace text', () => {
+ const content = 'Hello world'
+ const changes = [createChange(6, 5, 'universe')]
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hello universe')
+ })
+
+ it('should handle multiple changes in sequence', () => {
+ const content = 'Hello world'
+ const changes = [createChange(0, 5, 'Hi'), createChange(3, 5, 'everyone')]
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hi everyone')
+ })
+
+ it('should handle deletion', () => {
+ const content = 'Hello beautiful world'
+ const changes = [createChange(5, 10, '')]
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hello world')
+ })
+
+ it('should handle empty changes array', () => {
+ const content = 'Hello world'
+ const changes: vscode.TextDocumentContentChangeEvent[] = []
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hello world')
+ })
+
+ it('should handle empty content', () => {
+ const content = ''
+ const changes = [createChange(0, 0, 'Hello')]
+ expect(applyTextDocumentChanges(content, changes)).toBe('Hello')
+ })
+})
+
+describe('groupConsecutiveItemsByPredicate', () => {
+ it('should return empty array when given an empty array', () => {
+ const result = groupConsecutiveItemsByPredicate([], (a, b) => a === b)
+ expect(result).toEqual([])
+ })
+
+ it('should group all items together when predicate is always true', () => {
+ const items = [1, 2, 3, 4]
+ const result = groupConsecutiveItemsByPredicate(items, () => true)
+ expect(result).toEqual([[1, 2, 3, 4]])
+ })
+
+ it('should not group any items when predicate is always false', () => {
+ const items = [1, 2, 3, 4]
+ const result = groupConsecutiveItemsByPredicate(items, () => false)
+ expect(result).toEqual([[1], [2], [3], [4]])
+ })
+
+ it('should group consecutive identical items', () => {
+ const items = [1, 1, 2, 2, 2, 3, 1, 1]
+ const result = groupConsecutiveItemsByPredicate(items, (a, b) => a === b)
+ expect(result).toEqual([[1, 1], [2, 2, 2], [3], [1, 1]])
+ })
+
+ it('should group consecutive items based on a custom predicate (even numbers)', () => {
+ const items = [1, 2, 4, 3, 6, 8, 7]
+ const result = groupConsecutiveItemsByPredicate(items, (a, b) => a % 2 === 0 && b % 2 === 0)
+ expect(result).toEqual([[1], [2, 4], [3], [6, 8], [7]])
+ })
+
+ it('should correctly group items with complex objects', () => {
+ const items = [
+ { type: 'A', value: 1 },
+ { type: 'A', value: 2 },
+ { type: 'B', value: 3 },
+ { type: 'B', value: 4 },
+ { type: 'A', value: 5 },
+ ]
+ const result = groupConsecutiveItemsByPredicate(items, (a, b) => a.type === b.type)
+ expect(result).toEqual([
+ [
+ { type: 'A', value: 1 },
+ { type: 'A', value: 2 },
+ ],
+ [
+ { type: 'B', value: 3 },
+ { type: 'B', value: 4 },
+ ],
+ [{ type: 'A', value: 5 }],
+ ])
+ })
+
+ it('should group based on custom logic (sum of digits is even)', () => {
+ const items = [11, 22, 34, 45, 55]
+ const sumDigitsIsEven = (n: number) =>
+ n
+ .toString()
+ .split('')
+ .map(Number)
+ .reduce((a, b) => a + b, 0) %
+ 2 ===
+ 0
+
+ const result = groupConsecutiveItemsByPredicate(
+ items,
+ (a, b) => sumDigitsIsEven(a) === sumDigitsIsEven(b)
+ )
+ expect(result).toEqual([[11, 22], [34, 45], [55]])
+ })
+})
+
+describe('computeDiffWithLineNumbers', () => {
+ const createTestUri = () =>
+ ({
+ fsPath: '/path/to/file.ts',
+ toString: () => '/path/to/file.ts',
+ }) as vscode.Uri
+
+ const assertDiffResult = (result: any, expectedSnapshot: string) => {
+ expect(result).toBeInstanceOf(PromptString)
+ expect(result).toMatchInlineSnapshot(expectedSnapshot)
+ }
+
+ it('should compute diff with line numbers for added content', () => {
+ const uri = createTestUri()
+ const originalContent = 'line 1\nline 2\nline 3'
+ const modifiedContent = 'line 1\nline 2\nnew line\nline 3'
+ const numContextLines = 2
+
+ const result = computeDiffWithLineNumbers(uri, originalContent, modifiedContent, numContextLines)
+
+ assertDiffResult(
+ result,
+ dedent`
+ "1 | line 1
+ 2 | line 2
+ 3+| new line
+ 4 | line 3"
+ `
+ )
+ })
+
+ it('should compute diff with line numbers for removed content', () => {
+ const uri = createTestUri()
+ const originalContent = 'line 1\nline 2\nline to remove\nline 3'
+ const modifiedContent = 'line 1\nline 2\nline 3'
+ const numContextLines = 2
+
+ const result = computeDiffWithLineNumbers(uri, originalContent, modifiedContent, numContextLines)
+
+ assertDiffResult(
+ result,
+ dedent`
+ "1 | line 1
+ 2 | line 2
+ 3-| line to remove
+ 3 | line 3"
+ `
+ )
+ })
+
+ it('should compute diff with line numbers for modified content', () => {
+ const uri = createTestUri()
+ const originalContent = 'line 1\nold line\nline 3'
+ const modifiedContent = 'line 1\nnew line\nline 3'
+ const numContextLines = 1
+
+ const result = computeDiffWithLineNumbers(uri, originalContent, modifiedContent, numContextLines)
+
+ assertDiffResult(
+ result,
+ dedent`
+ "1 | line 1
+ 2-| old line
+ 2+| new line
+ 3 | line 3"
+ `
+ )
+ })
+
+ it('should respect numContextLines parameter', () => {
+ const uri = createTestUri()
+ const originalContent = 'line 1\nline 2\nline 3\nline 4\nline 5'
+ const modifiedContent = 'line 1\nline 2\nmodified line\nline 4\nline 5'
+ const numContextLines = 1
+
+ const result = computeDiffWithLineNumbers(uri, originalContent, modifiedContent, numContextLines)
+
+ assertDiffResult(
+ result,
+ dedent`
+ "2 | line 2
+ 3-| line 3
+ 3+| modified line
+ 4 | line 4"
+ `
+ )
+ })
+
+ it('should handle empty content', () => {
+ const uri = createTestUri()
+ const result = computeDiffWithLineNumbers(uri, '', 'new content', 1)
+
+ assertDiffResult(
+ result,
+ dedent`
+ "1+| new content"
+ `
+ )
+ })
+})
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.ts
index d0f211b0f0f7..57f7771242ba 100644
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.ts
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/utils.ts
@@ -1,7 +1,145 @@
import { PromptString } from '@sourcegraph/cody-shared'
import { displayPath } from '@sourcegraph/cody-shared/src/editor/displayPath'
import { structuredPatch } from 'diff'
-import type * as vscode from 'vscode'
+import * as vscode from 'vscode'
+import type { DiffHunk, TextDocumentChange, UnifiedPatchResponse } from './base'
+
+/**
+ * Represents a group of text document changes with their range information.
+ * The grouped changes are consecutive changes made in the document that should be treated as a single entity when computing diffs.
+ *
+ * @example
+ * When typing "hello world" in a document, each character typed generates a separate change event.
+ * These changes are grouped together as a single entity in this interface.
+ */
+export interface TextDocumentChangeGroup {
+ /** Array of individual text document changes in this group */
+ changes: TextDocumentChange[]
+
+ /**
+ * The union of the inserted ranges of all changes in this group
+ */
+ insertedRange: vscode.Range
+
+ /**
+ * The union of the replace ranges of all changes in this group
+ */
+ replacementRange: vscode.Range
+}
+
+/**
+ * Groups consecutive text document changes together based on line overlap.
+ * This function helps create more meaningful diffs by combining related changes that occur on overlapping lines.
+ *
+ * For example, when a user types multiple characters or performs multiple edits in the same lines of text,
+ * these changes are grouped together as a single logical change instead of being treated as separate changes.
+ *
+ * @param documentChanges - Array of individual text document changes to be grouped
+ * @returns Array of TextDocumentChangeGroup objects, each containing related changes and their combined line range
+ */
+export function groupOverlappingDocumentChanges(
+ documentChanges: TextDocumentChange[]
+): TextDocumentChangeGroup[] {
+ return mergeDocumentChanges({
+ items: documentChanges.map(change => ({
+ insertedRange: change.insertedRange,
+ replacementRange: change.change.range,
+ originalChange: change,
+ })),
+ mergePredicate: (a, b) => doLineOverlapForRanges(a, b),
+ getChanges: item => [item.originalChange],
+ })
+}
+
+/**
+ * Combines consecutive text document change groups that have non-overlapping line ranges.
+ * The function can generally be called after `groupOverlappingDocumentChanges` to further consolidate changes.
+ *
+ * This function takes an array of `TextDocumentChangeGroup` objects and merges consecutive groups
+ * where their line ranges do not overlap. By combining these non-overlapping groups, it creates
+ * larger groups of changes that can be processed together, even if they affect different parts
+ * of the document.
+ *
+ * @param groupedChanges - Array of `TextDocumentChangeGroup` objects to be combined.
+ * @returns Array of `TextDocumentChangeGroup` objects where consecutive non-overlapping groups have been merged.
+ */
+export function groupNonOverlappingChangeGroups(
+ groupedChanges: TextDocumentChangeGroup[]
+): TextDocumentChangeGroup[] {
+ return mergeDocumentChanges({
+ items: groupedChanges,
+ mergePredicate: (a, b) => !doLineOverlapForRanges(a, b),
+ getChanges: group => group.changes,
+ })
+}
+
+/**
+ * Merges document changes based on a predicate and extracts changes using a provided function.
+ *
+ * @param items - Array of objects containing insertedRange and replacementRange properties
+ * @param mergePredicate - Function that determines if two ranges should be merged
+ * @param getChanges - Function that extracts TextDocumentChange array from an item
+ * @returns Array of TextDocumentChangeGroup objects containing merged changes and their ranges
+ */
+function mergeDocumentChanges<
+ T extends { insertedRange: vscode.Range; replacementRange: vscode.Range },
+>(args: {
+ items: T[]
+ mergePredicate: (a: vscode.Range, b: vscode.Range) => boolean
+ getChanges: (item: T) => TextDocumentChange[]
+}): TextDocumentChangeGroup[] {
+ if (args.items.length === 0) {
+ return []
+ }
+
+ const mergedGroups = groupConsecutiveItemsByPredicate(args.items, (lastItem, currentItem) => {
+ return args.mergePredicate(lastItem.insertedRange, currentItem.replacementRange)
+ })
+
+ return mergedGroups
+ .filter(group => group.length > 0)
+ .map(group => ({
+ changes: group.flatMap(item => args.getChanges(item)),
+ insertedRange: getRangeUnion(group.map(item => item.insertedRange)),
+ replacementRange: getRangeUnion(group.map(item => item.replacementRange)),
+ }))
+}
+
+function getRangeUnion(ranges: vscode.Range[]): vscode.Range {
+ if (ranges.length === 0) {
+ throw new Error('Cannot get union of empty ranges')
+ }
+ let start = ranges[0].start
+ let end = ranges[0].end
+ for (const range of ranges) {
+ start = start.isBefore(range.start) ? start : range.start
+ end = end.isAfter(range.end) ? end : range.end
+ }
+ return new vscode.Range(start, end)
+}
+
+/**
+ * Utility function to combine consecutive items in an array based on a predicate.
+ */
+export function groupConsecutiveItemsByPredicate(
+ items: T[],
+ shouldGroup: (a: T, b: T) => boolean
+): T[][] {
+ return items.reduce((groups, item) => {
+ if (groups.length === 0) {
+ groups.push([item])
+ } else {
+ const lastGroup = groups[groups.length - 1]
+ const lastItem = lastGroup[lastGroup.length - 1]
+ if (shouldGroup(lastItem, item)) {
+ lastGroup.push(item)
+ } else {
+ groups.push([item])
+ }
+ }
+ return groups
+ }, [])
+}
export function computeDiffWithLineNumbers(
uri: vscode.Uri,
@@ -51,6 +189,53 @@ export function getDiffStringForHunkWithLineNumbers(hunk: Diff.Hunk): string {
return lines.join('\n')
}
+export function getUnifiedDiffHunkFromTextDocumentChange(params: {
+ uri: vscode.Uri
+ oldContent: string
+ changes: TextDocumentChange[]
+ addLineNumbersForDiff: boolean
+ contextLines: number
+}): UnifiedPatchResponse {
+ const newContent = applyTextDocumentChanges(
+ params.oldContent,
+ params.changes.map(c => c.change)
+ )
+ const diff = params.addLineNumbersForDiff
+ ? computeDiffWithLineNumbers(params.uri, params.oldContent, newContent, params.contextLines)
+ : PromptString.fromGitDiff(params.uri, params.oldContent, newContent)
+
+ return {
+ uri: params.uri,
+ newContent,
+ diff,
+ latestEditTimestamp: Math.max(...params.changes.map(c => c.timestamp)),
+ }
+}
+
+export function divideGroupedChangesIntoShortTermAndLongTerm(changes: TextDocumentChangeGroup[]): {
+ shortTermChanges: TextDocumentChangeGroup[]
+ longTermChanges: TextDocumentChangeGroup[]
+} {
+ if (changes.length <= 1) {
+ return {
+ shortTermChanges: changes,
+ longTermChanges: [],
+ }
+ }
+ return {
+ shortTermChanges: changes.slice(-1),
+ longTermChanges: changes.slice(0, -1),
+ }
+}
+
+export function getDiffHunkFromUnifiedPatch(unifiedPatch: UnifiedPatchResponse): DiffHunk {
+ return {
+ uri: unifiedPatch.uri,
+ latestEditTimestamp: unifiedPatch.latestEditTimestamp,
+ diff: unifiedPatch.diff,
+ }
+}
+
export function applyTextDocumentChanges(
content: string,
changes: vscode.TextDocumentContentChangeEvent[]
@@ -63,3 +248,7 @@ export function applyTextDocumentChanges(
}
return content
}
+
+function doLineOverlapForRanges(a: vscode.Range, b: vscode.Range): boolean {
+ return a.start.line <= b.end.line && a.end.line >= b.start.line
+}
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts
index 729582870e2b..238bff869dba 100644
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.test.ts
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as vscode from 'vscode'
import { range } from '../../../../testutils/textDocument'
import { document } from '../../../test-helpers'
-import { RecentEditsRetrieverDiffStrategyIdentifier } from './recent-edits-diff-helpers/recent-edits-diff-strategy'
+import { LineLevelDiffStrategy } from './recent-edits-diff-helpers/line-level-diff'
+import { UnifiedDiffStrategy } from './recent-edits-diff-helpers/unified-diff'
import { RecentEditsRetriever } from './recent-edits-retriever'
const FIVE_MINUTES = 5 * 60 * 1000
@@ -25,7 +26,10 @@ describe('RecentEditsRetriever', () => {
retriever = new RecentEditsRetriever(
{
maxAgeMs: FIVE_MINUTES,
- diffStrategyIdentifier: RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff,
+ diffStrategyList: [
+ new UnifiedDiffStrategy({ addLineNumbers: false }),
+ new LineLevelDiffStrategy({ shouldGroupNonOverlappingLines: false }),
+ ],
},
{
onDidChangeTextDocument(listener) {
@@ -116,7 +120,7 @@ describe('RecentEditsRetriever', () => {
const diffHunks = await retriever.getDiff(testDocument.uri)
expect(diffHunks).not.toBeNull()
- expect(diffHunks?.length).toBe(1)
+ expect(diffHunks?.length).toBe(3)
expect(
diffHunks?.[0].diff?.toString().split('\n').slice(2).join('\n')
).toMatchInlineSnapshot(`
@@ -162,7 +166,7 @@ describe('RecentEditsRetriever', () => {
const diffHunks = await retriever.getDiff(testDocument.uri)
expect(diffHunks).not.toBeNull()
- expect(diffHunks?.length).toBe(1)
+ expect(diffHunks?.length).toBe(2)
expect(
diffHunks?.[0].diff?.toString().split('\n').slice(2).join('\n')
).toMatchInlineSnapshot(`
@@ -203,7 +207,7 @@ describe('RecentEditsRetriever', () => {
const diffHunks = await retriever.getDiff(newUri)
expect(diffHunks).not.toBeNull()
- expect(diffHunks?.length).toBe(1)
+ expect(diffHunks?.length).toBe(2)
expect(
diffHunks?.[0].diff?.toString().split('\n').slice(2).join('\n')
).toMatchInlineSnapshot(`
diff --git a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts
index b5d1d97fa3be..797a78d2001b 100644
--- a/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts
+++ b/vscode/src/completions/context/retrievers/recent-user-actions/recent-edits-retriever.ts
@@ -1,15 +1,14 @@
import { type PromptString, contextFiltersProvider } from '@sourcegraph/cody-shared'
import type { AutocompleteContextSnippet } from '@sourcegraph/cody-shared'
import * as vscode from 'vscode'
+import { getPositionAfterTextInsertion } from '../../../text-processing/utils'
import type { ContextRetriever, ContextRetrieverOptions } from '../../../types'
import { RetrieverIdentifier, type ShouldUseContextParams, shouldBeUsedAsContext } from '../../utils'
-import {
- type DiffHunk,
- type RecentEditsRetrieverDiffStrategy,
- type RecentEditsRetrieverDiffStrategyIdentifier,
- type TextDocumentChange,
- createDiffStrategy,
-} from './recent-edits-diff-helpers/recent-edits-diff-strategy'
+import type {
+ DiffHunk,
+ RecentEditsRetrieverDiffStrategy,
+ TextDocumentChange,
+} from './recent-edits-diff-helpers/base'
import { applyTextDocumentChanges } from './recent-edits-diff-helpers/utils'
interface TrackedDocument {
@@ -19,9 +18,13 @@ interface TrackedDocument {
changes: TextDocumentChange[]
}
+interface DiffHunkWithStrategy extends DiffHunk {
+ diffStrategyName: string
+}
+
export interface RecentEditsRetrieverOptions {
maxAgeMs: number
- diffStrategyIdentifier: RecentEditsRetrieverDiffStrategyIdentifier
+ diffStrategyList: RecentEditsRetrieverDiffStrategy[]
}
interface DiffAcrossDocuments {
@@ -29,6 +32,7 @@ interface DiffAcrossDocuments {
uri: vscode.Uri
languageId: string
latestChangeTimestamp: number
+ diffStrategyName: string
}
export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever {
@@ -38,8 +42,7 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
public identifier = RetrieverIdentifier.RecentEditsRetriever
private disposables: vscode.Disposable[] = []
private readonly maxAgeMs: number
- private readonly diffStrategyIdentifier: RecentEditsRetrieverDiffStrategyIdentifier
- private readonly diffStrategy: RecentEditsRetrieverDiffStrategy
+ private readonly diffStrategyList: RecentEditsRetrieverDiffStrategy[]
constructor(
options: RecentEditsRetrieverOptions,
@@ -49,8 +52,7 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
> = vscode.workspace
) {
this.maxAgeMs = options.maxAgeMs
- this.diffStrategyIdentifier = options.diffStrategyIdentifier
- this.diffStrategy = createDiffStrategy(this.diffStrategyIdentifier)
+ this.diffStrategyList = options.diffStrategyList
// Track the already open documents when editor was opened
for (const document of vscode.workspace.textDocuments) {
@@ -67,8 +69,13 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
public async retrieve(options: ContextRetrieverOptions): Promise {
const rawDiffs = await this.getDiffAcrossDocuments()
const diffs = this.filterCandidateDiffs(rawDiffs, options.document)
- // Heuristics ordering by timestamp, taking the most recent diffs first.
- diffs.sort((a, b) => b.latestChangeTimestamp - a.latestChangeTimestamp)
+ // Sort first by strategy name and then by timestamp
+ diffs.sort((a, b) => {
+ if (a.diffStrategyName !== b.diffStrategyName) {
+ return a.diffStrategyName.localeCompare(b.diffStrategyName)
+ }
+ return b.latestChangeTimestamp - a.latestChangeTimestamp
+ })
const autocompleteContextSnippets = []
for (const diff of diffs) {
@@ -79,7 +86,7 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
content,
metadata: {
timeSinceActionMs: Date.now() - diff.latestChangeTimestamp,
- recentEditsRetrieverDiffStrategy: this.diffStrategyIdentifier,
+ recentEditsRetrieverDiffStrategy: diff.diffStrategyName,
},
} satisfies Omit
autocompleteContextSnippets.push(autocompleteSnippet)
@@ -94,13 +101,17 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
const diffs: DiffAcrossDocuments[] = []
const diffPromises = Array.from(this.trackedDocuments.entries()).map(
async ([uri, trackedDocument]) => {
+ if (trackedDocument.changes.length === 0) {
+ return null
+ }
const diffHunks = await this.getDiff(vscode.Uri.parse(uri))
- if (diffHunks && trackedDocument.changes.length > 0) {
+ if (diffHunks) {
return diffHunks.map(diffHunk => ({
diff: diffHunk.diff,
uri: trackedDocument.uri,
languageId: trackedDocument.languageId,
latestChangeTimestamp: diffHunk.latestEditTimestamp,
+ diffStrategyName: diffHunk.diffStrategyName,
}))
}
return null
@@ -131,7 +142,7 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
return filterCandidateDiffs
}
- public async getDiff(uri: vscode.Uri): Promise {
+ public async getDiff(uri: vscode.Uri): Promise {
if (await contextFiltersProvider.isUriIgnored(uri)) {
return null
}
@@ -140,11 +151,20 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
if (!trackedDocument) {
return null
}
- const diffHunks = this.diffStrategy.getDiffHunks({
- uri: trackedDocument.uri,
- oldContent: trackedDocument.content,
- changes: trackedDocument.changes,
- })
+ const diffHunks: DiffHunkWithStrategy[] = []
+ for (const diffStrategy of this.diffStrategyList) {
+ const hunks = diffStrategy.getDiffHunks({
+ uri: trackedDocument.uri,
+ oldContent: trackedDocument.content,
+ changes: trackedDocument.changes,
+ })
+ for (const hunk of hunks) {
+ diffHunks.push({
+ ...hunk,
+ diffStrategyName: diffStrategy.getDiffStrategyName(),
+ })
+ }
+ }
return diffHunks
}
@@ -160,12 +180,16 @@ export class RecentEditsRetriever implements vscode.Disposable, ContextRetriever
const now = Date.now()
for (const change of event.contentChanges) {
+ const insertedRange = new vscode.Range(
+ change.range.start,
+ getPositionAfterTextInsertion(change.range.start, change.text)
+ )
trackedDocument.changes.push({
timestamp: now,
change,
+ insertedRange,
})
}
-
this.reconcileOutdatedChanges()
}
diff --git a/vscode/src/supercompletions/supercompletion-provider.ts b/vscode/src/supercompletions/supercompletion-provider.ts
index 988502ba14bc..68feaf418729 100644
--- a/vscode/src/supercompletions/supercompletion-provider.ts
+++ b/vscode/src/supercompletions/supercompletion-provider.ts
@@ -1,6 +1,6 @@
import type { ChatClient } from '@sourcegraph/cody-shared'
import * as vscode from 'vscode'
-import { RecentEditsRetrieverDiffStrategyIdentifier } from '../completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/recent-edits-diff-strategy'
+import { UnifiedDiffStrategy } from '../completions/context/retrievers/recent-user-actions/recent-edits-diff-helpers/unified-diff'
import { RecentEditsRetriever } from '../completions/context/retrievers/recent-user-actions/recent-edits-retriever'
import type { CodyStatusBar } from '../services/StatusBar'
import { type Supercompletion, getSupercompletions } from './get-supercompletion'
@@ -31,7 +31,7 @@ export class SupercompletionProvider implements vscode.Disposable {
this.recentEditsRetriever = new RecentEditsRetriever(
{
maxAgeMs: EDIT_HISTORY_TIMEOUT,
- diffStrategyIdentifier: RecentEditsRetrieverDiffStrategyIdentifier.UnifiedDiff,
+ diffStrategyList: [new UnifiedDiffStrategy({ addLineNumbers: false })],
},
workspace
)