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:
gsxdsm
2026-04-04 05:50:59 -07:00
parent e250f99b8b
commit 911b7bd0c7
4 changed files with 427 additions and 24 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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) {