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:
@@ -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 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user