Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/claudeSettingsChangeTracker.ts
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*--------------------------------------------------------------------------------------------*/45import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';6import { FileType } from '../../../../platform/filesystem/common/fileTypes';7import { ILogService } from '../../../../platform/log/common/logService';8import { URI } from '../../../../util/vs/base/common/uri';910/**11* Resolver function that returns URIs to track.12* Called each time a snapshot is taken to get current paths.13*/14export type SettingsPathResolver = () => URI[];1516/**17* Directory resolver configuration.18* Provides directories to enumerate and an optional file extension filter.19*/20interface DirectoryResolverConfig {21/** Resolver that returns directory URIs to enumerate */22resolver: () => URI[];23/** File extension to filter by (e.g., '.md'). If not provided, all files are included. */24extension?: string;25}2627/**28* Tracks modification times of settings files (CLAUDE.md, hooks, etc.)29* to detect when a session should be restarted to pick up changes.30*31* This is designed to be easily expandable - just register additional32* path resolvers for new file types to track.33*/34export class ClaudeSettingsChangeTracker {35private readonly _pathResolvers: SettingsPathResolver[] = [];36private readonly _directoryResolvers: DirectoryResolverConfig[] = [];37private _snapshot: Map<string, number> = new Map();3839constructor(40@IFileSystemService private readonly fileSystemService: IFileSystemService,41@ILogService private readonly logService: ILogService,42) { }4344/**45* Registers a path resolver that provides URIs to track.46* Resolvers are called each time a snapshot is taken.47*48* @param resolver Function that returns URIs to track49*/50registerPathResolver(resolver: SettingsPathResolver): void {51this._pathResolvers.push(resolver);52}5354/**55* Registers directories to track. All files in these directories56* (optionally filtered by extension) will be tracked for changes.57*58* @param resolver Function that returns directory URIs to enumerate59* @param extension Optional file extension to filter by (e.g., '.md')60*/61registerDirectoryResolver(resolver: () => URI[], extension?: string): void {62this._directoryResolvers.push({ resolver, extension });63}6465/**66* Enumerates files in a directory, optionally filtering by extension.67*/68private async _enumerateDirectory(dir: URI, extension?: string): Promise<URI[]> {69const files: URI[] = [];70try {71const entries = await this.fileSystemService.readDirectory(dir);72for (const [name, type] of entries) {73if (type & FileType.File) {74if (!extension || name.endsWith(extension)) {75files.push(URI.joinPath(dir, name));76}77}78}79} catch {80// Directory doesn't exist or can't be read81}82return files;83}8485/**86* Resolves all paths from path resolvers and directory resolvers.87*/88private async _getAllPaths(): Promise<URI[]> {89const syncPaths = this._pathResolvers.flatMap(resolver => resolver());9091// Enumerate all directories92const directoryFiles: URI[] = [];93for (const config of this._directoryResolvers) {94const dirs = config.resolver();95for (const dir of dirs) {96const files = await this._enumerateDirectory(dir, config.extension);97directoryFiles.push(...files);98}99}100101return [...syncPaths, ...directoryFiles];102}103104/**105* Takes a snapshot of modification times for all tracked files.106* Call this when starting or restarting a session.107*/108async takeSnapshot(): Promise<void> {109this._snapshot.clear();110111const allPaths = await this._getAllPaths();112113for (const uri of allPaths) {114try {115const stat = await this.fileSystemService.stat(uri);116this._snapshot.set(uri.toString(), stat.mtime);117this.logService.trace(`[ClaudeSettingsChangeTracker] Snapshot: ${uri.fsPath} mtime=${stat.mtime}`);118} catch {119// File doesn't exist yet - record as 0 so we detect if it's created120this._snapshot.set(uri.toString(), 0);121this.logService.trace(`[ClaudeSettingsChangeTracker] Snapshot: ${uri.fsPath} (does not exist)`);122}123}124}125126/**127* Checks a single URI for changes against the snapshot.128* Returns the URI if changed, undefined otherwise.129*/130private async _checkUri(uri: URI): Promise<URI | undefined> {131const uriString = uri.toString();132const snapshotMtime = this._snapshot.get(uriString);133134try {135const stat = await this.fileSystemService.stat(uri);136if (snapshotMtime === undefined) {137// New file that wasn't in snapshot - treat as changed138this.logService.trace(`[ClaudeSettingsChangeTracker] New file detected: ${uri.fsPath}`);139return uri;140} else if (stat.mtime > snapshotMtime) {141this.logService.trace(`[ClaudeSettingsChangeTracker] Changed: ${uri.fsPath} (${snapshotMtime} -> ${stat.mtime})`);142return uri;143}144} catch {145// File doesn't exist now but was expected - treat as changed146if (snapshotMtime !== undefined && snapshotMtime > 0) {147this.logService.trace(`[ClaudeSettingsChangeTracker] Deleted: ${uri.fsPath}`);148return uri;149}150}151return undefined;152}153154/**155* Async generator that lazily iterates through resolvers and yields changed files.156* Allows early termination without invoking remaining resolvers.157*/158private async *_changedFilesGenerator(): AsyncGenerator<URI> {159const seenPaths = new Set<string>();160161// Lazily iterate through path resolvers162for (const resolver of this._pathResolvers) {163for (const uri of resolver()) {164seenPaths.add(uri.toString());165const changed = await this._checkUri(uri);166if (changed) {167yield changed;168}169}170}171172// Lazily iterate through directory resolvers173for (const config of this._directoryResolvers) {174for (const dir of config.resolver()) {175const files = await this._enumerateDirectory(dir, config.extension);176for (const uri of files) {177seenPaths.add(uri.toString());178const changed = await this._checkUri(uri);179if (changed) {180yield changed;181}182}183}184}185186// Check snapshot for files that no longer exist (deleted)187// This must happen after we've seen all current paths188for (const [uriString, mtime] of this._snapshot) {189if (!seenPaths.has(uriString) && mtime > 0) {190// File was in snapshot but not in current paths - it was deleted191const uri = URI.parse(uriString);192this.logService.trace(`[ClaudeSettingsChangeTracker] Deleted (not in current paths): ${uri.fsPath}`);193yield uri;194}195}196}197198/**199* Checks if any files have changed. Returns early on first change found.200*201* @returns true if any tracked file has been modified since the last snapshot202*/203async hasChanges(): Promise<boolean> {204for await (const _uri of this._changedFilesGenerator()) {205return true;206}207return false;208}209}210211212