diff --git a/.changeset/fn-6981-git-manager-mobile-tabs.md b/.changeset/fn-6981-git-manager-mobile-tabs.md new file mode 100644 index 0000000000..917c6455a9 --- /dev/null +++ b/.changeset/fn-6981-git-manager-mobile-tabs.md @@ -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. diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 8b5974946a..1d99df7538 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -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 { diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index e00773951a..aba6522748 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -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( ); - 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( + + ); + + 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"); }); diff --git a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx index 5e5d91a471..9cf89b1359 100644 --- a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx @@ -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", () => {