diff --git a/apps/desktop/src/renderer/src/components/tab-bar.test.tsx b/apps/desktop/src/renderer/src/components/tab-bar.test.tsx new file mode 100644 index 000000000..9938af3f7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/tab-bar.test.tsx @@ -0,0 +1,134 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, fireEvent, within } from "@testing-library/react"; + +type MockTab = { + id: string; + path: string; + title: string; + icon: string; + pinned: boolean; +}; + +const state = vi.hoisted(() => ({ + activeWorkspaceSlug: "acme" as string | null, + byWorkspace: { + acme: { + activeTabId: "tA", + tabs: [ + { id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false }, + { id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false }, + ] as MockTab[], + }, + } as Record, + togglePin: vi.fn<(tabId: string) => void>(), + closeTab: vi.fn<(tabId: string) => void>(), + setActiveTab: vi.fn<(tabId: string) => void>(), + moveTab: vi.fn<(from: number, to: number) => void>(), + addTab: vi.fn<(path: string, title: string, icon: string) => string>(), +})); + +vi.mock("@/stores/tab-store", () => { + const store = { + get activeWorkspaceSlug() { + return state.activeWorkspaceSlug; + }, + get byWorkspace() { + return state.byWorkspace; + }, + togglePin: state.togglePin, + closeTab: state.closeTab, + setActiveTab: state.setActiveTab, + moveTab: state.moveTab, + addTab: state.addTab, + }; + const useTabStore = Object.assign( + (selector?: (s: typeof store) => unknown) => + selector ? selector(store) : store, + { getState: () => store }, + ); + const useActiveGroup = () => + state.activeWorkspaceSlug + ? (state.byWorkspace[state.activeWorkspaceSlug] ?? null) + : null; + const resolveRouteIcon = () => "ListTodo"; + return { useTabStore, useActiveGroup, resolveRouteIcon }; +}); + +vi.mock("@multica/core/paths", () => ({ + paths: { + workspace: (slug: string) => ({ + issues: () => `/${slug}/issues`, + }), + }, +})); + +import { TabBar } from "./tab-bar"; + +function reset() { + state.activeWorkspaceSlug = "acme"; + state.byWorkspace = { + acme: { + activeTabId: "tA", + tabs: [ + { id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false }, + { id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false }, + ], + }, + }; + state.togglePin.mockReset(); + state.closeTab.mockReset(); + state.setActiveTab.mockReset(); + state.moveTab.mockReset(); + state.addTab.mockReset(); +} + +beforeEach(reset); + +describe("TabBar hover action buttons", () => { + it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => { + state.byWorkspace.acme.tabs = [ + { id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true }, + { id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false }, + ]; + const { getAllByLabelText } = render(); + expect(getAllByLabelText("Unpin tab")).toHaveLength(1); + expect(getAllByLabelText("Pin tab")).toHaveLength(1); + }); + + it("clicking the Pin button calls togglePin for the tab", () => { + const { getAllByLabelText } = render(); + const pinButtons = getAllByLabelText("Pin tab"); + fireEvent.click(pinButtons[1]); // click Pin on tB (Projects) + expect(state.togglePin).toHaveBeenCalledWith("tB"); + }); + + it("clicking the Unpin button on a pinned tab calls togglePin", () => { + state.byWorkspace.acme.tabs = [ + { id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true }, + { id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false }, + ]; + const { getByLabelText } = render(); + fireEvent.click(getByLabelText("Unpin tab")); + expect(state.togglePin).toHaveBeenCalledWith("tA"); + }); + + it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => { + state.byWorkspace.acme.tabs = [ + { id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true }, + { id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false }, + ]; + const { queryAllByLabelText } = render(); + // Only the unpinned tab exposes a Close affordance — pinned tab requires + // explicit Unpin first (RFC §3 D3c FINAL). + expect(queryAllByLabelText("Close tab")).toHaveLength(1); + }); + + it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => { + state.byWorkspace.acme.tabs = [ + { id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true }, + ]; + const { getByLabelText } = render(); + const pinnedTab = getByLabelText("Issues (pinned)"); + expect(within(pinnedTab).getByText("Issues")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/tab-bar.tsx b/apps/desktop/src/renderer/src/components/tab-bar.tsx index b280e94ea..ff033dffc 100644 --- a/apps/desktop/src/renderer/src/components/tab-bar.tsx +++ b/apps/desktop/src/renderer/src/components/tab-bar.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect } from "react"; +import { Fragment } from "react"; import { Inbox, CircleUser, @@ -36,7 +36,6 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, - ContextMenuShortcut, ContextMenuTrigger, } from "@multica/ui/components/ui/context-menu"; import { cn } from "@multica/ui/lib/utils"; @@ -44,7 +43,6 @@ import { useTabStore, useActiveGroup, resolveRouteIcon, - getActiveTab, type Tab, } from "@/stores/tab-store"; import { paths } from "@multica/core/paths"; @@ -69,7 +67,7 @@ function SortableTabItem({ /** * True iff this is the only tab in the workspace. Hiding X on the last * tab matches existing behavior and avoids the surprise of the store's - * last-tab reseed kicking in. Pinned tabs always hide X (D3c). + * last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c). */ isOnly: boolean; }) { @@ -105,18 +103,20 @@ function SortableTabItem({ closeTab(tab.id); }; - const stopDragOnClose = (e: React.PointerEvent) => { + const handleTogglePin = (e: React.MouseEvent) => { + e.stopPropagation(); + togglePin(tab.id); + }; + + const stopDragOnAction = (e: React.PointerEvent) => { e.stopPropagation(); }; - // Pinned tabs: - // - icon-only (no title, no X) — Chrome style, RFC §3 D1v-i FINAL. - // - narrow fixed width so they collapse to ~icon + padding. - // - accent left border so they read as a distinct group even when the - // bar is crowded and the inter-zone gap (rendered by TabBar) gets - // hidden by horizontal scroll. + // Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only weak + // visual differences vs. unpinned tabs are the accent left border and the + // suppressed X (closing requires explicit Unpin). Pin/Unpin is reachable + // via the hover action button below and the right-click menu fallback. const showCloseButton = !tab.pinned && !isOnly; - const showTitle = !tab.pinned; const tabButton = (