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}
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("../../api", async () => {
|
||||
removeGitRemote: vi.fn(),
|
||||
renameGitRemote: vi.fn(),
|
||||
updateGitRemoteUrl: vi.fn(),
|
||||
fetchAheadCommits: vi.fn(),
|
||||
fetchRemoteCommits: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,6 +65,8 @@ import {
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
} from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -169,6 +173,8 @@ describe("GitManagerModal", () => {
|
||||
(removeGitRemote as any).mockResolvedValue(undefined);
|
||||
(renameGitRemote as any).mockResolvedValue(undefined);
|
||||
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
|
||||
(fetchAheadCommits as any).mockResolvedValue([]);
|
||||
(fetchRemoteCommits as any).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
// ── Basic Rendering ─────────────────────────────────────────
|
||||
@@ -1086,6 +1092,152 @@ describe("GitManagerModal", () => {
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Commits to Push Section ───────────────────────────────────
|
||||
|
||||
it("shows commits to push section when ahead > 0", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 2,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchAheadCommits as any).mockResolvedValue([
|
||||
{ hash: "aaa1111", shortHash: "aaa1", message: "First ahead commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
{ hash: "bbb2222", shortHash: "bbb2", message: "Second ahead commit", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
|
||||
expect(screen.getByText("First ahead commit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second ahead commit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when ahead > 0 but no ahead commits returned", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchAheadCommits as any).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No ahead commits found/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show commits to push when ahead === 0", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("commits-to-push")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Remote Selection & Recent Commits ──────────────────────────
|
||||
|
||||
it("shows recent commits section for auto-selected remote", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([
|
||||
{ hash: "rc1", shortHash: "rc1", message: "Remote commit 1", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("remote-commits-section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remote commit 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when remote has no commits", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No commits found on origin/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when remote commits fetch fails", async () => {
|
||||
(fetchRemoteCommits as any).mockRejectedValue(new Error("Network failure"));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Network failure")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show remote commits section when no remotes configured", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No remotes configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("remote-commits-section")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights selected remote in the list", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
|
||||
{ name: "upstream", fetchUrl: "https://github.com/c/d.git", pushUrl: "https://github.com/c/d.git" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
// First remote (origin) should be auto-selected
|
||||
await waitFor(() => {
|
||||
const originItem = screen.getByText("origin").closest(".gm-remote-item");
|
||||
expect(originItem?.classList.contains("selected")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh Button ─────────────────────────────────────────
|
||||
|
||||
it("refreshes data when refresh button is clicked", async () => {
|
||||
|
||||
Reference in New Issue
Block a user