feat: phase 6 of git implementation

This commit is contained in:
2026-02-16 15:34:48 +01:00
parent 339e513a2d
commit e9743cb70f
8 changed files with 249 additions and 2 deletions

View File

@@ -69,6 +69,14 @@ export interface GitHistoryEntry {
syncStatus?: GitHistorySyncStatus;
}
export interface GitRemoteStateDto {
localBranch: string | null;
upstreamBranch: string | null;
hasUpstream: boolean;
ahead: number;
behind: number;
}
export type GitHistorySyncStatus = 'both' | 'local-only' | 'remote-only';
export type GitInitPhase =
@@ -711,6 +719,26 @@ export class GitEngine {
});
}
async getRemoteState(projectPath: string): Promise<GitRemoteStateDto> {
const git = simpleGit(projectPath);
const status = await git.status();
const localBranch = typeof status.current === 'string' && status.current.trim().length > 0
? status.current
: null;
const upstreamBranch = typeof status.tracking === 'string' && status.tracking.trim().length > 0
? status.tracking
: null;
return {
localBranch,
upstreamBranch,
hasUpstream: Boolean(upstreamBranch),
ahead: typeof status.ahead === 'number' ? status.ahead : Number(status.ahead ?? 0),
behind: typeof status.behind === 'number' ? status.behind : Number(status.behind ?? 0),
};
}
async fetch(projectPath: string): Promise<GitActionResult> {
const git = this.createNonInteractiveGit(projectPath);
try {

View File

@@ -68,6 +68,11 @@ export function registerIpcHandlers(): void {
return engine.getHistory(projectPath, limit);
});
safeHandle('git:remoteState', async (_, projectPath: string) => {
const engine = getGitEngine();
return engine.getRemoteState(projectPath);
});
safeHandle('git:fetch', async (_, projectPath: string) => {
const engine = getGitEngine();
return engine.fetch(projectPath);

View File

@@ -14,6 +14,7 @@ export const electronAPI: ElectronAPI = {
getDiffContent: (projectPath: string, filePath: string) => ipcRenderer.invoke('git:diffContent', projectPath, filePath),
getCommitDiffContent: (projectPath: string, commitHash: string) => ipcRenderer.invoke('git:commitDiffContent', projectPath, commitHash),
getHistory: (projectPath: string, limit?: number) => ipcRenderer.invoke('git:history', projectPath, limit),
getRemoteState: (projectPath: string) => ipcRenderer.invoke('git:remoteState', projectPath),
fetch: (projectPath: string) => ipcRenderer.invoke('git:fetch', projectPath),
pull: (projectPath: string) => ipcRenderer.invoke('git:pull', projectPath),
push: (projectPath: string) => ipcRenderer.invoke('git:push', projectPath),

View File

@@ -271,6 +271,14 @@ export interface GitHistoryEntry {
syncStatus?: GitHistorySyncStatus;
}
export interface GitRemoteStateDto {
localBranch: string | null;
upstreamBranch: string | null;
hasUpstream: boolean;
ahead: number;
behind: number;
}
export type GitInitPhase =
| 'checking-git'
| 'initializing-repo'
@@ -395,6 +403,7 @@ export interface ElectronAPI {
getDiffContent: (projectPath: string, filePath: string) => Promise<GitDiffContentDto>;
getCommitDiffContent: (projectPath: string, commitHash: string) => Promise<GitCommitDiffContentDto>;
getHistory: (projectPath: string, limit?: number) => Promise<GitHistoryEntry[]>;
getRemoteState: (projectPath: string) => Promise<GitRemoteStateDto>;
fetch: (projectPath: string) => Promise<GitActionResult>;
pull: (projectPath: string) => Promise<GitActionResult>;
push: (projectPath: string) => Promise<GitActionResult>;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/electronApi';
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
import './GitSidebar.css';
import '../Sidebar/Sidebar.css';
@@ -36,17 +36,21 @@ export const GitSidebar: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [errorGuidance, setErrorGuidance] = useState<string[]>([]);
const [isRepo, setIsRepo] = useState(false);
const [hasRemote, setHasRemote] = useState(false);
const [currentBranch, setCurrentBranch] = useState<string | null>(null);
const [statusFiles, setStatusFiles] = useState<Array<{ path: string; status: string }>>([]);
const [commitMessage, setCommitMessage] = useState('');
const [historyLoading, setHistoryLoading] = useState(false);
const [historyEntries, setHistoryEntries] = useState<GitHistoryEntry[]>([]);
const [remoteState, setRemoteState] = useState<GitRemoteStateDto | null>(null);
const [remoteStateError, setRemoteStateError] = useState<string | null>(null);
const [initProgress, setInitProgress] = useState<GitInitProgress | null>(null);
const [initTranscript, setInitTranscript] = useState<GitInitProgress[]>([]);
const [isTranscriptExpanded, setIsTranscriptExpanded] = useState(false);
const remoteUrlInputRef = useRef<HTMLInputElement | null>(null);
const commitMessageInputRef = useRef<HTMLInputElement | null>(null);
const statusRefreshInFlightRef = useRef(false);
const remoteRefreshInFlightRef = useRef(false);
const refreshRepoDetails = useCallback(
async (targetProjectPath: string, options?: { background?: boolean }) => {
@@ -80,6 +84,45 @@ export const GitSidebar: React.FC = () => {
[],
);
const refreshRemoteState = useCallback(
async (targetProjectPath: string, options?: { background?: boolean; fetchFirst?: boolean }) => {
if (remoteRefreshInFlightRef.current) {
return;
}
const background = options?.background ?? false;
const fetchFirst = options?.fetchFirst ?? false;
remoteRefreshInFlightRef.current = true;
try {
if (fetchFirst) {
const fetchResult = await window.electronAPI.git.fetch(targetProjectPath);
if (!fetchResult.success) {
const message = fetchResult.error || 'Failed to fetch remote updates.';
setRemoteStateError(message);
if (!background) {
setError(message);
}
return;
}
}
const nextRemoteState = await window.electronAPI.git.getRemoteState(targetProjectPath);
setRemoteState(nextRemoteState);
setRemoteStateError(null);
} catch {
const message = 'Unable to refresh remote tracking state.';
setRemoteStateError(message);
if (!background) {
setError(message);
}
} finally {
remoteRefreshInFlightRef.current = false;
}
},
[],
);
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
@@ -167,23 +210,35 @@ export const GitSidebar: React.FC = () => {
const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath);
setIsRepo(repoState.isRepo);
setHasRemote(repoState.hasRemote);
setCurrentBranch(repoState.currentBranch || null);
if (repoState.isRepo) {
await refreshRepoDetails(resolvedProjectPath);
if (repoState.hasRemote) {
await refreshRemoteState(resolvedProjectPath);
} else {
setRemoteState(null);
setRemoteStateError(null);
}
} else {
setStatusFiles([]);
setHistoryEntries([]);
setRemoteState(null);
setRemoteStateError(null);
}
} catch {
setError('Unable to load repository status.');
setIsRepo(false);
setHasRemote(false);
setStatusFiles([]);
setHistoryEntries([]);
setRemoteState(null);
setRemoteStateError(null);
} finally {
setLoading(false);
}
}, [refreshRepoDetails, resolveProjectPath]);
}, [refreshRemoteState, refreshRepoDetails, resolveProjectPath]);
useEffect(() => {
void loadRepoState();
@@ -217,6 +272,20 @@ export const GitSidebar: React.FC = () => {
};
}, [isRepo, projectPath, refreshRepoDetails]);
useEffect(() => {
if (!isRepo || !hasRemote || !projectPath) {
return;
}
const intervalId = globalThis.setInterval(() => {
void refreshRemoteState(projectPath, { background: true, fetchFirst: true });
}, 30000);
return () => {
globalThis.clearInterval(intervalId);
};
}, [hasRemote, isRepo, projectPath, refreshRemoteState]);
const handleInitialize = async () => {
if (!projectPath) {
return;
@@ -508,6 +577,13 @@ export const GitSidebar: React.FC = () => {
</div>
)}
{currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>}
{remoteState?.hasUpstream && remoteState.localBranch && remoteState.upstreamBranch && (
<div className="git-sidebar-empty-state">{remoteState.localBranch} {remoteState.upstreamBranch}</div>
)}
{remoteState?.hasUpstream && (
<div className="git-sidebar-empty-state">ahead {remoteState.ahead} / behind {remoteState.behind}</div>
)}
{remoteStateError && <div className="git-sidebar-empty-state git-sidebar-error">{remoteStateError}</div>}
</div>
{error && (
<div className="git-sidebar-empty-state git-sidebar-error">