feat: git badge for waiting commits
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import {
|
import {
|
||||||
@@ -65,7 +65,71 @@ const GitIcon = () => (
|
|||||||
|
|
||||||
export const ActivityBar: React.FC = () => {
|
export const ActivityBar: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { activeView, setActiveView, sidebarVisible, toggleSidebar, tabs, activeTabId } = useAppStore();
|
const { activeView, setActiveView, sidebarVisible, toggleSidebar, tabs, activeTabId, activeProject } = useAppStore();
|
||||||
|
const [pendingPullCount, setPendingPullCount] = useState(0);
|
||||||
|
const gitRefreshInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
const refreshPendingPullCount = useCallback(async () => {
|
||||||
|
if (gitRefreshInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
setPendingPullCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeProject) {
|
||||||
|
setPendingPullCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitApi = window.electronAPI?.git;
|
||||||
|
if (!gitApi?.getRepoState || !gitApi?.fetch || !gitApi?.getRemoteState) {
|
||||||
|
setPendingPullCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gitRefreshInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
const targetProjectPath = activeProject.dataPath || (await window.electronAPI?.app.getDefaultProjectPath(activeProject.id));
|
||||||
|
if (!targetProjectPath) {
|
||||||
|
setPendingPullCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoState = await gitApi.getRepoState(targetProjectPath);
|
||||||
|
if (!repoState.isRepo || !repoState.hasRemote) {
|
||||||
|
setPendingPullCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchResult = await gitApi.fetch(targetProjectPath);
|
||||||
|
if (!fetchResult.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteState = await gitApi.getRemoteState(targetProjectPath);
|
||||||
|
setPendingPullCount(Math.max(0, remoteState.behind));
|
||||||
|
} catch {
|
||||||
|
setPendingPullCount(0);
|
||||||
|
} finally {
|
||||||
|
gitRefreshInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
}, [activeProject]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshPendingPullCount();
|
||||||
|
|
||||||
|
const intervalId = globalThis.setInterval(() => {
|
||||||
|
void refreshPendingPullCount();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
globalThis.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [refreshPendingPullCount]);
|
||||||
|
|
||||||
const snapshot: ActivitySnapshot = {
|
const snapshot: ActivitySnapshot = {
|
||||||
activeView,
|
activeView,
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
@@ -136,6 +200,7 @@ export const ActivityBar: React.FC = () => {
|
|||||||
title={getTitle('git')}
|
title={getTitle('git')}
|
||||||
>
|
>
|
||||||
<GitIcon />
|
<GitIcon />
|
||||||
|
{pendingPullCount > 0 ? <span className="activity-bar-badge">{pendingPullCount > 99 ? '99+' : pendingPullCount}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${isActivityActive(snapshot, 'settings') ? 'active' : ''}`}
|
className={`activity-bar-item ${isActivityActive(snapshot, 'settings') ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
remoteRefreshInFlightRef.current = true;
|
remoteRefreshInFlightRef.current = true;
|
||||||
try {
|
try {
|
||||||
|
if (fetchFirst && typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (fetchFirst) {
|
if (fetchFirst) {
|
||||||
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
|
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
|
||||||
if (!fetchResult.success) {
|
if (!fetchResult.success) {
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { ActivityBar } from '../../../src/renderer/components/ActivityBar/ActivityBar';
|
import { ActivityBar } from '../../../src/renderer/components/ActivityBar/ActivityBar';
|
||||||
import { I18nProvider } from '../../../src/renderer/i18n';
|
import { I18nProvider } from '../../../src/renderer/i18n';
|
||||||
import { useAppStore } from '../../../src/renderer/store';
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
describe('ActivityBar tags behavior', () => {
|
describe('ActivityBar tags behavior', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
app: {
|
||||||
|
...(window as any).electronAPI?.app,
|
||||||
|
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
...(window as any).electronAPI?.git,
|
||||||
|
getRepoState: vi.fn().mockResolvedValue({
|
||||||
|
isRepo: true,
|
||||||
|
rootPath: '/repo/path',
|
||||||
|
currentBranch: 'main',
|
||||||
|
hasRemote: true,
|
||||||
|
}),
|
||||||
|
fetch: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
getRemoteState: vi.fn().mockResolvedValue({
|
||||||
|
localBranch: 'main',
|
||||||
|
upstreamBranch: 'origin/main',
|
||||||
|
hasUpstream: true,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
activeProject: {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Test Project',
|
||||||
|
slug: 'test-project',
|
||||||
|
dataPath: '/repo/path',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
activeView: 'posts',
|
activeView: 'posts',
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
tabs: [],
|
tabs: [],
|
||||||
@@ -15,6 +49,10 @@ describe('ActivityBar tags behavior', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('switches sidebar to tags view when clicking Tags', () => {
|
it('switches sidebar to tags view when clicking Tags', () => {
|
||||||
render(
|
render(
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
@@ -95,4 +133,69 @@ describe('ActivityBar tags behavior', () => {
|
|||||||
|
|
||||||
expect(screen.getByTitle('Tags (click again to toggle sidebar)')).toBeInTheDocument();
|
expect(screen.getByTitle('Tags (click again to toggle sidebar)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a badge on the git activity icon when remote commits are available to pull', async () => {
|
||||||
|
(window as any).electronAPI.git.getRemoteState = vi.fn().mockResolvedValue({
|
||||||
|
localBranch: 'main',
|
||||||
|
upstreamBranch: 'origin/main',
|
||||||
|
hasUpstream: true,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<ActivityBar />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('4')).toHaveClass('activity-bar-badge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls remote git status on a regular interval', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<ActivityBar />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips periodic git fetch polling while offline', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(globalThis.navigator, 'onLine', {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider>
|
||||||
|
<ActivityBar />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(60000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(0);
|
||||||
|
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -750,6 +750,50 @@ describe('GitSidebar', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips remote fetch polling while offline', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
Object.defineProperty(globalThis.navigator, 'onLine', {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||||
|
isRepo: true,
|
||||||
|
rootPath: '/repo/path',
|
||||||
|
currentBranch: 'main',
|
||||||
|
hasRemote: true,
|
||||||
|
});
|
||||||
|
(window as any).electronAPI.git.getRemoteState = vi.fn().mockResolvedValue({
|
||||||
|
localBranch: 'main',
|
||||||
|
upstreamBranch: 'origin/main',
|
||||||
|
hasUpstream: true,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
});
|
||||||
|
(window as any).electronAPI.git.fetch = vi.fn().mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<GitSidebar />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(30000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(0);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('polls repository status on an interval and prevents overlapping in-flight requests', async () => {
|
it('polls repository status on an interval and prevents overlapping in-flight requests', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user