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 )