feat(FN-803): add ahead/behind commit lists to Git Manager Remotes tab
- Add backend API routes for remote commit lists (local-ahead, remote-ahead, all commits by branch) - Surface ahead/behind commit counts and expandable commit lists in Remotes tab UI - Add expand/collapse rows showing commit hash, message, author, and relative date - Style commit list rows with alternating backgrounds and monospace hashes - Add comprehensive tests for API routes and GitManagerModal component - Update README documentation for Git Manager Remotes tab
This commit is contained in:
@@ -40,6 +40,8 @@ import {
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
} from "../api";
|
||||
import {
|
||||
GitBranch as GitBranchIcon,
|
||||
@@ -1498,11 +1500,46 @@ function RemotesPanel({
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
// Fetch remotes when panel mounts
|
||||
// Ahead commits (local commits to push)
|
||||
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
|
||||
const [loadingAhead, setLoadingAhead] = useState(false);
|
||||
|
||||
// Selected remote and its recent commits
|
||||
const [selectedRemote, setSelectedRemote] = useState<string | null>(null);
|
||||
const [remoteCommits, setRemoteCommits] = useState<GitCommit[]>([]);
|
||||
const [loadingRemoteCommits, setLoadingRemoteCommits] = useState(false);
|
||||
const [remoteCommitsError, setRemoteCommitsError] = useState<string | null>(null);
|
||||
|
||||
// Fetch remotes and ahead commits when panel mounts
|
||||
useEffect(() => {
|
||||
loadRemotes();
|
||||
loadAheadCommits();
|
||||
}, []);
|
||||
|
||||
// Auto-select first remote when remotes load
|
||||
useEffect(() => {
|
||||
if (remotes.length > 0 && !selectedRemote) {
|
||||
setSelectedRemote(remotes[0].name);
|
||||
}
|
||||
}, [remotes]);
|
||||
|
||||
// Load commits for selected remote
|
||||
useEffect(() => {
|
||||
if (selectedRemote) {
|
||||
loadRemoteCommits(selectedRemote);
|
||||
} else {
|
||||
setRemoteCommits([]);
|
||||
setRemoteCommitsError(null);
|
||||
}
|
||||
}, [selectedRemote]);
|
||||
|
||||
// Clear selected remote if it was removed from the list
|
||||
useEffect(() => {
|
||||
if (selectedRemote && !remotes.find((r) => r.name === selectedRemote)) {
|
||||
setSelectedRemote(remotes.length > 0 ? remotes[0].name : null);
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
const loadRemotes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -1515,6 +1552,33 @@ function RemotesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const loadAheadCommits = async () => {
|
||||
setLoadingAhead(true);
|
||||
try {
|
||||
const commits = await fetchAheadCommits();
|
||||
setAheadCommits(commits);
|
||||
} catch {
|
||||
// Silently ignore — ahead commits are a nice-to-have
|
||||
setAheadCommits([]);
|
||||
} finally {
|
||||
setLoadingAhead(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRemoteCommits = async (remoteName: string) => {
|
||||
setLoadingRemoteCommits(true);
|
||||
setRemoteCommitsError(null);
|
||||
try {
|
||||
const commits = await fetchRemoteCommits(remoteName, undefined, 10);
|
||||
setRemoteCommits(commits);
|
||||
} catch (err: any) {
|
||||
setRemoteCommitsError(err.message || "Failed to load remote commits");
|
||||
setRemoteCommits([]);
|
||||
} finally {
|
||||
setLoadingRemoteCommits(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRemote = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newRemoteName.trim() || !newRemoteUrl.trim()) return;
|
||||
@@ -1645,6 +1709,49 @@ function RemotesPanel({
|
||||
|
||||
{/* Remote Operations (Fetch/Pull/Push) */}
|
||||
<div className="gm-remote-operations">
|
||||
{/* Commits to Push */}
|
||||
{status && status.ahead > 0 && (
|
||||
<div className="gm-commits-to-push" data-testid="commits-to-push">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<ArrowUp size={14} />
|
||||
Commits to Push ({status.ahead})
|
||||
</h5>
|
||||
</div>
|
||||
{loadingAhead ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : aheadCommits.length > 0 ? (
|
||||
<div className="gm-ahead-commits-list" data-testid="ahead-commits-list">
|
||||
{aheadCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact">
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-empty">
|
||||
No ahead commits found (may need to fetch first)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ahead/Behind indicators */}
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="gm-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
@@ -1713,7 +1820,13 @@ function RemotesPanel({
|
||||
<div className="gm-empty">No remotes configured</div>
|
||||
) : (
|
||||
remotes.map((remote) => (
|
||||
<div key={remote.name} className="gm-remote-item">
|
||||
<div
|
||||
key={remote.name}
|
||||
className={`gm-remote-item${selectedRemote === remote.name ? " selected" : ""}`}
|
||||
onClick={() => setSelectedRemote(remote.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="gm-remote-info">
|
||||
{editingRemote === `name-${remote.name}` ? (
|
||||
<div className="gm-remote-edit">
|
||||
@@ -1750,7 +1863,7 @@ function RemotesPanel({
|
||||
<span className="gm-remote-name">{remote.name}</span>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => startEditingName(remote)}
|
||||
onClick={(e) => { e.stopPropagation(); startEditingName(remote); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Rename remote"
|
||||
>
|
||||
@@ -1804,7 +1917,7 @@ function RemotesPanel({
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => startEditingUrl(remote)}
|
||||
onClick={(e) => { e.stopPropagation(); startEditingUrl(remote); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit URL"
|
||||
>
|
||||
@@ -1818,7 +1931,7 @@ function RemotesPanel({
|
||||
<div className="gm-remote-actions-inline">
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRemoveRemote(remote.name)}
|
||||
onClick={(e) => { e.stopPropagation(); handleRemoveRemote(remote.name); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Remove remote"
|
||||
>
|
||||
@@ -1834,6 +1947,53 @@ function RemotesPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Remote Commits */}
|
||||
{selectedRemote && (
|
||||
<div className="gm-remote-commits-section" data-testid="remote-commits-section">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<Radio size={14} />
|
||||
Recent commits on {selectedRemote}
|
||||
</h5>
|
||||
</div>
|
||||
{loadingRemoteCommits ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading commits...
|
||||
</div>
|
||||
) : remoteCommitsError ? (
|
||||
<div className="gm-error">
|
||||
<AlertCircle size={14} />
|
||||
{remoteCommitsError}
|
||||
</div>
|
||||
) : remoteCommits.length === 0 ? (
|
||||
<div className="gm-empty">
|
||||
No commits found on {selectedRemote}. Try fetching first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-commits-list" data-testid="remote-commits-list">
|
||||
{remoteCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact">
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRemoteResult && (
|
||||
<div className="gm-remote-result">
|
||||
{lastRemoteResult.message}
|
||||
|
||||
Reference in New Issue
Block a user