diff --git a/packages/desktop/src/executors/base/DiffMetadataExtractor.ts b/packages/desktop/src/executors/base/DiffMetadataExtractor.ts index 345100b..8d261a0 100644 --- a/packages/desktop/src/executors/base/DiffMetadataExtractor.ts +++ b/packages/desktop/src/executors/base/DiffMetadataExtractor.ts @@ -54,7 +54,7 @@ export class DiffMetadataExtractor { return null; } - private extractClaudeEdit(metadata: Record): DiffMetadata[] | null { + private async extractClaudeEdit(metadata: Record): Promise { const input = metadata.input as Record | undefined; if (!input) return null; @@ -64,6 +64,23 @@ export class DiffMetadataExtractor { if (!filePath || oldString === undefined || newString === undefined) return null; + // Read the current file content to get the full file after edit + const currentContent = await this.readFileIfExists(filePath); + + // If we can read the file, the edit has been applied, so currentContent is the new full content + // We need to reconstruct the old full content by reversing the edit + if (currentContent !== null) { + // The file now contains newString where oldString used to be + // Reconstruct old content: replace newString back with oldString + const fullOldContent = currentContent.replace(newString, oldString); + return [{ + filePath, + oldString: fullOldContent, + newString: currentContent, + }]; + } + + // Fallback: return the partial strings if file can't be read return [{ filePath, oldString, diff --git a/packages/desktop/src/executors/base/__tests__/DiffMetadataExtractor.test.ts b/packages/desktop/src/executors/base/__tests__/DiffMetadataExtractor.test.ts new file mode 100644 index 0000000..5ba3e80 --- /dev/null +++ b/packages/desktop/src/executors/base/__tests__/DiffMetadataExtractor.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DiffMetadataExtractor } from '../DiffMetadataExtractor'; +import * as fs from 'fs/promises'; + +// Mock fs/promises +vi.mock('fs/promises'); + +describe('DiffMetadataExtractor', () => { + let extractor: DiffMetadataExtractor; + const mockCwd = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + extractor = new DiffMetadataExtractor({ cwd: mockCwd }); + }); + + describe('extractClaudeEdit', () => { + it('should return full file content when file can be read', async () => { + const fullFileContent = `# My Document + +## Section 1 +This is the updated content. + +## Section 2 +More content here.`; + + const originalSection = 'This is the original content.'; + const updatedSection = 'This is the updated content.'; + + // The file now contains the updated content + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue(fullFileContent); + + const metadata = { + input: { + file_path: '/test/project/README.md', + old_string: originalSection, + new_string: updatedSection, + }, + }; + + const result = await extractor.extract('Edit', metadata); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(result![0].filePath).toBe('/test/project/README.md'); + // newString should be the full file content + expect(result![0].newString).toBe(fullFileContent); + // oldString should be the reconstructed full content with original text + expect(result![0].oldString).toContain(originalSection); + expect(result![0].oldString).not.toContain(updatedSection); + }); + + it('should return partial strings when file cannot be read', async () => { + vi.mocked(fs.stat).mockRejectedValue(new Error('File not found')); + + const metadata = { + input: { + file_path: '/test/project/missing.md', + old_string: 'old text', + new_string: 'new text', + }, + }; + + const result = await extractor.extract('Edit', metadata); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(result![0].oldString).toBe('old text'); + expect(result![0].newString).toBe('new text'); + }); + + it('should handle multiline edit replacements', async () => { + const fullFileContent = `function hello() { + console.log('Hello, World!'); + return true; +}`; + + const oldCode = `console.log('Hi'); + return false;`; + const newCode = `console.log('Hello, World!'); + return true;`; + + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue(fullFileContent); + + const metadata = { + input: { + file_path: '/test/project/index.ts', + old_string: oldCode, + new_string: newCode, + }, + }; + + const result = await extractor.extract('Edit', metadata); + + expect(result).not.toBeNull(); + expect(result![0].newString).toBe(fullFileContent); + expect(result![0].oldString).toContain(oldCode); + }); + + it('should return null for missing input', async () => { + const result = await extractor.extract('Edit', {}); + expect(result).toBeNull(); + }); + + it('should return null for missing file_path', async () => { + const metadata = { + input: { + old_string: 'old', + new_string: 'new', + }, + }; + + const result = await extractor.extract('Edit', metadata); + expect(result).toBeNull(); + }); + + it('should return null for missing old_string', async () => { + const metadata = { + input: { + file_path: '/test/file.md', + new_string: 'new', + }, + }; + + const result = await extractor.extract('Edit', metadata); + expect(result).toBeNull(); + }); + + it('should return null for missing new_string', async () => { + const metadata = { + input: { + file_path: '/test/file.md', + old_string: 'old', + }, + }; + + const result = await extractor.extract('Edit', metadata); + expect(result).toBeNull(); + }); + }); + + describe('extractClaudeWrite', () => { + it('should return full content for new file', async () => { + vi.mocked(fs.stat).mockRejectedValue(new Error('File not found')); + + const metadata = { + input: { + file_path: '/test/project/new-file.md', + content: '# New Document\n\nContent here.', + }, + }; + + const result = await extractor.extract('Write', metadata); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(result![0].oldString).toBe(''); + expect(result![0].newString).toBe('# New Document\n\nContent here.'); + expect(result![0].isNewFile).toBe(true); + }); + + it('should return old and new content for existing file', async () => { + const existingContent = '# Old Document\n\nOld content.'; + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue(existingContent); + + const metadata = { + input: { + file_path: '/test/project/existing.md', + content: '# New Document\n\nNew content.', + }, + }; + + const result = await extractor.extract('Write', metadata); + + expect(result).not.toBeNull(); + expect(result![0].oldString).toBe(existingContent); + expect(result![0].newString).toBe('# New Document\n\nNew content.'); + expect(result![0].isNewFile).toBeFalsy(); + }); + }); + + describe('extractClaudeBash', () => { + it('should extract diff for rm command', async () => { + const fileContent = 'file content to be deleted'; + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue(fileContent); + + const metadata = { + input: { + command: 'rm /test/project/file.txt', + }, + }; + + const result = await extractor.extract('Bash', metadata); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(result![0].oldString).toBe(fileContent); + expect(result![0].newString).toBe(''); + expect(result![0].isDelete).toBe(true); + }); + + it('should extract diff for rm -f command', async () => { + const fileContent = 'content'; + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue(fileContent); + + const metadata = { + input: { + command: 'rm -f /test/project/file.txt', + }, + }; + + const result = await extractor.extract('Bash', metadata); + + expect(result).not.toBeNull(); + expect(result![0].isDelete).toBe(true); + }); + + it('should return null for non-rm commands', async () => { + const metadata = { + input: { + command: 'ls -la', + }, + }; + + const result = await extractor.extract('Bash', metadata); + expect(result).toBeNull(); + }); + + it('should handle multiple files in rm command', async () => { + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile) + .mockResolvedValueOnce('content1') + .mockResolvedValueOnce('content2'); + + const metadata = { + input: { + command: 'rm file1.txt file2.txt', + }, + }; + + const result = await extractor.extract('Bash', metadata); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(2); + expect(result![0].oldString).toBe('content1'); + expect(result![1].oldString).toBe('content2'); + }); + }); + + describe('extractCodexFileChange', () => { + it('should extract diff from unified diff format', async () => { + const metadata = { + changes: [ + { + path: '/test/file.ts', + diff: `--- a/file.ts ++++ b/file.ts +@@ -1,3 +1,3 @@ + const a = 1; +-const b = 2; ++const b = 3; + const c = 3;`, + kind: { type: 'update' }, + }, + ], + }; + + const result = await extractor.extract('fileChange', metadata); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(result![0].filePath).toBe('/test/file.ts'); + expect(result![0].oldString).toContain('const b = 2;'); + expect(result![0].newString).toContain('const b = 3;'); + }); + + it('should handle new file with content', async () => { + const metadata = { + changes: [ + { + path: '/test/new-file.ts', + content: 'export const hello = "world";', + kind: { type: 'add' }, + }, + ], + }; + + const result = await extractor.extract('fileChange', metadata); + + expect(result).not.toBeNull(); + expect(result![0].oldString).toBe(''); + expect(result![0].newString).toBe('export const hello = "world";'); + expect(result![0].isNewFile).toBe(true); + }); + + it('should handle file deletion', async () => { + const metadata = { + changes: [ + { + path: '/test/deleted-file.ts', + content: 'old content', + kind: { type: 'delete' }, + }, + ], + }; + + const result = await extractor.extract('fileChange', metadata); + + expect(result).not.toBeNull(); + expect(result![0].oldString).toBe('old content'); + expect(result![0].newString).toBe(''); + expect(result![0].isDelete).toBe(true); + }); + }); + + describe('extractCodexCommand', () => { + it('should extract diff for rm command same as Claude Bash', async () => { + const fileContent = 'file to delete'; + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue(fileContent); + + const metadata = { + command: 'rm /test/file.txt', + }; + + const result = await extractor.extract('commandExecution', metadata); + + expect(result).not.toBeNull(); + expect(result![0].oldString).toBe(fileContent); + expect(result![0].newString).toBe(''); + expect(result![0].isDelete).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should return null for unknown tool', async () => { + const result = await extractor.extract('UnknownTool', { input: {} }); + expect(result).toBeNull(); + }); + + it('should return null for null metadata', async () => { + const result = await extractor.extract('Edit', undefined); + expect(result).toBeNull(); + }); + + it('should skip binary files', async () => { + vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as any); + vi.mocked(fs.readFile).mockResolvedValue('binary\0content'); + + const metadata = { + input: { + file_path: '/test/binary.bin', + old_string: 'old', + new_string: 'new', + }, + }; + + const result = await extractor.extract('Edit', metadata); + + // Should fall back to partial strings since binary file is skipped + expect(result).not.toBeNull(); + expect(result![0].oldString).toBe('old'); + expect(result![0].newString).toBe('new'); + }); + + it('should skip large files', async () => { + vi.mocked(fs.stat).mockResolvedValue({ size: 2 * 1024 * 1024 } as any); // 2MB + + const metadata = { + input: { + file_path: '/test/large.md', + old_string: 'old', + new_string: 'new', + }, + }; + + const result = await extractor.extract('Edit', metadata); + + // Should fall back to partial strings since large file is skipped + expect(result).not.toBeNull(); + expect(result![0].oldString).toBe('old'); + expect(result![0].newString).toBe('new'); + }); + }); +}); diff --git a/packages/ui/src/components/panels/timeline/InlineDiffViewer.tsx b/packages/ui/src/components/panels/timeline/InlineDiffViewer.tsx index a8ffca5..434c3d9 100644 --- a/packages/ui/src/components/panels/timeline/InlineDiffViewer.tsx +++ b/packages/ui/src/components/panels/timeline/InlineDiffViewer.tsx @@ -154,9 +154,9 @@ export function InlineDiffViewer({ filePath, className, }: InlineDiffViewerProps) { + const isMarkdown = useMemo(() => isMarkdownFile(filePath || ''), [filePath]); const [showPreview, setShowPreview] = useState(false); const [isExpanded, setIsExpanded] = useState(true); - const isMarkdown = useMemo(() => isMarkdownFile(filePath || ''), [filePath]); const diffLines = useMemo(() => { return generateDiff(oldString, newString);