From e0536bb4f7d6bc12f41e4fd8a235a686a2b49242 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 21 Feb 2026 19:05:11 +0100 Subject: [PATCH] feat: git log limited to last 20 --- src/main/engine/GitEngine.ts | 13 ++- .../components/GitSidebar/GitSidebar.tsx | 42 +++++++- src/renderer/i18n/locales/de.json | 1 + src/renderer/i18n/locales/en.json | 1 + src/renderer/i18n/locales/es.json | 1 + src/renderer/i18n/locales/fr.json | 1 + src/renderer/i18n/locales/it.json | 1 + tests/engine/GitEngine.test.ts | 88 +++++++++++++++++ tests/renderer/components/GitSidebar.test.tsx | 95 +++++++++++++++++++ 9 files changed, 237 insertions(+), 6 deletions(-) diff --git a/src/main/engine/GitEngine.ts b/src/main/engine/GitEngine.ts index cb2f9b5..abad317 100644 --- a/src/main/engine/GitEngine.ts +++ b/src/main/engine/GitEngine.ts @@ -732,7 +732,9 @@ export class GitEngine { })); } - const remoteHistory = await git.log([status.tracking, '--max-count', String(limit)]); + const behindCount = typeof status.behind === 'number' ? status.behind : Number(status.behind ?? 0); + const remoteHistoryLimit = Math.max(limit, limit + Math.max(behindCount, 0)); + const remoteHistory = await git.log([status.tracking, '--max-count', String(remoteHistoryLimit)]); type CommitLike = { hash: string; @@ -762,9 +764,8 @@ export class GitEngine { } } - return Array.from(combined.values()) + const classifiedEntries = Array.from(combined.values()) .sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()) - .slice(0, limit) .map((entry) => { const inLocal = localMap.has(entry.hash); const inRemote = remoteMap.has(entry.hash); @@ -779,6 +780,12 @@ export class GitEngine { syncStatus, }; }); + + const remoteOnlyEntries = classifiedEntries.filter((entry) => entry.syncStatus === 'remote-only'); + const localAndSyncedEntries = classifiedEntries.filter((entry) => entry.syncStatus !== 'remote-only').slice(0, limit); + + return [...localAndSyncedEntries, ...remoteOnlyEntries] + .sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()); } async getFileHistory(projectPath: string, filePath: string, limit = 50): Promise { diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 57240ea..08d0869 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -7,6 +7,7 @@ import './GitSidebar.css'; import '../Sidebar/Sidebar.css'; type GitSidebarStatusFile = { path: string; status: string }; +const HISTORY_PAGE_SIZE = 20; const mergeStatusFilesIncremental = ( previous: GitSidebarStatusFile[], @@ -45,6 +46,8 @@ export const GitSidebar: React.FC = () => { const [commitMessage, setCommitMessage] = useState(''); const [historyLoading, setHistoryLoading] = useState(false); const [historyEntries, setHistoryEntries] = useState([]); + const [historyLocalLimit, setHistoryLocalLimit] = useState(HISTORY_PAGE_SIZE); + const [hasMoreLocalHistory, setHasMoreLocalHistory] = useState(false); const [remoteState, setRemoteState] = useState(null); const [remoteStateError, setRemoteStateError] = useState(null); const [initProgress, setInitProgress] = useState(null); @@ -56,12 +59,13 @@ export const GitSidebar: React.FC = () => { const remoteRefreshInFlightRef = useRef(false); const refreshRepoDetails = useCallback( - async (targetProjectPath: string, options?: { background?: boolean }) => { + async (targetProjectPath: string, options?: { background?: boolean; historyLimit?: number }) => { if (statusRefreshInFlightRef.current) { return; } const background = options?.background ?? false; + const historyLimit = options?.historyLimit ?? historyLocalLimit; statusRefreshInFlightRef.current = true; if (!background) { @@ -72,10 +76,12 @@ export const GitSidebar: React.FC = () => { try { const [status, history] = await Promise.all([ window.electronAPI.git.getStatus(targetProjectPath), - window.electronAPI.git.getHistory(targetProjectPath, 20), + window.electronAPI.git.getHistory(targetProjectPath, historyLimit), ]); setStatusFiles((previous) => mergeStatusFilesIncremental(previous, status.files)); setHistoryEntries(history); + const nonRemoteOnlyCount = history.filter((entry) => entry.syncStatus !== 'remote-only').length; + setHasMoreLocalHistory(nonRemoteOnlyCount >= historyLimit); } finally { statusRefreshInFlightRef.current = false; if (!background) { @@ -84,7 +90,7 @@ export const GitSidebar: React.FC = () => { } } }, - [], + [historyLocalLimit], ); const refreshRemoteState = useCallback( @@ -192,6 +198,7 @@ export const GitSidebar: React.FC = () => { if (!availability.gitFound) { setError(tr('gitSidebar.error.gitMissing')); setIsRepo(false); + setHasMoreLocalHistory(false); return; } @@ -201,6 +208,7 @@ export const GitSidebar: React.FC = () => { if (!resolvedProjectPath) { setError(tr('gitSidebar.error.noActiveProject')); setIsRepo(false); + setHasMoreLocalHistory(false); return; } @@ -220,6 +228,7 @@ export const GitSidebar: React.FC = () => { } else { setStatusFiles([]); setHistoryEntries([]); + setHasMoreLocalHistory(false); setRemoteState(null); setRemoteStateError(null); } @@ -229,6 +238,7 @@ export const GitSidebar: React.FC = () => { setHasRemote(false); setStatusFiles([]); setHistoryEntries([]); + setHasMoreLocalHistory(false); setRemoteState(null); setRemoteStateError(null); } finally { @@ -240,6 +250,11 @@ export const GitSidebar: React.FC = () => { void loadRepoState(); }, [loadRepoState]); + useEffect(() => { + setHistoryLocalLimit(HISTORY_PAGE_SIZE); + setHasMoreLocalHistory(false); + }, [activeProject?.id]); + useEffect(() => { const unsubscribe = window.electronAPI.git.onInitProgress((progress) => { setInitProgress(progress); @@ -396,6 +411,17 @@ export const GitSidebar: React.FC = () => { } }; + const handleLoadMoreHistory = async () => { + const effectiveProjectPath = projectPath ?? (await resolveProjectPath()); + if (!effectiveProjectPath) { + return; + } + + const nextLimit = historyLocalLimit + HISTORY_PAGE_SIZE; + setHistoryLocalLimit(nextLimit); + void refreshRepoDetails(effectiveProjectPath, { historyLimit: nextLimit }); + }; + if (loading) { return (
@@ -572,6 +598,16 @@ export const GitSidebar: React.FC = () => { ))}
)} + {!historyLoading && hasMoreLocalHistory && ( + + )} {currentBranch &&
{tr('gitSidebar.branch', { branch: currentBranch })}
} {remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
{remoteState.localBranch} → {remoteState.upstreamBranch}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 8043ec6..3afeb2f 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -276,6 +276,7 @@ "gitSidebar.action.pruning": "Bereinigen...", "gitSidebar.action.commit": "Commit erstellen", "gitSidebar.action.committing": "Commit wird erstellt...", + "gitSidebar.action.loadMoreHistory": "Mehr laden", "gitSidebar.action.initializeGit": "Git initialisieren", "gitSidebar.action.initializing": "Initialisieren...", "gitSidebar.openChanges": "Offene Änderungen ({count})", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index b34af7c..728ad01 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -276,6 +276,7 @@ "gitSidebar.action.pruning": "Pruning...", "gitSidebar.action.commit": "Commit", "gitSidebar.action.committing": "Committing...", + "gitSidebar.action.loadMoreHistory": "Load more", "gitSidebar.action.initializeGit": "Initialize Git", "gitSidebar.action.initializing": "Initializing...", "gitSidebar.openChanges": "Open changes ({count})", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 9a8858e..c8856a4 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -276,6 +276,7 @@ "gitSidebar.action.pruning": "Podando...", "gitSidebar.action.commit": "Realizar commit", "gitSidebar.action.committing": "Haciendo commit...", + "gitSidebar.action.loadMoreHistory": "Cargar más", "gitSidebar.action.initializeGit": "Inicializar Git", "gitSidebar.action.initializing": "Inicializando...", "gitSidebar.openChanges": "Cambios abiertos ({count})", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 911e4d9..697d5ce 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -276,6 +276,7 @@ "gitSidebar.action.pruning": "Purge...", "gitSidebar.action.commit": "Valider", "gitSidebar.action.committing": "Commit en cours...", + "gitSidebar.action.loadMoreHistory": "Charger plus", "gitSidebar.action.initializeGit": "Initialiser Git", "gitSidebar.action.initializing": "Initialisation...", "gitSidebar.openChanges": "Modifications ouvertes ({count})", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 8c6c941..bf070f0 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -276,6 +276,7 @@ "gitSidebar.action.pruning": "Pulizia...", "gitSidebar.action.commit": "Registra commit", "gitSidebar.action.committing": "Commit in corso...", + "gitSidebar.action.loadMoreHistory": "Carica altro", "gitSidebar.action.initializeGit": "Inizializza Git", "gitSidebar.action.initializing": "Inizializzazione...", "gitSidebar.openChanges": "Modifiche aperte ({count})", diff --git a/tests/engine/GitEngine.test.ts b/tests/engine/GitEngine.test.ts index 8af682d..b3660ba 100644 --- a/tests/engine/GitEngine.test.ts +++ b/tests/engine/GitEngine.test.ts @@ -346,6 +346,94 @@ describe('GitEngine', () => { }, ]); }); + + it('should include all remote-only commits in addition to the local history limit', async () => { + mockStatus.mockResolvedValue({ current: 'main', tracking: 'origin/main', behind: 2 }); + mockLog + .mockResolvedValueOnce({ + all: [ + { + hash: 'loc1111', + date: '2026-02-16T12:00:00.000Z', + message: 'feat: newest local', + author_name: 'Local Dev', + }, + { + hash: 'both222', + date: '2026-02-16T11:00:00.000Z', + message: 'feat: shared local', + author_name: 'Shared Dev', + }, + { + hash: 'loc3333', + date: '2026-02-16T10:00:00.000Z', + message: 'feat: older local', + author_name: 'Local Dev', + }, + ], + }) + .mockResolvedValueOnce({ + all: [ + { + hash: 'rem9999', + date: '2026-02-16T13:00:00.000Z', + message: 'fix: newest remote waiting', + author_name: 'Remote Dev', + }, + { + hash: 'rem8888', + date: '2026-02-16T12:30:00.000Z', + message: 'fix: older remote waiting', + author_name: 'Remote Dev', + }, + { + hash: 'both222', + date: '2026-02-16T11:00:00.000Z', + message: 'feat: shared local', + author_name: 'Shared Dev', + }, + ], + }); + + const result = await gitEngine.getHistory('/tmp/project', 2); + + expect(mockLog).toHaveBeenNthCalledWith(1, { maxCount: 2 }); + expect(mockLog).toHaveBeenNthCalledWith(2, ['origin/main', '--max-count', '4']); + expect(result).toEqual([ + { + hash: 'rem9999', + shortHash: 'rem9999', + date: '2026-02-16T13:00:00.000Z', + subject: 'fix: newest remote waiting', + author: 'Remote Dev', + syncStatus: 'remote-only', + }, + { + hash: 'rem8888', + shortHash: 'rem8888', + date: '2026-02-16T12:30:00.000Z', + subject: 'fix: older remote waiting', + author: 'Remote Dev', + syncStatus: 'remote-only', + }, + { + hash: 'loc1111', + shortHash: 'loc1111', + date: '2026-02-16T12:00:00.000Z', + subject: 'feat: newest local', + author: 'Local Dev', + syncStatus: 'local-only', + }, + { + hash: 'both222', + shortHash: 'both222', + date: '2026-02-16T11:00:00.000Z', + subject: 'feat: shared local', + author: 'Shared Dev', + syncStatus: 'both', + }, + ]); + }); }); describe('getFileHistory', () => { diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 90f2931..7f1156a 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -156,6 +156,101 @@ describe('GitSidebar', () => { expect(syncedCommit).toHaveClass('git-sidebar-history-item--both'); }); + it('loads 20 commits by default and requests more when load more is clicked', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + + const createEntry = (index: number, syncStatus: 'both' | 'local-only' | 'remote-only' = 'both') => ({ + hash: `hash-${index}`, + shortHash: `h${index}`, + date: `2026-02-${String(28 - index).padStart(2, '0')}T10:00:00.000Z`, + subject: `commit ${index}`, + author: `Dev ${index}`, + syncStatus, + }); + + (window as any).electronAPI.git.getHistory = vi.fn().mockImplementation((_projectPath: string, limit: number) => { + if (limit <= 20) { + return Promise.resolve([ + ...Array.from({ length: 20 }, (_, index) => createEntry(index + 1)), + createEntry(101, 'remote-only'), + createEntry(102, 'remote-only'), + ]); + } + + return Promise.resolve([ + ...Array.from({ length: 25 }, (_, index) => createEntry(index + 1)), + createEntry(101, 'remote-only'), + createEntry(102, 'remote-only'), + ]); + }); + + render(); + + expect(await screen.findByText('commit 20')).toBeInTheDocument(); + expect(screen.queryByText('commit 25')).not.toBeInTheDocument(); + expect(screen.getByText('commit 101')).toBeInTheDocument(); + expect(screen.getByText('commit 102')).toBeInTheDocument(); + expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 20); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /load more/i })); + }); + + await vi.waitFor(() => { + expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 40); + }); + expect(await screen.findByText('commit 25')).toBeInTheDocument(); + }); + + it('keeps remote-only commits visible even when local history is limited to 20', async () => { + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + + const localEntries = Array.from({ length: 20 }, (_, index) => ({ + hash: `local-${index}`, + shortHash: `l${index}`, + date: `2026-01-${String(28 - index).padStart(2, '0')}T10:00:00.000Z`, + subject: `local commit ${index + 1}`, + author: 'Local Dev', + syncStatus: 'both' as const, + })); + + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([ + ...localEntries, + { + hash: 'remote-1', + shortHash: 'r1', + date: '2026-02-26T10:00:00.000Z', + subject: 'remote waiting 1', + author: 'Remote Dev', + syncStatus: 'remote-only', + }, + { + hash: 'remote-2', + shortHash: 'r2', + date: '2026-02-25T10:00:00.000Z', + subject: 'remote waiting 2', + author: 'Remote Dev', + syncStatus: 'remote-only', + }, + ]); + + render(); + + expect(await screen.findByText('local commit 20')).toBeInTheDocument(); + expect(screen.getByText('remote waiting 1')).toBeInTheDocument(); + expect(screen.getByText('remote waiting 2')).toBeInTheDocument(); + }); + it('renders commit status legend in version history section', async () => { (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ isRepo: true,