Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts
13399 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 { promises as fs } from 'fs';
7
import * as vscode from 'vscode';
8
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
9
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
10
import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils';
11
import { DiffChange } from '../../../platform/git/vscode/git';
12
import { ILogService } from '../../../platform/log/common/logService';
13
import { SequencerByKey } from '../../../util/vs/base/common/async';
14
import { Disposable } from '../../../util/vs/base/common/lifecycle';
15
import { ResourceMap } from '../../../util/vs/base/common/map';
16
import * as path from '../../../util/vs/base/common/path';
17
import { generateUuid } from '../../../util/vs/base/common/uuid';
18
import { IChatSessionMetadataStore, RepositoryProperties, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore';
19
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
20
import { ChatSessionWorktreeFile } from '../common/chatSessionWorktreeService';
21
22
/**
23
* Service for tracking workspace folder selections for chat sessions.
24
* This is used in multi-root workspaces where some folders may not have git repositories.
25
*/
26
export class ChatSessionWorkspaceFolderService extends Disposable implements IChatSessionWorkspaceFolderService {
27
declare _serviceBrand: undefined;
28
29
private static readonly EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
30
private readonly _onDidChangeWorkspaceFolderChanges = this._register(new vscode.EventEmitter<{ sessionId: string }>());
31
readonly onDidChangeWorkspaceFolderChanges = this._onDidChangeWorkspaceFolderChanges.event;
32
33
private readonly workspaceState = new Map<string, WorkspaceFolderEntry>();
34
private readonly sessionRepoKeys = new Map<string, string>();
35
private readonly sessionsWithNoRepoProperties = new Set<string>();
36
private readonly workspaceFolderChanges = new Map<string, ChatSessionWorktreeFile[]>();
37
private readonly sessionsAssociatedWithFolders = new ResourceMap<Set<string>>();
38
39
private readonly workspaceChangesSequencer = new SequencerByKey<string>();
40
private readonly repoChangesSequencer = new SequencerByKey<string>();
41
42
constructor(
43
@IGitService private readonly gitService: IGitService,
44
@ILogService private readonly logService: ILogService,
45
@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore,
46
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
47
) {
48
super();
49
}
50
51
async deleteTrackedWorkspaceFolder(sessionId: string): Promise<void> {
52
this.invalidateSessionCache(sessionId);
53
const entry = this.workspaceState.get(sessionId);
54
if (entry?.folderPath) {
55
const folderUri = vscode.Uri.file(entry.folderPath);
56
this.sessionsAssociatedWithFolders.get(folderUri)?.delete(sessionId);
57
}
58
this.workspaceState.delete(sessionId);
59
await this.metadataStore.deleteSessionMetadata(sessionId);
60
}
61
62
async trackSessionWorkspaceFolder(sessionId: string, workspaceFolderUri: string, repositoryProperties?: RepositoryProperties): Promise<void> {
63
const entry: WorkspaceFolderEntry = {
64
folderPath: workspaceFolderUri,
65
timestamp: Date.now()
66
};
67
this.workspaceState.set(sessionId, entry);
68
69
// Associate session with workspace folder for cache invalidation
70
const folderUri = vscode.Uri.file(workspaceFolderUri);
71
const sessionIds = this.sessionsAssociatedWithFolders.get(folderUri) ?? new Set<string>();
72
sessionIds.add(sessionId);
73
this.sessionsAssociatedWithFolders.set(folderUri, sessionIds);
74
75
await this.metadataStore.storeWorkspaceFolderInfo(sessionId, entry);
76
if (repositoryProperties) {
77
this.sessionsWithNoRepoProperties.delete(sessionId);
78
await this.metadataStore.storeRepositoryProperties(sessionId, repositoryProperties);
79
}
80
this.logService.trace(`[ChatSessionWorkspaceFolderService] Tracked workspace folder ${workspaceFolderUri} for session ${sessionId}`);
81
}
82
83
async getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined> {
84
const entry = this.workspaceState.get(sessionId);
85
if (entry?.folderPath) {
86
return vscode.Uri.file(entry.folderPath);
87
}
88
return await this.metadataStore.getSessionWorkspaceFolder(sessionId);
89
}
90
91
async getSessionWorkspaceFolderEntry(sessionId: string): Promise<WorkspaceFolderEntry | undefined> {
92
const entry = this.workspaceState.get(sessionId);
93
if (entry) {
94
return entry;
95
}
96
return await this.metadataStore.getSessionWorkspaceFolderEntry(sessionId);
97
}
98
99
async getRepositoryProperties(sessionId: string): Promise<RepositoryProperties | undefined> {
100
return await this.metadataStore.getRepositoryProperties(sessionId);
101
}
102
103
async handleRequestCompleted(sessionId: string): Promise<void> {
104
// Clear changes cache
105
this.invalidateSessionCache(sessionId);
106
}
107
108
async hasCachedChanges(sessionId: string): Promise<boolean> {
109
const existingRepoKey = this.sessionRepoKeys.get(sessionId);
110
const cachedChanges = existingRepoKey ? this.workspaceFolderChanges.get(existingRepoKey) : undefined;
111
return !!cachedChanges;
112
}
113
114
async getWorkspaceChanges(sessionId: string): Promise<readonly ChatSessionWorktreeFile[] | undefined> {
115
return this.workspaceChangesSequencer.queue(sessionId, async () => {
116
117
// Fast path: session previously had no repository properties
118
if (this.sessionsWithNoRepoProperties.has(sessionId)) {
119
return [];
120
}
121
122
// Fast path: check if we already have the repo key and a cached result
123
const existingRepoKey = this.sessionRepoKeys.get(sessionId);
124
const cachedChanges = existingRepoKey ? this.workspaceFolderChanges.get(existingRepoKey) : undefined;
125
if (cachedChanges) {
126
return cachedChanges;
127
}
128
129
const repositoryProperties = await this.getRepositoryProperties(sessionId);
130
if (!repositoryProperties) {
131
this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository properties found for session ${sessionId}`);
132
this.sessionsWithNoRepoProperties.add(sessionId);
133
return [];
134
}
135
136
const repoKey = `${repositoryProperties.repositoryPath}\0${repositoryProperties.baseBranchName ?? ''}\0${repositoryProperties.branchName ?? ''}`;
137
this.sessionRepoKeys.set(sessionId, repoKey);
138
139
return this.repoChangesSequencer.queue(repoKey, async () => {
140
// Check cache again — another session may have computed it while we waited in the repo sequencer
141
const cachedChanges = this.workspaceFolderChanges.get(repoKey);
142
if (cachedChanges) {
143
return cachedChanges;
144
}
145
146
const properties = await this.computeWorkspaceChanges(repositoryProperties, sessionId);
147
this.workspaceFolderChanges.set(repoKey, properties?.changes ?? []);
148
149
if (properties) {
150
await this.metadataStore.storeRepositoryProperties(sessionId, {
151
...repositoryProperties,
152
mergeBaseCommit: properties.mergeBaseCommit,
153
hasGitHubRemote: properties.hasGitHubRemote,
154
upstreamBranchName: properties.upstreamBranchName,
155
incomingChanges: properties.incomingChanges,
156
outgoingChanges: properties.outgoingChanges,
157
uncommittedChanges: properties.uncommittedChanges
158
});
159
}
160
161
return properties?.changes ?? [];
162
});
163
});
164
}
165
166
private async computeWorkspaceChanges(repositoryProperties: RepositoryProperties, sessionId: string): Promise<{
167
readonly changes: ChatSessionWorktreeFile[];
168
readonly mergeBaseCommit?: string;
169
readonly hasGitHubRemote?: boolean;
170
readonly upstreamBranchName?: string;
171
readonly incomingChanges?: number;
172
readonly outgoingChanges?: number;
173
readonly uncommittedChanges?: number;
174
} | undefined> {
175
const repository = await this.gitService.getRepository(vscode.Uri.file(repositoryProperties.repositoryPath));
176
if (repository) {
177
const sessionIds = this.sessionsAssociatedWithFolders.get(repository.rootUri) ?? new Set<string>();
178
sessionIds.add(sessionId);
179
this.sessionsAssociatedWithFolders.set(repository.rootUri, sessionIds);
180
}
181
if (!repository?.changes) {
182
this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository found for session ${sessionId}`);
183
return undefined;
184
}
185
186
// Check for untracked changes, only if the session branch matches the current branch
187
const hasUntrackedChanges = repositoryProperties.branchName === repository.headBranchName
188
? [
189
...repository.changes?.workingTree ?? [],
190
...repository.changes?.untrackedChanges ?? [],
191
].some(change => change.status === 7 /* UNTRACKED */)
192
: false;
193
194
const diffChanges: DiffChange[] = [];
195
196
// If the repository is using a virtual file system, we need to
197
// disable rename detection to avoid expensive git operations
198
const noRenamesArg = repository.isUsingVirtualFileSystem
199
? ['--no-renames']
200
: [];
201
202
const mergeBaseArg = repositoryProperties.baseBranchName
203
? ['--merge-base', repositoryProperties.baseBranchName]
204
: [];
205
206
if (hasUntrackedChanges) {
207
// Tracked + untracked changes
208
const tmpDirName = `vscode-sessions-${generateUuid()}`;
209
const diffIndexFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index');
210
const pathspecFile = path.join(this.extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`);
211
212
const env = buildTempIndexEnv(repository, diffIndexFile);
213
214
try {
215
// Create temp index file directory
216
await fs.mkdir(path.dirname(diffIndexFile), { recursive: true });
217
218
try {
219
// Populate temp index from HEAD, fall back to empty tree if no commits exist
220
await this.gitService.exec(repository.rootUri, ['read-tree', 'HEAD'], env);
221
} catch {
222
// Fall back to empty tree for repositories with no commits
223
await this.gitService.exec(repository.rootUri, ['read-tree', ChatSessionWorkspaceFolderService.EMPTY_TREE_OBJECT], env);
224
}
225
226
// Stage entire working directory into temp index
227
const uncommittedFilePaths = getUncommittedFilePaths(repository);
228
await fs.writeFile(pathspecFile, uncommittedFilePaths.join('\n'), 'utf8');
229
await this.gitService.exec(repository.rootUri, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env);
230
231
// Diff the temp index with the base branch
232
const result = await this.gitService.exec(repository.rootUri, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--'], env);
233
diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result));
234
} catch (error) {
235
this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`);
236
return undefined;
237
} finally {
238
try {
239
await fs.rm(path.dirname(diffIndexFile), { recursive: true, force: true });
240
} catch (error) {
241
this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while cleaning up temp index file: ${error}`);
242
}
243
}
244
} else {
245
// Tracked changes
246
try {
247
const result = await this.gitService.exec(repository.rootUri, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--']);
248
diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result));
249
} catch (error) {
250
this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while processing workspace changes: ${error}`);
251
return undefined;
252
}
253
}
254
255
// Since the diff may be computed using the merge base commit of the current
256
// branch and the base branch, we need to compute it as well so that we can use
257
// it as the originalRef (left-hand side) of the diff editor
258
let mergeBaseCommit: string | undefined;
259
try {
260
if (repositoryProperties.branchName && repositoryProperties.baseBranchName) {
261
mergeBaseCommit = await this.gitService.getMergeBase(repository.rootUri, repositoryProperties.branchName, repositoryProperties.baseBranchName);
262
}
263
} catch (error) {
264
this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while getting merge base (${repositoryProperties.branchName}, ${repositoryProperties.baseBranchName}): ${error}`);
265
}
266
267
const changes = diffChanges.map(change => ({
268
filePath: change.uri.fsPath,
269
originalFilePath: change.status !== 1 /* INDEX_ADDED */
270
? change.originalUri?.fsPath
271
: undefined,
272
modifiedFilePath: change.status !== 6 /* DELETED */
273
? change.uri.fsPath
274
: undefined,
275
statistics: {
276
additions: change.insertions,
277
deletions: change.deletions
278
}
279
} satisfies ChatSessionWorktreeFile));
280
281
const repositoryState = {
282
mergeBaseCommit,
283
hasGitHubRemote: getGitHubRepoInfoFromContext(repository) !== undefined,
284
upstreamBranchName: repository.upstreamRemote && repository.upstreamBranchName
285
? `${repository.upstreamRemote}/${repository.upstreamBranchName}`
286
: undefined,
287
incomingChanges: repository.headIncomingChanges ?? 0,
288
outgoingChanges: repository.headOutgoingChanges ?? 0,
289
uncommittedChanges:
290
(repository.changes?.mergeChanges.length ?? 0) +
291
(repository.changes?.indexChanges.length ?? 0) +
292
(repository.changes?.workingTree.length ?? 0) +
293
(repository.changes?.untrackedChanges.length ?? 0)
294
};
295
296
return { changes, ...repositoryState };
297
}
298
299
clearWorkspaceChanges(sessionId: string): string[];
300
clearWorkspaceChanges(folderUri: vscode.Uri): string[];
301
clearWorkspaceChanges(sessionIdOrFolderUri: string | vscode.Uri): string[] {
302
const sessionIds = typeof sessionIdOrFolderUri === 'string' ? [sessionIdOrFolderUri] : this.getAssociatedSessions(sessionIdOrFolderUri);
303
for (const sessionId of sessionIds) {
304
this.invalidateSessionCache(sessionId);
305
}
306
return sessionIds;
307
}
308
309
private invalidateSessionCache(sessionId: string): void {
310
const repoKey = this.sessionRepoKeys.get(sessionId);
311
this.sessionRepoKeys.delete(sessionId);
312
this.sessionsWithNoRepoProperties.delete(sessionId);
313
if (repoKey) {
314
this.workspaceFolderChanges.delete(repoKey);
315
}
316
this._onDidChangeWorkspaceFolderChanges.fire({ sessionId });
317
}
318
319
getAssociatedSessions(folderUri: vscode.Uri): string[] {
320
const folderSessionIds = this.sessionsAssociatedWithFolders.get(folderUri) ?? new Set<string>();
321
return Array.from(folderSessionIds);
322
}
323
}
324
325