Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/node/sessionDiffAggregator.ts
13394 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { URI } from '../../../base/common/uri.js';
7
import type { IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js';
8
import type { IDiffComputeService } from '../common/diffComputeService.js';
9
import { FileEditKind, type ISessionFileDiff } from '../common/state/sessionState.js';
10
import { buildSessionDbUri } from './copilot/fileEditTracker.js';
11
12
function getFileEditUri(diff: ISessionFileDiff): string | undefined {
13
return diff.after?.uri ?? diff.before?.uri;
14
}
15
16
function createSessionFileDiff(sessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff {
17
const hasBefore = identity.firstKind !== FileEditKind.Create;
18
const hasAfter = identity.lastKind !== FileEditKind.Delete;
19
return {
20
...(hasBefore ? {
21
before: {
22
uri: URI.file(identity.firstFilePath).toString(),
23
content: { uri: buildSessionDbUri(sessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') },
24
},
25
} : {}),
26
...(hasAfter ? {
27
after: {
28
uri: URI.file(identity.terminalPath).toString(),
29
content: { uri: buildSessionDbUri(sessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') },
30
},
31
} : {}),
32
diff: { added, removed },
33
};
34
}
35
36
/**
37
* Represents a file's identity across renames, tracking its first and last
38
* snapshots in the session for diff computation.
39
*/
40
interface IFileIdentity {
41
/** The last known URI for this file. */
42
terminalPath: string;
43
/** Tool call ID of the first edit (for fetching "before" content). */
44
firstToolCallId: string;
45
/** File path used in the first edit's database record. */
46
firstFilePath: string;
47
/** The kind of the first edit (Create means no "before" content). */
48
firstKind: FileEditKind;
49
/** Tool call ID of the last edit (for fetching "after" content). */
50
lastToolCallId: string;
51
/** File path used in the last edit's database record. */
52
lastFilePath: string;
53
/** The kind of the last edit (Delete means no "after" content). */
54
lastKind: FileEditKind;
55
}
56
57
/**
58
* Options for incremental diff computation. When provided,
59
* {@link computeSessionDiffs} reuses previous diff results for file
60
* identities that were not touched in the given turn.
61
*/
62
export interface IIncrementalDiffOptions {
63
/** The turn ID that just completed — only identities touched by edits
64
* in this turn will be recomputed. */
65
changedTurnId: string;
66
/** Previously computed diffs (from the last dispatch). Entries for
67
* untouched identities are carried over without recomputation. */
68
previousDiffs: ISessionFileDiff[];
69
}
70
71
/**
72
* Computes aggregated diff statistics for a session by comparing each file's
73
* first snapshot to its last snapshot, tracking renames across the chain.
74
*
75
* When {@link incremental} is provided, only identities that were touched
76
* by edits in the given turn are recomputed; all other identities reuse
77
* the previous diff results. This avoids expensive content fetches and
78
* diff computations for unchanged files.
79
*
80
* Returns an {@link ISessionFileDiff} array with the "last known URI" for each
81
* file and the total lines added/removed across the session.
82
*/
83
export async function computeSessionDiffs(
84
sessionUri: string,
85
db: ISessionDatabase,
86
diffService: IDiffComputeService,
87
incremental?: IIncrementalDiffOptions,
88
): Promise<ISessionFileDiff[]> {
89
// In incremental mode, try to fetch only the current turn's edits.
90
// When the turn only introduces new files (no renames, no re-edits of
91
// previously changed files), the full edit history is not needed.
92
let edits: IFileEditRecord[];
93
let fastPath = false;
94
95
if (incremental) {
96
const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId);
97
if (turnEdits.length === 0) {
98
return [...incremental.previousDiffs];
99
}
100
101
const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri));
102
const needsFullHistory = turnEdits.some(e =>
103
e.kind === FileEditKind.Rename ||
104
previousDiffsUris.has(URI.file(e.filePath).toString())
105
);
106
107
if (needsFullHistory) {
108
edits = await db.getAllFileEdits();
109
} else {
110
edits = turnEdits;
111
fastPath = true;
112
}
113
} else {
114
edits = await db.getAllFileEdits();
115
}
116
117
if (edits.length === 0) {
118
return [];
119
}
120
121
// Build file identity graph. We need to:
122
// 1. Track renames: when a file is renamed A→B, its identity follows to B
123
// 2. Find the first "before" snapshot and last "after" snapshot per identity
124
125
// Maps a file path to its canonical identity key (follows rename chains)
126
const pathToIdentityKey = new Map<string, string>();
127
// Maps identity keys to their accumulated data
128
const identities = new Map<string, IFileIdentity>();
129
// Track which identity keys were touched by the incremental turn.
130
// In fast-path mode all identities are from the current turn, so no tracking needed.
131
const touchedIdentityKeys = (incremental && !fastPath) ? new Set<string>() : undefined;
132
133
for (const edit of edits) {
134
let identityKey: string;
135
136
if (edit.kind === FileEditKind.Rename && edit.originalPath) {
137
// Rename: follow the chain from originalPath to find the identity
138
identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath;
139
// Update the mapping: the new path now points to the same identity
140
pathToIdentityKey.set(edit.filePath, identityKey);
141
// Remove old path mapping (the file no longer exists at that path)
142
pathToIdentityKey.delete(edit.originalPath);
143
} else {
144
// Regular edit, create, or delete: look up or create identity
145
identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath;
146
pathToIdentityKey.set(edit.filePath, identityKey);
147
}
148
149
if (touchedIdentityKeys && edit.turnId === incremental!.changedTurnId) {
150
touchedIdentityKeys.add(identityKey);
151
}
152
153
const existing = identities.get(identityKey);
154
if (!existing) {
155
// First time seeing this file identity
156
identities.set(identityKey, {
157
terminalPath: edit.filePath,
158
firstToolCallId: edit.toolCallId,
159
firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath,
160
firstKind: edit.kind,
161
lastToolCallId: edit.toolCallId,
162
lastFilePath: edit.filePath,
163
lastKind: edit.kind,
164
});
165
} else {
166
// Update last snapshot info and terminal path
167
existing.terminalPath = edit.filePath;
168
existing.lastToolCallId = edit.toolCallId;
169
existing.lastFilePath = edit.filePath;
170
existing.lastKind = edit.kind;
171
}
172
}
173
174
// In incremental slow-path mode, build a lookup map from URI string →
175
// previous diff so untouched identities can carry over their previous results.
176
const previousDiffsMap = (incremental && !fastPath)
177
? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d]))
178
: undefined;
179
180
// Compute diffs for each file identity
181
const results: ISessionFileDiff[] = [];
182
const diffPromises: Promise<void>[] = [];
183
184
for (const [identityKey, identity] of identities) {
185
// In incremental slow-path mode, skip recomputation for untouched identities
186
if (touchedIdentityKeys && !touchedIdentityKeys.has(identityKey)) {
187
const uri = URI.file(identity.terminalPath).toString();
188
const prev = previousDiffsMap!.get(uri);
189
if (prev) {
190
results.push(prev);
191
}
192
// If no previous entry, the file previously had zero net change — skip
193
continue;
194
}
195
196
diffPromises.push((async () => {
197
// Determine "before" text
198
let beforeText: string;
199
if (identity.firstKind === FileEditKind.Create) {
200
beforeText = '';
201
} else {
202
const content = await db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath);
203
beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : '';
204
}
205
206
// Determine "after" text
207
let afterText: string;
208
if (identity.lastKind === FileEditKind.Delete) {
209
afterText = '';
210
} else {
211
const content = await db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath);
212
afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : '';
213
}
214
215
// Skip files with no net change
216
if (beforeText === afterText) {
217
return;
218
}
219
220
const counts = await diffService.computeDiffCounts(beforeText, afterText);
221
results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed));
222
})());
223
}
224
225
await Promise.allSettled(diffPromises);
226
227
// In fast-path mode, carry over previous diffs for untouched files
228
// (they were not in the identity graph since we only loaded the current turn)
229
if (fastPath) {
230
results.push(...incremental!.previousDiffs);
231
}
232
233
return results;
234
}
235
236