Skip to content

Commit ab68643

Browse files
authored
Merge pull request #49 from beNative/codex/add-diff-fetching-and-rendering-features
Add commit diff fetching and display enhancements
2 parents da40184 + dc30e87 commit ab68643

5 files changed

Lines changed: 361 additions & 15 deletions

File tree

components/modals/CommitHistoryModal.tsx

Lines changed: 280 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import React, { useState, useEffect } from 'react';
2-
import type { Repository, Commit } from '../../types';
2+
import type { Repository, Commit, CommitDiffFile } from '../../types';
33
import { ClockIcon } from '../icons/ClockIcon';
44
import { XIcon } from '../icons/XIcon';
55
import { MagnifyingGlassIcon } from '../icons/MagnifyingGlassIcon';
6+
import { ChevronDownIcon } from '../icons/ChevronDownIcon';
7+
import { ChevronRightIcon } from '../icons/ChevronRightIcon';
8+
import { ClipboardIcon } from '../icons/ClipboardIcon';
69

710
interface CommitHistoryModalProps {
811
isOpen: boolean;
@@ -33,6 +36,49 @@ const HighlightedText: React.FC<{ text: string; highlight: string }> = ({ text,
3336
};
3437

3538

39+
const DIFF_PAGE_SIZE = 5;
40+
41+
const getFileExtension = (filePath: string): string => {
42+
const trimmed = filePath.split('/').pop() ?? filePath;
43+
const lastDot = trimmed.lastIndexOf('.');
44+
if (lastDot === -1) {
45+
return '(no extension)';
46+
}
47+
return trimmed.slice(lastDot + 1).toLowerCase();
48+
};
49+
50+
const DiffContent: React.FC<{ diff: string }> = ({ diff }) => {
51+
const lines = diff.split('\n');
52+
return (
53+
<pre className="bg-gray-900 text-gray-100 text-xs leading-relaxed rounded-md p-3 overflow-auto max-h-96 whitespace-pre font-mono">
54+
{lines.map((line, idx) => {
55+
let lineClass = '';
56+
if (line.startsWith('+++') || line.startsWith('---')) {
57+
lineClass = 'text-blue-300';
58+
} else if (line.startsWith('@@')) {
59+
lineClass = 'text-amber-300';
60+
} else if (line.startsWith('+')) {
61+
lineClass = 'text-emerald-400';
62+
} else if (line.startsWith('-')) {
63+
lineClass = 'text-rose-400';
64+
} else if (line.startsWith('diff --git') || line.startsWith('Index: ')) {
65+
lineClass = 'text-sky-300 font-semibold';
66+
}
67+
68+
const classes = ['block'];
69+
if (lineClass) {
70+
classes.push(lineClass);
71+
}
72+
73+
return (
74+
<code key={idx} className={classes.join(' ')}>{line || '\u00A0'}</code>
75+
);
76+
})}
77+
</pre>
78+
);
79+
};
80+
81+
3682
const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, repository, initialCommits, onClose }) => {
3783
const [commits, setCommits] = useState<Commit[]>([]);
3884
const [isLoading, setIsLoading] = useState(false);
@@ -41,6 +87,13 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
4187
const [searchQuery, setSearchQuery] = useState('');
4288
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
4389
const [matchStats, setMatchStats] = useState({ commitCount: 0, occurrenceCount: 0 });
90+
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(() => new Set());
91+
const [diffCache, setDiffCache] = useState<Record<string, CommitDiffFile[]>>({});
92+
const [diffLoading, setDiffLoading] = useState<Record<string, boolean>>({});
93+
const [diffErrors, setDiffErrors] = useState<Record<string, string | null>>({});
94+
const [diffFilters, setDiffFilters] = useState<Record<string, string>>({});
95+
const [diffVisibleCount, setDiffVisibleCount] = useState<Record<string, number>>({});
96+
const [copiedCommit, setCopiedCommit] = useState<string | null>(null);
4497

4598
// Debounce search input
4699
useEffect(() => {
@@ -103,6 +156,30 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
103156
}
104157
}, [isOpen]);
105158

