feat: git log limited to last 20
This commit is contained in:
@@ -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<GitHistoryEntry[]> {
|
||||
|
||||
@@ -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<GitHistoryEntry[]>([]);
|
||||
const [historyLocalLimit, setHistoryLocalLimit] = useState(HISTORY_PAGE_SIZE);
|
||||
const [hasMoreLocalHistory, setHasMoreLocalHistory] = useState(false);
|
||||
const [remoteState, setRemoteState] = useState<GitRemoteStateDto | null>(null);
|
||||
const [remoteStateError, setRemoteStateError] = useState<string | null>(null);
|
||||
const [initProgress, setInitProgress] = useState<GitInitProgress | null>(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 (
|
||||
<div className="git-sidebar">
|
||||
@@ -572,6 +598,16 @@ export const GitSidebar: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!historyLoading && hasMoreLocalHistory && (
|
||||
<button
|
||||
type="button"
|
||||
className="git-sidebar-button"
|
||||
onClick={() => void handleLoadMoreHistory()}
|
||||
disabled={statusRefreshInFlightRef.current}
|
||||
>
|
||||
{tr('gitSidebar.action.loadMoreHistory')}
|
||||
</button>
|
||||
)}
|
||||
{currentBranch && <div className="git-sidebar-empty-state">{tr('gitSidebar.branch', { branch: currentBranch })}</div>}
|
||||
{remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
|
||||
<div className="git-sidebar-empty-state">{remoteState.localBranch} → {remoteState.upstreamBranch}</div>
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(<GitSidebar />);
|
||||
|
||||
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(<GitSidebar />);
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user