[content] src/capture/filesystemWatcher.ts (typescript)
// Callbacks
let onFileChangeCallback: ((
	file: string,
	changeType: 'create' | 'change' | 'delete',
	diff: string | null
) => void) | null = null

/**
 * Check if a file path should be excluded from watching
 */
function isExcluded(filePath: string): boolean {
	if (!workspaceRoot || !gitignoreMatcher) {
		return false
	}

	const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/')
	return gitignoreMatcher.ignores(relativePath)
}

/**
 * Load gitignore patterns from workspace
 */
function loadGitignore(): void {
	const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
	if (!workspaceFolder) {
		return
	}

	workspaceRoot = workspaceFolder.uri.fsPath
	
	const ig = ignore()
	ig.add('.git')
	
	try {
		const gitignorePath = path.join(workspaceRoot, '.gitignore')
		const content = fs.readFileSync(gitignorePath, 'utf-8')
		ig.add(content)
	} catch {
		// .gitignore doesn't exist or can't be read
	}
	
	gitignoreMatcher = ig
}

/**
 * Compute a unified diff between old and new content using the 'diff' library
 * Returns a string representation of the changes in unified diff format
 */
function computeDiff(oldContent: string | null, newContent: string | null, filePath?: string): string | null {
	if (oldContent === null && newContent === null) {
		return null
	}

	if (oldContent === newContent) {
		return null // No change
	}

	const fileName = filePath ? path.basename(filePath) : 'file'
	const oldStr = oldContent ?? ''
	const newStr = newContent ?? ''

	// createTwoFilesPatch produces a standard unified diff
	const patch = createTwoFilesPatch(
		`a/${fileName}`,
		`b/${fileName}`,
		oldStr,
		newStr,
		'', // old header
		'', // new header
		{ context: 3 } // number of context lines around changes
	)

	// The patch includes the header lines; return the full patch
	return patch
}

/**
 * Get cached content for a file
 */
export function getCachedContent(filePath: string): string | null {
	const cached = fileCache.get(filePath)
	return cached ? cached.content : null
}

/**
 * Update the cache for a file
 */
function updateCache(filePath: string, content: string | null): void {
	if (content === null) {
		fileCache.delete(filePath)
		const idx = fileCacheAccessOrder.indexOf(filePath)
		if (idx !== -1) {
			fileCacheAccessOrder.splice(idx, 1)
		}
		return
	}

	fileCache.set(filePath, {
		content,
		timestamp: Date.now()
	})

	// Update LRU order
	const idx = fileCacheAccessOrder.indexOf(filePath)
	if (idx !== -1) {
		fileCacheAccessOrder.splice(idx, 1)
	}
	fileCacheAccessOrder.push(filePath)

	// Enforce cache size limit (LRU eviction)
	while (fileCacheAccessOrder.length > MAX_CACHE_SIZE) {
		const oldest = fileCacheAccessOrder.shift()
		if (oldest) {
			fileCache.delete(oldest)
		}
	}
}

/**
 * Read file content safely
 */
async function readFileContent(filePath: string): Promise<string | null> {
	try {
		const content = await fs.promises.readFile(filePath, 'utf-8')
		return content
	} catch {
		return null
	}
}

/**
 * Compute content hash for deduplication
 */
export function computeContentHash(content: string): string {
	return crypto.createHash('md5').update(content).digest('hex')
}

/**
 * Process a file change event (after debounce)
 */
async function processFileChange(
	filePath: string,
	changeType: 'create' | 'change' | 'delete'
): Promise<void> {
	if (!onFileChangeCallback) {
		return
	}

	const oldContent = getCachedContent(filePath)
	let newContent: string | null = null
	let diff: string | null = null

	if (changeType === 'delete') {
		diff = computeDiff(oldContent, null, filePath)
		updateCache(filePath, null)
	} else {
		newContent = await readFileContent(filePath)
		if (newContent !== null) {
			diff = computeDiff(oldContent, newContent, filePath)
			updateCache(filePath, newContent)
		}
	}

	// Only fire callback if there's an actual change
	if (diff !== null || changeType === 'create' || changeType === 'delete') {
		onFileChangeCallback(filePath, changeType, diff)
	}
}

