feat(FN-3724): add split-button for git pull with rebase option

The merge adds a pull split-button in the GitManager modal that lets users choose between a regular `git pull` and a `git pull --rebase`, with styling and test coverage for the menu interactions and API payload. It also fixes plugin toggle rendering (FN-3723) and adds a release note for plugin insta

Fusion-Task-Id: FN-3724
This commit is contained in:
Fusion
2026-05-07 23:57:28 -07:00
committed by gsxdsm
parent 4c6125a710
commit 86e76ad0db
8 changed files with 264 additions and 36 deletions

View File

@@ -438,7 +438,7 @@ describe("Git Management API", () => {
});
describe("pullBranch", () => {
it("sends POST to pull", async () => {
it("sends POST to pull with default rebase false", async () => {
const result = { success: true, message: "Pulled 2 commits" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
@@ -448,6 +448,20 @@ describe("Git Management API", () => {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ rebase: false }),
});
});
it("sends rebase true when requested", async () => {
const result = { success: true, message: "Rebased and pulled" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
await pullBranch({ rebase: true });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ rebase: true }),
});
});

View File

@@ -2358,9 +2358,18 @@ export function fetchRemote(remote?: string, projectId?: string): Promise<GitFet
}
/** Pull current branch */
export function pullBranch(projectId?: string): Promise<GitPullResult> {
return api<GitPullResult>(withProjectId("/git/pull", projectId), {
export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
export function pullBranch(projectId?: string): Promise<GitPullResult>;
export function pullBranch(
optionsOrProjectId?: { rebase?: boolean } | string,
projectId?: string,
): Promise<GitPullResult> {
const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId;
const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId;
return api<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
method: "POST",
body: JSON.stringify({ rebase: options?.rebase ?? false }),
});
}

View File

@@ -777,15 +777,16 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
}
}, [addToast, projectId]);
const handlePull = useCallback(async () => {
const handlePull = useCallback(async (options?: { rebase?: boolean }) => {
setRemoteLoading("pull");
try {
const result = await pullBranch(projectId);
const result = await pullBranch(options, projectId);
setLastRemoteResult(result);
if (result.conflict) {
addToast("Merge conflict detected. Resolve manually.", "error");
} else {
addToast(result.message || "Pull completed", "success");
const fallbackMessage = options?.rebase ? "Pull --rebase completed" : "Pull completed";
addToast(result.message || fallbackMessage, "success");
}
const statusData = await fetchGitStatus(projectId);
setStatus(statusData);
@@ -1909,7 +1910,7 @@ function RemotesPanel({
remoteLoading: string | null;
lastRemoteResult: GitFetchResult | GitPullResult | GitPushResult | null;
onFetch: () => void;
onPull: () => void;
onPull: (options?: { rebase?: boolean }) => void;
onPush: () => void;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
@@ -1933,6 +1934,8 @@ function RemotesPanel({
const [editUrlValue, setEditUrlValue] = useState("");
const [editNameValue, setEditNameValue] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [pullMenuOpen, setPullMenuOpen] = useState(false);
const pullSplitRef = useRef<HTMLDivElement | null>(null);
// Ahead commits (local commits to push)
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
@@ -1948,6 +1951,38 @@ function RemotesPanel({
// Derived state for selected remote
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
useEffect(() => {
if (remoteLoading !== null || loading) {
setPullMenuOpen(false);
}
}, [remoteLoading, loading]);
useEffect(() => {
if (!pullMenuOpen) {
return;
}
const handleDocumentClick = (event: MouseEvent) => {
if (!pullSplitRef.current?.contains(event.target as Node)) {
setPullMenuOpen(false);
}
};
const handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setPullMenuOpen(false);
}
};
document.addEventListener("mousedown", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeyDown);
return () => {
document.removeEventListener("mousedown", handleDocumentClick);
document.removeEventListener("keydown", handleDocumentKeyDown);
};
}, [pullMenuOpen]);
// 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);
@@ -2317,18 +2352,57 @@ function RemotesPanel({
)}
Fetch
</button>
<button
className="btn btn-primary"
onClick={onPull}
disabled={remoteLoading !== null || loading}
>
{remoteLoading === "pull" ? (
<Loader2 size={14} className="spin" />
) : (
<GitPullRequest size={14} />
)}
Pull
</button>
<div className="gm-pull-split" ref={pullSplitRef}>
<button
className="btn btn-primary gm-pull-split-main"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: false });
}}
disabled={remoteLoading !== null || loading}
>
{remoteLoading === "pull" ? (
<Loader2 size={14} className="spin" />
) : (
<GitPullRequest size={14} />
)}
Pull
</button>
<button
className="btn btn-primary btn-icon gm-pull-split-toggle"
onClick={() => setPullMenuOpen((open) => !open)}
disabled={remoteLoading !== null || loading}
aria-label="Pull options"
aria-haspopup="menu"
aria-expanded={pullMenuOpen}
>
<ChevronDown size={14} />
</button>
{pullMenuOpen ? (
<div className="gm-pull-menu" role="menu" aria-label="Pull options menu">
<button
className="gm-pull-menu-item"
role="menuitem"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: false });
}}
>
Pull
</button>
<button
className="gm-pull-menu-item"
role="menuitem"
onClick={() => {
setPullMenuOpen(false);
onPull({ rebase: true });
}}
>
Pull --rebase
</button>
</div>
) : null}
</div>
<button
className="btn btn-primary"
onClick={onPush}

View File

@@ -2982,6 +2982,66 @@
flex: 1 1 calc(var(--space-2xl) * 5);
}
.gm-pull-split {
position: relative;
display: flex;
flex: 1 1 calc(var(--space-2xl) * 5);
}
.gm-pull-split .btn {
flex: 1 1 auto;
}
.gm-pull-split-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.gm-pull-split-toggle {
flex: 0 0 auto;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 0;
padding-inline: var(--space-sm);
}
.gm-pull-menu {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
z-index: 4;
min-width: calc(var(--space-2xl) * 5);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 80%, var(--card));
box-shadow: var(--shadow-lg);
}
.gm-pull-menu-item {
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
font: inherit;
padding: var(--space-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.gm-pull-menu-item:hover {
background: var(--card-hover);
}
.gm-pull-menu-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.gm-remote-result {
padding: var(--space-sm) var(--space-md);
background: var(--card);
@@ -3584,6 +3644,16 @@
flex-wrap: wrap;
}
.gm-pull-split {
flex: 1 1 100%;
}
.gm-pull-menu {
left: 0;
right: auto;
min-width: 100%;
}
.gm-remotes-layout {
display: flex;
flex-direction: column;

View File

@@ -1363,20 +1363,62 @@ describe("GitManagerModal", () => {
});
});
it("calls pullBranch when Pull button clicked", async () => {
it("calls pullBranch with rebase false when Pull button clicked", async () => {
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 sync card and scope button search within it
const syncCard = await screen.findByTestId("remote-sync-card");
const pullButton = within(syncCard).getByRole("button", { name: /pull/i });
const pullButton = within(syncCard).getByRole("button", { name: /^pull$/i });
await user.click(pullButton);
await waitFor(() => {
expect(pullBranch).toHaveBeenCalled();
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
});
});
it("calls pullBranch with rebase true from pull options menu", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /pull options/i }));
await user.click(screen.getByRole("menuitem", { name: /pull --rebase/i }));
await waitFor(() => {
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
});
});
it("closes pull options menu on outside click and Escape", async () => {
const user = userEvent.setup();
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
const syncCard = await screen.findByTestId("remote-sync-card");
const pullOptionsButton = within(syncCard).getByRole("button", { name: /pull options/i });
await user.click(pullOptionsButton);
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
await user.click(document.body);
await waitFor(() => {
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
});
await user.click(pullOptionsButton);
expect(screen.getByRole("menu", { name: /pull options menu/i })).toBeInTheDocument();
await user.keyboard("{Escape}");
await waitFor(() => {
expect(screen.queryByRole("menu", { name: /pull options menu/i })).not.toBeInTheDocument();
});
});
@@ -2114,10 +2156,10 @@ describe("GitManagerModal", () => {
});
const syncCard = screen.getByTestId("remote-sync-card");
await user.click(within(syncCard).getByRole("button", { name: /pull/i }));
await user.click(within(syncCard).getByRole("button", { name: /^pull$/i }));
await waitFor(() => {
expect(pullBranch).toHaveBeenCalled();
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
expect(screen.getByText("Remote commit after pull")).toBeInTheDocument();
expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument();
@@ -2126,13 +2168,18 @@ describe("GitManagerModal", () => {
it("refreshes recent remote commits after push without reopening modal", async () => {
const user = userEvent.setup();
(fetchRemoteCommits as any)
.mockResolvedValueOnce([
{ hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
])
.mockResolvedValueOnce([
let remoteCommitsCallCount = 0;
(fetchRemoteCommits as any).mockImplementation(() => {
remoteCommitsCallCount += 1;
if (remoteCommitsCallCount <= 1) {
return Promise.resolve([
{ hash: "rc10", shortHash: "rc10", message: "Remote commit before push", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
]);
}
return Promise.resolve([
{ hash: "rc11", shortHash: "rc11", message: "Remote commit after push", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
]);
});
render(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />

View File

@@ -464,16 +464,21 @@ export interface GitPullResult {
conflict?: boolean;
}
export async function pullGitBranch(cwd?: string): Promise<GitPullResult> {
export async function pullGitBranch(cwd?: string, options?: { rebase?: boolean }): Promise<GitPullResult> {
const rebase = options?.rebase === true;
try {
const output = await runGitCommand(["pull"], cwd, 30000);
return { success: true, message: output.trim() };
const output = await runGitCommand(rebase ? ["pull", "--rebase"] : ["pull"], cwd, 30000);
const message = output.trim();
if (message) {
return { success: true, message };
}
return { success: true, message: rebase ? "Pull completed (rebase)" : "Pull completed" };
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
const message = getCommandErrorMessage(err);
if (message.includes("CONFLICT") || message.includes("Merge conflict")) {
if (message.includes("CONFLICT") || message.includes("Merge conflict") || message.includes("could not apply")) {
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
}
throw new Error(message || "Pull failed");
@@ -1653,7 +1658,11 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
const result = await pullGitBranch(rootDir);
const { rebase } = req.body ?? {};
if (rebase !== undefined && typeof rebase !== "boolean") {
throw badRequest("rebase must be a boolean");
}
const result = await pullGitBranch(rootDir, { rebase: rebase === true });
if (result.conflict) {
throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", {
...result,