454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useAppStore } from '../../store';
|
|
import type { GitInitProgress, GitHistoryEntry } from '../../../main/shared/electronApi';
|
|
import './GitSidebar.css';
|
|
import '../Sidebar/Sidebar.css';
|
|
|
|
export const GitSidebar: React.FC = () => {
|
|
const { activeProject, openTab, tabs, closeTab } = useAppStore();
|
|
const [projectPath, setProjectPath] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [initializing, setInitializing] = useState(false);
|
|
const [statusLoading, setStatusLoading] = useState(false);
|
|
const [actionLoading, setActionLoading] = useState<'fetch' | 'pull' | 'push' | 'commit' | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [errorGuidance, setErrorGuidance] = useState<string[]>([]);
|
|
const [isRepo, setIsRepo] = 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 [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 getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
|
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
|
|
|
|
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'commit'): string => {
|
|
if (action === 'push') {
|
|
return 'Pushing commits to remote... this can take a while for large uploads.';
|
|
}
|
|
if (action === 'fetch') {
|
|
return 'Fetching remote updates...';
|
|
}
|
|
if (action === 'pull') {
|
|
return 'Pulling latest changes...';
|
|
}
|
|
return 'Creating commit...';
|
|
};
|
|
|
|
const openDiffTab = useCallback(
|
|
(filePath: string, isTransient: boolean) => {
|
|
openTab({
|
|
type: 'git-diff',
|
|
id: getDiffTabId(filePath),
|
|
isTransient,
|
|
});
|
|
},
|
|
[openTab],
|
|
);
|
|
|
|
const openCommitDiffTab = useCallback(
|
|
(commitHash: string, isTransient: boolean) => {
|
|
openTab({
|
|
type: 'git-diff',
|
|
id: getCommitDiffTabId(commitHash),
|
|
isTransient,
|
|
});
|
|
},
|
|
[openTab],
|
|
);
|
|
|
|
const resolveProjectPath = useCallback(async (): Promise<string | null> => {
|
|
if (!activeProject) {
|
|
return null;
|
|
}
|
|
|
|
if (activeProject.dataPath) {
|
|
return activeProject.dataPath;
|
|
}
|
|
|
|
return window.electronAPI.app.getDefaultProjectPath(activeProject.id);
|
|
}, [activeProject]);
|
|
|
|
const loadRepoState = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setErrorGuidance([]);
|
|
|
|
try {
|
|
const availability = await window.electronAPI.git.checkAvailability();
|
|
if (!availability.gitFound) {
|
|
setError('Git executable not found. Please install Git and restart the app.');
|
|
setIsRepo(false);
|
|
return;
|
|
}
|
|
|
|
const resolvedProjectPath = await resolveProjectPath();
|
|
setProjectPath(resolvedProjectPath);
|
|
|
|
if (!resolvedProjectPath) {
|
|
setError('No active project selected.');
|
|
setIsRepo(false);
|
|
return;
|
|
}
|
|
|
|
const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath);
|
|
setIsRepo(repoState.isRepo);
|
|
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);
|
|
}
|
|
} else {
|
|
setStatusFiles([]);
|
|
setHistoryEntries([]);
|
|
}
|
|
} catch {
|
|
setError('Unable to load repository status.');
|
|
setIsRepo(false);
|
|
setStatusFiles([]);
|
|
setHistoryEntries([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [resolveProjectPath]);
|
|
|
|
useEffect(() => {
|
|
void loadRepoState();
|
|
}, [loadRepoState]);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = window.electronAPI.git.onInitProgress((progress) => {
|
|
setInitProgress(progress);
|
|
setInitTranscript((previous) => [...previous, progress].slice(-12));
|
|
if (progress.phase === 'failed') {
|
|
setIsTranscriptExpanded(true);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
};
|
|
}, []);
|
|
|
|
const handleInitialize = async () => {
|
|
if (!projectPath) {
|
|
return;
|
|
}
|
|
|
|
setInitializing(true);
|
|
setError(null);
|
|
setInitTranscript([]);
|
|
setInitProgress({
|
|
phase: 'initializing-repo',
|
|
progress: 0,
|
|
message: 'Preparing repository initialization...',
|
|
});
|
|
|
|
try {
|
|
const normalizedRemoteUrl = remoteUrlInputRef.current?.value.trim() || '';
|
|
const result = normalizedRemoteUrl
|
|
? await window.electronAPI.git.init(projectPath, normalizedRemoteUrl)
|
|
: await window.electronAPI.git.init(projectPath);
|
|
if (!result.success) {
|
|
setError(result.error || 'Failed to initialize git repository.');
|
|
return;
|
|
}
|
|
|
|
await loadRepoState();
|
|
} catch {
|
|
setError('Failed to initialize git repository.');
|
|
} finally {
|
|
setInitializing(false);
|
|
}
|
|
};
|
|
|
|
const handleRepoAction = async (action: 'fetch' | 'pull' | 'push') => {
|
|
if (actionLoading) {
|
|
return;
|
|
}
|
|
|
|
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
|
if (!effectiveProjectPath) {
|
|
setError('No active project selected.');
|
|
return;
|
|
}
|
|
if (!projectPath) {
|
|
setProjectPath(effectiveProjectPath);
|
|
}
|
|
|
|
setActionLoading(action);
|
|
setError(null);
|
|
setErrorGuidance([]);
|
|
try {
|
|
const result =
|
|
action === 'fetch'
|
|
? await window.electronAPI.git.fetch(effectiveProjectPath)
|
|
: action === 'pull'
|
|
? await window.electronAPI.git.pull(effectiveProjectPath)
|
|
: await window.electronAPI.git.push(effectiveProjectPath);
|
|
if (!result.success) {
|
|
setError(result.error || `Failed to ${action}.`);
|
|
setErrorGuidance(result.guidance || []);
|
|
return;
|
|
}
|
|
await loadRepoState();
|
|
} catch {
|
|
setError(`Failed to ${action}.`);
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleCommit = async () => {
|
|
if (actionLoading) {
|
|
return;
|
|
}
|
|
|
|
const effectiveProjectPath = projectPath ?? (await resolveProjectPath());
|
|
if (!effectiveProjectPath) {
|
|
setError('No active project selected.');
|
|
return;
|
|
}
|
|
if (!projectPath) {
|
|
setProjectPath(effectiveProjectPath);
|
|
}
|
|
|
|
setActionLoading('commit');
|
|
setError(null);
|
|
setErrorGuidance([]);
|
|
try {
|
|
const messageToCommit = commitMessageInputRef.current?.value ?? commitMessage;
|
|
const result = await window.electronAPI.git.commitAll(effectiveProjectPath, messageToCommit);
|
|
if (!result.success) {
|
|
setError(result.error || 'Failed to commit changes.');
|
|
setErrorGuidance(result.guidance || []);
|
|
return;
|
|
}
|
|
|
|
tabs
|
|
.filter((tab) => tab.type === 'git-diff')
|
|
.forEach((tab) => closeTab(tab.id));
|
|
|
|
setCommitMessage('');
|
|
await loadRepoState();
|
|
} catch {
|
|
setError('Failed to commit changes.');
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="git-sidebar">
|
|
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
|
<div className="git-sidebar-empty">Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const transcriptSection = initTranscript.length > 0 ? (
|
|
<div className="git-sidebar-transcript">
|
|
<button
|
|
type="button"
|
|
className="git-sidebar-transcript-toggle"
|
|
onClick={() => setIsTranscriptExpanded((previous) => !previous)}
|
|
aria-expanded={isTranscriptExpanded}
|
|
>
|
|
Initialization transcript
|
|
</button>
|
|
{isTranscriptExpanded && (
|
|
<ul className="git-sidebar-transcript-list">
|
|
{initTranscript.map((entry, index) => (
|
|
<li key={`${entry.phase}-${entry.progress}-${index}`}>
|
|
{entry.progress}% — {entry.message.toLowerCase().replace(/\.+$/, '')}
|
|
{entry.detail ? ` (${entry.detail})` : ''}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
if (isRepo) {
|
|
return (
|
|
<div className="git-sidebar">
|
|
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
|
<div className="git-sidebar-content">
|
|
<div className="git-sidebar-actions" role="group" aria-label="Repository actions">
|
|
<button
|
|
type="button"
|
|
className="git-sidebar-button"
|
|
onClick={() => handleRepoAction('fetch')}
|
|
disabled={actionLoading !== null}
|
|
>
|
|
{actionLoading === 'fetch' ? 'Fetching...' : 'Fetch'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="git-sidebar-button"
|
|
onClick={() => handleRepoAction('pull')}
|
|
disabled={actionLoading !== null}
|
|
>
|
|
{actionLoading === 'pull' ? 'Pulling...' : 'Pull'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="git-sidebar-button"
|
|
onClick={() => handleRepoAction('push')}
|
|
disabled={actionLoading !== null}
|
|
>
|
|
{actionLoading === 'push' ? 'Pushing...' : 'Push'}
|
|
</button>
|
|
</div>
|
|
{actionLoading && (
|
|
<div className="git-sidebar-empty-state git-sidebar-progress" role="status">
|
|
{getActionProgressMessage(actionLoading)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="git-sidebar-section">
|
|
<div className="sidebar-section-title">Open Changes ({statusFiles.length})</div>
|
|
|
|
<div className="git-sidebar-commit-row">
|
|
<input
|
|
ref={commitMessageInputRef}
|
|
className="git-sidebar-input"
|
|
type="text"
|
|
placeholder="Commit message"
|
|
value={commitMessage}
|
|
onChange={(event) => setCommitMessage(event.target.value)}
|
|
disabled={actionLoading !== null}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="git-sidebar-button"
|
|
onClick={handleCommit}
|
|
disabled={actionLoading !== null}
|
|
>
|
|
{actionLoading === 'commit' ? 'Committing...' : 'Commit'}
|
|
</button>
|
|
</div>
|
|
|
|
{statusLoading ? (
|
|
<div className="git-sidebar-empty-state">Loading changes...</div>
|
|
) : statusFiles.length === 0 ? (
|
|
<div className="git-sidebar-empty-state">No changes</div>
|
|
) : (
|
|
<div className="git-sidebar-file-list" role="list" aria-label="Open Changes">
|
|
{statusFiles.map((file) => (
|
|
<button
|
|
key={file.path}
|
|
type="button"
|
|
className="git-sidebar-file-item"
|
|
onClick={() => openDiffTab(file.path, true)}
|
|
onDoubleClick={() => openDiffTab(file.path, false)}
|
|
title={`${file.status}: ${file.path}`}
|
|
>
|
|
<span className="git-sidebar-file-path">{file.path}</span>
|
|
<span className="git-sidebar-file-status">{file.status}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="git-sidebar-section git-sidebar-history">
|
|
<div className="sidebar-section-title">Version History ({historyEntries.length})</div>
|
|
{historyLoading ? (
|
|
<div className="git-sidebar-empty-state">Loading history...</div>
|
|
) : historyEntries.length === 0 ? (
|
|
<div className="git-sidebar-empty-state">No commits yet</div>
|
|
) : (
|
|
<div className="git-sidebar-history-list" role="list" aria-label="Version History">
|
|
{historyEntries.map((entry) => (
|
|
<button
|
|
key={entry.hash}
|
|
type="button"
|
|
className="git-sidebar-history-item"
|
|
onClick={() => openCommitDiffTab(entry.hash, true)}
|
|
onDoubleClick={() => openCommitDiffTab(entry.hash, false)}
|
|
title={`${entry.shortHash}: ${entry.subject}`}
|
|
>
|
|
<div className="git-sidebar-history-subject">{entry.subject}</div>
|
|
<div className="git-sidebar-history-meta">
|
|
<span>{entry.shortHash}</span>
|
|
<span>{entry.author}</span>
|
|
<span>{new Date(entry.date).toLocaleDateString()}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{currentBranch && <div className="git-sidebar-empty-state">Branch: {currentBranch}</div>}
|
|
</div>
|
|
{error && (
|
|
<div className="git-sidebar-empty-state git-sidebar-error">
|
|
<div>{error}</div>
|
|
{errorGuidance.length > 0 && (
|
|
<ul className="git-sidebar-guidance-list">
|
|
{errorGuidance.map((step) => (
|
|
<li key={step}>{step}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
{transcriptSection}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="git-sidebar">
|
|
<div className="git-sidebar-header">SOURCE CONTROL</div>
|
|
<div className="git-sidebar-empty">
|
|
<div className="git-sidebar-main">
|
|
<p>This project is not a git repository.</p>
|
|
<input
|
|
ref={remoteUrlInputRef}
|
|
className="git-sidebar-input"
|
|
type="text"
|
|
placeholder="Optional remote repository URL"
|
|
disabled={initializing}
|
|
/>
|
|
{initializing && (
|
|
<p className="git-sidebar-progress">
|
|
{initProgress?.message || 'Initializing repository...'}
|
|
{typeof initProgress?.progress === 'number' ? ` (${initProgress.progress}%)` : ''}
|
|
{initProgress?.detail ? ` — ${initProgress.detail}` : ''}
|
|
</p>
|
|
)}
|
|
{error && <p className="git-sidebar-error">{error}</p>}
|
|
<button
|
|
className="git-sidebar-button"
|
|
onClick={handleInitialize}
|
|
disabled={initializing || !projectPath}
|
|
>
|
|
{initializing ? 'Initializing...' : 'Initialize Git'}
|
|
</button>
|
|
</div>
|
|
{transcriptSection}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|