/**
 * Handle a file system event with debouncing
 */
function handleFileEvent(uri: vscode.Uri, eventType: 'create' | 'change' | 'delete'): void {
	const filePath = uri.fsPath

	if (isExcluded(filePath)) {
		return
	}

	const pending = pendingChanges.get(filePath)
	if (pending) {
		clearTimeout(pending.timeout)
	}

	const timeout = setTimeout(() => {
		pendingChanges.delete(filePath)
		processFileChange(filePath, eventType)
	}, DEBOUNCE_WINDOW_MS)

	pendingChanges.set(filePath, {
		type: eventType,
		timeout
	})
}

/**
 * Set the callback for file change events
 */
export function setFileChangeCallback(
	callback: ((file: string, changeType: 'create' | 'change' | 'delete', diff: string | null) => void) | null
): void {
	onFileChangeCallback = callback
}

/**
 * Pre-populate the cache with content of currently open files
 */
async function initializeCache(): Promise<void> {
	for (const document of vscode.workspace.textDocuments) {
		if (document.uri.scheme === 'file' && !isExcluded(document.fileName)) {
			updateCache(document.fileName, document.getText())
		}
	}
}

/**
 * Update cache when a file is opened or changed via VS Code API
 * This should be called by the recording orchestrator when user actions occur
 */
export function updateCacheFromDocument(document: vscode.TextDocument): void {
	if (document.uri.scheme === 'file' && !isExcluded(document.fileName)) {
		updateCache(document.fileName, document.getText())
	}
}

/**
 * Initialize the filesystem watcher
 */
export async function initializeFilesystemWatcher(context: vscode.ExtensionContext): Promise<void> {
	const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
	if (!workspaceFolder) {
		return
	}

	// Load gitignore patterns
	await loadGitignore()

	// Initialize cache with currently open files
	await initializeCache()

	// Create filesystem watcher
	fileWatcher = vscode.workspace.createFileSystemWatcher(
		new vscode.RelativePattern(workspaceFolder, '**/*')
	)

	fileWatcher.onDidCreate((uri) => handleFileEvent(uri, 'create'))
	fileWatcher.onDidChange((uri) => handleFileEvent(uri, 'change'))
	fileWatcher.onDidDelete((uri) => handleFileEvent(uri, 'delete'))

	context.subscriptions.push(fileWatcher)
}

/**
 * Cleanup the filesystem watcher
 */
export function cleanupFilesystemWatcher(): void {
	if (fileWatcher) {
		fileWatcher.dispose()
		fileWatcher = null
	}

	// Clear pending changes
	for (const [, pending] of pendingChanges) {
		clearTimeout(pending.timeout)
	}
	pendingChanges.clear()

	// Don't clear the cache - it might be useful for the next recording
}

/**
 * Reset the filesystem watcher state (invalidate cache)
 */
export function resetFilesystemState(): void {
	fileCache.clear()
	fileCacheAccessOrder.length = 0

	// Clear pending changes
	for (const [, pending] of pendingChanges) {
		clearTimeout(pending.timeout)
	}
	pendingChanges.clear()
}

/**
 * Force a cache refresh for all tracked files
 * Useful after git operations that may have changed many files
 */
export async function refreshCache(): Promise<void> {
	// Reload gitignore in case it changed
	await loadGitignore()

	const filesToRefresh = [...fileCache.keys()]
	
	for (const filePath of filesToRefresh) {
		const content = await readFileContent(filePath)
		if (content !== null) {
			updateCache(filePath, content)
		} else {
			// File no longer exists
			updateCache(filePath, null)
		}
	}

	// Also refresh from open documents
	await initializeCache()
}