159+
useEffect(() => {
160+
if (!isOpen) {
161+
setExpandedCommits(new Set());
162+
setDiffCache({});
163+
setDiffLoading({});
164+
setDiffErrors({});
165+
setDiffFilters({});
166+
setDiffVisibleCount({});
167+
setCopiedCommit(null);
168+
}
169+
}, [isOpen]);
170+
171+
useEffect(() => {
172+
if (!repository) {
173+
setExpandedCommits(new Set());
174+
setDiffCache({});
175+
setDiffLoading({});
176+
setDiffErrors({});
177+
setDiffFilters({});
178+
setDiffVisibleCount({});
179+
setCopiedCommit(null);
180+
}
181+
}, [repository]);
182+
106183
const handleLoadMore = async () => {
107184
if (!repository || isMoreLoading) return;
108185
setIsMoreLoading(true);
@@ -129,6 +206,80 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
129206
}
130207
};
131208

209+
const loadCommitDiff = async (commit: Commit) => {
210+
if (!repository) {
211+
return;
212+
}
213+
214+
setDiffLoading(prev => ({ ...prev, [commit.hash]: true }));
215+
setDiffErrors(prev => ({ ...prev, [commit.hash]: null }));
216+
try {
217+
const files = await window.electronAPI.getCommitDiff(repository, commit.hash);
218+
setDiffCache(prev => ({ ...prev, [commit.hash]: files }));
219+
setDiffFilters(prev => ({ ...prev, [commit.hash]: 'all' }));
220+
const initialVisible = files.length === 0 ? 0 : Math.min(DIFF_PAGE_SIZE, files.length);
221+
setDiffVisibleCount(prev => ({ ...prev, [commit.hash]: initialVisible }));
222+
} catch (error) {
223+
console.error(`Failed to load diff for commit ${commit.hash}`, error);
224+
setDiffErrors(prev => ({ ...prev, [commit.hash]: 'Failed to load diff for this commit.' }));
225+
} finally {
226+
setDiffLoading(prev => ({ ...prev, [commit.hash]: false }));
227+
}
228+
};
229+
230+
const handleToggleCommit = (commit: Commit) => {
231+
const isExpanded = expandedCommits.has(commit.hash);
232+
setExpandedCommits(prev => {
233+
const next = new Set(prev);
234+
if (next.has(commit.hash)) {
235+
next.delete(commit.hash);
236+
} else {
237+
next.add(commit.hash);
238+
}
239+
return next;
240+
});
241+
242+
if (!isExpanded && !diffCache[commit.hash] && !diffLoading[commit.hash]) {
243+
loadCommitDiff(commit);
244+
}
245+
};
246+
247+
const handleCopyDiff = async (commitHash: string) => {
248+
const files = diffCache[commitHash];
249+
if (!files || files.length === 0) {
250+
return;
251+
}
252+
253+
try {
254+
if (!navigator?.clipboard?.writeText) {
255+
console.warn('Clipboard API is not available in this environment.');
256+
return;
257+
}
258+
await navigator.clipboard.writeText(files.map(file => file.diff).join('\n\n'));
259+
setCopiedCommit(commitHash);
260+
setTimeout(() => setCopiedCommit(prev => (prev === commitHash ? null : prev)), 2000);
261+
} catch (error) {
262+
console.error('Failed to copy diff to clipboard', error);
263+
}
264+
};
265+
266+
const handleFilterChange = (commitHash: string, filter: string) => {
267+
setDiffFilters(prev => ({ ...prev, [commitHash]: filter }));
268+
const files = diffCache[commitHash] || [];
269+
const filteredLength = filter === 'all'
270+
? files.length
271+
: files.filter(file => getFileExtension(file.filePath) === filter).length;
272+
const nextCount = filteredLength === 0 ? 0 : Math.min(DIFF_PAGE_SIZE, filteredLength);
273+
setDiffVisibleCount(prev => ({ ...prev, [commitHash]: nextCount }));
274+
};
275+
276+
const handleShowMoreFiles = (commitHash: string) => {
277+
setDiffVisibleCount(prev => ({
278+
...prev,
279+
[commitHash]: (prev[commitHash] || DIFF_PAGE_SIZE) + DIFF_PAGE_SIZE,
280+
}));
281+
};
282+
132283

