Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsChangeTracker.ts
13405 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 { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
7
import { FileType } from '../../../../platform/filesystem/common/fileTypes';
8
import { ILogService } from '../../../../platform/log/common/logService';
9
import { URI } from '../../../../util/vs/base/common/uri';
10
11
/**
12
* Resolver function that returns URIs to track.
13
* Called each time a snapshot is taken to get current paths.
14
*/
15
export type SettingsPathResolver = () => URI[];
16
17
/**
18
* Directory resolver configuration.
19
* Provides directories to enumerate and an optional file extension filter.
20
*/
21
interface DirectoryResolverConfig {
22
/** Resolver that returns directory URIs to enumerate */
23
resolver: () => URI[];
24
/** File extension to filter by (e.g., '.md'). If not provided, all files are included. */
25
extension?: string;
26
}
27
28
/**
29
* Tracks modification times of settings files (CLAUDE.md, hooks, etc.)
30
* to detect when a session should be restarted to pick up changes.
31
*
32
* This is designed to be easily expandable - just register additional
33
* path resolvers for new file types to track.
34
*/
35
export class ClaudeSettingsChangeTracker {
36
private readonly _pathResolvers: SettingsPathResolver[] = [];
37
private readonly _directoryResolvers: DirectoryResolverConfig[] = [];
38
private _snapshot: Map<string, number> = new Map();
39
40
constructor(
41
@IFileSystemService private readonly fileSystemService: IFileSystemService,
42
@ILogService private readonly logService: ILogService,
43
) { }
44
45
/**
46
* Registers a path resolver that provides URIs to track.
47
* Resolvers are called each time a snapshot is taken.
48
*
49
* @param resolver Function that returns URIs to track
50
*/
51
registerPathResolver(resolver: SettingsPathResolver): void {
52
this._pathResolvers.push(resolver);
53
}
54
55
/**
56
* Registers directories to track. All files in these directories
57
* (optionally filtered by extension) will be tracked for changes.
58
*
59
* @param resolver Function that returns directory URIs to enumerate
60
* @param extension Optional file extension to filter by (e.g., '.md')
61
*/
62
registerDirectoryResolver(resolver: () => URI[], extension?: string): void {
63
this._directoryResolvers.push({ resolver, extension });
64
}
65
66
/**
67
* Enumerates files in a directory, optionally filtering by extension.
68
*/
69
private async _enumerateDirectory(dir: URI, extension?: string): Promise<URI[]> {
70
const files: URI[] = [];
71
try {
72
const entries = await this.fileSystemService.readDirectory(dir);
73
for (const [name, type] of entries) {
74
if (type & FileType.File) {
75
if (!extension || name.endsWith(extension)) {
76
files.push(URI.joinPath(dir, name));
77
}
78
}
79
}
80
} catch {
81
// Directory doesn't exist or can't be read
82
}
83
return files;
84
}
85
86
/**
87
* Resolves all paths from path resolvers and directory resolvers.
88
*/
89
private async _getAllPaths(): Promise<URI[]> {
90
const syncPaths = this._pathResolvers.flatMap(resolver => resolver());
91
92
// Enumerate all directories
93
const directoryFiles: URI[] = [];
94
for (const config of this._directoryResolvers) {
95
const dirs = config.resolver();
96
for (const dir of dirs) {
97
const files = await this._enumerateDirectory(dir, config.extension);
98
directoryFiles.push(...files);
99
}
100
}
101
102
return [...syncPaths, ...directoryFiles];
103
}
104
105
/**
106
* Takes a snapshot of modification times for all tracked files.
107
* Call this when starting or restarting a session.
108
*/
109
async takeSnapshot(): Promise<void> {
110
this._snapshot.clear();
111
112
const allPaths = await this._getAllPaths();
113
114
for (const uri of allPaths) {
115
try {
116
const stat = await this.fileSystemService.stat(uri);
117
this._snapshot.set(uri.toString(), stat.mtime);
118
this.logService.trace(`[ClaudeSettingsChangeTracker] Snapshot: ${uri.fsPath} mtime=${stat.mtime}`);
119
} catch {
120
// File doesn't exist yet - record as 0 so we detect if it's created
121
this._snapshot.set(uri.toString(), 0);
122
this.logService.trace(`[ClaudeSettingsChangeTracker] Snapshot: ${uri.fsPath} (does not exist)`);
123
}
124
}
125
}
126
127
/**
128
* Checks a single URI for changes against the snapshot.
129
* Returns the URI if changed, undefined otherwise.
130
*/
131
private async _checkUri(uri: URI): Promise<URI | undefined> {
132
const uriString = uri.toString();
133
const snapshotMtime = this._snapshot.get(uriString);
134
135
try {
136
const stat = await this.fileSystemService.stat(uri);
137
if (snapshotMtime === undefined) {
138
// New file that wasn't in snapshot - treat as changed
139
this.logService.trace(`[ClaudeSettingsChangeTracker] New file detected: ${uri.fsPath}`);
140
return uri;
141
} else if (stat.mtime > snapshotMtime) {
142
this.logService.trace(`[ClaudeSettingsChangeTracker] Changed: ${uri.fsPath} (${snapshotMtime} -> ${stat.mtime})`);
143
return uri;
144
}
145
} catch {
146
// File doesn't exist now but was expected - treat as changed
147
if (snapshotMtime !== undefined && snapshotMtime > 0) {
148
this.logService.trace(`[ClaudeSettingsChangeTracker] Deleted: ${uri.fsPath}`);
149
return uri;
150
}
151
}
152
return undefined;
153
}
154
155
/**
156
* Async generator that lazily iterates through resolvers and yields changed files.
157
* Allows early termination without invoking remaining resolvers.
158
*/
159
private async *_changedFilesGenerator(): AsyncGenerator<URI> {
160
const seenPaths = new Set<string>();
161
162
// Lazily iterate through path resolvers
163
for (const resolver of this._pathResolvers) {
164
for (const uri of resolver()) {
165
seenPaths.add(uri.toString());
166
const changed = await this._checkUri(uri);
167
if (changed) {
168
yield changed;
169
}
170
}
171
}
172
173
// Lazily iterate through directory resolvers
174
for (const config of this._directoryResolvers) {
175
for (const dir of config.resolver()) {
176
const files = await this._enumerateDirectory(dir, config.extension);
177
for (const uri of files) {
178
seenPaths.add(uri.toString());
179
const changed = await this._checkUri(uri);
180
if (changed) {
181
yield changed;
182
}
183
}
184
}
185
}
186
187
// Check snapshot for files that no longer exist (deleted)
188
// This must happen after we've seen all current paths
189
for (const [uriString, mtime] of this._snapshot) {
190
if (!seenPaths.has(uriString) && mtime > 0) {
191
// File was in snapshot but not in current paths - it was deleted
192
const uri = URI.parse(uriString);
193
this.logService.trace(`[ClaudeSettingsChangeTracker] Deleted (not in current paths): ${uri.fsPath}`);
194
yield uri;
195
}
196
}
197
}
198
199
/**
200
* Checks if any files have changed. Returns early on first change found.
201
*
202
* @returns true if any tracked file has been modified since the last snapshot
203
*/
204
async hasChanges(): Promise<boolean> {
205
for await (const _uri of this._changedFilesGenerator()) {
206
return true;
207
}
208
return false;
209
}
210
}
211
212