import { getHomeDir } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { normalizePathForComparison } from '@shared/utils/platformPath'; import { createHash } from 'crypto'; import { diffLines } from 'diff'; import { createReadStream } from 'fs'; import { access, readFile } from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; import type { GitDiffFallback } from './GitDiffFallback'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { FileChangeWithContent, SnippetDiff } from '@shared/types'; const logger = createLogger('Service:FileContentResolver'); /** Кеш-запись для resolved content */ interface ContentCacheEntry { original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; validationFingerprint: string; expiresAt: number; } /** * Resolves full file contents (original + modified) for CodeMirror diff view. * * Uses three-level resolution strategy: * 1. File-history backup (most accurate) * 2. Snippet reconstruction (reverse-apply edits from current disk state) * 3. Fallback to current file on disk */ export class FileContentResolver { private cache = new Map(); private readonly provisionalCacheTtl = 5 * 1000; constructor( private readonly logsFinder: TeamMemberLogsFinder, private readonly gitFallback?: GitDiffFallback ) {} /** Invalidate cached content for a file (e.g. after user saves edits) */ invalidateFile(filePath: string): void { const normalizedFilePath = this.normalizeResolverPath(filePath); for (const key of this.cache.keys()) { if (key.endsWith(`:${normalizedFilePath}`)) { this.cache.delete(key); } } } /** * Resolve full file contents for a single file. * Returns original (before changes) and modified (after changes) content. */ async resolveFileContent( teamName: string, memberName: string, filePath: string, snippets: SnippetDiff[] ): Promise<{ original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; }> { const ledgerResult = this.tryLedgerContent(snippets); if (ledgerResult) { return ledgerResult; } // Read current file from disk (= modified state after agent's changes) let currentContent: string | null = null; try { currentContent = await readFile(filePath, 'utf8'); } catch { logger.debug(`Файл недоступен на диске: ${filePath}`); } const cacheKey = this.buildCacheKey(teamName, memberName, filePath); const validationFingerprint = this.buildValidationFingerprint( filePath, currentContent, snippets ); const cached = this.cache.get(cacheKey); if ( cached && cached.expiresAt > Date.now() && cached.validationFingerprint === validationFingerprint ) { return { original: cached.original, modified: cached.modified, source: cached.source }; } // Fast path: if the agent created the file and it still exists on disk, // the original content is definitely empty, so skip expensive history lookup. const hasWriteNew = snippets.some((s) => !s.isError && s.type === 'write-new'); if (hasWriteNew && currentContent !== null) { const result = { original: '', modified: currentContent, source: 'snippet-reconstruction' as const, }; this.cacheResult(cacheKey, validationFingerprint, result); return result; } // Strategy 1: Try file-history backup const historyResult = await this.tryFileHistoryBackup(teamName, memberName, filePath); if (historyResult) { const result = { original: historyResult, modified: currentContent, source: 'file-history' as const, }; this.cacheResult(cacheKey, validationFingerprint, result); return result; } // Strategy 2: Try snippet reconstruction const reconstructed = this.trySnippetReconstruction(currentContent, snippets); if (reconstructed !== null) { const result = { original: reconstructed, modified: currentContent, source: 'snippet-reconstruction' as const, }; this.cacheResult(cacheKey, validationFingerprint, result); return result; } // Strategy 3 (Phase 4): Git fallback if (this.gitFallback) { const gitResult = await this.tryGitFallback(filePath, currentContent, snippets); if (gitResult) { const result = { original: gitResult, modified: currentContent, source: 'git-fallback' as const, }; this.cacheResult(cacheKey, validationFingerprint, result); return result; } } // Strategy 4: Fallback — only current file on disk if (currentContent !== null) { const result = { original: null, modified: currentContent, source: 'disk-current' as const, }; this.cacheResult(cacheKey, validationFingerprint, result); return result; } // Nothing available const unavailable = { original: null, modified: null, source: 'unavailable' as const }; this.cacheResult(cacheKey, validationFingerprint, unavailable); return unavailable; } /** * Get full file content for a single file (IPC-facing method). * Returns a FileChangeWithContent object ready for the renderer. */ async getFileContent( teamName: string, memberName: string, filePath: string, snippets: SnippetDiff[] = [] ): Promise { const resolved = await this.resolveFileContent(teamName, memberName, filePath, snippets); // Compute accurate stats from full content diff let linesAdded = 0; let linesRemoved = 0; if (resolved.original !== null && resolved.modified !== null) { const changes = diffLines(resolved.original, resolved.modified); for (const c of changes) { if (c.added) linesAdded += c.count ?? 0; if (c.removed) linesRemoved += c.count ?? 0; } } else if (resolved.original === null && resolved.modified !== null) { // Use diffLines for consistency with ChangeExtractorService.countLines() const changes = diffLines('', resolved.modified); for (const c of changes) { if (c.added) linesAdded += c.count ?? 0; } } const isNewFile = snippets.some( (s) => s.type === 'write-new' || s.ledger?.operation === 'create' ); return { filePath, relativePath: this.getDisplayRelativePath(filePath, 3), snippets, linesAdded, linesRemoved, isNewFile, originalFullContent: resolved.original, modifiedFullContent: resolved.modified, contentSource: resolved.source, }; } /** * Resolve full contents for multiple files at once. * Returns a map of filePath -> FileChangeWithContent. */ async resolveAllFileContents( teamName: string, memberName: string, files: { filePath: string; relativePath: string; snippets: SnippetDiff[]; linesAdded: number; linesRemoved: number; isNewFile: boolean; }[] ): Promise> { const results = new Map(); // Resolve all files in parallel const promises = files.map(async (file) => { const resolved = await this.resolveFileContent( teamName, memberName, file.filePath, file.snippets ); // Compute accurate stats from full content diff let linesAdded = file.linesAdded; let linesRemoved = file.linesRemoved; if (resolved.original !== null && resolved.modified !== null) { linesAdded = 0; linesRemoved = 0; const changes = diffLines(resolved.original, resolved.modified); for (const c of changes) { if (c.added) linesAdded += c.count ?? 0; if (c.removed) linesRemoved += c.count ?? 0; } } const entry: FileChangeWithContent = { filePath: file.filePath, relativePath: file.relativePath, snippets: file.snippets, linesAdded, linesRemoved, isNewFile: file.isNewFile, originalFullContent: resolved.original, modifiedFullContent: resolved.modified, contentSource: resolved.source, }; results.set(file.filePath, entry); }); await Promise.all(promises); return results; } // ── Private: Resolution strategies ── private tryLedgerContent(snippets: SnippetDiff[]): { original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; } | null { const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError); if (ledgerSnippets.length === 0) { return null; } const first = ledgerSnippets[0]?.ledger; const last = ledgerSnippets[ledgerSnippets.length - 1]?.ledger; if (!first || !last) { return null; } const canUseSyntheticOriginal = first.originalFullContent === null && first.operation === 'create' && last.modifiedFullContent !== null && !first.beforeState?.unavailableReason; const canUseSyntheticModified = last.modifiedFullContent === null && last.operation === 'delete' && first.originalFullContent !== null && !last.afterState?.unavailableReason; const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null); const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null); if (original === null && modified === null) { const hasUnavailableLedgerState = ledgerSnippets.some( (snippet) => snippet.ledger?.beforeState?.unavailableReason || snippet.ledger?.afterState?.unavailableReason || snippet.ledger?.textAvailability === 'unavailable' ); if (hasUnavailableLedgerState) { return { original: null, modified: null, source: 'unavailable' }; } return null; } const hasSnapshot = ledgerSnippets.some( (snippet) => snippet.ledger?.source === 'ledger-snapshot' ); return { original, modified, source: hasSnapshot ? 'ledger-snapshot' : 'ledger-exact', }; } /** * Strategy 1: Read original content from Claude's file-history backup. * * Claude saves file snapshots at `~/.claude/file-history/{sessionId}/{backupFileName}`. * The mapping is stored as `type: "file-history-snapshot"` entries in JSONL. */ private async tryFileHistoryBackup( teamName: string, memberName: string, filePath: string ): Promise { let logPaths: string[]; try { logPaths = await this.logsFinder.findMemberLogPaths(teamName, memberName); } catch { return null; } if (logPaths.length === 0) return null; for (const logPath of logPaths) { const sessionId = this.extractSessionId(logPath); if (!sessionId) continue; const backupFileName = await this.findFileHistoryBackup(logPath, filePath); if (!backupFileName) continue; // Construct the file-history path const historyPath = path.join( getHomeDir(), '.claude', 'file-history', sessionId, backupFileName ); try { await access(historyPath); const content = await readFile(historyPath, 'utf8'); logger.debug(`File-history backup найден: ${historyPath}`); return content; } catch { // Backup file doesn't exist, try next log continue; } } return null; } /** * Extract sessionId from a JSONL log path. * * Paths can be: * - `~/.claude/projects/{encodedPath}/{sessionId}.jsonl` (lead session) * - `~/.claude/projects/{encodedPath}/{sessionId}/subagents/agent-{id}.jsonl` (subagent) * * For lead sessions, sessionId = filename without extension. * For subagents, sessionId = the parent directory's parent name. */ private extractSessionId(logPath: string): string | null { const parts = path .normalize(logPath) .split(/[/\\]+/) .filter(Boolean); // Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl const subagentsIdx = parts.indexOf('subagents'); if (subagentsIdx > 0) { return parts[subagentsIdx - 1] || null; } // Lead session: .../{sessionId}.jsonl const fileName = parts[parts.length - 1]; if (fileName?.endsWith('.jsonl')) { return fileName.replace('.jsonl', ''); } return null; } /** * Stream a JSONL file looking for file-history-snapshot entries that reference the target file. * Returns the backup file name if found. */ private async findFileHistoryBackup( logPath: string, targetFilePath: string ): Promise { try { const stream = createReadStream(logPath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; // Quick check before JSON parse if (!trimmed.includes('file-history-snapshot')) continue; try { const entry = JSON.parse(trimmed) as Record; if (entry.type !== 'file-history-snapshot') continue; const snapshot = entry.snapshot as Record | undefined; if (!snapshot) continue; const trackedFileBackups = snapshot.trackedFileBackups as | Record | undefined; if (!trackedFileBackups) continue; const backupFileName = trackedFileBackups[targetFilePath]; if (backupFileName) { rl.close(); stream.destroy(); return backupFileName; } } catch { // Skip malformed JSON } } rl.close(); stream.destroy(); } catch { logger.debug(`Не удалось прочитать JSONL для file-history: ${logPath}`); } return null; } /** * Strategy 2: Reconstruct original content by reverse-applying snippets. * * Algorithm: * 1. Start with current file content from disk (= modified state) * 2. Sort snippets by timestamp DESCENDING (newest first) * 3. For each snippet, reverse the edit operation * 4. Result = original content before any agent changes * * Returns null if reconstruction is not possible (chain broken). */ private trySnippetReconstruction( currentContent: string | null, snippets: SnippetDiff[] ): string | null { // `readFile()` can legitimately return an empty string for empty files. // Only treat `null` as "missing on disk". if (currentContent === null) return null; if (snippets.length === 0) return null; // Filter out errored snippets const validSnippets = snippets.filter((s) => !s.isError); if (validSnippets.length === 0) return null; // Sort by timestamp descending (reverse order to undo newest first) const sorted = [...validSnippets].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); let content = currentContent; for (const snippet of sorted) { switch (snippet.type) { case 'write-new': { // File was created by agent -> original was empty return ''; } case 'write-update': { // Full file overwrite — can't reconstruct previous content from snippets alone return null; } case 'notebook-edit': case 'shell-snapshot': case 'hook-snapshot': { // Snapshot/full-file changes are only safe when ledger content is available. return null; } case 'edit': case 'multi-edit': { // Guard: empty newString means deletion — can't find position to reverse if (!snippet.newString) return null; if (snippet.replaceAll) { // Reverse replaceAll: replace all occurrences of newString -> oldString if (!content.includes(snippet.newString)) { // Chain broken — newString not in current content return null; } content = content.split(snippet.newString).join(snippet.oldString); } else { // Reverse single edit: replace first occurrence of newString -> oldString const idx = content.indexOf(snippet.newString); if (idx === -1) { // Chain broken — can't find the new string to reverse return null; } content = content.substring(0, idx) + snippet.oldString + content.substring(idx + snippet.newString.length); } break; } } } return content; } // ── Private: Git fallback (Phase 4) ── /** * Strategy 3 (Phase 4): Git fallback — find original content from git history. * Uses the timestamp of the first snippet to locate a commit before changes. */ private async tryGitFallback( filePath: string, _currentContent: string | null, snippets: SnippetDiff[] ): Promise { if (!this.gitFallback) return null; // Determine project path from file path (heuristic: find .git parent) const projectPath = await this.guessProjectPath(filePath); if (!projectPath) return null; const isGit = await this.gitFallback.isGitRepo(projectPath); if (!isGit) return null; // Use earliest snippet timestamp to find the "before" state const timestamps = snippets .filter((s) => !s.isError && s.timestamp) .map((s) => s.timestamp) .sort((a, b) => a.localeCompare(b)); const firstTimestamp = timestamps[0]; if (!firstTimestamp) return null; const commitHash = await this.gitFallback.findCommitNearTimestamp( projectPath, filePath, firstTimestamp ); if (!commitHash) return null; const original = await this.gitFallback.getFileAtCommit(projectPath, filePath, commitHash); return original; } /** * Guess the project root path from a file path. * Simple heuristic: look for common markers (package.json, .git directory). */ private async guessProjectPath(filePath: string): Promise { const normalized = path.normalize(filePath); let dir = path.dirname(normalized); const parsed = path.parse(dir); const root = parsed.root; const markers = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml'] as const; const hasMarker = async (candidateDir: string): Promise => { for (const marker of markers) { try { await access(path.join(candidateDir, marker)); return true; } catch { // ignore } } return false; }; // Walk up from file directory; prefer stable "real" roots over string heuristics. // This keeps git fallback working on Windows (\\ separators) and with mixed separators. const MAX_UP = 30; for (let i = 0; i < MAX_UP; i++) { const base = path.basename(dir); const candidate = base === 'src' || base === 'lib' ? path.dirname(dir) : dir; if (await hasMarker(candidate)) return candidate; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } // Safety: if we can't confidently find a project root, don't guess. // Returning null avoids running git in the wrong directory. // (The resolver will still fall back to other content strategies.) if (!root) return null; return null; } private getDisplayRelativePath(filePath: string, segmentCount: number): string { const normalized = path.normalize(filePath); const parts = normalized.split(/[/\\]+/).filter(Boolean); return parts.slice(-segmentCount).join('/'); } // ── Private: Cache helpers ── private normalizeResolverPath(filePath: string): string { return normalizePathForComparison(filePath); } private buildCacheKey(teamName: string, memberName: string, filePath: string): string { return `${teamName}:${memberName}:${this.normalizeResolverPath(filePath)}`; } private hashString(input: string): string { return createHash('sha256').update(input).digest('hex'); } private buildDiskFingerprint(currentContent: string | null): string { if (currentContent === null) return 'missing'; return this.hashString(`present:${currentContent}`); } private buildSnippetFingerprint(snippets: SnippetDiff[]): string { const hash = createHash('sha256'); for (const snippet of snippets) { hash.update('\u0000snippet\u0000'); hash.update(this.normalizeResolverPath(snippet.filePath)); hash.update('\u0000'); hash.update(snippet.toolUseId); hash.update('\u0000'); hash.update(snippet.type); hash.update('\u0000'); hash.update(snippet.oldString); hash.update('\u0000'); hash.update(snippet.newString); hash.update('\u0000'); hash.update(snippet.replaceAll ? '1' : '0'); hash.update('\u0000'); hash.update(snippet.timestamp); hash.update('\u0000'); hash.update(snippet.isError ? '1' : '0'); hash.update('\u0000'); hash.update(snippet.contextHash ?? ''); } return hash.digest('hex'); } private buildValidationFingerprint( filePath: string, currentContent: string | null, snippets: SnippetDiff[] ): string { const normalizedPath = this.normalizeResolverPath(filePath); const diskFingerprint = this.buildDiskFingerprint(currentContent); const snippetFingerprint = this.buildSnippetFingerprint(snippets); return this.hashString(`${normalizedPath}|${diskFingerprint}|${snippetFingerprint}`); } private getCacheTtlForSource(_source: FileChangeWithContent['contentSource']): number { return this.provisionalCacheTtl; } private cacheResult( key: string, validationFingerprint: string, result: { original: string | null; modified: string | null; source: FileChangeWithContent['contentSource']; } ): void { this.cache.set(key, { original: result.original, modified: result.modified, source: result.source, validationFingerprint, expiresAt: Date.now() + this.getCacheTtlForSource(result.source), }); } }