133284
if (!isOpen || !repository) {
134285
return null;
@@ -189,17 +340,134 @@ const CommitHistoryModal: React.FC<CommitHistoryModalProps> = ({ isOpen, reposit
189340
) : (
190341
<>
191342
<ul className="space-y-3">
192-
{commits.map(commit => (
193-
<li key={commit.hash} className="p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
194-
<pre className="font-sans whitespace-pre-wrap text-gray-900 dark:text-gray-100">
195-
<HighlightedText text={commit.message} highlight={debouncedSearchQuery} />
196-
</pre>
197-
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
198-
<span>{commit.author}</span>
199-
<span title={commit.hash} className="font-mono">{commit.shortHash} &bull; {commit.date}</span>
200-
</div>
201-
</li>
202-
))}
343+
{commits.map(commit => {
344+
const isExpanded = expandedCommits.has(commit.hash);
345+
const diffFiles = diffCache[commit.hash] || [];
346+
const filter = diffFilters[commit.hash] ?? 'all';
347+
const filteredFiles = filter === 'all' ? diffFiles : diffFiles.filter(file => getFileExtension(file.filePath) === filter);
348+
const visibleCount = diffVisibleCount[commit.hash] ?? (filteredFiles.length === 0 ? 0 : Math.min(DIFF_PAGE_SIZE, filteredFiles.length));
349+
const visibleFiles = filteredFiles.slice(0, visibleCount);
350+
const hasMoreFiles = visibleCount < filteredFiles.length;
351+
const commitDiffError = diffErrors[commit.hash];
352+
const isDiffLoading = diffLoading[commit.hash];
353+
const fileTypes = Array.from(new Set(diffFiles.map(file => getFileExtension(file.filePath)))).sort((a, b) => a.localeCompare(b));
354+
const copyLabel = copiedCommit === commit.hash ? 'Copied!' : 'Copy patch';
355+
const showingCount = Math.min(visibleCount, filteredFiles.length);
356+
const noFilesMessage = diffFiles.length === 0 && filter === 'all'
357+
? 'No files changed in this commit.'
358+
: 'No files match the selected filter.';
359+
360+
return (
361+
<li key={commit.hash} className="p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
362+
<button
363+
type="button"
364+
onClick={() => handleToggleCommit(commit)}
365+
className="flex w-full items-start gap-3 text-left"
366+
>
367+
<span className="mt-1 text-gray-500 dark:text-gray-400">
368+
{isExpanded ? <ChevronDownIcon className="h-5 w-5" /> : <ChevronRightIcon className="h-5 w-5" />}
369+
</span>
370+
<div className="flex-1">
371+
<div className="font-sans whitespace-pre-wrap text-gray-900 dark:text-gray-100">
372+
<HighlightedText text={commit.message} highlight={debouncedSearchQuery} />
373+
</div>
374+
</div>
375+
</button>
376+
<div className="flex flex-col gap-2 text-xs text-gray-500 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
377+
<span>{commit.author}</span>
378+
<span title={commit.hash} className="font-mono">{commit.shortHash} &bull; {commit.date}</span>
379+
</div>
380+
381+
{isExpanded && (
382+
<div className="mt-3 space-y-3">
383+
{isDiffLoading ? (
384+
<p className="text-sm text-gray-500 dark:text-gray-400">Loading diff...</p>
385+
) : commitDiffError ? (
386+
<p className="text-sm text-red-500 dark:text-red-400">{commitDiffError}</p>
387+
) : (
388+
<div className="space-y-3">
389+
<div className="flex flex-col gap-3 rounded-md border border-gray-200 bg-white p-3 text-xs dark:border-gray-700 dark:bg-gray-900/60 sm:flex-row sm:items-center sm:justify-between">
390+
<div className="space-y-1 text-gray-600 dark:text-gray-300">
391+
<p>
392+
Showing <span className="font-semibold text-gray-900 dark:text-gray-100">{showingCount}</span> of{' '}
393+
<span className="font-semibold text-gray-900 dark:text-gray-100">{filteredFiles.length}</span> file{filteredFiles.length === 1 ? '' : 's'}
394+
{filter !== 'all' && diffFiles.length > 0 ? (
395+
<span className="ml-1 text-gray-500 dark:text-gray-400">(filtering from {diffFiles.length})</span>
396+
) : null}
397+
</p>
398+
</div>
399+
<div className="flex flex-wrap items-center gap-2">
400+
{fileTypes.length > 0 && (
401+
<select
402+
value={filter}
403+
onChange={(event) => handleFilterChange(commit.hash, event.target.value)}
404+
className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
405+
>
406+
<option value="all">All file types</option>
407+
{fileTypes.map(type => (
408+
<option key={type} value={type}>{type}</option>
409+
))}
410+
</select>
411+
)}
412+
<button
413+
type="button"
414+
onClick={() => handleCopyDiff(commit.hash)}
415+
className="inline-flex items-center gap-1 rounded-md border border-blue-500 px-2 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50 dark:border-blue-400 dark:text-blue-300 dark:hover:bg-blue-900/40"
416+
>
417+
<ClipboardIcon className="h-4 w-4" />
418+
{copyLabel}
419+
</button>
420+
<button
421+
type="button"
422+
onClick={() => handleToggleCommit(commit)}
423+
className="inline-flex items-center gap-1 rounded-md border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
424+
>
425+
Collapse
426+
</button>
427+
</div>
428+
</div>
429+
430+
{filteredFiles.length === 0 ? (
431+
<p className="text-sm text-gray-500 dark:text-gray-400">{noFilesMessage}</p>
432+
) : (
433+
<div className="space-y-3">
434+
{visibleFiles.map(file => {
435+
const extension = getFileExtension(file.filePath);
436+
return (
437+
<div key={`${commit.hash}-${file.filePath}`} className="overflow-hidden rounded-md border border-gray-200 bg-gray-100 dark:border-gray-700 dark:bg-gray-900">
438+
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-gray-200 bg-gray-200 px-3 py-2 text-xs font-medium text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200">
439+
<span className="truncate" title={file.filePath}>{file.filePath}</span>
440+
<div className="flex flex-wrap items-center gap-2">
441+
<span className="rounded bg-gray-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-gray-700 dark:bg-gray-700 dark:text-gray-200">{extension}</span>
442+
{file.isBinary && (
443+
<span className="rounded bg-amber-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-900 dark:bg-amber-500/20 dark:text-amber-200">Binary</span>
444+
)}
445+
</div>
446+
</div>
447+
<DiffContent diff={file.diff} />
448+
</div>
449+
);
450+
})}
451+
{hasMoreFiles && (
452+
<div className="text-center">
453+
<button
454+
type="button"
455+
onClick={() => handleShowMoreFiles(commit.hash)}
456+
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700"
457+
>
458+
Load more files
459+
</button>
460+
</div>
461+
)}
462+
</div>
463+
)}
464+
</div>
465+
)}
466+
</div>
467+
)}
468+
</li>
469+
);
470+
})}
203471
</ul>
204472
{hasMore && (
205473
<div className="mt-4 text-center">

electron/electron.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IpcRendererEvent } from 'electron';
2-
import type { Repository, Task, TaskStep, GlobalSettings, LogLevel, LocalPathState as AppLocalPathState, DetailedStatus, Commit, BranchInfo, DebugLogEntry, VcsType, ProjectInfo, UpdateStatusMessage, Category, AppDataContextState, ReleaseInfo } from '../types';
2+
import type { Repository, Task, TaskStep, GlobalSettings, LogLevel, LocalPathState as AppLocalPathState, DetailedStatus, Commit, BranchInfo, DebugLogEntry, VcsType, ProjectInfo, UpdateStatusMessage, Category, AppDataContextState, ReleaseInfo, CommitDiffFile } from '../types';
33

44
export type LocalPathState = AppLocalPathState;
55

@@ -29,6 +29,7 @@ export interface IElectronAPI {
2929
checkVcsStatus: (repo: Repository) => Promise<{ isDirty: boolean; output: string; untrackedFiles: string[]; changedFiles: string[] }>;
3030
getDetailedVcsStatus: (repo: Repository) => Promise<DetailedStatus | null>;
3131
getCommitHistory: (repo: Repository, skipCount?: number, searchQuery?: string) => Promise<Commit[]>;
32+
getCommitDiff: (repo: Repository, commitHash: string) => Promise<CommitDiffFile[]>;
3233
listBranches: (repoPath: string) => Promise<BranchInfo>;
3334
checkoutBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
3435
createBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;

0 commit comments

Comments
 (0)