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

/**
 * Parse .gitignore file and return patterns
 */
function parseGitignore(gitignorePath: string): string[] {
	try {
		const content = fs.readFileSync(gitignorePath, 'utf-8')
		const patterns: string[] = []

		for (const line of content.split('
')) {
			const trimmed = line.trim()
			// Skip empty lines and comments
			if (!trimmed || trimmed.startsWith('#')) {
				continue
			}
			// Skip negation patterns (we don't support them for simplicity)
			if (trimmed.startsWith('!')) {
				continue
			}
			patterns.push(trimmed)
		}

		return patterns
	} catch {
		return []
	}
}

/**
 * Convert a gitignore pattern to a check function
 * This is a simplified implementation that handles common patterns
 */
function matchesGitignorePattern(relativePath: string, pattern: string): boolean {
	// Normalize path separators
	const normalizedPath = relativePath.replace(/\\/g, '/')
	
	// Handle directory-only patterns (ending with /)
	const isDirectoryPattern = pattern.endsWith('/')
	const cleanPattern = isDirectoryPattern ? pattern.slice(0, -1) : pattern

	// Handle patterns starting with / (relative to repo root)
	const isRootPattern = cleanPattern.startsWith('/')
	const searchPattern = isRootPattern ? cleanPattern.slice(1) : cleanPattern

	// Convert glob pattern to regex
	let regexPattern = searchPattern
		// Escape regex special chars except * and ?
		.replace(/[.+^${}()|[\]\\]/g, '\\$&')
		// ** matches anything including /
		.replace(/\*\*/g, '.*')
		// * matches anything except /
		.replace(/\*/g, '[^/]*')
		// ? matches single char except /
		.replace(/\?/g, '[^/]')

	if (isRootPattern) {
		// Pattern must match from the start
		regexPattern = '^' + regexPattern
	} else {
		// Pattern can match anywhere in path (after a /)
		regexPattern = '(^|/)' + regexPattern
	}

	if (isDirectoryPattern) {
		// Must match a directory (followed by / or end)
		regexPattern = regexPattern + '(/|$)'
	} else {
		// Can match file or directory
		regexPattern = regexPattern + '(/|$)'
	}

	try {
		const regex = new RegExp(regexPattern)
		return regex.test(normalizedPath)
	} catch {
		return false
	}
}

/**
 * Check if a file path should be excluded from watching
 */
function isExcluded(filePath: string): boolean {
	const normalizedPath = filePath.replace(/\\/g, '/')

	// Always exclude .git
	for (const pattern of ALWAYS_EXCLUDED) {
		if (normalizedPath.includes(pattern)) {
			return true
		}
	}

	// Check against gitignore patterns
	if (workspaceRoot) {
		const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/')
		for (const pattern of gitignorePatterns) {
			if (matchesGitignorePattern(relativePath, pattern)) {
				return true
			}
		}
	}

	return false
}

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

	workspaceRoot = workspaceFolder.uri.fsPath
	const gitignorePath = path.join(workspaceRoot, '.gitignore')
	gitignorePatterns = parseGitignore(gitignorePath)
}

/**
 * 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)
		updateCache(filePath, null)
	} else {
		newContent = await readFileContent(filePath)
		if (newContent !== null) {
			diff = computeDiff(oldContent, newContent)
			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()
}