mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
fix(desktop): animate overflowing tab additions
This commit is contained in:
@@ -122,7 +122,7 @@ function MainTopBar() {
|
||||
transition={toolbarMotion}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full items-center">
|
||||
<div className="relative z-10 flex h-full min-w-0 max-w-full items-center">
|
||||
<TabBar />
|
||||
</div>
|
||||
</motion.header>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, within } from "@testing-library/react";
|
||||
import { afterAll, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
renderHook,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
@@ -22,6 +29,7 @@ const state = vi.hoisted(() => ({
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
togglePin: vi.fn<(tabId: string) => void>(),
|
||||
closeTab: vi.fn<(tabId: string) => void>(),
|
||||
closeOtherTabs: 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>(),
|
||||
@@ -37,6 +45,7 @@ vi.mock("@/stores/tab-store", () => {
|
||||
},
|
||||
togglePin: state.togglePin,
|
||||
closeTab: state.closeTab,
|
||||
closeOtherTabs: state.closeOtherTabs,
|
||||
setActiveTab: state.setActiveTab,
|
||||
moveTab: state.moveTab,
|
||||
addTab: state.addTab,
|
||||
@@ -77,12 +86,35 @@ function reset() {
|
||||
};
|
||||
state.togglePin.mockReset();
|
||||
state.closeTab.mockReset();
|
||||
state.closeOtherTabs.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.moveTab.mockReset();
|
||||
state.addTab.mockReset();
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("TabBar hover action buttons", () => {
|
||||
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
|
||||
@@ -149,3 +181,234 @@ describe("TabBar hover action buttons", () => {
|
||||
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabBar overflow", () => {
|
||||
it("keeps tabs readable in a bounded horizontal scroller", () => {
|
||||
state.byWorkspace.acme.tabs = Array.from({ length: 8 }, (_, index) => ({
|
||||
id: `t${index}`,
|
||||
path: `/acme/tab-${index}`,
|
||||
title: `Tab ${index}`,
|
||||
icon: "ListTodo",
|
||||
pinned: index === 0,
|
||||
}));
|
||||
|
||||
const { container, getByLabelText } = render(<TabBar />);
|
||||
const tabBar = container.firstElementChild;
|
||||
const tabScroller = container.querySelector("[data-tab-scroll-container]");
|
||||
|
||||
expect(tabBar).toHaveClass("min-w-0", "max-w-full");
|
||||
expect(tabScroller).toHaveClass(
|
||||
"min-w-0",
|
||||
"no-scrollbar",
|
||||
"overflow-x-auto",
|
||||
"overflow-y-hidden",
|
||||
);
|
||||
expect(getByLabelText("Tab 1").closest("[data-tab-frame]")).toHaveClass(
|
||||
"w-40",
|
||||
"min-w-32",
|
||||
);
|
||||
|
||||
const newTabButton = getByLabelText("New tab");
|
||||
expect(tabScroller).not.toContainElement(newTabButton);
|
||||
});
|
||||
|
||||
it("uses a directional mask instead of a visible scrollbar", async () => {
|
||||
const tabScroller = document.createElement("div");
|
||||
Object.defineProperties(tabScroller, {
|
||||
clientWidth: { configurable: true, value: 320 },
|
||||
scrollWidth: { configurable: true, value: 960 },
|
||||
});
|
||||
const tabScrollRef = { current: tabScroller };
|
||||
const { result } = renderHook(() =>
|
||||
useScrollFade(tabScrollRef, 24, "horizontal"),
|
||||
);
|
||||
|
||||
tabScroller.scrollLeft = 0;
|
||||
fireEvent.scroll(tabScroller);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.maskImage).toBe(
|
||||
"linear-gradient(to right, black 0%, black calc(100% - 24px), transparent 100%)",
|
||||
);
|
||||
});
|
||||
|
||||
tabScroller.scrollLeft = 240;
|
||||
fireEvent.scroll(tabScroller);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.maskImage).toBe(
|
||||
"linear-gradient(to right, transparent 0%, black 24px, black calc(100% - 24px), transparent 100%)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls only the tab strip when the active tab moves out of view", () => {
|
||||
state.byWorkspace.acme.tabs = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `t${index}`,
|
||||
path: `/acme/tab-${index}`,
|
||||
title: `Tab ${index}`,
|
||||
icon: "ListTodo",
|
||||
pinned: false,
|
||||
}));
|
||||
state.byWorkspace.acme.activeTabId = "t0";
|
||||
|
||||
const { container, getByLabelText, rerender } = render(<TabBar />);
|
||||
const tabScroller = container.querySelector(
|
||||
"[data-tab-scroll-container]",
|
||||
) as HTMLDivElement;
|
||||
const lastTab = getByLabelText("Tab 5");
|
||||
|
||||
vi.spyOn(tabScroller, "getBoundingClientRect").mockReturnValue({
|
||||
left: 100,
|
||||
right: 420,
|
||||
} as DOMRect);
|
||||
vi.spyOn(lastTab, "getBoundingClientRect").mockReturnValue({
|
||||
left: 450,
|
||||
right: 578,
|
||||
} as DOMRect);
|
||||
Object.defineProperties(tabScroller, {
|
||||
clientWidth: { configurable: true, value: 320 },
|
||||
scrollWidth: { configurable: true, value: 960 },
|
||||
});
|
||||
tabScroller.scrollLeft = 40;
|
||||
|
||||
state.byWorkspace.acme.activeTabId = "t5";
|
||||
rerender(<TabBar />);
|
||||
|
||||
expect(tabScroller.scrollLeft).toBe(222);
|
||||
});
|
||||
|
||||
it("smoothly reveals a newly added active tab", () => {
|
||||
state.byWorkspace.acme.tabs = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `t${index}`,
|
||||
path: `/acme/tab-${index}`,
|
||||
title: `Tab ${index}`,
|
||||
icon: "ListTodo",
|
||||
pinned: false,
|
||||
}));
|
||||
state.byWorkspace.acme.activeTabId = "t0";
|
||||
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
if (this.matches("[data-tab-scroll-container]")) {
|
||||
return { left: 100, right: 420 } as DOMRect;
|
||||
}
|
||||
if (this.matches('[data-tab-id="t6"]')) {
|
||||
return { left: 450, right: 578 } as DOMRect;
|
||||
}
|
||||
return { left: 120, right: 248 } as DOMRect;
|
||||
});
|
||||
|
||||
const { container, getByLabelText, rerender } = render(<TabBar />);
|
||||
const tabScroller = container.querySelector(
|
||||
"[data-tab-scroll-container]",
|
||||
) as HTMLDivElement;
|
||||
Object.defineProperties(tabScroller, {
|
||||
clientWidth: { configurable: true, value: 320 },
|
||||
scrollWidth: { configurable: true, value: 960 },
|
||||
});
|
||||
tabScroller.scrollLeft = 40;
|
||||
const scrollTo = vi.fn(({ left }: ScrollToOptions) => {
|
||||
if (typeof left === "number") tabScroller.scrollLeft = left;
|
||||
});
|
||||
Object.defineProperty(tabScroller, "scrollTo", {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
|
||||
state.byWorkspace.acme.tabs = [
|
||||
...state.byWorkspace.acme.tabs,
|
||||
{
|
||||
id: "t6",
|
||||
path: "/acme/tab-6",
|
||||
title: "Tab 6",
|
||||
icon: "ListTodo",
|
||||
pinned: false,
|
||||
},
|
||||
];
|
||||
state.byWorkspace.acme.activeTabId = "t6";
|
||||
rerender(<TabBar />);
|
||||
|
||||
expect(getByLabelText("Tab 6")).toHaveAttribute(
|
||||
"data-tab-entering",
|
||||
"true",
|
||||
);
|
||||
expect(scrollTo).toHaveBeenCalledWith({ left: 222, behavior: "smooth" });
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps background additions offscreen and acknowledges them at the edge", () => {
|
||||
state.byWorkspace.acme.tabs = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `t${index}`,
|
||||
path: `/acme/tab-${index}`,
|
||||
title: `Tab ${index}`,
|
||||
icon: "ListTodo",
|
||||
pinned: false,
|
||||
}));
|
||||
state.byWorkspace.acme.activeTabId = "t0";
|
||||
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
if (this.matches("[data-tab-scroll-container]")) {
|
||||
return { left: 100, right: 420 } as DOMRect;
|
||||
}
|
||||
if (this.matches('[data-tab-id="t6"]')) {
|
||||
return { left: 450, right: 578 } as DOMRect;
|
||||
}
|
||||
return { left: 120, right: 248 } as DOMRect;
|
||||
});
|
||||
|
||||
const { container, rerender } = render(<TabBar />);
|
||||
const tabScroller = container.querySelector(
|
||||
"[data-tab-scroll-container]",
|
||||
) as HTMLDivElement;
|
||||
Object.defineProperties(tabScroller, {
|
||||
clientWidth: { configurable: true, value: 320 },
|
||||
scrollWidth: { configurable: true, value: 960 },
|
||||
});
|
||||
tabScroller.scrollLeft = 40;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(tabScroller, "scrollTo", {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
|
||||
state.byWorkspace.acme.tabs = [
|
||||
...state.byWorkspace.acme.tabs,
|
||||
{
|
||||
id: "t6",
|
||||
path: "/acme/tab-6",
|
||||
title: "Tab 6",
|
||||
icon: "ListTodo",
|
||||
pinned: false,
|
||||
},
|
||||
];
|
||||
rerender(<TabBar />);
|
||||
|
||||
expect(tabScroller.scrollLeft).toBe(40);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(
|
||||
container.querySelector('[data-new-tab-edge-feedback="true"]'),
|
||||
).toBeInTheDocument();
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabBar context menu", () => {
|
||||
it("closes other tabs from the context menu", async () => {
|
||||
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 },
|
||||
{ id: "tC", path: "/acme/agents", title: "Agents", icon: "Bot", pinned: false },
|
||||
];
|
||||
|
||||
const { findByText, getByLabelText } = render(<TabBar />);
|
||||
fireEvent.contextMenu(getByLabelText("Projects"));
|
||||
fireEvent.click(await findByText("Close other tabs"));
|
||||
|
||||
expect(state.closeOtherTabs).toHaveBeenCalledWith("tB");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -11,6 +19,7 @@ import {
|
||||
Plus,
|
||||
Pin,
|
||||
PinOff,
|
||||
ListX,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -38,6 +47,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
useTabStore,
|
||||
@@ -57,10 +67,97 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
const TAB_SCROLL_FADE_SIZE = 24;
|
||||
const TAB_ENTRY_EASE = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
type TabSnapshot = {
|
||||
workspaceSlug: string | null;
|
||||
ids: Set<string>;
|
||||
};
|
||||
|
||||
function getAddedTabIds(
|
||||
previous: TabSnapshot | null,
|
||||
workspaceSlug: string | null,
|
||||
currentIds: string[],
|
||||
) {
|
||||
if (
|
||||
!previous ||
|
||||
previous.workspaceSlug !== workspaceSlug ||
|
||||
currentIds.length <= previous.ids.size
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return currentIds.filter((id) => !previous.ids.has(id));
|
||||
}
|
||||
|
||||
function getTabElement(
|
||||
scroller: HTMLDivElement,
|
||||
tabId?: string,
|
||||
): HTMLElement | null {
|
||||
if (!tabId) {
|
||||
return scroller.querySelector<HTMLElement>('[data-tab-active="true"]');
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>("[data-tab-id]"),
|
||||
).find((candidate) => candidate.dataset.tabId === tabId) ?? null;
|
||||
}
|
||||
|
||||
function getTabScrollTarget(
|
||||
scroller: HTMLDivElement,
|
||||
tab: HTMLElement,
|
||||
): number {
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const tabRect = tab.getBoundingClientRect();
|
||||
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
const hasHiddenLeft = scroller.scrollLeft > 1;
|
||||
const hasHiddenRight = scroller.scrollLeft < maxScrollLeft - 1;
|
||||
const visibleLeft =
|
||||
scrollerRect.left + (hasHiddenLeft ? TAB_SCROLL_FADE_SIZE : 0);
|
||||
const visibleRight =
|
||||
scrollerRect.right - (hasHiddenRight ? TAB_SCROLL_FADE_SIZE : 0);
|
||||
|
||||
if (tabRect.left < visibleLeft) {
|
||||
return Math.max(0, scroller.scrollLeft - (visibleLeft - tabRect.left));
|
||||
}
|
||||
if (tabRect.right > visibleRight) {
|
||||
return Math.min(
|
||||
maxScrollLeft,
|
||||
scroller.scrollLeft + (tabRect.right - visibleRight),
|
||||
);
|
||||
}
|
||||
return scroller.scrollLeft;
|
||||
}
|
||||
|
||||
// Keep scrolling scoped to the strip. Native scrollIntoView can also move
|
||||
// scrollable desktop-shell ancestors and displace the whole window chrome.
|
||||
function keepTabVisible(
|
||||
scroller: HTMLDivElement | null,
|
||||
tabId?: string,
|
||||
behavior: ScrollBehavior = "auto",
|
||||
) {
|
||||
if (!scroller) return;
|
||||
const tab = getTabElement(scroller, tabId);
|
||||
if (!tab) return;
|
||||
|
||||
const target = getTabScrollTarget(scroller, tab);
|
||||
if (Math.abs(target - scroller.scrollLeft) <= 1) return;
|
||||
|
||||
if (behavior === "smooth" && typeof scroller.scrollTo === "function") {
|
||||
scroller.scrollTo({ left: target, behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
scroller.scrollLeft = target;
|
||||
}
|
||||
|
||||
function SortableTabItem({
|
||||
tab,
|
||||
isActive,
|
||||
isOnly,
|
||||
canCloseOthers,
|
||||
isNew,
|
||||
shouldReduceMotion,
|
||||
}: {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
@@ -70,9 +167,13 @@ function SortableTabItem({
|
||||
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
|
||||
*/
|
||||
isOnly: boolean;
|
||||
canCloseOthers: boolean;
|
||||
isNew: boolean;
|
||||
shouldReduceMotion: boolean;
|
||||
}) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const closeOtherTabs = useTabStore((s) => s.closeOtherTabs);
|
||||
const togglePin = useTabStore((s) => s.togglePin);
|
||||
|
||||
const {
|
||||
@@ -121,19 +222,28 @@ function SortableTabItem({
|
||||
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
|
||||
// reachable via the hover action button below and the right-click menu.
|
||||
const showCloseButton = !tab.pinned && !isOnly;
|
||||
const [isEntering, setIsEntering] = useState(isNew && !shouldReduceMotion);
|
||||
const [showAddedHighlight, setShowAddedHighlight] = useState(isNew);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
setIsEntering(false);
|
||||
setShowAddedHighlight(false);
|
||||
}, [isDragging]);
|
||||
|
||||
const tabButton = (
|
||||
<button
|
||||
type="button"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
|
||||
data-tab-active={isActive ? "true" : undefined}
|
||||
data-tab-entering={isEntering ? "true" : undefined}
|
||||
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"group flex size-full min-w-0 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
@@ -176,33 +286,130 @@ function SortableTabItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={tabButton} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => togglePin(tab.id)}>
|
||||
{tab.pinned ? (
|
||||
<>
|
||||
<PinOff />
|
||||
Unpin tab
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin />
|
||||
Pin tab
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={tab.pinned || isOnly}
|
||||
onClick={() => closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
Close tab
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-tab-frame
|
||||
data-tab-id={tab.id}
|
||||
className="h-7 w-40 min-w-32"
|
||||
>
|
||||
<motion.div
|
||||
className="relative size-full"
|
||||
initial={isEntering ? { opacity: 0, x: 8 } : false}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: isEntering ? 0.18 : 0, ease: TAB_ENTRY_EASE }}
|
||||
onAnimationComplete={() => setIsEntering(false)}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={tabButton} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => togglePin(tab.id)}>
|
||||
{tab.pinned ? (
|
||||
<>
|
||||
<PinOff />
|
||||
Unpin tab
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin />
|
||||
Pin tab
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={tab.pinned || isOnly}
|
||||
onClick={() => closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
Close tab
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={!canCloseOthers}
|
||||
onClick={() => closeOtherTabs(tab.id)}
|
||||
>
|
||||
<ListX />
|
||||
Close other tabs
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{showAddedHighlight && (
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 rounded-md bg-primary/10 ring-1 ring-inset ring-primary/20"
|
||||
initial={{ opacity: shouldReduceMotion ? 0.25 : 0.65 }}
|
||||
animate={{ opacity: 0 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0.16 : 0.42 }}
|
||||
onAnimationComplete={() => setShowAddedHighlight(false)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabEdgeFeedback({
|
||||
newTabId,
|
||||
scrollerRef,
|
||||
shouldReduceMotion,
|
||||
}: {
|
||||
newTabId: string | null;
|
||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||
shouldReduceMotion: boolean;
|
||||
}) {
|
||||
const sequenceRef = useRef(0);
|
||||
const [signal, setSignal] = useState<{
|
||||
tabId: string;
|
||||
sequence: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller || !newTabId) return;
|
||||
const tab = getTabElement(scroller, newTabId);
|
||||
if (!tab || getTabScrollTarget(scroller, tab) === scroller.scrollLeft) {
|
||||
return;
|
||||
}
|
||||
|
||||
sequenceRef.current += 1;
|
||||
setSignal({ tabId: newTabId, sequence: sequenceRef.current });
|
||||
}, [newTabId, scrollerRef]);
|
||||
|
||||
if (!signal) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${signal.tabId}-${signal.sequence}`}
|
||||
aria-hidden
|
||||
data-new-tab-edge-feedback="true"
|
||||
className="pointer-events-none absolute inset-y-2 right-0 z-20 w-8 rounded-r-md bg-gradient-to-l from-primary/35 via-primary/10 to-transparent"
|
||||
initial={{
|
||||
opacity: shouldReduceMotion ? 0.45 : 0,
|
||||
x: shouldReduceMotion ? 0 : 4,
|
||||
}}
|
||||
animate={{
|
||||
opacity: shouldReduceMotion ? [0.45, 0] : [0, 0.8, 0],
|
||||
x: 0,
|
||||
}}
|
||||
transition={{
|
||||
opacity: {
|
||||
duration: shouldReduceMotion ? 0.2 : 0.45,
|
||||
times: shouldReduceMotion ? [0, 1] : [0, 0.18, 1],
|
||||
ease: "easeOut",
|
||||
},
|
||||
x: {
|
||||
duration: shouldReduceMotion ? 0 : 0.18,
|
||||
ease: TAB_ENTRY_EASE,
|
||||
},
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
setSignal((current) =>
|
||||
current?.sequence === signal.sequence ? null : current,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,6 +431,8 @@ function NewTabButton() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label="New tab"
|
||||
title="New tab"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
|
||||
>
|
||||
@@ -235,6 +444,15 @@ function NewTabButton() {
|
||||
export function TabBar() {
|
||||
const group = useActiveGroup();
|
||||
const moveTab = useTabStore((s) => s.moveTab);
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const shouldReduceMotion = useReducedMotion() ?? false;
|
||||
const tabScrollRef = useRef<HTMLDivElement>(null);
|
||||
const previousTabsRef = useRef<TabSnapshot | null>(null);
|
||||
const tabFadeStyle = useScrollFade(
|
||||
tabScrollRef,
|
||||
TAB_SCROLL_FADE_SIZE,
|
||||
"horizontal",
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -245,9 +463,58 @@ export function TabBar() {
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
const tabOrder = tabIds.join("\0");
|
||||
const addedTabIds = getAddedTabIds(
|
||||
previousTabsRef.current,
|
||||
activeWorkspaceSlug,
|
||||
tabIds,
|
||||
);
|
||||
const addedTabIdSet = new Set(addedTabIds);
|
||||
const newestTabId = addedTabIds.at(-1) ?? null;
|
||||
const backgroundAddedTabId =
|
||||
newestTabId && newestTabId !== activeTabId ? newestTabId : null;
|
||||
const pinnedCount = tabs.filter((t) => t.pinned).length;
|
||||
const unpinnedCount = tabs.length - pinnedCount;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const currentTabIds = tabOrder ? tabOrder.split("\0") : [];
|
||||
const newlyAddedIds = getAddedTabIds(
|
||||
previousTabsRef.current,
|
||||
activeWorkspaceSlug,
|
||||
currentTabIds,
|
||||
);
|
||||
previousTabsRef.current = {
|
||||
workspaceSlug: activeWorkspaceSlug,
|
||||
ids: new Set(currentTabIds),
|
||||
};
|
||||
|
||||
if (newlyAddedIds.length > 0) {
|
||||
if (newlyAddedIds.includes(activeTabId)) {
|
||||
keepTabVisible(
|
||||
tabScrollRef.current,
|
||||
activeTabId,
|
||||
shouldReduceMotion ? "auto" : "smooth",
|
||||
);
|
||||
}
|
||||
// Background additions intentionally preserve the user's current tab and
|
||||
// scroll position. NewTabEdgeFeedback handles the offscreen acknowledgement.
|
||||
return;
|
||||
}
|
||||
|
||||
keepTabVisible(tabScrollRef.current);
|
||||
}, [activeWorkspaceSlug, activeTabId, shouldReduceMotion, tabOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = tabScrollRef.current;
|
||||
if (!scroller || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
// Sidebar and window resizing can hide the active tab without changing
|
||||
// activeTabId, so keep it visible as the strip's viewport changes.
|
||||
const resizeObserver = new ResizeObserver(() => keepTabVisible(scroller));
|
||||
resizeObserver.observe(scroller);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
@@ -260,33 +527,52 @@ export function TabBar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
<SortableTabItem
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{tab.pinned &&
|
||||
index === pinnedCount - 1 &&
|
||||
unpinnedCount > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-1 h-4 w-px bg-border"
|
||||
<div className="flex h-full w-full min-w-0 max-w-full items-center justify-start gap-0.5 px-2">
|
||||
<div className="relative flex h-full min-w-0 flex-1 items-center">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div
|
||||
ref={tabScrollRef}
|
||||
data-tab-scroll-container
|
||||
className="no-scrollbar flex h-full min-w-0 flex-1 items-center gap-0.5 overflow-x-auto overflow-y-hidden overscroll-x-contain"
|
||||
style={tabFadeStyle}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
<SortableTabItem
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
canCloseOthers={tabs.some(
|
||||
(candidate) => candidate.id !== tab.id && !candidate.pinned,
|
||||
)}
|
||||
isNew={addedTabIdSet.has(tab.id)}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{tab.pinned &&
|
||||
index === pinnedCount - 1 &&
|
||||
unpinnedCount > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-1 h-4 w-px shrink-0 bg-border"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</DndContext>
|
||||
<NewTabEdgeFeedback
|
||||
newTabId={backgroundAddedTabId}
|
||||
scrollerRef={tabScrollRef}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
</div>
|
||||
{group && <NewTabButton />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -320,6 +320,59 @@ describe("useTabStore actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk tab closing", () => {
|
||||
it("closes other unpinned tabs, preserves pinned tabs, and activates the target", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const agentsId = store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const settingsId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
store.togglePin(issuesId);
|
||||
store.setActiveTab(agentsId);
|
||||
|
||||
const before = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
const agentsDispose = vi.mocked(before.find((tab) => tab.id === agentsId)!.router.dispose);
|
||||
const settingsDispose = vi.mocked(
|
||||
before.find((tab) => tab.id === settingsId)!.router.dispose,
|
||||
);
|
||||
|
||||
store.closeOtherTabs(projectsId);
|
||||
|
||||
const group = useTabStore.getState().byWorkspace.acme;
|
||||
expect(group.tabs.map((tab) => tab.id)).toEqual([issuesId, projectsId]);
|
||||
expect(group.activeTabId).toBe(projectsId);
|
||||
expect(agentsDispose).not.toHaveBeenCalled();
|
||||
expect(settingsDispose).not.toHaveBeenCalled();
|
||||
|
||||
vi.runAllTimers();
|
||||
expect(agentsDispose).toHaveBeenCalledOnce();
|
||||
expect(settingsDispose).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a surviving active tab when closing other tabs", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
store.addTab("/acme/settings", "Settings", "Settings");
|
||||
store.togglePin(issuesId);
|
||||
store.setActiveTab(issuesId);
|
||||
|
||||
store.closeOtherTabs(projectsId);
|
||||
|
||||
const group = useTabStore.getState().byWorkspace.acme;
|
||||
expect(group.tabs.map((tab) => tab.id)).toEqual([issuesId, projectsId]);
|
||||
expect(group.activeTabId).toBe(issuesId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("flips a tab's pinned state", () => {
|
||||
const store = useTabStore.getState();
|
||||
|
||||
@@ -76,6 +76,8 @@ interface TabStore {
|
||||
* "every live workspace has at least one tab" holds.
|
||||
*/
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Close every other unpinned tab in the target tab's workspace. */
|
||||
closeOtherTabs: (tabId: string) => void;
|
||||
/**
|
||||
* Activate a tab. Finds it across all workspaces. Sets both the owning
|
||||
* workspace as active and that group's activeTabId; needed for any code
|
||||
@@ -257,6 +259,44 @@ function findTabLocation(
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCloseOtherTabsResult(
|
||||
byWorkspace: Record<string, WorkspaceTabGroup>,
|
||||
tabId: string,
|
||||
): {
|
||||
nextByWorkspace: Record<string, WorkspaceTabGroup>;
|
||||
closingTabs: Tab[];
|
||||
} | null {
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return null;
|
||||
const { slug, group } = hit;
|
||||
const closingTabs = group.tabs.filter(
|
||||
(tab) => !tab.pinned && tab.id !== tabId,
|
||||
);
|
||||
if (closingTabs.length === 0) return null;
|
||||
|
||||
const closingIds = new Set(closingTabs.map((tab) => tab.id));
|
||||
const nextTabs = group.tabs.filter((tab) => !closingIds.has(tab.id));
|
||||
const nextActiveTabId = closingIds.has(group.activeTabId)
|
||||
? tabId
|
||||
: group.activeTabId;
|
||||
|
||||
return {
|
||||
nextByWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
closingTabs,
|
||||
};
|
||||
}
|
||||
|
||||
function disposeTabRoutersAfterUnmount(tabs: readonly Tab[]) {
|
||||
if (tabs.length === 0) return;
|
||||
// Let React unmount every closed tab's RouterProvider before disposal.
|
||||
window.setTimeout(() => {
|
||||
for (const tab of tabs) tab.router.dispose();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -388,10 +428,6 @@ export const useTabStore = create<TabStore>()(
|
||||
const { slug, group, index } = hit;
|
||||
|
||||
const closing = group.tabs[index];
|
||||
const disposeClosingRouter = () => {
|
||||
// Let React unmount the tab's RouterProvider before disposing it.
|
||||
window.setTimeout(() => closing.router.dispose(), 0);
|
||||
};
|
||||
|
||||
if (group.tabs.length === 1) {
|
||||
// Last tab in this workspace — reseed a default so the workspace
|
||||
@@ -404,7 +440,7 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: [fresh], activeTabId: fresh.id },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
disposeTabRoutersAfterUnmount([closing]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -420,7 +456,15 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
disposeTabRoutersAfterUnmount([closing]);
|
||||
},
|
||||
|
||||
closeOtherTabs(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const result = buildCloseOtherTabsResult(byWorkspace, tabId);
|
||||
if (!result) return;
|
||||
set({ byWorkspace: result.nextByWorkspace });
|
||||
disposeTabRoutersAfterUnmount(result.closingTabs);
|
||||
},
|
||||
|
||||
setActiveTab(tabId) {
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
|
||||
|
||||
export type ScrollFadeAxis = "vertical" | "horizontal";
|
||||
|
||||
/**
|
||||
* Returns a dynamic maskImage style based on scroll position.
|
||||
* - At top → fade bottom only
|
||||
* - At bottom → fade top only
|
||||
* - At start → fade end only
|
||||
* - At end → fade start only
|
||||
* - In middle → fade both
|
||||
* - No overflow → undefined (no mask)
|
||||
*/
|
||||
export function useScrollFade(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
fadeSize = 32
|
||||
fadeSize = 32,
|
||||
axis: ScrollFadeAxis = "vertical",
|
||||
): CSSProperties | undefined {
|
||||
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
|
||||
const [fade, setFade] = useState<"none" | "start" | "end" | "both">("none");
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
const scrollable = scrollHeight - clientHeight;
|
||||
const position = axis === "horizontal" ? el.scrollLeft : el.scrollTop;
|
||||
const scrollSize = axis === "horizontal" ? el.scrollWidth : el.scrollHeight;
|
||||
const clientSize = axis === "horizontal" ? el.clientWidth : el.clientHeight;
|
||||
const scrollable = scrollSize - clientSize;
|
||||
|
||||
if (scrollable <= 0) {
|
||||
setFade("none");
|
||||
return;
|
||||
}
|
||||
|
||||
const atTop = scrollTop <= 1;
|
||||
const atBottom = scrollTop >= scrollable - 1;
|
||||
const atStart = position <= 1;
|
||||
const atEnd = position >= scrollable - 1;
|
||||
|
||||
if (atTop && atBottom) setFade("none");
|
||||
else if (atTop) setFade("bottom");
|
||||
else if (atBottom) setFade("top");
|
||||
if (atStart && atEnd) setFade("none");
|
||||
else if (atStart) setFade("end");
|
||||
else if (atEnd) setFade("start");
|
||||
else setFade("both");
|
||||
}, [ref]);
|
||||
}, [axis, ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -44,10 +49,9 @@ export function useScrollFade(
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
// ResizeObserver only fires on the container's own box. When children
|
||||
// grow inside a flex/auto-height parent (e.g. async-loaded list items,
|
||||
// collapsibles), scrollHeight changes but clientHeight does not — the
|
||||
// mask would stay "none" until the user scrolls. MutationObserver on
|
||||
// childList catches those content insertions.
|
||||
// grow inside a flex/auto-sized parent, the scroll extent can change while
|
||||
// the viewport does not — the mask would stay "none" until the user scrolls.
|
||||
// MutationObserver on childList catches those content insertions.
|
||||
const mo = new MutationObserver(update);
|
||||
mo.observe(el, { childList: true, subtree: true });
|
||||
|
||||
@@ -61,13 +65,17 @@ export function useScrollFade(
|
||||
|
||||
if (fade === "none") return undefined;
|
||||
|
||||
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
|
||||
const bottom =
|
||||
fade === "bottom" || fade === "both"
|
||||
const start =
|
||||
fade === "start" || fade === "both"
|
||||
? `transparent 0%, black ${fadeSize}px`
|
||||
: "black 0%";
|
||||
const end =
|
||||
fade === "end" || fade === "both"
|
||||
? `black calc(100% - ${fadeSize}px), transparent 100%`
|
||||
: "black 100%";
|
||||
|
||||
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
|
||||
const direction = axis === "horizontal" ? "right" : "bottom";
|
||||
const gradient = `linear-gradient(to ${direction}, ${start}, ${end})`;
|
||||
|
||||
return {
|
||||
maskImage: gradient,
|
||||
|
||||
Reference in New Issue
Block a user