[content] src/capture/filesystemWatcher.ts (typescript)
/**
 * 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 folder = vscode.workspace.workspaceFolders?.[0]
	if (!folder) {
		return
	}

	workspaceFolder = folder
	workspaceRoot = folder.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
}

/**
 * Check if a file should be tracked (respects VS Code's files.exclude)
 */
async function shouldTrackFile(filePath: string): Promise<boolean> {
	if (fileCache.has(filePath)) return true
	if (isExcluded(filePath)) return false
	if (!workspaceRoot || !workspaceFolder) return false
	
	const relativePath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/')
	const matches = await vscode.workspace.findFiles(
		new vscode.RelativePattern(workspaceFolder, relativePath)
	)
	return matches.length > 0
}

/**
 * Compute a unified diff between old and new content
 * 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
	}

	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,
		'',
		'',
		{ context: 3 }
	)

	return patch
}



/**
 * 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
	}
}


/**
 * Process a file change event (after debounce)
 */
async function processFileChange(
	filePath: string,
	changeType: 'create' | 'change' | 'delete'
): Promise<void> {
	// For new files not in cache, check if we should track them
	if (!fileCache.has(filePath) && changeType !== 'delete') {
		const shouldTrack = await shouldTrackFile(filePath)
		if (!shouldTrack) {
			return
		}
	}

	const oldContent = fileCache.get(filePath) ?? null
	let newContent: string | null = null
	let diff: string | null = null

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

	// Only fire callback if there's an actual change
	if (diff !== null || changeType === 'create' || changeType === 'delete') {
		handleFileChange(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
	})
}

/**
 * Background initialization: cache all workspace files
 */
async function initializeCacheBackground(): Promise<void> {
	const files = await vscode.workspace.findFiles('**/*')
	
	for (const file of files) {
		if (isExcluded(file.fsPath)) continue
		
		// Yield to event loop between files to avoid blocking
		await new Promise(resolve => setImmediate(resolve))
		
		try {
			const content = await vscode.workspace.fs.readFile(file)
			fileCache.set(file.fsPath, content.toString())
		} catch {
			// File might have been deleted or is unreadable, ignore
		}
	}
}


/**
 * Initialize the filesystem watcher
 */
export async function initializeFilesystemWatcher(context: vscode.ExtensionContext): Promise<void> {
	if (fileWatcher) {
		return
	}

	const folder = vscode.workspace.workspaceFolders?.[0]
	if (!folder) {
		return
	}

	loadGitignore()

	initializeCacheBackground()

	fileWatcher = vscode.workspace.createFileSystemWatcher(
		new vscode.RelativePattern(folder, '**/*')
	)

	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()
}

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

	// 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
	loadGitignore()

	// Re-initialize the cache in background
	await initializeCacheBackground()
}