feat(FN-982): add branch selection and commit viewing to Git Manager
- Add branch commit fetching API endpoint (GET /api/git/branches/:name/commits) - Enhance BranchesPanel with selectable branches and commit diff viewer - Add CSS styles for branch selection list and commit detail view - Fix relativeDate utility to handle invalid/empty date strings gracefully - Fix interview route ordering in mission-routes - Add loading state for branch commit diff to prevent false error flash - Add comprehensive tests for branch selection, commit viewing, and route handling
This commit is contained in:
@@ -869,6 +869,12 @@ export function fetchGitBranches(): Promise<GitBranch[]> {
|
||||
return api<GitBranch[]>("/git/branches");
|
||||
}
|
||||
|
||||
/** Fetch recent commits for a specific branch */
|
||||
export function fetchBranchCommits(branchName: string, limit?: number): Promise<GitCommit[]> {
|
||||
const query = limit ? `?limit=${limit}` : "";
|
||||
return api<GitCommit[]>(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`);
|
||||
}
|
||||
|
||||
/** Fetch all worktrees */
|
||||
export function fetchGitWorktrees(): Promise<GitWorktree[]> {
|
||||
return api<GitWorktree[]>("/git/worktrees");
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
updateGitRemoteUrl,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
fetchBranchCommits,
|
||||
} from "../api";
|
||||
import {
|
||||
GitBranch as GitBranchIcon,
|
||||
@@ -138,9 +139,11 @@ function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Format relative date */
|
||||
function relativeDate(dateStr: string): string {
|
||||
/** Format relative date. Returns "—" for invalid/empty dates. */
|
||||
function relativeDate(dateStr: string | undefined | null): string {
|
||||
if (!dateStr) return "—";
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return "—";
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
@@ -195,6 +198,12 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [branchBase, setBranchBase] = useState("");
|
||||
const [branchSearch, setBranchSearch] = useState("");
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
|
||||
const [branchCommits, setBranchCommits] = useState<GitCommit[]>([]);
|
||||
const [loadingBranchCommits, setLoadingBranchCommits] = useState(false);
|
||||
const [expandedBranchCommit, setExpandedBranchCommit] = useState<string | null>(null);
|
||||
const [branchCommitDiff, setBranchCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||
const [loadingBranchCommitDiff, setLoadingBranchCommitDiff] = useState(false);
|
||||
|
||||
// ── Worktrees state
|
||||
const [worktrees, setWorktrees] = useState<GitWorktree[]>([]);
|
||||
@@ -502,6 +511,61 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
|
||||
return branches.filter((b) => b.name.toLowerCase().includes(q));
|
||||
}, [branches, branchSearch]);
|
||||
|
||||
// ── Branch Selection Handlers ───────────────────────────────────
|
||||
|
||||
/** Toggle branch selection to show commits for that branch */
|
||||
const handleSelectBranch = useCallback(async (name: string) => {
|
||||
if (selectedBranch === name) {
|
||||
// Deselect
|
||||
setSelectedBranch(null);
|
||||
setBranchCommits([]);
|
||||
setExpandedBranchCommit(null);
|
||||
setBranchCommitDiff(null);
|
||||
return;
|
||||
}
|
||||
setSelectedBranch(name);
|
||||
setBranchCommits([]);
|
||||
setExpandedBranchCommit(null);
|
||||
setBranchCommitDiff(null);
|
||||
setLoadingBranchCommits(true);
|
||||
try {
|
||||
const data = await fetchBranchCommits(name, 10);
|
||||
setBranchCommits(data);
|
||||
} catch {
|
||||
setBranchCommits([]);
|
||||
} finally {
|
||||
setLoadingBranchCommits(false);
|
||||
}
|
||||
}, [selectedBranch]);
|
||||
|
||||
/** Click a commit in the branch view to expand/collapse its diff */
|
||||
const handleBranchCommitClick = useCallback(async (hash: string) => {
|
||||
if (expandedBranchCommit === hash) {
|
||||
setExpandedBranchCommit(null);
|
||||
setBranchCommitDiff(null);
|
||||
return;
|
||||
}
|
||||
setExpandedBranchCommit(hash);
|
||||
setBranchCommitDiff(null);
|
||||
setLoadingBranchCommitDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash);
|
||||
setBranchCommitDiff(diff);
|
||||
} catch {
|
||||
setBranchCommitDiff(null);
|
||||
} finally {
|
||||
setLoadingBranchCommitDiff(false);
|
||||
}
|
||||
}, [expandedBranchCommit]);
|
||||
|
||||
/** Close branch details panel */
|
||||
const handleCloseBranchDetails = useCallback(() => {
|
||||
setSelectedBranch(null);
|
||||
setBranchCommits([]);
|
||||
setExpandedBranchCommit(null);
|
||||
setBranchCommitDiff(null);
|
||||
}, []);
|
||||
|
||||
// ── Stash Handlers ──────────────────────────────────────────────
|
||||
|
||||
const handleCreateStash = useCallback(async (e: React.FormEvent) => {
|
||||
@@ -731,6 +795,15 @@ export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManager
|
||||
onDeleteBranch={handleDeleteBranch}
|
||||
loading={loading}
|
||||
allBranches={branches}
|
||||
selectedBranch={selectedBranch}
|
||||
branchCommits={branchCommits}
|
||||
loadingBranchCommits={loadingBranchCommits}
|
||||
expandedBranchCommit={expandedBranchCommit}
|
||||
branchCommitDiff={branchCommitDiff}
|
||||
loadingBranchCommitDiff={loadingBranchCommitDiff}
|
||||
onSelectBranch={handleSelectBranch}
|
||||
onBranchCommitClick={handleBranchCommitClick}
|
||||
onCloseBranchDetails={handleCloseBranchDetails}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1190,7 +1263,7 @@ function CommitsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/** Branches panel with creation, search, checkout, delete */
|
||||
/** Branches panel with creation, search, checkout, delete, and branch commit viewing */
|
||||
function BranchesPanel({
|
||||
branches,
|
||||
branchSearch,
|
||||
@@ -1204,6 +1277,15 @@ function BranchesPanel({
|
||||
onDeleteBranch,
|
||||
loading,
|
||||
allBranches,
|
||||
selectedBranch,
|
||||
branchCommits,
|
||||
loadingBranchCommits,
|
||||
expandedBranchCommit,
|
||||
branchCommitDiff,
|
||||
loadingBranchCommitDiff,
|
||||
onSelectBranch,
|
||||
onBranchCommitClick,
|
||||
onCloseBranchDetails,
|
||||
}: {
|
||||
branches: GitBranch[];
|
||||
branchSearch: string;
|
||||
@@ -1217,6 +1299,15 @@ function BranchesPanel({
|
||||
onDeleteBranch: (name: string) => void;
|
||||
loading: boolean;
|
||||
allBranches: GitBranch[];
|
||||
selectedBranch: string | null;
|
||||
branchCommits: GitCommit[];
|
||||
loadingBranchCommits: boolean;
|
||||
expandedBranchCommit: string | null;
|
||||
branchCommitDiff: { stat: string; patch: string } | null;
|
||||
loadingBranchCommitDiff: boolean;
|
||||
onSelectBranch: (name: string) => void;
|
||||
onBranchCommitClick: (hash: string) => void;
|
||||
onCloseBranchDetails: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="gm-panel" data-testid="branches-panel">
|
||||
@@ -1273,44 +1364,116 @@ function BranchesPanel({
|
||||
</div>
|
||||
) : (
|
||||
branches.map((branch) => (
|
||||
<div
|
||||
key={branch.name}
|
||||
className={`gm-branch-item${branch.isCurrent ? " current" : ""}`}
|
||||
>
|
||||
<div className="gm-branch-info">
|
||||
<span className="gm-branch-name">
|
||||
{branch.isCurrent && <Check size={14} className="gm-current-icon" />}
|
||||
{branch.name}
|
||||
</span>
|
||||
{branch.remote && (
|
||||
<span className="gm-branch-remote">→ {branch.remote}</span>
|
||||
)}
|
||||
{branch.lastCommitDate && (
|
||||
<span className="gm-branch-date">{relativeDate(branch.lastCommitDate)}</span>
|
||||
)}
|
||||
<div key={branch.name}>
|
||||
<div
|
||||
className={`gm-branch-item${branch.isCurrent ? " current" : ""}${selectedBranch === branch.name ? " selected" : ""}`}
|
||||
onClick={() => onSelectBranch(branch.name)}
|
||||
>
|
||||
<div className="gm-branch-info">
|
||||
<span className="gm-branch-name">
|
||||
{branch.isCurrent && <Check size={14} className="gm-current-icon" />}
|
||||
{branch.name}
|
||||
</span>
|
||||
{branch.remote && (
|
||||
<span className="gm-branch-remote">→ {branch.remote}</span>
|
||||
)}
|
||||
{branch.lastCommitDate && (
|
||||
<span className="gm-branch-date">{relativeDate(branch.lastCommitDate)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="gm-branch-actions">
|
||||
{!branch.isCurrent && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={(e) => { e.stopPropagation(); onCheckoutBranch(branch.name); }}
|
||||
disabled={loading}
|
||||
title="Checkout"
|
||||
>
|
||||
<GitBranchIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteBranch(branch.name); }}
|
||||
disabled={loading}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="gm-branch-actions">
|
||||
{!branch.isCurrent && (
|
||||
<>
|
||||
|
||||
{/* Branch commit details — shown when this branch is selected */}
|
||||
{selectedBranch === branch.name && (
|
||||
<div className="gm-branch-details">
|
||||
<div className="gm-branch-details-header">
|
||||
<span className="gm-branch-details-title">
|
||||
<GitCommitIcon size={14} />
|
||||
Commits on {branch.name}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => onCheckoutBranch(branch.name)}
|
||||
disabled={loading}
|
||||
title="Checkout"
|
||||
className="gm-icon-btn"
|
||||
onClick={onCloseBranchDetails}
|
||||
title="Close"
|
||||
data-testid="close-branch-details"
|
||||
>
|
||||
<GitBranchIcon size={14} />
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => onDeleteBranch(branch.name)}
|
||||
disabled={loading}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{loadingBranchCommits ? (
|
||||
<div className="gm-branch-details-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading commits...
|
||||
</div>
|
||||
) : branchCommits.length === 0 ? (
|
||||
<div className="gm-empty">No commits found</div>
|
||||
) : (
|
||||
<div className="gm-branch-commits-list">
|
||||
{branchCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-branch-commit">
|
||||
<button
|
||||
className="gm-branch-commit-row"
|
||||
onClick={() => onBranchCommitClick(commit.hash)}
|
||||
data-testid={`branch-commit-${commit.shortHash}`}
|
||||
>
|
||||
<span className="gm-commit-hash">{commit.shortHash}</span>
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<div className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
{commit.parents.length > 1 && (
|
||||
<span className="gm-merge-badge">merge</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{expandedBranchCommit === commit.hash && (
|
||||
<div className="gm-commit-diff">
|
||||
{loadingBranchCommitDiff ? (
|
||||
<div className="gm-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : branchCommitDiff ? (
|
||||
<>
|
||||
{branchCommitDiff.stat && <pre className="gm-diff-stat">{branchCommitDiff.stat}</pre>}
|
||||
<pre className="gm-diff-patch">{branchCommitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="gm-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("../../api", async () => {
|
||||
updateGitRemoteUrl: vi.fn(),
|
||||
fetchAheadCommits: vi.fn(),
|
||||
fetchRemoteCommits: vi.fn(),
|
||||
fetchBranchCommits: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
updateGitRemoteUrl,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
fetchBranchCommits,
|
||||
} from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -631,6 +633,264 @@ describe("GitManagerModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Branch Selection & Commits ───────────────────────────────
|
||||
|
||||
it("selects a branch and fetches its commits on click", async () => {
|
||||
(fetchBranchCommits as any).mockResolvedValue([
|
||||
{
|
||||
hash: "def456789abc",
|
||||
shortHash: "def4567",
|
||||
message: "Feature commit",
|
||||
author: "Dev",
|
||||
date: "2026-03-01T00:00:00Z",
|
||||
parents: [],
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the branch items inside the branches list
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
// "feature" is the non-current branch, should be the second one
|
||||
expect(branchItems.length).toBe(2);
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchBranchCommits).toHaveBeenCalledWith("feature", 10);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Feature commit")).toBeInTheDocument();
|
||||
expect(screen.getByText("def4567")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("deselects a branch when clicking it again", async () => {
|
||||
(fetchBranchCommits as any).mockResolvedValue([
|
||||
{
|
||||
hash: "def456789abc",
|
||||
shortHash: "def4567",
|
||||
message: "Feature commit",
|
||||
author: "Dev",
|
||||
date: "2026-03-01T00:00:00Z",
|
||||
parents: [],
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchBranchCommits).toHaveBeenCalledWith("feature", 10);
|
||||
});
|
||||
|
||||
// Click again to deselect
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Commits on feature")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching branch commits", async () => {
|
||||
// Make fetchBranchCommits hang (never resolve)
|
||||
(fetchBranchCommits as any).mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading commits...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes branch details via close button", async () => {
|
||||
(fetchBranchCommits as any).mockResolvedValue([
|
||||
{
|
||||
hash: "def456789abc",
|
||||
shortHash: "def4567",
|
||||
message: "Feature commit",
|
||||
author: "Dev",
|
||||
date: "2026-03-01T00:00:00Z",
|
||||
parents: [],
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Commits on feature")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the close button
|
||||
const closeBtn = screen.getByTestId("close-branch-details");
|
||||
fireEvent.click(closeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Commits on feature")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("expands commit diff when clicking a commit in branch view", async () => {
|
||||
(fetchBranchCommits as any).mockResolvedValue([
|
||||
{
|
||||
hash: "def456789abc",
|
||||
shortHash: "def4567",
|
||||
message: "Feature commit",
|
||||
author: "Dev",
|
||||
date: "2026-03-01T00:00:00Z",
|
||||
parents: [],
|
||||
},
|
||||
]);
|
||||
(fetchCommitDiff as any).mockResolvedValue({
|
||||
stat: " file.ts | 2 +-",
|
||||
patch: "diff --git a/file.ts b/file.ts\n-old\n+new",
|
||||
});
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Feature commit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the commit to expand diff
|
||||
const commitRow = screen.getByTestId("branch-commit-def4567");
|
||||
fireEvent.click(commitRow);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCommitDiff).toHaveBeenCalledWith("def456789abc");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles fetchBranchCommits error gracefully", async () => {
|
||||
(fetchBranchCommits as any).mockRejectedValue(new Error("Network error"));
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchBranchCommits).toHaveBeenCalledWith("feature", 10);
|
||||
});
|
||||
|
||||
// Should show empty state since fetch failed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No commits found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows merge badge for merge commits in branch view", async () => {
|
||||
(fetchBranchCommits as any).mockResolvedValue([
|
||||
{
|
||||
hash: "def456789abc",
|
||||
shortHash: "def4567",
|
||||
message: "Merge PR #42",
|
||||
author: "Dev",
|
||||
date: "2026-03-01T00:00:00Z",
|
||||
parents: ["abc123", "def456"],
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("branches-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const branchItems = screen.getByTestId("branches-panel").querySelectorAll(".gm-branch-item");
|
||||
fireEvent.click(branchItems[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("merge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── relativeDate function ──────────────────────────────────────
|
||||
|
||||
it("shows em-dash for branches with empty lastCommitDate", async () => {
|
||||
(fetchGitBranches as any).mockResolvedValue([
|
||||
{ name: "main", isCurrent: true, lastCommitDate: "" },
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
// With empty lastCommitDate, the date span should not render at all
|
||||
// because the component conditionally renders only when lastCommitDate is truthy
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId("branches-panel");
|
||||
const branchDates = panel.querySelectorAll(".gm-branch-date");
|
||||
expect(branchDates.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows em-dash for branches with invalid lastCommitDate", async () => {
|
||||
(fetchGitBranches as any).mockResolvedValue([
|
||||
{ name: "stale", isCurrent: true, lastCommitDate: "not-a-date" },
|
||||
]);
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /branches/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const panel = screen.getByTestId("branches-panel");
|
||||
const dateSpan = panel.querySelector(".gm-branch-date");
|
||||
expect(dateSpan).toBeTruthy();
|
||||
expect(dateSpan!.textContent).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Worktrees Panel ────────────────────────────────────────
|
||||
|
||||
it("loads worktrees and shows task associations", async () => {
|
||||
|
||||
@@ -16220,6 +16220,80 @@ html .column.drag-over * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Branch Selection Details ── */
|
||||
|
||||
.gm-branch-item.selected {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gm-branch-item.selected:hover {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
}
|
||||
|
||||
.gm-branch-details {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
.gm-branch-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-branch-details-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-branch-details-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-branch-commits-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gm-branch-commit {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.gm-branch-commit:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gm-branch-commit-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.gm-branch-commit-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* ── Worktrees Panel ── */
|
||||
|
||||
.gm-worktree-stats {
|
||||
@@ -16858,6 +16932,18 @@ html .column.drag-over * {
|
||||
background: rgba(9, 105, 218, 0.04);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-branch-item.selected {
|
||||
background: rgba(9, 105, 218, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-branch-item.selected:hover {
|
||||
background: rgba(9, 105, 218, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-branch-details {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="light"] .gm-hash {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user