feat(FN-858): add click-to-diff in Remotes tab commit lists
- Add clickable commit rows in GitManagerModal Remotes tab that open inline diff view - Add diff viewer with syntax highlighting and file-level stats - Add CSS styles for diff view, commit detail, and click-to-diff interactions - Add comprehensive tests for click-to-diff behavior in Remotes tab - Clean up unused types and store methods (diffFile, getCommitDiff, DiffOptions) - Update dashboard README with inline commit diff feature documentation
This commit is contained in:
@@ -203,6 +203,7 @@ The Git Manager provides comprehensive repository visualization and management d
|
||||
- View operation results and error states
|
||||
- **Commits to Push**: See which local commits are ahead of the upstream tracking branch (pending push) with short hash, message, author, and relative date. The list stays synchronized with the ahead count — it refreshes automatically after fetch, pull, push, and manual refresh operations, and clears when the ahead count drops to zero (e.g., after a successful push).
|
||||
- **Remote Commit Inspection**: Click any remote to view its recent commit history — useful for checking what's on a remote without switching to the terminal
|
||||
- **Inline Commit Diffs**: Click any commit in the "Commits to Push" or "Recent commits on {remote}" lists to expand an inline diff viewer showing file changes (stat + patch). Click the same commit again to collapse. Only one diff is expanded per list at a time.
|
||||
- Auto-selects the first remote and loads its recent commits on mount
|
||||
|
||||
### File Browser
|
||||
|
||||
@@ -1510,6 +1510,14 @@ function RemotesPanel({
|
||||
const [loadingRemoteCommits, setLoadingRemoteCommits] = useState(false);
|
||||
const [remoteCommitsError, setRemoteCommitsError] = useState<string | null>(null);
|
||||
|
||||
// Inline commit diff expansion (one per list context)
|
||||
const [expandedAheadCommit, setExpandedAheadCommit] = useState<string | null>(null);
|
||||
const [aheadCommitDiff, setAheadCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||
const [loadingAheadCommitDiff, setLoadingAheadCommitDiff] = useState(false);
|
||||
const [expandedRemoteCommit, setExpandedRemoteCommit] = useState<string | null>(null);
|
||||
const [remoteCommitDiff, setRemoteCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||
const [loadingRemoteCommitDiff, setLoadingRemoteCommitDiff] = useState(false);
|
||||
|
||||
// Fetch remotes when panel mounts
|
||||
useEffect(() => {
|
||||
loadRemotes();
|
||||
@@ -1658,6 +1666,47 @@ function RemotesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompactCommitClick = useCallback(async (
|
||||
hash: string,
|
||||
listType: "ahead" | "remote",
|
||||
) => {
|
||||
if (listType === "ahead") {
|
||||
if (expandedAheadCommit === hash) {
|
||||
setExpandedAheadCommit(null);
|
||||
setAheadCommitDiff(null);
|
||||
return;
|
||||
}
|
||||
setExpandedAheadCommit(hash);
|
||||
setAheadCommitDiff(null);
|
||||
setLoadingAheadCommitDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash);
|
||||
setAheadCommitDiff(diff);
|
||||
} catch {
|
||||
setAheadCommitDiff(null);
|
||||
} finally {
|
||||
setLoadingAheadCommitDiff(false);
|
||||
}
|
||||
} else {
|
||||
if (expandedRemoteCommit === hash) {
|
||||
setExpandedRemoteCommit(null);
|
||||
setRemoteCommitDiff(null);
|
||||
return;
|
||||
}
|
||||
setExpandedRemoteCommit(hash);
|
||||
setRemoteCommitDiff(null);
|
||||
setLoadingRemoteCommitDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash);
|
||||
setRemoteCommitDiff(diff);
|
||||
} catch {
|
||||
setRemoteCommitDiff(null);
|
||||
} finally {
|
||||
setLoadingRemoteCommitDiff(false);
|
||||
}
|
||||
}
|
||||
}, [expandedAheadCommit, expandedRemoteCommit]);
|
||||
|
||||
const startEditingUrl = (remote: GitRemoteDetailed) => {
|
||||
setEditingRemote(`url-${remote.name}`);
|
||||
setEditUrlValue(remote.pushUrl || remote.fetchUrl);
|
||||
@@ -1737,20 +1786,48 @@ function RemotesPanel({
|
||||
) : 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>
|
||||
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
|
||||
<div
|
||||
className="gm-commit-item-compact gm-commit-clickable"
|
||||
onClick={() => handleCompactCommitClick(commit.hash, "ahead")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Click to view diff"
|
||||
>
|
||||
<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>
|
||||
<span className="gm-commit-expand-icon">
|
||||
{expandedAheadCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
{expandedAheadCommit === commit.hash && (
|
||||
<div className="gm-commit-diff gm-commit-diff-compact">
|
||||
{loadingAheadCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : aheadCommitDiff ? (
|
||||
<>
|
||||
{aheadCommitDiff.stat && <pre className="gm-diff-stat">{aheadCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{aheadCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1984,20 +2061,48 @@ function RemotesPanel({
|
||||
) : (
|
||||
<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>
|
||||
<div key={commit.hash} className="gm-commit-item-compact-wrapper">
|
||||
<div
|
||||
className="gm-commit-item-compact gm-commit-clickable"
|
||||
onClick={() => handleCompactCommitClick(commit.hash, "remote")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Click to view diff"
|
||||
>
|
||||
<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>
|
||||
<span className="gm-commit-expand-icon">
|
||||
{expandedRemoteCommit === commit.hash ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</div>
|
||||
{expandedRemoteCommit === commit.hash && (
|
||||
<div className="gm-commit-diff gm-commit-diff-compact">
|
||||
{loadingRemoteCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : remoteCommitDiff ? (
|
||||
<>
|
||||
{remoteCommitDiff.stat && <pre className="gm-diff-stat">{remoteCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{remoteCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1376,4 +1376,266 @@ describe("GitManagerModal", () => {
|
||||
expect(fetchGitStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remotes Tab: Click-to-Diff for Commits to Push ────────────
|
||||
|
||||
it("expands diff when clicking a commit in Commits to Push", 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: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockResolvedValue({
|
||||
stat: " file.ts | 3 ++-",
|
||||
patch: "diff --git a/file.ts b/file.ts\n-old\n+new",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
// Wait for ahead commits to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
|
||||
expect(screen.getByText("First ahead commit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the commit to expand diff
|
||||
await user.click(screen.getByText("First ahead commit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("aaa1111");
|
||||
});
|
||||
|
||||
// Diff content should be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/file\.ts \| 3/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/-old/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+new/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses diff when clicking same ahead commit again", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchAheadCommits as any).mockResolvedValue([
|
||||
{ hash: "aaa1111", shortHash: "aaa1", message: "Toggle commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockResolvedValue({
|
||||
stat: " file.ts | 1 +",
|
||||
patch: "diff --git a/file.ts\n+line",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Toggle commit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await user.click(screen.getByText("Toggle commit"));
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("aaa1111");
|
||||
});
|
||||
|
||||
// Diff should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/file\.ts \| 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click again to collapse
|
||||
await user.click(screen.getByText("Toggle commit"));
|
||||
|
||||
// Diff content should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/file\.ts \| 1/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when diff fetch fails for ahead commit", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchAheadCommits as any).mockResolvedValue([
|
||||
{ hash: "aaa1111", shortHash: "aaa1", message: "Error commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockRejectedValue(new Error("Diff load failed"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Error commit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await user.click(screen.getByText("Error commit"));
|
||||
|
||||
// Should show error fallback
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to load diff")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Remotes Tab: Click-to-Diff for Remote Commits ─────────────
|
||||
|
||||
it("expands diff when clicking a commit in remote commits section", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([
|
||||
{ hash: "rc1hash1", shortHash: "rc1", message: "Remote commit 1", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
{ hash: "rc2hash2", shortHash: "rc2", message: "Remote commit 2", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockResolvedValue({
|
||||
stat: " src/app.ts | 5 ++---",
|
||||
patch: "diff --git a/src/app.ts\n-old line\n+new line",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
// Wait for remote commits to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("remote-commits-section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remote commit 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the remote commit to expand diff
|
||||
await user.click(screen.getByText("Remote commit 1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("rc1hash1");
|
||||
});
|
||||
|
||||
// Diff content should be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/src\/app\.ts \| 5/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses diff when clicking same remote commit again", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([
|
||||
{ hash: "rc1hash1", shortHash: "rc1", message: "Remote toggle commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockResolvedValue({
|
||||
stat: " file.ts | 2 +",
|
||||
patch: "diff --git a/file.ts\n+line",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Remote toggle commit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await user.click(screen.getByText("Remote toggle commit"));
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("rc1hash1");
|
||||
});
|
||||
|
||||
// Diff should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/file\.ts \| 2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click again to collapse
|
||||
await user.click(screen.getByText("Remote toggle commit"));
|
||||
|
||||
// Diff content should be gone
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/file\.ts \| 2/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when diff fetch fails for remote commit", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([
|
||||
{ hash: "rc1hash1", shortHash: "rc1", message: "Error remote commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockRejectedValue(new Error("Diff load failed"));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Error remote commit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click to expand
|
||||
await user.click(screen.getByText("Error remote commit"));
|
||||
|
||||
// Should show error fallback
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Failed to load diff")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("only expands one diff at a time in remote commits list", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([
|
||||
{ hash: "rc1hash1", shortHash: "rc1", message: "First remote", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
{ hash: "rc2hash2", shortHash: "rc2", message: "Second remote", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
|
||||
]);
|
||||
(fetchCommitDiff as any).mockResolvedValue({
|
||||
stat: " file.ts | 1 +",
|
||||
patch: "diff --git a/file.ts\n+line",
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("First remote")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second remote")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click first commit
|
||||
await user.click(screen.getByText("First remote"));
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("rc1hash1");
|
||||
});
|
||||
|
||||
// Click second commit — should collapse first, expand second
|
||||
await user.click(screen.getByText("Second remote"));
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("rc2hash2");
|
||||
});
|
||||
|
||||
// fetchCommitDiff should have been called for both commits
|
||||
expect(fetchCommitDiff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14164,6 +14164,41 @@ html .column.drag-over * {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Remotes Tab: Clickable Commit Diff ── */
|
||||
|
||||
.gm-commit-item-compact-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gm-commit-clickable {
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.gm-commit-clickable:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.gm-commit-expand-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.gm-commit-diff-compact {
|
||||
margin: var(--space-xs) var(--space-sm) var(--space-sm) var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-ahead-commits-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
Reference in New Issue
Block a user