Path: blob/main/src/vs/platform/agentHost/node/sessionDiffAggregator.ts
13394 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*--------------------------------------------------------------------------------------------*/45import { URI } from '../../../base/common/uri.js';6import type { IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js';7import type { IDiffComputeService } from '../common/diffComputeService.js';8import { FileEditKind, type ISessionFileDiff } from '../common/state/sessionState.js';9import { buildSessionDbUri } from './copilot/fileEditTracker.js';1011function getFileEditUri(diff: ISessionFileDiff): string | undefined {12return diff.after?.uri ?? diff.before?.uri;13}1415function createSessionFileDiff(sessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff {16const hasBefore = identity.firstKind !== FileEditKind.Create;17const hasAfter = identity.lastKind !== FileEditKind.Delete;18return {19...(hasBefore ? {20before: {21uri: URI.file(identity.firstFilePath).toString(),22content: { uri: buildSessionDbUri(sessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') },23},24} : {}),25...(hasAfter ? {26after: {27uri: URI.file(identity.terminalPath).toString(),28content: { uri: buildSessionDbUri(sessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') },29},30} : {}),31diff: { added, removed },32};33}3435/**36* Represents a file's identity across renames, tracking its first and last37* snapshots in the session for diff computation.38*/39interface IFileIdentity {40/** The last known URI for this file. */41terminalPath: string;42/** Tool call ID of the first edit (for fetching "before" content). */43firstToolCallId: string;44/** File path used in the first edit's database record. */45firstFilePath: string;46/** The kind of the first edit (Create means no "before" content). */47firstKind: FileEditKind;48/** Tool call ID of the last edit (for fetching "after" content). */49lastToolCallId: string;50/** File path used in the last edit's database record. */51lastFilePath: string;52/** The kind of the last edit (Delete means no "after" content). */53lastKind: FileEditKind;54}5556/**57* Options for incremental diff computation. When provided,58* {@link computeSessionDiffs} reuses previous diff results for file59* identities that were not touched in the given turn.60*/61export interface IIncrementalDiffOptions {62/** The turn ID that just completed — only identities touched by edits63* in this turn will be recomputed. */64changedTurnId: string;65/** Previously computed diffs (from the last dispatch). Entries for66* untouched identities are carried over without recomputation. */67previousDiffs: ISessionFileDiff[];68}6970/**71* Computes aggregated diff statistics for a session by comparing each file's72* first snapshot to its last snapshot, tracking renames across the chain.73*74* When {@link incremental} is provided, only identities that were touched75* by edits in the given turn are recomputed; all other identities reuse76* the previous diff results. This avoids expensive content fetches and77* diff computations for unchanged files.78*79* Returns an {@link ISessionFileDiff} array with the "last known URI" for each80* file and the total lines added/removed across the session.81*/82export async function computeSessionDiffs(83sessionUri: string,84db: ISessionDatabase,85diffService: IDiffComputeService,86incremental?: IIncrementalDiffOptions,87): Promise<ISessionFileDiff[]> {88// In incremental mode, try to fetch only the current turn's edits.89// When the turn only introduces new files (no renames, no re-edits of90// previously changed files), the full edit history is not needed.91let edits: IFileEditRecord[];92let fastPath = false;9394if (incremental) {95const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId);96if (turnEdits.length === 0) {97return [...incremental.previousDiffs];98}99100const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri));101const needsFullHistory = turnEdits.some(e =>102e.kind === FileEditKind.Rename ||103previousDiffsUris.has(URI.file(e.filePath).toString())104);105106if (needsFullHistory) {107edits = await db.getAllFileEdits();108} else {109edits = turnEdits;110fastPath = true;111}112} else {113edits = await db.getAllFileEdits();114}115116if (edits.length === 0) {117return [];118}119120// Build file identity graph. We need to:121// 1. Track renames: when a file is renamed A→B, its identity follows to B122// 2. Find the first "before" snapshot and last "after" snapshot per identity123124// Maps a file path to its canonical identity key (follows rename chains)125const pathToIdentityKey = new Map<string, string>();126// Maps identity keys to their accumulated data127const identities = new Map<string, IFileIdentity>();128// Track which identity keys were touched by the incremental turn.129// In fast-path mode all identities are from the current turn, so no tracking needed.130const touchedIdentityKeys = (incremental && !fastPath) ? new Set<string>() : undefined;131132for (const edit of edits) {133let identityKey: string;134135if (edit.kind === FileEditKind.Rename && edit.originalPath) {136// Rename: follow the chain from originalPath to find the identity137identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath;138// Update the mapping: the new path now points to the same identity139pathToIdentityKey.set(edit.filePath, identityKey);140// Remove old path mapping (the file no longer exists at that path)141pathToIdentityKey.delete(edit.originalPath);142} else {143// Regular edit, create, or delete: look up or create identity144identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath;145pathToIdentityKey.set(edit.filePath, identityKey);146}147148if (touchedIdentityKeys && edit.turnId === incremental!.changedTurnId) {149touchedIdentityKeys.add(identityKey);150}151152const existing = identities.get(identityKey);153if (!existing) {154// First time seeing this file identity155identities.set(identityKey, {156terminalPath: edit.filePath,157firstToolCallId: edit.toolCallId,158firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath,159firstKind: edit.kind,160lastToolCallId: edit.toolCallId,161lastFilePath: edit.filePath,162lastKind: edit.kind,163});164} else {165// Update last snapshot info and terminal path166existing.terminalPath = edit.filePath;167existing.lastToolCallId = edit.toolCallId;168existing.lastFilePath = edit.filePath;169existing.lastKind = edit.kind;170}171}172173// In incremental slow-path mode, build a lookup map from URI string →174// previous diff so untouched identities can carry over their previous results.175const previousDiffsMap = (incremental && !fastPath)176? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d]))177: undefined;178179// Compute diffs for each file identity180const results: ISessionFileDiff[] = [];181const diffPromises: Promise<void>[] = [];182183for (const [identityKey, identity] of identities) {184// In incremental slow-path mode, skip recomputation for untouched identities185if (touchedIdentityKeys && !touchedIdentityKeys.has(identityKey)) {186const uri = URI.file(identity.terminalPath).toString();187const prev = previousDiffsMap!.get(uri);188if (prev) {189results.push(prev);190}191// If no previous entry, the file previously had zero net change — skip192continue;193}194195diffPromises.push((async () => {196// Determine "before" text197let beforeText: string;198if (identity.firstKind === FileEditKind.Create) {199beforeText = '';200} else {201const content = await db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath);202beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : '';203}204205// Determine "after" text206let afterText: string;207if (identity.lastKind === FileEditKind.Delete) {208afterText = '';209} else {210const content = await db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath);211afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : '';212}213214// Skip files with no net change215if (beforeText === afterText) {216return;217}218219const counts = await diffService.computeDiffCounts(beforeText, afterText);220results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed));221})());222}223224await Promise.allSettled(diffPromises);225226// In fast-path mode, carry over previous diffs for untouched files227// (they were not in the identity graph since we only loaded the current turn)228if (fastPath) {229results.push(...incremental!.previousDiffs);230}231232return results;233}234235236