From 339e513a2d16cd492ccef9a2994361a565a006a7 Mon Sep 17 00:00:00 2001 From: hugo Date: Mon, 16 Feb 2026 15:29:53 +0100 Subject: [PATCH] feat: phase 5 of git implementation --- .../components/GitSidebar/GitSidebar.tsx | 85 +++++++++++--- tests/renderer/components/GitSidebar.test.tsx | 110 ++++++++++++++++++ 2 files changed, 181 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 0d1ccd7..f444a5f 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -4,6 +4,28 @@ import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/elec import './GitSidebar.css'; import '../Sidebar/Sidebar.css'; +type GitSidebarStatusFile = { path: string; status: string }; + +const mergeStatusFilesIncremental = ( + previous: GitSidebarStatusFile[], + next: GitSidebarStatusFile[], +): GitSidebarStatusFile[] => { + const previousByPath = new Map(previous.map((entry) => [entry.path, entry])); + + return next.map((entry) => { + const existing = previousByPath.get(entry.path); + if (!existing) { + return entry; + } + + if (existing.status === entry.status) { + return existing; + } + + return entry; + }); +}; + export const GitSidebar: React.FC = () => { const { activeProject, openTab, tabs, closeTab } = useAppStore(); const [projectPath, setProjectPath] = useState(null); @@ -24,6 +46,39 @@ export const GitSidebar: React.FC = () => { const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false); const remoteUrlInputRef = useRef(null); const commitMessageInputRef = useRef(null); + const statusRefreshInFlightRef = useRef(false); + + const refreshRepoDetails = useCallback( + async (targetProjectPath: string, options?: { background?: boolean }) => { + if (statusRefreshInFlightRef.current) { + return; + } + + const background = options?.background ?? false; + + statusRefreshInFlightRef.current = true; + if (!background) { + setStatusLoading(true); + setHistoryLoading(true); + } + + try { + const [status, history] = await Promise.all([ + window.electronAPI.git.getStatus(targetProjectPath), + window.electronAPI.git.getHistory(targetProjectPath, 20), + ]); + setStatusFiles((previous) => mergeStatusFilesIncremental(previous, status.files)); + setHistoryEntries(history); + } finally { + statusRefreshInFlightRef.current = false; + if (!background) { + setStatusLoading(false); + setHistoryLoading(false); + } + } + }, + [], + ); const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`; const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`; @@ -115,19 +170,7 @@ export const GitSidebar: React.FC = () => { setCurrentBranch(repoState.currentBranch || null); if (repoState.isRepo) { - setStatusLoading(true); - setHistoryLoading(true); - try { - const [status, history] = await Promise.all([ - window.electronAPI.git.getStatus(resolvedProjectPath), - window.electronAPI.git.getHistory(resolvedProjectPath, 20), - ]); - setStatusFiles(status.files); - setHistoryEntries(history); - } finally { - setStatusLoading(false); - setHistoryLoading(false); - } + await refreshRepoDetails(resolvedProjectPath); } else { setStatusFiles([]); setHistoryEntries([]); @@ -140,7 +183,7 @@ export const GitSidebar: React.FC = () => { } finally { setLoading(false); } - }, [resolveProjectPath]); + }, [refreshRepoDetails, resolveProjectPath]); useEffect(() => { void loadRepoState(); @@ -160,6 +203,20 @@ export const GitSidebar: React.FC = () => { }; }, []); + useEffect(() => { + if (!isRepo || !projectPath) { + return; + } + + const intervalId = globalThis.setInterval(() => { + void refreshRepoDetails(projectPath, { background: true }); + }, 2000); + + return () => { + globalThis.clearInterval(intervalId); + }; + }, [isRepo, projectPath, refreshRepoDetails]); + const handleInitialize = async () => { if (!projectPath) { return; diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 291aa14..0636ec3 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -636,4 +636,114 @@ describe('GitSidebar', () => { resolvePush?.({ success: true }); }); }); + + it('polls repository status on an interval and prevents overlapping in-flight requests', async () => { + vi.useFakeTimers(); + + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + + let resolveStatus: ((value: { files: Array<{ path: string; status: string }>; counts: { untracked: number; modified: number; deleted: number; renamed: number; staged: number; total: number } }) => void) | null = null; + (window as any).electronAPI.git.getStatus = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([]); + + try { + render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect((window as any).electronAPI.git.getStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(8000); + }); + + expect((window as any).electronAPI.git.getStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveStatus?.({ + files: [{ path: 'posts/first.md', status: 'modified' }], + counts: { untracked: 0, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 1 }, + }); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + }); + + expect((window as any).electronAPI.git.getStatus).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('applies incremental open-changes updates while preserving unchanged item identity and scroll position', async () => { + vi.useFakeTimers(); + + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: true, + }); + + (window as any).electronAPI.git.getStatus = vi + .fn() + .mockResolvedValueOnce({ + files: [ + { path: 'posts/first.md', status: 'modified' }, + { path: 'posts/second.md', status: 'untracked' }, + ], + counts: { untracked: 1, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 2 }, + }) + .mockResolvedValueOnce({ + files: [ + { path: 'posts/first.md', status: 'modified' }, + { path: 'posts/third.md', status: 'deleted' }, + ], + counts: { untracked: 0, modified: 1, deleted: 1, renamed: 0, staged: 0, total: 2 }, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([]); + + try { + render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const firstBefore = screen.getByRole('button', { name: /posts\/first\.md/i }); + const list = screen.getByRole('list', { name: /open changes/i }); + list.scrollTop = 120; + + await act(async () => { + vi.advanceTimersByTime(2000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByRole('button', { name: /posts\/third\.md/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /posts\/second\.md/i })).not.toBeInTheDocument(); + const firstAfter = screen.getByRole('button', { name: /posts\/first\.md/i }); + expect(firstAfter).toBe(firstBefore); + expect(list.scrollTop).toBe(120); + } finally { + vi.useRealTimers(); + } + }); });