FN-6981: make Git Manager tabs scroll on mobile
Keep Git Manager navigation reachable in narrow mobile and embedded layouts. - Make Git Manager tab strips non-wrapping horizontal scrollers with touch-friendly panning. - Constrain repo selector sizing so tabs and Refresh stay reachable on narrow widths. - Extend modal and CSS coverage for standalone, embedded, and workspace-selector mobile cases. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6981-git-manager-mobile-tabs.md | 7 +++ packages/dashboard/app/components/ScriptsModal.css | 43 ++++++++++++- .../components/__tests__/GitManagerModal.test.tsx | 59 +++++++++++++----- .../__tests__/core-modals-mobile.test.tsx | 70 +++++++++++++++++++++- 4 files changed, 160 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-6981 Fusion-Task-Lineage: c8a2910c-3642-4e14-a841-21cc32bc4cdb
This commit is contained in:
7
.changeset/fn-6981-git-manager-mobile-tabs.md
Normal file
7
.changeset/fn-6981-git-manager-mobile-tabs.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Git Manager tabs reachable in mobile and docked layouts.
|
||||
category: fix
|
||||
dev: Makes the shared Git Manager tablist a non-wrapping horizontal touch scroller in mobile and embedded narrow containers.
|
||||
@@ -2091,6 +2091,9 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
|
||||
/*
|
||||
FNXC:GitManager 2026-06-22-19:20:
|
||||
The dock tab strip is ONE ROW that scrolls left-right when needed, showing as many section icons as fit. Tabs are compact ICON-ONLY (labels are sr-only; the button title gives a tooltip) so the maximum number of sections is visible before horizontal scroll kicks in. width:auto overrides the base .gm-nav-item width:100% that otherwise made each tab fill the row (one per swipe).
|
||||
|
||||
FNXC:GitManager 2026-06-24-21:25:
|
||||
The embedded right-dock tablist must opt out of the global mobile pan-y lock the same way as the standalone modal: horizontal touch scrolling keeps late tabs and Refresh reachable while pan-y still lets vertical page/content scroll win when the gesture is vertical.
|
||||
*/
|
||||
.gm-modal--embedded .gm-sidebar {
|
||||
flex: 0 0 auto;
|
||||
@@ -2105,7 +2108,7 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
touch-action: pan-x pan-y;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
@@ -2120,6 +2123,23 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
min-width: calc(var(--space-xl) + var(--space-xs));
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.gm-modal--embedded .gm-repo-selector-wrap {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: none;
|
||||
padding: var(--space-xs);
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.gm-modal--embedded .gm-repo-selector {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
max-width: calc(var(--space-2xl) * 4);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: calc(var(--space-xs) / 2) var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Icon-only: hide the section label (kept for screen readers); the button title is the tooltip. */
|
||||
@@ -4309,10 +4329,14 @@ Refresh button pinned at the end of the section nav strip (replaces the removed
|
||||
|
||||
FNXC:GitManager 2026-06-21-18:00:
|
||||
FN-6900 requires the Git Manager section tabs to remain visible and switchable on touch viewports. In the mobile column layout, the content pane is the flexible sibling, so the tab strip must be `flex: 0 0 auto` with a token-sized minimum height instead of shrinking to a zero-height row.
|
||||
|
||||
FNXC:GitManager 2026-06-24-21:25:
|
||||
FN-6981 requires the actual mobile tablist scroller to be a single non-wrapping horizontal strip in both modal and embedded presentations. Keep `pan-x pan-y` so horizontal swipes reveal later tabs/Refresh without stealing vertical scroll gestures from the page or content panel.
|
||||
*/
|
||||
.gm-sidebar {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
@@ -4341,6 +4365,23 @@ Refresh button pinned at the end of the section nav strip (replaces the removed
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
min-width: calc(var(--space-xl) + var(--space-xs));
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.gm-repo-selector-wrap {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: none;
|
||||
padding: var(--space-xs);
|
||||
min-height: calc(var(--space-xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.gm-repo-selector {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
max-width: calc(var(--space-2xl) * 4);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: calc(var(--space-xs) / 2) var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.gm-nav-label {
|
||||
|
||||
@@ -287,6 +287,7 @@ describe("GitManagerModal", () => {
|
||||
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
|
||||
(fetchAheadCommits as any).mockResolvedValue([]);
|
||||
(fetchRemoteCommits as any).mockResolvedValue([]);
|
||||
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: [] });
|
||||
});
|
||||
|
||||
// ── Workspace root-race toast suppression ───────────────────
|
||||
@@ -410,15 +411,33 @@ describe("GitManagerModal", () => {
|
||||
expect(modal.style.getPropertyValue("--vv-offset-top")).toBe("18px");
|
||||
});
|
||||
|
||||
it("renders all navigation sections", async () => {
|
||||
it("renders all navigation sections plus Refresh without a workspace selector by default", async () => {
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
await waitFor(() => {
|
||||
for (const label of gitManagerSectionLabels) {
|
||||
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
|
||||
for (const label of gitManagerSectionLabels) {
|
||||
expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
expect(within(tablist).getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
expect(within(tablist).queryByRole("combobox", { name: "Select repository" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all navigation sections plus Refresh when the workspace repo selector is present", async () => {
|
||||
(fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["apps/dashboard", "packages/core"] });
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
|
||||
const tablist = await screen.findByRole("tablist", { name: /git manager sections/i });
|
||||
const repoSelector = await within(tablist).findByRole("combobox", { name: "Select repository" });
|
||||
expect(repoSelector).toHaveValue("apps/dashboard");
|
||||
for (const label of gitManagerSectionLabels) {
|
||||
expect(within(tablist).getByRole("tab", { name: label })).toBeInTheDocument();
|
||||
}
|
||||
expect(within(tablist).getByRole("button", { name: "Refresh" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1489,8 +1508,8 @@ describe("GitManagerModal", () => {
|
||||
await user.click(screen.getByText("Pop"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((applyStash as any).mock.calls).toContainEqual([0, false, undefined]);
|
||||
expect((applyStash as any).mock.calls).toContainEqual([0, true, undefined]);
|
||||
expect((applyStash as any).mock.calls).toContainEqual([0, false, undefined, undefined]);
|
||||
expect((applyStash as any).mock.calls).toContainEqual([0, true, undefined, undefined]);
|
||||
expect(screen.getByTitle("Drop stash")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1633,7 +1652,7 @@ describe("GitManagerModal", () => {
|
||||
await user.click(pullButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1649,7 +1668,7 @@ describe("GitManagerModal", () => {
|
||||
await user.click(screen.getByRole("menuitem", { name: /pull --rebase/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1788,8 +1807,8 @@ describe("GitManagerModal", () => {
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined);
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
|
||||
expect((pullBranch as any).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
@@ -1819,7 +1838,7 @@ describe("GitManagerModal", () => {
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
|
||||
});
|
||||
expect(pushBranch).not.toHaveBeenCalled();
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Merge conflict detected. Resolve manually.", "error");
|
||||
@@ -1838,7 +1857,7 @@ describe("GitManagerModal", () => {
|
||||
await user.click(syncButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined);
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: true }, undefined, undefined);
|
||||
});
|
||||
expect(pushBranch).not.toHaveBeenCalled();
|
||||
expect(mockAddToast).toHaveBeenCalledWith("sync pull failed", "error");
|
||||
@@ -1870,7 +1889,7 @@ describe("GitManagerModal", () => {
|
||||
resolvePull?.({ success: true, message: "Already up to date." });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined);
|
||||
expect(pushBranch).toHaveBeenCalledWith(undefined, undefined);
|
||||
expect(syncButton).not.toBeDisabled();
|
||||
});
|
||||
expectLatestCallStartsWith(fetchGitStatus as any, undefined, { extended: true });
|
||||
@@ -2596,7 +2615,7 @@ describe("GitManagerModal", () => {
|
||||
await user.click(within(syncCard).getByRole("button", { name: /^pull$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined);
|
||||
expect(pullBranch).toHaveBeenCalledWith({ rebase: false }, undefined, undefined);
|
||||
expect(fetchRemoteCommits).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText("Remote commit after pull")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Remote commit before pull")).not.toBeInTheDocument();
|
||||
@@ -3488,7 +3507,10 @@ describe("GitManagerModal", () => {
|
||||
expect(sidebarRules[0]).toContain("min-height: calc(var(--space-2xl) + var(--space-md));");
|
||||
expect(sidebarRules[0]).toContain("overflow-x: auto;");
|
||||
expect(sidebarRules[0]).toContain("overflow-y: hidden;");
|
||||
expect(sidebarRules[0]).toContain("flex-wrap: nowrap;");
|
||||
expect(sidebarRules[0]).toContain("touch-action: pan-x pan-y;");
|
||||
expect(sidebarRules[0]).toContain("-webkit-overflow-scrolling: touch;");
|
||||
expect(sidebarRules[0]).toContain("overscroll-behavior-x: contain;");
|
||||
|
||||
const navItemRules = getRuleBlocks(mobile768, ".gm-nav-item");
|
||||
expect(navItemRules).toHaveLength(1);
|
||||
@@ -3496,6 +3518,11 @@ describe("GitManagerModal", () => {
|
||||
expect(navItemRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(navItemRules[0]).toContain("width: auto;");
|
||||
|
||||
const refreshRules = getRuleBlocks(mobile768, ".gm-nav-refresh");
|
||||
expect(refreshRules).toHaveLength(1);
|
||||
expect(refreshRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(refreshRules[0]).toContain("width: auto;");
|
||||
|
||||
expect(mobile720).not.toContain(".gm-sidebar");
|
||||
expect(mobile720).not.toContain(".gm-nav-item");
|
||||
});
|
||||
|
||||
@@ -34,6 +34,12 @@ function getMainMobileBlock(css: string): string {
|
||||
return block;
|
||||
}
|
||||
|
||||
function getEmbeddedGitManagerBlock(css: string): string {
|
||||
const block = getMediaBlocks(css, /@container\s+gm-embedded\s+\(max-width:\s*560px\)\s*\{/g);
|
||||
expect(block).toContain(".gm-modal--embedded .gm-sidebar");
|
||||
return block;
|
||||
}
|
||||
|
||||
function getTabletBlock(css: string): string {
|
||||
const block = getMediaBlocks(
|
||||
css,
|
||||
@@ -91,8 +97,9 @@ describe("core modals mobile css coverage", () => {
|
||||
expect(mobileRule).toContain("max-height: 100dvh;");
|
||||
expect(mobileRule).toContain("resize: none;");
|
||||
|
||||
const embeddedRule = getFirstRuleBlock(css, ".task-detail-content--embedded");
|
||||
expect(embeddedRule).toContain("height: 100%;");
|
||||
const embeddedRule = getRuleBlocks(css, ".task-detail-content--embedded")
|
||||
.find((rule) => rule.includes("height: 100%;"));
|
||||
expect(embeddedRule).toBeTruthy();
|
||||
expect(tabletBlock).not.toContain(".task-detail-content--embedded");
|
||||
});
|
||||
|
||||
@@ -244,11 +251,13 @@ describe("core modals mobile css coverage", () => {
|
||||
expect(sidebarRules.length).toBeGreaterThan(0);
|
||||
for (const sidebarRule of sidebarRules) {
|
||||
expect(sidebarRule).toContain("flex: 0 0 auto;");
|
||||
expect(sidebarRule).toContain("flex-wrap: nowrap;");
|
||||
expect(sidebarRule).toContain("min-height: calc(var(--space-2xl) + var(--space-md));");
|
||||
expect(sidebarRule).toContain("overflow-x: auto;");
|
||||
expect(sidebarRule).toContain("overflow-y: hidden;");
|
||||
expect(sidebarRule).toContain("touch-action: pan-x pan-y;");
|
||||
expect(sidebarRule).toContain("-webkit-overflow-scrolling: touch;");
|
||||
expect(sidebarRule).toContain("overscroll-behavior-x: contain;");
|
||||
}
|
||||
|
||||
const navItemRules = getRuleBlocks(mobileBlock, ".gm-nav-item");
|
||||
@@ -256,6 +265,63 @@ describe("core modals mobile css coverage", () => {
|
||||
for (const navItemRule of navItemRules) {
|
||||
expect(navItemRule).toMatch(/flex:\s*0 0 auto;|flex-shrink:\s*0;/);
|
||||
}
|
||||
|
||||
const refreshRules = getRuleBlocks(mobileBlock, ".gm-nav-refresh");
|
||||
expect(refreshRules.length).toBeGreaterThan(0);
|
||||
for (const refreshRule of refreshRules) {
|
||||
expect(refreshRule).toContain("flex: 0 0 auto;");
|
||||
expect(refreshRule).toContain("width: auto;");
|
||||
}
|
||||
});
|
||||
|
||||
it("GitManagerModal: embedded narrow tab strip is independently horizontally scrollable", () => {
|
||||
const css = loadAllAppCss();
|
||||
const embeddedBlock = getEmbeddedGitManagerBlock(css);
|
||||
|
||||
const sidebarRules = getRuleBlocks(embeddedBlock, ".gm-modal--embedded .gm-sidebar");
|
||||
expect(sidebarRules).toHaveLength(1);
|
||||
expect(sidebarRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(sidebarRules[0]).toContain("flex-wrap: nowrap;");
|
||||
expect(sidebarRules[0]).toContain("overflow-x: auto;");
|
||||
expect(sidebarRules[0]).toContain("overflow-y: hidden;");
|
||||
expect(sidebarRules[0]).toContain("touch-action: pan-x pan-y;");
|
||||
expect(sidebarRules[0]).toContain("-webkit-overflow-scrolling: touch;");
|
||||
expect(sidebarRules[0]).toContain("overscroll-behavior-x: contain;");
|
||||
|
||||
const navItemRules = getRuleBlocks(embeddedBlock, ".gm-modal--embedded .gm-nav-item");
|
||||
expect(navItemRules).toHaveLength(1);
|
||||
expect(navItemRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(navItemRules[0]).toContain("width: auto;");
|
||||
|
||||
const refreshRules = getRuleBlocks(embeddedBlock, ".gm-modal--embedded .gm-nav-refresh");
|
||||
expect(refreshRules).toHaveLength(1);
|
||||
expect(refreshRules[0]).toContain("flex: 0 0 auto;");
|
||||
expect(refreshRules[0]).toContain("width: auto;");
|
||||
});
|
||||
|
||||
it("GitManagerModal: workspace repo selector does not consume the mobile tab strip", () => {
|
||||
const css = loadAllAppCss();
|
||||
const mobileBlock = getMainMobileBlock(css);
|
||||
const embeddedBlock = getEmbeddedGitManagerBlock(css);
|
||||
|
||||
const standaloneWrapRules = getRuleBlocks(mobileBlock, ".gm-repo-selector-wrap");
|
||||
expect(standaloneWrapRules.length).toBeGreaterThan(0);
|
||||
for (const wrapRule of standaloneWrapRules) {
|
||||
expect(wrapRule).toContain("flex: 0 0 auto;");
|
||||
}
|
||||
const standaloneSelectRules = getRuleBlocks(mobileBlock, ".gm-repo-selector");
|
||||
expect(standaloneSelectRules.length).toBeGreaterThan(0);
|
||||
for (const selectRule of standaloneSelectRules) {
|
||||
expect(selectRule).toContain("width: auto;");
|
||||
}
|
||||
|
||||
const embeddedWrapRules = getRuleBlocks(embeddedBlock, ".gm-modal--embedded .gm-repo-selector-wrap");
|
||||
expect(embeddedWrapRules).toHaveLength(1);
|
||||
expect(embeddedWrapRules[0]).toContain("flex: 0 0 auto;");
|
||||
|
||||
const embeddedSelectRules = getRuleBlocks(embeddedBlock, ".gm-modal--embedded .gm-repo-selector");
|
||||
expect(embeddedSelectRules).toHaveLength(1);
|
||||
expect(embeddedSelectRules[0]).toContain("width: auto;");
|
||||
});
|
||||
|
||||
it("GitManagerModal: nav items keep a token-sized touch target on mobile", () => {
|
||||
|
||||
Reference in New Issue
Block a user