Path: blob/main/extensions/copilot/src/extension/prompts/node/feedback/currentChange.tsx
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { BasePromptElementProps, PromptElement, PromptPiece, PromptReference, PromptSizing } from '@vscode/prompt-tsx';5import type { Position, Selection, TextDocument } from 'vscode';6import { TextDocumentSnapshot } from '../../../../platform/editing/common/textDocumentSnapshot';7import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';8import { Repository } from '../../../../platform/git/vscode/git';9import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';10import { ILogService } from '../../../../platform/log/common/logService';11import { IParserService } from '../../../../platform/parser/node/parserService';12import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';13import { Location, Range, Uri } from '../../../../vscodeTypes';14import { Tag } from '../base/tag';15import { CodeBlock } from '../panel/safeElements';16import { SymbolAtCursor } from '../panel/symbolAtCursor';1718export interface CurrentChangeProps extends BasePromptElementProps {19input: CurrentChangeInput[];20logService: ILogService;21}2223export interface CurrentChangeInput {24document: TextDocumentSnapshot;25relativeDocumentPath: string;26change?: Change;27selection?: Selection | Range;28}2930interface CurrentChangeState extends BasePromptElementProps {31input: {32input: CurrentChangeInput;33hunks: Hunk[];34}[];35}3637export interface Change {38repository: Repository;39uri: Uri;40hunks: Hunk[];41}4243export interface Hunk {44range: Range;45text: string;46}4748interface GitHunk {49startDeletedLine: number; // 1-based50deletedLines: number;51startAddedLine: number; // 1-based52addedLines: number;53additions: { start: number; length: number }[];54diffText: string;55}5657interface Text {58input: CurrentChangeInput;59hunks: Hunk[];60tokens: number;61}6263export class CurrentChange extends PromptElement<CurrentChangeProps, CurrentChangeState> {64constructor(65props: CurrentChangeProps,66@IParserService private readonly parserService: IParserService,67@IIgnoreService private readonly ignoreService: IIgnoreService68) {69super(props);70}7172override async prepare(sizing: PromptSizing): Promise<CurrentChangeState> {73const allowed = [];74for (const input of this.props.input) {75if (!await this.ignoreService.isCopilotIgnored(input.document.uri)) {76allowed.push(input);77}78}7980const texts: Text[] = await Promise.all(allowed.map(async input => {81const { document, change, selection } = input;82let textAll: string;83if (change?.hunks.length) {84const first = change.hunks[0];85textAll = [86first.range.start.line > 0 ? CurrentChange.enumeratedLines(document, 0, first.range.start.line) : '',87...change.hunks.map((hunk, i, a) => {88const nextHunkLine = i + 1 < a.length ? a[i + 1].range.start.line : document.lineCount;89return [90CurrentChange.enumeratedChangeLines(hunk.text, hunk.range.start.line), '\n',91hunk.range.end.line < nextHunkLine ? CurrentChange.enumeratedLines(document, hunk.range.end.line, nextHunkLine) : '',92];93}).flat(),94].join('');95} else if (selection) {96const selectionEndLine = selection.end.line + (selection.end.character > 0 ? 1 : 0); // Being line-based.97textAll = CurrentChange.enumeratedSelectedLines(document, 0, document.lineCount, selection.start.line, selectionEndLine);98} else {99textAll = CurrentChange.enumeratedLines(document, 0, document.lineCount);100}101return {102input,103hunks: [{104range: new Range(0, 0, input.document.lineCount, 0),105text: textAll,106}],107tokens: await sizing.countTokens(textAll),108};109}));110111let currentTokens = texts.reduce((acc, { tokens }) => acc + tokens, 0);112113this.props.logService.info(`[CurrentChange] Full documents: ${currentTokens} tokens, ${sizing.tokenBudget} budget`);114if (currentTokens <= sizing.tokenBudget) {115return {116input: texts.map(({ input, hunks }) => ({117input,118hunks,119}))120};121}122123const sorted = texts.slice().sort((a, b) => b.tokens - a.tokens);124for (const text of sorted) {125const { input, tokens } = text;126const { document, change, selection } = input;127if (change?.hunks.length) {128const definitionHunks = [];129let definitionTokens = 0;130for (const hunk of change.hunks) {131const definition = await SymbolAtCursor.getDefinitionAtRange(this.ignoreService, this.parserService, document, hunk.range, false);132if (definition) {133const definitionEndLine = definition.range.end.line + (definition.range.end.character > 0 ? 1 : 0); // Being line-based.134const hunkEndLine = hunk.range.end.line + (hunk.range.end.character > 0 ? 1 : 0); // Being line-based.135const textDefinition = [136hunk.range.start.line > definition.range.start.line ? CurrentChange.enumeratedLines(document, definition.range.start.line, hunk.range.start.line) : '',137CurrentChange.enumeratedChangeLines(hunk.text, hunk.range.start.line), '\n',138definitionEndLine > hunkEndLine ? CurrentChange.enumeratedLines(document, hunkEndLine, definitionEndLine) : '',139].join('');140definitionHunks.push({141range: new Range(Math.min(hunk.range.start.line, definition.range.start.line), 0, Math.max(definitionEndLine, hunkEndLine), 0),142text: textDefinition,143});144definitionTokens += await sizing.countTokens(textDefinition);145} else {146const hunkText = CurrentChange.enumeratedChangeLines(hunk.text, hunk.range.start.line);147const hunkEndLine = hunk.range.end.line + (hunk.range.end.character > 0 ? 1 : 0); // Being line-based.148definitionHunks.push({149range: new Range(hunk.range.start.line, 0, hunkEndLine, 0),150text: hunkText,151});152definitionTokens += await sizing.countTokens(hunkText);153}154}155text.hunks = definitionHunks;156text.tokens = definitionTokens;157currentTokens += text.tokens - tokens;158} else if (selection) {159const definition = await SymbolAtCursor.getDefinitionAtRange(this.ignoreService, this.parserService, document, selection, false);160if (definition) {161const definitionEndLine = definition.range.end.line + (definition.range.end.character > 0 ? 1 : 0); // Being line-based.162const selectionEndLine = selection.end.line + (selection.end.character > 0 ? 1 : 0); // Being line-based.163const textDefinition = CurrentChange.enumeratedSelectedLines(document, definition.range.start.line, definitionEndLine, selection.start.line, selectionEndLine);164const textDefinitionTokens = await sizing.countTokens(textDefinition);165text.hunks = [{166range: definition.range,167text: textDefinition,168}];169text.tokens = textDefinitionTokens;170currentTokens += text.tokens - tokens;171} else {172const selectionEndLine = selection.end.line + (selection.end.character > 0 ? 1 : 0); // Being line-based.173const hunkText = CurrentChange.enumeratedSelectedLines(document, selection.start.line, selectionEndLine, selection.start.line, selectionEndLine);174text.hunks = [{175range: new Range(selection.start.line, 0, selectionEndLine, 0),176text: hunkText,177}];178text.tokens = await sizing.countTokens(hunkText);179currentTokens += text.tokens - tokens;180}181} else {182text.hunks = [];183text.tokens = 0;184currentTokens += text.tokens - tokens;185}186187this.props.logService.info(`[CurrentChange] Reduced ${input.relativeDocumentPath} to defintions: ${currentTokens} tokens, ${sizing.tokenBudget} budget`);188if (currentTokens <= sizing.tokenBudget) {189return {190input: texts.map(({ input, hunks }) => ({191input,192hunks,193}))194};195}196}197198this.props.logService.info(`[CurrentChange] Still too large: ${currentTokens} tokens, ${sizing.tokenBudget} budget, ${texts.length} inputs`);199if (texts.length > 1) {200const err = new Error('Split prompt.');201(err as any).code = 'split_input';202throw err;203}204return {205input: texts.map(({ input, hunks }) => ({206input,207hunks,208}))209};210}211212override render(state: CurrentChangeState, sizing: PromptSizing): PromptPiece<any, any> | undefined {213const input = state.input.filter(i => i.hunks.length > 0);214if (!input.length) {215return;216}217218return (<>219<Tag name='currentChange' priority={this.props.priority}>220{221input.map(input => (<>222{input.input.change ? <>223Change at cursor:<br />224<br />225Each line is annotated with the line number in the file.<br />226</> : <>227Current selection with the selected lines labeled as such:<br />228</>}229<br />230From the file: {input.input.relativeDocumentPath}<br />231{232input.hunks.map(hunk => (233<CodeBlock references={[new PromptReference(new Location(input.input.document.uri, hunk.range))]} uri={input.input.document.uri} code={hunk.text} languageId={`${input.input.document.languageId}/${input.input.relativeDocumentPath}: FROM_LINE: ${hunk.range.start.line + 1} - TO_LINE: ${hunk.range.end.line}`} />234))235}236<br />237<br />238</>))239}240</Tag >241</>);242}243244static async getCurrentChanges(gitExtensionService: IGitExtensionService, group: 'index' | 'workingTree' | 'all'): Promise<Change[]> {245const git = gitExtensionService.getExtensionApi();246if (!git) {247return [];248}249const changes = await Promise.all(git.repositories.map(async repository => {250const stats = await (251group === 'index' ? repository.diffIndexWithHEAD() :252group === 'workingTree' ? repository.diffWithHEAD() :253repository.diffWith('HEAD')254);255const changes = await Promise.all(stats.map(async change => {256const text = await (group === 'index' ? repository.diffIndexWithHEAD(change.uri.fsPath) : repository.diffWithHEAD(change.uri.fsPath));257return {258repository,259uri: change.uri,260hunks: CurrentChange.parseDiff(text)261.map(hunk => CurrentChange.gitHunkToHunk(hunk))262} satisfies Change;263}));264return changes;265}));266return changes.flat();267}268269static async getCurrentChange(accessor: ServicesAccessor, _document: TextDocument, cursor: Position): Promise<Change | undefined> {270const document = TextDocumentSnapshot.create(_document);271const gitExtensionService = accessor.get(IGitExtensionService);272const git = gitExtensionService.getExtensionApi();273if (!git) {274return;275}276277const repository = git.getRepository(document.uri);278if (!repository) {279return;280}281282const diff = await repository.diffWithHEAD(document.uri.fsPath);283if (!diff) {284return;285}286287const hunks = CurrentChange.parseDiff(diff);288const overlappingHunk = hunks.find(hunk => {289return hunk.additions.some(addition => {290const start = addition.start - 1;291const end = start + addition.length - 1;292return cursor.line >= start && cursor.line <= end;293});294});295296if (!overlappingHunk) {297return;298}299300return {301repository,302uri: document.uri,303hunks: [CurrentChange.gitHunkToHunk(overlappingHunk)]304} satisfies Change;305}306307static async getChanges(gitExtensionService: IGitExtensionService, repositoryUri: Uri, uri: Uri, diff: string): Promise<Change | undefined> {308const git = gitExtensionService.getExtensionApi();309if (!git) {310return;311}312313const hunks = CurrentChange.parseDiff(diff);314315const repository = git.repositories.find(r => r.rootUri.toString().toLowerCase() === repositoryUri.toString().toLowerCase());316if (!repository) {317return;318}319320return {321repository,322uri,323hunks: hunks.map(hunk => CurrentChange.gitHunkToHunk(hunk))324} satisfies Change;325}326327private static gitHunkToHunk(hunk: GitHunk): Hunk {328const range = new Range(hunk.startAddedLine - 1, 0, hunk.startAddedLine - 1 + hunk.addedLines, 0);329return {330range,331text: hunk.diffText,332};333}334335private static parseDiff(diff: string): GitHunk[] {336const hunkTexts = diff.split('\n@@');337if (hunkTexts.length && hunkTexts[hunkTexts.length - 1].endsWith('\n')) {338hunkTexts[hunkTexts.length - 1] = hunkTexts[hunkTexts.length - 1].slice(0, -1);339}340const hunks = hunkTexts.map(chunk => {341const rangeMatch = chunk.match(/-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?/);342if (rangeMatch) {343let startDeletedLine = parseInt(rangeMatch[1]);344const deletedLines = rangeMatch[2] ? parseInt(rangeMatch[2]) : 1;345let startAddedLine = parseInt(rangeMatch[3]);346const addedLines = rangeMatch[4] ? parseInt(rangeMatch[4]) : 1;347348const additions: { start: number; length: number }[] = [];349const lines = chunk.split('\n')350.slice(1);351let d = 0;352let addStart: number | undefined;353for (const line of lines) {354const ch = line.charAt(0);355if (ch === '+') {356if (addStart === undefined) {357addStart = startAddedLine + d;358}359d++;360} else {361if (addStart !== undefined) {362additions.push({ start: addStart, length: startAddedLine + d - addStart });363addStart = undefined;364}365if (ch === ' ') {366d++;367}368}369}370if (addStart !== undefined) {371additions.push({ start: addStart, length: startAddedLine + d - addStart });372addStart = undefined;373}374if (startDeletedLine === 0) {375startDeletedLine = 1; // when deletedLines is 0?376}377if (startAddedLine === 0) {378startAddedLine = 1; // when addedLines is 0?379}380return {381startDeletedLine, // 1-based382deletedLines,383startAddedLine, // 1-based384addedLines,385additions,386diffText: lines.join('\n'),387};388}389return null;390}).filter(Boolean as unknown as (<C>(x: C) => x is NonNullable<C>));391return hunks;392}393394private static enumeratedLines(document: TextDocumentSnapshot, startLine: number, endLine: number) {395const text = document.getText(new Range(startLine, 0, endLine, 0));396const lines = text.split('\n');397const code = lines398.map((line, i) => i === endLine - startLine ? line : `/* Line ${startLine + i + 1} */${line}`)399.join('\n');400return code;401}402403private static enumeratedSelectedLines(document: TextDocumentSnapshot, startLine: number, endLine: number, startSelectionLine: number, endSelectionLine: number) {404const text = document.getText(new Range(startLine, 0, endLine, 0));405const lines = text.split('\n');406const code = lines407.map((line, i) => {408if (i === endLine - startLine) {409return line;410}411const currentLine = startLine + i;412return `/* ${startSelectionLine <= currentLine && currentLine < endSelectionLine ? 'Selected ' : ''}Line ${currentLine + 1} */${line}`;413})414.join('\n');415return code;416}417418private static enumeratedChangeLines(text: string, startLine: number) {419let removedLines = 0;420const code = text.split('\n')421.filter(line => line[0] !== '-') // TODO: Try with removed lines included.422.map((line, i) => {423const changeChar = line[0];424const removal = changeChar === '-';425if (removal) {426removedLines++;427}428const addition = changeChar === '+';429return `/* ${removal ? 'Removed Line' : `${addition ? 'Changed ' : ''}Line ${startLine + i - removedLines + 1}`} */${line.substring(1)}`;430})431.join('\n');432return code;433}434}435436437