feat: phase 5 of git implementation
This commit is contained in:
@@ -4,6 +4,28 @@ import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/elec
|
|||||||
import './GitSidebar.css';
|
import './GitSidebar.css';
|
||||||
import '../Sidebar/Sidebar.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 = () => {
|
export const GitSidebar: React.FC = () => {
|
||||||
const { activeProject, openTab, tabs, closeTab } = useAppStore();
|
const { activeProject, openTab, tabs, closeTab } = useAppStore();
|
||||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||||
@@ -24,6 +46,39 @@ export const GitSidebar: React.FC = () => {
|
|||||||
const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false);
|
const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false);
|
||||||
const remoteUrlInputRef = useRef<HTMLInputElement | null>(null);
|
const remoteUrlInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const commitMessageInputRef = useRef<HTMLInputElement | null>(null);
|
const commitMessageInputRef = useRef<HTMLInputElement | null>(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 getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
||||||
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
|
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
|
||||||
@@ -115,19 +170,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
setCurrentBranch(repoState.currentBranch || null);
|
setCurrentBranch(repoState.currentBranch || null);
|
||||||
|
|
||||||
if (repoState.isRepo) {
|
if (repoState.isRepo) {
|
||||||
setStatusLoading(true);
|
await refreshRepoDetails(resolvedProjectPath);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setStatusFiles([]);
|
setStatusFiles([]);
|
||||||
setHistoryEntries([]);
|
setHistoryEntries([]);
|
||||||
@@ -140,7 +183,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [resolveProjectPath]);
|
}, [refreshRepoDetails, resolveProjectPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadRepoState();
|
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 () => {
|
const handleInitialize = async () => {
|
||||||
if (!projectPath) {
|
if (!projectPath) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -636,4 +636,114 @@ describe('GitSidebar', () => {
|
|||||||
resolvePush?.({ success: true });
|
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(<GitSidebar />);
|
||||||
|
|
||||||
|
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(<GitSidebar />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user