MUL-7233: add desktop navigation history menus (#8250)

* feat(desktop): add navigation history menus

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): share browsing history across tabs

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): seed navigation origin in browsing history

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): right-align window toolbar

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): record inbox issue history

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): keep toolbar clear after sidebar toggle

Co-authored-by: multica-agent <[email protected]>

* fix(desktop): address navigation history review

Co-authored-by: multica-agent <[email protected]>

* test(views): assert persisted sidebar width portably

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiayuan Zhang
2026-09-10 20:45:55 +08:00
committed by GitHub
co-authored by multica-agent Lambda
parent 0590011318
commit 7e1dfba124
14 changed files with 1621 additions and 133 deletions
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import { useSidebar } from "@multica/ui/components/ui/sidebar";
@@ -20,8 +20,12 @@ vi.mock("@/hooks/use-tab-history", () => ({
useTabHistory: () => ({
canGoBack: false,
canGoForward: false,
historyEntries: [],
historyIndex: 0,
browsingHistory: [],
goBack: vi.fn(),
goForward: vi.fn(),
goToHistoryIndex: vi.fn(),
}),
useNavigationInputBindings: () => {},
}));
@@ -126,5 +130,17 @@ describe("DesktopShell sidebar trigger", () => {
"data-external-trigger",
"true",
);
const mainTopBar = container.querySelector('[data-slot="main-top-bar"]');
expect(mainTopBar).toHaveAttribute("data-sidebar-resize-consumer");
expect(mainTopBar).toHaveClass("transition-[padding-left]");
expect(mainTopBar).toHaveStyle({
paddingLeft:
"max(0px, calc(256px - var(--sidebar-live-width, var(--sidebar-width))))",
});
fireEvent.click(
container.querySelector('[data-slot="sidebar-trigger"]')!,
);
expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" });
});
});
@@ -1,5 +1,4 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
@@ -9,7 +8,6 @@ import {
} from "@/hooks/use-tab-history";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
@@ -35,9 +33,9 @@ import {
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
import { WindowToolbar, WINDOW_TOOLBAR_CLEARANCE } from "./window-toolbar";
const TOP_BAR_HEIGHT_CLASS = "h-12";
const WINDOW_TOOLBAR_CLEARANCE = 184;
const toolbarMotion = {
type: "spring",
stiffness: 420,
@@ -45,56 +43,6 @@ const toolbarMotion = {
mass: 0.8,
} as const;
function WindowToolbar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
const navButtonClassName =
"flex size-7 items-center justify-center rounded-md text-faint-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
return (
<div
className={cn(
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
TOP_BAR_HEIGHT_CLASS,
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-1 pl-[70px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SidebarTrigger
className="size-7 text-faint-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
title="Go back"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
title="Go forward"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
);
}
function SidebarTopSpacer() {
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
}
@@ -115,31 +63,44 @@ function useNativeNavigationGestures() {
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width, leave room for the fixed window toolbar
// so tabs do not land beneath the traffic lights / navigation controls.
// is not occupying enough main-flow width, leave the remainder here so tabs
// do not land beneath the traffic lights / navigation controls. The matching
// 200ms transition cancels the sidebar gap's movement during toggle; live
// resize previews disable it through data-sidebar-resize-consumer.
function MainTopBar() {
const { state, isCompact } = useSidebar();
const sidebarHidden = state === "collapsed" || isCompact;
const toolbarClearance: React.CSSProperties["paddingLeft"] = sidebarHidden
? WINDOW_TOOLBAR_CLEARANCE
: `max(0px, calc(${WINDOW_TOOLBAR_CLEARANCE}px - var(--sidebar-live-width, var(--sidebar-width))))`;
return (
<motion.header
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
initial={false}
transition={toolbarMotion}
<header
data-slot="main-top-bar"
data-sidebar-resize-consumer
className={cn(
"relative shrink-0 flex items-center gap-2 transition-[padding-left] duration-200 ease-out motion-reduce:transition-none",
TOP_BAR_HEIGHT_CLASS,
)}
style={{ paddingLeft: toolbarClearance }}
>
<motion.div
<div
aria-hidden
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className="absolute inset-y-0 right-0"
initial={false}
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
style={
{
left: toolbarClearance,
WebkitAppRegion: "drag",
} as React.CSSProperties
}
/>
<div className="relative z-10 flex h-full min-w-0 max-w-full items-center">
<div
data-slot="main-top-bar-content"
className="relative z-10 flex h-full min-w-0 max-w-full items-center"
>
<TabBar />
</div>
</motion.header>
</header>
);
}
@@ -31,8 +31,12 @@ vi.mock("@/hooks/use-tab-history", () => ({
useTabHistory: () => ({
canGoBack: false,
canGoForward: false,
historyEntries: [],
historyIndex: 0,
browsingHistory: [],
goBack: vi.fn(),
goForward: vi.fn(),
goToHistoryIndex: vi.fn(),
}),
useNavigationInputBindings: () => {},
}));
@@ -0,0 +1,258 @@
import type { ComponentProps } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const historyState = vi.hoisted(() => ({
canGoBack: true,
canGoForward: true,
historyEntries: ["/acme/issues", "/acme/projects", "/acme/agents"],
historyIndex: 1,
browsingHistory: [
{ url: "/acme/settings", title: "Settings" },
{ url: "/acme/issues/issue-1", title: "MUL-1: Fix history" },
{ url: "/acme/projects", title: "Projects" },
],
goBack: vi.fn(),
goForward: vi.fn(),
goToHistoryIndex: vi.fn(),
}));
const navigationState = vi.hoisted(() => ({ push: vi.fn() }));
const sidebarState = vi.hoisted(() => ({
state: "expanded" as "expanded" | "collapsed",
isCompact: false,
}));
vi.mock("@/hooks/use-tab-history", () => ({
useTabHistory: () => historyState,
}));
vi.mock("@multica/views/navigation", () => ({
useNavigation: () => navigationState,
}));
vi.mock("@multica/views/layout", () => ({
useTabPresentation: (url: string, fallbackTitle?: string) => ({
visual: { kind: "icon", icon: "Inbox" },
title: fallbackTitle ?? `Title ${url}`,
}),
ResourceLeadingVisual: () => <span aria-hidden />,
}));
vi.mock("@multica/ui/components/ui/sidebar", () => ({
useSidebar: () => sidebarState,
SidebarTrigger: (props: ComponentProps<"button">) => (
<button type="button" aria-label="Toggle sidebar" {...props} />
),
}));
const {
WINDOW_TOOLBAR_CLEARANCE,
WindowToolbar,
browsingHistoryForMenu,
historyIndicesForMenu,
} = await import("./window-toolbar");
beforeEach(() => {
historyState.canGoBack = true;
historyState.canGoForward = true;
historyState.historyEntries = [
"/acme/issues",
"/acme/projects",
"/acme/agents",
];
historyState.historyIndex = 1;
historyState.browsingHistory = [
{ url: "/acme/settings", title: "Settings" },
{ url: "/acme/issues/issue-1", title: "MUL-1: Fix history" },
{ url: "/acme/projects", title: "Projects" },
];
historyState.goBack.mockReset();
historyState.goForward.mockReset();
historyState.goToHistoryIndex.mockReset();
navigationState.push.mockReset();
sidebarState.state = "expanded";
sidebarState.isCompact = false;
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("historyIndicesForMenu", () => {
it("orders backward destinations from nearest to furthest", () => {
expect(historyIndicesForMenu("back", 3, 5)).toEqual([2, 1, 0]);
});
it("orders forward destinations from nearest to furthest", () => {
expect(historyIndicesForMenu("forward", 1, 5)).toEqual([2, 3, 4]);
});
it("bounds directional history to thirty entries", () => {
expect(historyIndicesForMenu("back", 59, 60)).toHaveLength(30);
expect(historyIndicesForMenu("forward", 0, 60)).toHaveLength(30);
});
});
describe("browsingHistoryForMenu", () => {
it("uses workspace browsing history across tabs and excludes the current resource", () => {
expect(
browsingHistoryForMenu(
[
{ url: "/acme/issues/issue-2", title: "Issue 2" },
{ url: "/acme/issues?filter=mine", title: "Issues" },
{ url: "/acme/projects", title: "Projects" },
],
"/acme/issues",
),
).toEqual([
{ url: "/acme/issues/issue-2", title: "Issue 2" },
{ url: "/acme/projects", title: "Projects" },
]);
});
it("excludes only the current Inbox issue while keeping other Inbox visits", () => {
expect(
browsingHistoryForMenu(
[
{ url: "/acme/inbox?issue=issue-b", title: "Issue B" },
{ url: "/acme/inbox?issue=issue-a", title: "Issue A" },
{ url: "/acme/inbox", title: "Inbox" },
{ url: "/acme/projects", title: "Projects" },
],
"/acme/inbox?view=archived&issue=issue-b",
),
).toEqual([
{ url: "/acme/inbox?issue=issue-a", title: "Issue A" },
{ url: "/acme/inbox", title: "Inbox" },
{ url: "/acme/projects", title: "Projects" },
]);
});
it("bounds the recently viewed menu to thirty entries", () => {
const entries = Array.from(
{ length: 60 },
(_, index) => ({
url: `/acme/issues/issue-${index}`,
title: `Issue ${index}`,
}),
);
expect(browsingHistoryForMenu(entries, "/acme/settings")).toHaveLength(30);
});
});
describe("WindowToolbar history controls", () => {
it("right-aligns the controls to the expanded sidebar edge", () => {
render(<WindowToolbar />);
const toolbar = document.querySelector('[data-slot="window-toolbar"]');
expect(toolbar).toHaveClass("justify-end");
expect(toolbar).toHaveStyle({
width:
"max(var(--sidebar-live-width, var(--sidebar-width)), 256px)",
});
expect(toolbar).toHaveAttribute("data-sidebar-resize-consumer");
expect(toolbar).not.toHaveClass("transition-[width]");
});
it("keeps the controls clear of the traffic lights after toggling the sidebar", () => {
const { rerender } = render(<WindowToolbar />);
sidebarState.state = "collapsed";
rerender(<WindowToolbar />);
const toolbar = document.querySelector('[data-slot="window-toolbar"]');
expect(WINDOW_TOOLBAR_CLEARANCE).toBe(256);
expect(toolbar).toHaveClass("justify-end");
expect(toolbar).toHaveStyle({ width: "256px" });
});
it("opens workspace browsing history and navigates the active tab to a selected entry", () => {
render(<WindowToolbar />);
fireEvent.click(screen.getByRole("button", { name: "History" }));
expect(screen.getByText("Recently viewed")).toBeInTheDocument();
expect(screen.getByTitle("MUL-1: Fix history")).toBeInTheDocument();
fireEvent.click(
screen.getByRole("menuitem", { name: "MUL-1: Fix history" }),
);
expect(navigationState.push).toHaveBeenCalledWith(
"/acme/issues/issue-1",
);
expect(historyState.goToHistoryIndex).not.toHaveBeenCalled();
});
it("keeps browsing history available in a fresh tab while its Back and Forward stay disabled", () => {
historyState.canGoBack = false;
historyState.canGoForward = false;
historyState.historyEntries = ["/acme/issues"];
historyState.historyIndex = 0;
historyState.browsingHistory = [
{ url: "/acme/issues", title: "Issues" },
{ url: "/acme/issues/issue-1", title: "MUL-1: Fix history" },
];
render(<WindowToolbar />);
expect(screen.getByRole("button", { name: "History" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Go back" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Go forward" })).toBeDisabled();
});
it("keeps a normal Back click as one-step navigation", () => {
render(<WindowToolbar />);
fireEvent.click(screen.getByRole("button", { name: "Go back" }));
expect(historyState.goBack).toHaveBeenCalledOnce();
});
it("opens Back history on a sustained hold without also stepping back", () => {
render(<WindowToolbar />);
const back = screen.getByRole("button", { name: "Go back" });
fireEvent.pointerDown(back, { button: 0, clientX: 20, clientY: 20 });
act(() => vi.advanceTimersByTime(2_000));
expect(screen.getByText("Back history")).toBeInTheDocument();
fireEvent.pointerUp(back, { button: 0, clientX: 20, clientY: 20 });
fireEvent.click(back);
expect(historyState.goBack).not.toHaveBeenCalled();
expect(screen.getByText("Back history")).toBeInTheDocument();
});
it("offers the same menu from ArrowDown for keyboard users", () => {
render(<WindowToolbar />);
const forward = screen.getByRole("button", { name: "Go forward" });
fireEvent.keyDown(forward, { key: "ArrowDown" });
expect(screen.getByText("Forward history")).toBeInTheDocument();
});
it("jumps to the selected Back history index", () => {
render(<WindowToolbar />);
const back = screen.getByRole("button", { name: "Go back" });
fireEvent.keyDown(back, { key: "ArrowDown" });
fireEvent.click(
screen.getByRole("menuitem", { name: "Title /acme/issues" }),
);
expect(historyState.goToHistoryIndex).toHaveBeenCalledWith(0);
});
it("restores focus to the toolbar button when its menu closes", () => {
render(<WindowToolbar />);
const forward = screen.getByRole("button", { name: "Go forward" });
forward.focus();
fireEvent.keyDown(forward, { key: "ArrowDown" });
act(() => vi.runOnlyPendingTimers());
expect(screen.getAllByRole("menuitem")[0]).toHaveFocus();
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
act(() => vi.runOnlyPendingTimers());
expect(forward).toHaveFocus();
});
});
@@ -0,0 +1,417 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEventHandler,
} from "react";
import { ChevronLeft, ChevronRight, History } from "lucide-react";
import {
SidebarTrigger,
useSidebar,
} from "@multica/ui/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
} from "@multica/ui/components/ui/dropdown-menu";
import {
ResourceLeadingVisual,
useTabPresentation,
} from "@multica/views/layout";
import { useNavigation } from "@multica/views/navigation";
import {
useTabHistory,
type BrowsingHistoryEntry,
} from "@/hooks/use-tab-history";
import { browsingHistoryKeyForUrl } from "@/stores/tab-store";
export const WINDOW_TOOLBAR_CLEARANCE = 256;
const LONG_PRESS_DURATION_MS = 500;
const LONG_PRESS_MOVE_TOLERANCE_PX = 8;
const MAX_HISTORY_MENU_ITEMS = 30;
type HistoryMenuMode = "all" | "back" | "forward";
interface OpenHistoryMenu {
mode: HistoryMenuMode;
anchor: HTMLElement;
}
interface LongPressHandlers {
onPointerDown: PointerEventHandler<HTMLButtonElement>;
onPointerMove: PointerEventHandler<HTMLButtonElement>;
onPointerUp: PointerEventHandler<HTMLButtonElement>;
onPointerCancel: PointerEventHandler<HTMLButtonElement>;
onPointerLeave: PointerEventHandler<HTMLButtonElement>;
consumeClick: () => boolean;
}
function useLongPress(
disabled: boolean,
onLongPress: (anchor: HTMLButtonElement) => void,
): LongPressHandlers {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const originRef = useRef<{ x: number; y: number } | null>(null);
const suppressClickRef = useRef(false);
const clearPressTimer = useCallback(() => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
timerRef.current = null;
originRef.current = null;
}, []);
useEffect(() => {
return clearPressTimer;
}, [clearPressTimer]);
const onPointerDown = useCallback<PointerEventHandler<HTMLButtonElement>>(
(event) => {
if (disabled || event.button !== 0) return;
clearPressTimer();
// A completed long press suppresses the click produced by that same
// pointer gesture. Starting a new gesture makes any stale suppression
// irrelevant (for example, if the previous pointer left the button and
// therefore produced no click).
suppressClickRef.current = false;
originRef.current = { x: event.clientX, y: event.clientY };
const anchor = event.currentTarget;
timerRef.current = setTimeout(() => {
timerRef.current = null;
originRef.current = null;
suppressClickRef.current = true;
onLongPress(anchor);
}, LONG_PRESS_DURATION_MS);
},
[clearPressTimer, disabled, onLongPress],
);
const onPointerMove = useCallback<PointerEventHandler<HTMLButtonElement>>(
(event) => {
const origin = originRef.current;
if (!origin) return;
if (
Math.abs(event.clientX - origin.x) > LONG_PRESS_MOVE_TOLERANCE_PX ||
Math.abs(event.clientY - origin.y) > LONG_PRESS_MOVE_TOLERANCE_PX
) {
clearPressTimer();
}
},
[clearPressTimer],
);
const consumeClick = useCallback(() => {
if (!suppressClickRef.current) return false;
suppressClickRef.current = false;
return true;
}, []);
return {
onPointerDown,
onPointerMove,
onPointerUp: clearPressTimer,
onPointerCancel: clearPressTimer,
onPointerLeave: clearPressTimer,
consumeClick,
};
}
export function historyIndicesForMenu(
mode: Exclude<HistoryMenuMode, "all">,
currentIndex: number,
historyLength: number,
): number[] {
if (mode === "back") {
return Array.from(
{ length: Math.min(currentIndex, MAX_HISTORY_MENU_ITEMS) },
(_, offset) => currentIndex - offset - 1,
);
}
return Array.from(
{
length: Math.min(
Math.max(0, historyLength - currentIndex - 1),
MAX_HISTORY_MENU_ITEMS,
),
},
(_, offset) => currentIndex + offset + 1,
);
}
export function browsingHistoryForMenu(
browsingHistory: BrowsingHistoryEntry[],
currentUrl: string | undefined,
): BrowsingHistoryEntry[] {
const currentResource = currentUrl
? browsingHistoryKeyForUrl(currentUrl)
: undefined;
return browsingHistory
.filter(
(entry) => browsingHistoryKeyForUrl(entry.url) !== currentResource,
)
.slice(0, MAX_HISTORY_MENU_ITEMS);
}
function HistoryMenuItem({
url,
fallbackTitle,
onSelect,
}: {
url: string;
fallbackTitle?: string;
onSelect: () => void;
}) {
const { visual, title } = useTabPresentation(url, fallbackTitle);
return (
<DropdownMenuItem
className="h-8 min-w-0 gap-2 px-2"
onClick={onSelect}
>
<ResourceLeadingVisual visual={visual} />
<span className="min-w-0 flex-1 truncate" title={title}>
{title}
</span>
</DropdownMenuItem>
);
}
export function WindowToolbar() {
const { state: sidebarState, isCompact } = useSidebar();
const sidebarHidden = sidebarState === "collapsed" || isCompact;
const toolbarWidth: React.CSSProperties["width"] = sidebarHidden
? WINDOW_TOOLBAR_CLEARANCE
: `max(var(--sidebar-live-width, var(--sidebar-width)), ${WINDOW_TOOLBAR_CLEARANCE}px)`;
const {
canGoBack,
canGoForward,
historyEntries,
historyIndex,
browsingHistory,
goBack,
goForward,
goToHistoryIndex,
} = useTabHistory();
const { push } = useNavigation();
const [menu, setMenu] = useState<OpenHistoryMenu | null>(null);
const menuAnchorRef = useRef<HTMLElement | null>(null);
const menuContentRef = useRef<HTMLDivElement | null>(null);
const openMenu = useCallback((mode: HistoryMenuMode, anchor: HTMLElement) => {
menuAnchorRef.current = anchor;
setMenu({ mode, anchor });
}, []);
useEffect(() => {
if (menu === null) return;
const focusTimer = setTimeout(() => {
menuContentRef.current
?.querySelector<HTMLElement>('[role="menuitem"]')
?.focus();
}, 0);
return () => clearTimeout(focusTimer);
}, [menu]);
const openBackMenu = useCallback(
(anchor: HTMLButtonElement) => openMenu("back", anchor),
[openMenu],
);
const openForwardMenu = useCallback(
(anchor: HTMLButtonElement) => openMenu("forward", anchor),
[openMenu],
);
const backPress = useLongPress(!canGoBack, openBackMenu);
const forwardPress = useLongPress(!canGoForward, openForwardMenu);
const menuIndices = useMemo(
() =>
menu && menu.mode !== "all"
? historyIndicesForMenu(menu.mode, historyIndex, historyEntries.length)
: [],
[historyEntries.length, historyIndex, menu],
);
const browsingMenuEntries = useMemo(
() =>
browsingHistoryForMenu(
browsingHistory,
historyEntries[historyIndex],
),
[browsingHistory, historyEntries, historyIndex],
);
const browsingHistoryTitles = useMemo(
() =>
new Map(
browsingHistory.flatMap((entry) =>
entry.title
? [[browsingHistoryKeyForUrl(entry.url), entry.title] as const]
: [],
),
),
[browsingHistory],
);
const menuLabel =
menu?.mode === "back"
? "Back history"
: menu?.mode === "forward"
? "Forward history"
: "Recently viewed";
const navButtonClassName =
"flex size-7 items-center justify-center rounded-md text-faint-foreground transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-30 motion-reduce:transition-none";
const selectHistoryIndex = useCallback(
(index: number) => {
goToHistoryIndex(index);
setMenu(null);
},
[goToHistoryIndex],
);
const selectBrowsingHistory = useCallback(
(url: string) => {
push(url);
setMenu(null);
},
[push],
);
return (
<div
data-slot="window-toolbar"
data-sidebar-resize-consumer
className="fixed left-0 top-0 z-30 flex h-12 shrink-0 items-center justify-end px-3"
style={
{
WebkitAppRegion: "drag",
width: toolbarWidth,
} as React.CSSProperties
}
>
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SidebarTrigger
className="size-7 text-faint-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<button
type="button"
disabled={browsingMenuEntries.length === 0}
aria-label="History"
aria-haspopup="menu"
aria-expanded={menu?.mode === "all"}
title="History"
className={navButtonClassName}
onClick={(event) => openMenu("all", event.currentTarget)}
>
<History aria-hidden className="size-4" />
</button>
<button
type="button"
disabled={!canGoBack}
aria-label="Go back"
aria-haspopup="menu"
aria-expanded={menu?.mode === "back"}
title="Go back (press and hold for history)"
className={navButtonClassName}
onPointerDown={backPress.onPointerDown}
onPointerMove={backPress.onPointerMove}
onPointerUp={backPress.onPointerUp}
onPointerCancel={backPress.onPointerCancel}
onPointerLeave={backPress.onPointerLeave}
onContextMenu={(event) => {
event.preventDefault();
openMenu("back", event.currentTarget);
}}
onKeyDown={(event) => {
if (event.key !== "ArrowDown") return;
event.preventDefault();
openMenu("back", event.currentTarget);
}}
onClick={(event) => {
if (backPress.consumeClick()) {
event.preventDefault();
return;
}
goBack();
}}
>
<ChevronLeft aria-hidden className="size-4" />
</button>
<button
type="button"
disabled={!canGoForward}
aria-label="Go forward"
aria-haspopup="menu"
aria-expanded={menu?.mode === "forward"}
title="Go forward (press and hold for history)"
className={navButtonClassName}
onPointerDown={forwardPress.onPointerDown}
onPointerMove={forwardPress.onPointerMove}
onPointerUp={forwardPress.onPointerUp}
onPointerCancel={forwardPress.onPointerCancel}
onPointerLeave={forwardPress.onPointerLeave}
onContextMenu={(event) => {
event.preventDefault();
openMenu("forward", event.currentTarget);
}}
onKeyDown={(event) => {
if (event.key !== "ArrowDown") return;
event.preventDefault();
openMenu("forward", event.currentTarget);
}}
onClick={(event) => {
if (forwardPress.consumeClick()) {
event.preventDefault();
return;
}
goForward();
}}
>
<ChevronRight aria-hidden className="size-4" />
</button>
</div>
<DropdownMenu
open={menu !== null}
onOpenChange={(open) => {
if (!open) setMenu(null);
}}
onOpenChangeComplete={(open) => {
if (!open) menuAnchorRef.current?.focus();
}}
>
<DropdownMenuContent
ref={menuContentRef}
align="start"
anchor={menu?.anchor}
finalFocus={false}
className="w-80 max-w-[calc(100vw-1rem)] motion-reduce:animate-none motion-reduce:transition-none"
>
<DropdownMenuGroup>
<DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>
{menu?.mode === "all"
? browsingMenuEntries.map((entry) => (
<HistoryMenuItem
key={entry.url}
url={entry.url}
fallbackTitle={entry.title}
onSelect={() => selectBrowsingHistory(entry.url)}
/>
))
: menuIndices.map((index) => (
<HistoryMenuItem
key={`${index}:${historyEntries[index]}`}
url={historyEntries[index]}
fallbackTitle={browsingHistoryTitles.get(
browsingHistoryKeyForUrl(historyEntries[index]),
)}
onSelect={() => selectHistoryIndex(index)}
/>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -31,6 +31,8 @@ function seedHistory() {
],
activeTabId: "t1",
recentTabIds: ["t1"],
browsingHistory: ["/acme/issues/abc", "/acme/issues"],
browsingHistoryTitles: {},
},
},
});
@@ -32,6 +32,8 @@ function seedTabs() {
],
activeTabId: "t1",
recentTabIds: ["t1"],
browsingHistory: ["/acme/issues"],
browsingHistoryTitles: {},
},
},
});
@@ -1,5 +1,16 @@
import { useCallback, useEffect } from "react";
import { useTabStore, useActiveTabHistory } from "@/stores/tab-store";
import { useCallback, useEffect, useMemo } from "react";
import {
browsingHistoryKeyForUrl,
useTabStore,
useActiveBrowsingHistory,
useActiveBrowsingHistoryTitles,
useActiveTabHistory,
} from "@/stores/tab-store";
export interface BrowsingHistoryEntry {
url: string;
title?: string;
}
/**
* Shell back/forward for the active tab (MUL-4741 session architecture).
@@ -11,7 +22,17 @@ import { useTabStore, useActiveTabHistory } from "@/stores/tab-store";
* direction hints, no router.navigate(±1).
*/
export function useTabHistory() {
const { historyIndex, historyLength } = useActiveTabHistory();
const { historyIndex, historyLength, historyEntries } = useActiveTabHistory();
const browsingHistoryUrls = useActiveBrowsingHistory();
const browsingHistoryTitles = useActiveBrowsingHistoryTitles();
const browsingHistory = useMemo<BrowsingHistoryEntry[]>(
() =>
browsingHistoryUrls.map((url) => ({
url,
title: browsingHistoryTitles[browsingHistoryKeyForUrl(url)],
})),
[browsingHistoryTitles, browsingHistoryUrls],
);
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyLength - 1;
@@ -24,7 +45,20 @@ export function useTabHistory() {
useTabStore.getState().goForward();
}, []);
return { canGoBack, canGoForward, goBack, goForward };
const goToHistoryIndex = useCallback((index: number) => {
useTabStore.getState().goToHistoryIndex(index);
}, []);
return {
canGoBack,
canGoForward,
historyEntries,
historyIndex,
browsingHistory,
goBack,
goForward,
goToHistoryIndex,
};
}
function isEditableTarget(target: EventTarget | null): boolean {
@@ -39,6 +39,8 @@ function seedTabs(count: number, activeIndex = 1): void {
tabs,
activeTabId: `t${activeIndex}`,
recentTabIds: [],
browsingHistory: tabs.map((tab) => tab.url).reverse(),
browsingHistoryTitles: {},
},
},
});
@@ -3,9 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import {
sanitizeTabPath,
resourceKeyForUrl,
browsingHistoryKeyForUrl,
migrateV1ToV2,
migrateV2ToV3,
migrateV3ToV4,
migrateV4ToV5,
mergePersistedTabs,
useTabStore,
getActiveTab,
@@ -63,6 +65,26 @@ describe("resourceKeyForUrl", () => {
});
});
describe("browsingHistoryKeyForUrl", () => {
it("keeps Inbox selections distinct while ignoring their other view state", () => {
expect(browsingHistoryKeyForUrl("/acme/inbox")).toBe("/acme/inbox");
expect(browsingHistoryKeyForUrl("/acme/inbox?issue=issue-a")).toBe(
"/acme/inbox?issue=issue-a",
);
expect(
browsingHistoryKeyForUrl(
"/acme/inbox?view=archived&issue=issue-a#comment-comment-1",
),
).toBe("/acme/inbox?issue=issue-a");
});
it("continues treating ordinary query and hash changes as one resource", () => {
expect(browsingHistoryKeyForUrl("/acme/issues?filter=mine#top")).toBe(
"/acme/issues",
);
});
});
describe("migrateV1ToV2", () => {
it("groups v1 flat tabs by workspace slug", () => {
const v1 = {
@@ -125,6 +147,10 @@ describe("useTabStore actions", () => {
stack: ["/acme/issues"],
index: 0,
});
expect(s.byWorkspace.acme.browsingHistory).toEqual(["/acme/issues"]);
expect(s.byWorkspace.acme.browsingHistoryTitles).toEqual({
"/acme/issues": "Issues",
});
});
it("switchWorkspace without openPath restores the group's last active tab", () => {
@@ -158,6 +184,23 @@ describe("useTabStore actions", () => {
expect(activeTab?.url).toBe("/acme/issues");
});
it("records the URL of the tab actually restored by a cross-workspace open", () => {
const store = useTabStore.getState();
store.switchWorkspace("butter");
store.navigateActiveSession("/butter/issues?filter=mine", {
replace: true,
});
store.switchWorkspace("acme");
store.switchWorkspace("butter", "/butter/issues");
const state = useTabStore.getState();
expect(getActiveTab(state)?.url).toBe("/butter/issues?filter=mine");
expect(state.byWorkspace.butter.browsingHistory[0]).toBe(
"/butter/issues?filter=mine",
);
});
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
@@ -179,6 +222,35 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
});
it("shares browsing history across tabs while Back and Forward remain per-tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/issues/issue-1");
const firstTab = getActiveTab(useTabStore.getState())!;
const newTabId = store.addTab("/acme/issues", "Issues");
store.setActiveTab(newTabId);
const state = useTabStore.getState();
const newTab = getActiveTab(state)!;
expect(firstTab.history).toEqual({
stack: ["/acme/issues", "/acme/issues/issue-1"],
index: 1,
});
expect(newTab.history).toEqual({ stack: ["/acme/issues"], index: 0 });
expect(state.byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues",
"/acme/issues/issue-1",
]);
store.goBack();
expect(getActiveTab(useTabStore.getState())!.url).toBe("/acme/issues");
expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues",
"/acme/issues/issue-1",
]);
});
it("openTab with a different query focuses the existing tab and keeps its url (RFC §8.2 semantic change)", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // default tab at /acme/issues
@@ -273,12 +345,17 @@ describe("useTabStore actions", () => {
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/issues/issue-1");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeTab(onlyTabId);
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].url).toBe("/acme/issues");
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
expect(s.byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues",
"/acme/issues/issue-1",
]);
});
it("ignores updates addressed to a tab after it has been closed", () => {
@@ -408,6 +485,123 @@ describe("navigateActiveSession", () => {
stack: ["/acme/issues", "/acme/projects?sort=name"],
index: 1,
});
expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
"/acme/projects?sort=name",
"/acme/issues",
]);
});
it("records an opened issue and keeps only the latest URL for one resource", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/issues/issue-1");
store.navigateActiveSession("/acme/issues/issue-1?tab=activity", {
replace: true,
});
store.navigateActiveSession("/acme/issues/issue-1#comment-comment-1", {
replace: true,
});
expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues/issue-1#comment-comment-1",
"/acme/issues",
]);
});
it("persists the resolved title for a visited resource", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/issues/issue-1");
const active = getActiveTab(useTabStore.getState())!;
store.updateTab(active.id, { title: "MUL-1: Fix history" });
expect(
useTabStore.getState().byWorkspace.acme.browsingHistoryTitles,
).toEqual({
"/acme/issues": "Issues",
"/acme/issues/issue-1": "MUL-1: Fix history",
});
});
it("records each issue viewed inside Inbox without changing its replace-based session history", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/inbox");
store.navigateActiveSession("/acme/inbox?issue=issue-a", {
replace: true,
});
store.navigateActiveSession("/acme/inbox?issue=issue-b", {
replace: true,
});
const state = useTabStore.getState();
expect(getActiveTab(state)?.history).toEqual({
stack: ["/acme/issues", "/acme/inbox?issue=issue-b"],
index: 1,
});
expect(state.byWorkspace.acme.browsingHistory).toEqual([
"/acme/inbox?issue=issue-b",
"/acme/inbox?issue=issue-a",
"/acme/inbox",
"/acme/issues",
]);
});
it("moves a revisited Inbox issue to the front instead of duplicating it", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/inbox");
store.navigateActiveSession("/acme/inbox?issue=issue-a", {
replace: true,
});
store.navigateActiveSession("/acme/inbox?issue=issue-b", {
replace: true,
});
store.navigateActiveSession(
"/acme/inbox?view=archived&issue=issue-a#comment-comment-1",
{ replace: true },
);
expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
"/acme/inbox?view=archived&issue=issue-a#comment-comment-1",
"/acme/inbox?issue=issue-b",
"/acme/inbox",
"/acme/issues",
]);
});
it("records both sides of a navigation when upgrading from an empty browsing history", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const group = useTabStore.getState().byWorkspace.acme;
useTabStore.setState({
byWorkspace: {
acme: { ...group, browsingHistory: [] },
},
});
store.navigateActiveSession("/acme/issues/issue-1");
expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues/issue-1",
"/acme/issues",
]);
});
it("replaces an issue UUID visit with its canonical identifier URL", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/issues/01a08a00-0000-7000-8000-000000000001");
store.navigateActiveSession("/acme/issues/ACM-1", { replace: true });
expect(useTabStore.getState().byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues/ACM-1",
"/acme/issues",
]);
});
it("replace swaps the current history entry instead of pushing", () => {
@@ -470,6 +664,28 @@ describe("navigateActiveSession", () => {
store.goForward();
expect(getActiveTab(useTabStore.getState())!.history.index).toBe(1);
});
it("goToHistoryIndex jumps directly and ignores invalid indices", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.navigateActiveSession("/acme/projects");
store.navigateActiveSession("/acme/agents");
store.goToHistoryIndex(0);
let active = getActiveTab(useTabStore.getState())!;
expect(active.url).toBe("/acme/issues");
expect(active.history.index).toBe(0);
store.goToHistoryIndex(2);
active = getActiveTab(useTabStore.getState())!;
expect(active.url).toBe("/acme/agents");
expect(active.history.index).toBe(2);
store.goToHistoryIndex(-1);
store.goToHistoryIndex(3);
store.goToHistoryIndex(1.5);
expect(getActiveTab(useTabStore.getState())).toBe(active);
});
});
describe("reloadActiveTab", () => {
@@ -1105,6 +1321,54 @@ describe("migrateV3ToV4 (legacy view-state import, MUL-4741)", () => {
});
});
describe("migrateV4ToV5", () => {
it("seeds cross-tab browsing history from the destinations v4 knew", () => {
const v5 = migrateV4ToV5({
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t2",
recentTabIds: ["t1"],
tabs: [
{
id: "t1",
url: "/acme/issues/issue-1",
title: "Issue",
pinned: false,
history: {
stack: ["/acme/issues", "/acme/issues/issue-1"],
index: 1,
},
memento: { scroll: {}, view: {} },
},
{
id: "t2",
url: "/acme/projects",
title: "Projects",
pinned: false,
history: {
stack: ["/acme/issues", "/acme/projects"],
index: 1,
},
memento: { scroll: {}, view: {} },
},
],
},
},
});
expect(v5.byWorkspace.acme.browsingHistory).toEqual([
"/acme/projects",
"/acme/issues",
"/acme/issues/issue-1",
]);
expect(v5.byWorkspace.acme.browsingHistoryTitles).toEqual({
"/acme/issues/issue-1": "Issue",
"/acme/projects": "Projects",
});
});
});
describe("mergePersistedTabs (rehydration, MUL-4370)", () => {
const emptyState = (): {
activeWorkspaceSlug: string | null;
@@ -1220,4 +1484,95 @@ describe("mergePersistedTabs (rehydration, MUL-4370)", () => {
const group = rehydrateGroup("t1", { t1: "/acme/issues" });
expect(group.recentTabIds).toEqual([]);
});
it("restores, sanitizes, and resource-deduplicates workspace browsing history", () => {
const result = mergePersistedTabs(
{
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [persistedTab("/acme/issues")],
browsingHistory: [
"/acme/issues/issue-1?tab=activity",
"/acme/issues/issue-1",
"/acme/inbox?view=archived&issue=issue-b",
"/acme/inbox?issue=issue-a#comment-comment-1",
"/acme/inbox?issue=issue-a",
"/acme/inbox",
"/other/issues/issue-2",
"/login",
7,
],
},
},
},
emptyState(),
);
expect(result.byWorkspace.acme.browsingHistory).toEqual([
"/acme/issues/issue-1?tab=activity",
"/acme/inbox?view=archived&issue=issue-b",
"/acme/inbox?issue=issue-a#comment-comment-1",
"/acme/inbox",
]);
});
it("restores only titles belonging to valid browsing-history entries", () => {
const result = mergePersistedTabs(
{
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
persistedTab("/acme/issues/issue-1", {
title: "MUL-1: Fix history",
}),
],
browsingHistory: [
"/acme/issues/issue-1",
"/acme/projects",
],
browsingHistoryTitles: {
"/acme/issues/issue-1": "MUL-1: Fix history",
"/acme/projects": "Projects",
"/other/issues/issue-2": "Other workspace",
"/acme/agents": 7,
},
},
},
},
emptyState(),
);
expect(result.byWorkspace.acme.browsingHistoryTitles).toEqual({
"/acme/issues/issue-1": "MUL-1: Fix history",
"/acme/projects": "Projects",
});
});
it("backfills a known tab title when an older v5 payload has no title map", () => {
const result = mergePersistedTabs(
{
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
persistedTab("/acme/issues/issue-1", {
title: "MUL-1: Fix history",
}),
],
browsingHistory: ["/acme/issues/issue-1"],
},
},
},
emptyState(),
);
expect(result.byWorkspace.acme.browsingHistoryTitles).toEqual({
"/acme/issues/issue-1": "MUL-1: Fix history",
});
});
});
+453 -47
View File
@@ -114,6 +114,20 @@ export interface WorkspaceTabGroup {
tabs: TabSession[];
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
activeTabId: string;
/**
* Recently visited resources in this workspace, newest first. Unlike each
* tab's session history, this list is shared by every tab in the group —
* the same split browsers make between per-tab Back/Forward and profile
* browsing history. Entries are resource-deduplicated and bounded.
*/
browsingHistory: string[];
/**
* Last resolved display title for each browsing-history resource key.
* Kept separately so the URL list stays backward-compatible with v5
* payloads while cold-start menus can render useful labels before query
* caches refill.
*/
browsingHistoryTitles: Record<string, string>;
/**
* Previously visited tabs of this group, most recent first. Never contains
* `activeTabId`, never contains an id that is no longer in `tabs`.
@@ -207,6 +221,8 @@ interface TabStore {
/** Session-driven back/forward (there is no router history to pop). */
goBack: () => void;
goForward: () => void;
/** Jump directly to one entry in the active session's virtual history. */
goToHistoryIndex: (historyIndex: number) => void;
/**
* Persist captured scroll offsets for one route of a tab (Coordinator, on
* deactivate / before in-tab navigation). REPLACE semantics per route: all
@@ -319,6 +335,32 @@ export function resourceKeyForUrl(url: string): string {
return splitTabUrl(url).pathname;
}
/**
* Dedup identity for the workspace-wide browsing history.
*
* Tab sessions intentionally treat search params as view state, but selecting
* an Inbox row changes the content being viewed without leaving /inbox. Keep
* those selections distinct in global History while leaving Back/Forward's
* pathname-based session semantics unchanged.
*/
export function browsingHistoryKeyForUrl(url: string): string {
return inboxSelectionKeyForUrl(url) ?? resourceKeyForUrl(url);
}
function inboxSelectionKeyForUrl(url: string): string | null {
const { pathname, suffix } = splitTabUrl(url);
const segments = pathname.split("/").filter(Boolean);
if (segments.length !== 2 || segments[1] !== "inbox") return null;
if (!suffix.startsWith("?")) return null;
const hashIndex = suffix.indexOf("#");
const search = hashIndex === -1 ? suffix.slice(1) : suffix.slice(1, hashIndex);
const selectedKey = new URLSearchParams(search).get("issue");
if (!selectedKey) return null;
return `${pathname}?issue=${encodeURIComponent(selectedKey)}`;
}
/**
* Defensive: catch URLs that don't belong in the tab store, and normalize
* the ones that do.
@@ -400,6 +442,182 @@ function defaultTabFor(slug: string): TabSession {
return makeSession(path, "Issues");
}
const BROWSING_HISTORY_MAX_ENTRIES = 100;
function prependBrowsingHistoryEntry(
entries: string[],
url: string,
): string[] {
if (entries[0] === url) return entries;
const resourceKey = browsingHistoryKeyForUrl(url);
return [
url,
...entries.filter(
(entry) => browsingHistoryKeyForUrl(entry) !== resourceKey,
),
].slice(0, BROWSING_HISTORY_MAX_ENTRIES);
}
function normalizedBrowsingHistoryTitle(title: unknown): string | undefined {
if (typeof title !== "string" || title.trim().length === 0) return undefined;
return title;
}
function sameStringRecord(
left: Record<string, string>,
right: Record<string, string>,
): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return (
leftKeys.length === rightKeys.length &&
leftKeys.every((key) => left[key] === right[key])
);
}
interface BrowsingVisitOptions {
replacedUrl?: string;
title?: string;
}
function withBrowsingVisit(
group: WorkspaceTabGroup,
slug: string,
url: string,
options: BrowsingVisitOptions = {},
): WorkspaceTabGroup {
const clean = sanitizeTabPath(url);
if (!clean || extractWorkspaceSlug(clean) !== slug) return group;
const historyKey = browsingHistoryKeyForUrl(clean);
const replacedHistoryKey = options.replacedUrl
? browsingHistoryKeyForUrl(options.replacedUrl)
: undefined;
const preserveDistinctInboxVisit =
options.replacedUrl !== undefined &&
replacedHistoryKey !== historyKey &&
(inboxSelectionKeyForUrl(clean) !== null ||
inboxSelectionKeyForUrl(options.replacedUrl) !== null);
const previousEntries = replacedHistoryKey && !preserveDistinctInboxVisit
? group.browsingHistory.filter(
(entry) => browsingHistoryKeyForUrl(entry) !== replacedHistoryKey,
)
: group.browsingHistory;
const browsingHistory = prependBrowsingHistoryEntry(
previousEntries,
clean,
);
const knownTitle =
normalizedBrowsingHistoryTitle(options.title) ??
group.browsingHistoryTitles[historyKey] ??
(replacedHistoryKey
? group.browsingHistoryTitles[replacedHistoryKey]
: undefined);
const browsingHistoryTitles: Record<string, string> = {};
for (const entry of browsingHistory) {
const entryKey = browsingHistoryKeyForUrl(entry);
const title =
entryKey === historyKey
? knownTitle
: group.browsingHistoryTitles[entryKey];
if (title) browsingHistoryTitles[entryKey] = title;
}
return browsingHistory === group.browsingHistory &&
sameStringRecord(browsingHistoryTitles, group.browsingHistoryTitles)
? group
: { ...group, browsingHistory, browsingHistoryTitles };
}
function withActiveSessionVisit(
group: WorkspaceTabGroup,
slug: string,
): WorkspaceTabGroup {
const active = group.tabs.find((tab) => tab.id === group.activeTabId);
return active ? withBrowsingVisit(group, slug, active.url) : group;
}
function withBrowsingNavigation(
group: WorkspaceTabGroup,
slug: string,
current: TabSession,
nextUrl: string,
replace: boolean,
): WorkspaceTabGroup {
const withOrigin = withBrowsingVisit(group, slug, current.url);
return withBrowsingVisit(withOrigin, slug, nextUrl, {
replacedUrl: replace ? current.url : undefined,
});
}
function withBrowsingHistoryTitle(
group: WorkspaceTabGroup,
url: string,
title: string,
): WorkspaceTabGroup {
const normalizedTitle = normalizedBrowsingHistoryTitle(title);
if (!normalizedTitle) return group;
const historyKey = browsingHistoryKeyForUrl(url);
if (
!group.browsingHistory.some(
(entry) => browsingHistoryKeyForUrl(entry) === historyKey,
) ||
group.browsingHistoryTitles[historyKey] === normalizedTitle
) {
return group;
}
return {
...group,
browsingHistoryTitles: {
...group.browsingHistoryTitles,
[historyKey]: normalizedTitle,
},
};
}
function normalizeBrowsingHistory(value: unknown, slug: string): string[] {
if (!Array.isArray(value)) return [];
const result: string[] = [];
const seenResources = new Set<string>();
for (const candidate of value) {
if (typeof candidate !== "string") continue;
const clean = sanitizeTabPath(candidate);
if (!clean || extractWorkspaceSlug(clean) !== slug) continue;
const resourceKey = browsingHistoryKeyForUrl(clean);
if (seenResources.has(resourceKey)) continue;
seenResources.add(resourceKey);
result.push(clean);
if (result.length === BROWSING_HISTORY_MAX_ENTRIES) break;
}
return result;
}
function normalizeBrowsingHistoryTitles(
value: unknown,
browsingHistory: string[],
tabs: TabSession[],
): Record<string, string> {
const persistedTitles =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
const tabTitles = new Map(
tabs.flatMap((tab) => {
const title = normalizedBrowsingHistoryTitle(tab.title);
return title
? [[browsingHistoryKeyForUrl(tab.url), title] as const]
: [];
}),
);
const result: Record<string, string> = {};
for (const url of browsingHistory) {
const historyKey = browsingHistoryKeyForUrl(url);
const title =
normalizedBrowsingHistoryTitle(persistedTitles[historyKey]) ??
tabTitles.get(historyKey);
if (title) result[historyKey] = title;
}
return result;
}
// ---------------------------------------------------------------------------
// Group helpers
// ---------------------------------------------------------------------------
@@ -431,7 +649,13 @@ function reconcileGroup(
if (recentTabIds.includes(id)) continue;
recentTabIds.push(id);
}
return { tabs, activeTabId, recentTabIds };
return {
tabs,
activeTabId,
browsingHistory: prev?.browsingHistory ?? [],
browsingHistoryTitles: prev?.browsingHistoryTitles ?? {},
recentTabIds,
};
}
/**
@@ -514,11 +738,17 @@ export const useTabStore = create<TabStore>()(
const cleanDesired = desiredPath ? sanitizeTabPath(desiredPath) : null;
const seedPath = cleanDesired ?? defaultPathFor(slug);
const tab = makeSession(seedPath, "Issues");
const nextGroup = withBrowsingVisit(
reconcileGroup(null, [tab], tab.id),
slug,
seedPath,
{ title: tab.title },
);
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: reconcileGroup(null, [tab], tab.id),
[slug]: nextGroup,
},
});
return;
@@ -532,25 +762,37 @@ export const useTabStore = create<TabStore>()(
const key = resourceKeyForUrl(clean);
const match = existing.tabs.find((t) => t.resourceKey === key);
if (match) {
const nextGroup = withBrowsingVisit(
reconcileGroup(existing, existing.tabs, match.id),
slug,
match.url,
{ title: match.title },
);
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: reconcileGroup(existing, existing.tabs, match.id),
[slug]: nextGroup,
},
});
return;
}
const tab = makeSession(clean, "Issues");
const nextGroup = withBrowsingVisit(
reconcileGroup(
existing,
[...existing.tabs, tab],
tab.id,
),
slug,
clean,
{ title: tab.title },
);
set({
activeWorkspaceSlug: slug,
byWorkspace: {
...byWorkspace,
[slug]: reconcileGroup(
existing,
[...existing.tabs, tab],
tab.id,
),
[slug]: nextGroup,
},
});
return;
@@ -573,14 +815,20 @@ export const useTabStore = create<TabStore>()(
const key = resourceKeyForUrl(clean);
const existing = group.tabs.find((t) => t.resourceKey === key);
if (existing) {
const historyBase = withActiveSessionVisit(
group,
activeWorkspaceSlug,
);
const nextGroup = withBrowsingVisit(
reconcileGroup(historyBase, group.tabs, existing.id),
activeWorkspaceSlug,
existing.url,
{ title: existing.title },
);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: reconcileGroup(
group,
group.tabs,
existing.id,
),
[activeWorkspaceSlug]: nextGroup,
},
});
return existing.id;
@@ -604,14 +852,24 @@ export const useTabStore = create<TabStore>()(
tab,
...group.tabs.slice(insertAt),
];
const historyBase = withActiveSessionVisit(
group,
activeWorkspaceSlug,
);
const nextGroup = withBrowsingVisit(
reconcileGroup(
historyBase,
nextTabs,
opts?.activate === true ? tab.id : group.activeTabId,
),
activeWorkspaceSlug,
clean,
{ title: tab.title },
);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: reconcileGroup(
group,
nextTabs,
opts?.activate === true ? tab.id : group.activeTabId,
),
[activeWorkspaceSlug]: nextGroup,
},
});
return tab.id;
@@ -625,14 +883,24 @@ export const useTabStore = create<TabStore>()(
if (!group) return "";
const tab = makeSession(clean, title);
const historyBase = withActiveSessionVisit(
group,
activeWorkspaceSlug,
);
const nextGroup = withBrowsingVisit(
reconcileGroup(
historyBase,
[...group.tabs, tab],
group.activeTabId,
),
activeWorkspaceSlug,
clean,
{ title: tab.title },
);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: reconcileGroup(
group,
[...group.tabs, tab],
group.activeTabId,
),
[activeWorkspaceSlug]: nextGroup,
},
});
return tab.id;
@@ -649,10 +917,16 @@ export const useTabStore = create<TabStore>()(
// always has at least one tab. Closing a workspace as an explicit
// action is a separate concern (Leave/Delete in Settings).
const fresh = defaultTabFor(slug);
const nextGroup = withBrowsingVisit(
reconcileGroup(group, [fresh], fresh.id),
slug,
fresh.url,
{ title: fresh.title },
);
set({
byWorkspace: {
...byWorkspace,
[slug]: reconcileGroup(null, [fresh], fresh.id),
[slug]: nextGroup,
},
});
return;
@@ -701,15 +975,19 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: TabSession = { ...current, ...patch };
if (next.title === current.title) {
return;
}
const nextTabs = [...group.tabs];
nextTabs[index] = next;
const titleChanged = next.title !== current.title;
const nextTabs = titleChanged ? [...group.tabs] : group.tabs;
if (titleChanged) nextTabs[index] = next;
const nextGroup = withBrowsingHistoryTitle(
titleChanged ? { ...group, tabs: nextTabs } : group,
next.url,
next.title,
);
if (!titleChanged && nextGroup === group) return;
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
[slug]: nextGroup,
},
});
},
@@ -750,10 +1028,17 @@ export const useTabStore = create<TabStore>()(
};
const nextTabs = [...group.tabs];
nextTabs[index] = next;
const nextGroup = withBrowsingNavigation(
{ ...group, tabs: nextTabs },
activeWorkspaceSlug,
current,
clean,
replace,
);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, tabs: nextTabs },
[activeWorkspaceSlug]: nextGroup,
},
});
},
@@ -766,6 +1051,10 @@ export const useTabStore = create<TabStore>()(
stepHistory(get, set, +1);
},
goToHistoryIndex(historyIndex) {
setHistoryIndex(get, set, historyIndex);
},
commitScrollMemento(tabId, routeKey, entries) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
@@ -960,10 +1249,11 @@ export const useTabStore = create<TabStore>()(
const fallbackSlug = validSlugs.values().next().value;
if (fallbackSlug) {
const fresh = defaultTabFor(fallbackSlug);
nextByWorkspace[fallbackSlug] = reconcileGroup(
null,
[fresh],
fresh.id,
nextByWorkspace[fallbackSlug] = withBrowsingVisit(
reconcileGroup(null, [fresh], fresh.id),
fallbackSlug,
fresh.url,
{ title: fresh.title },
);
nextActive = fallbackSlug;
changed = true;
@@ -980,7 +1270,7 @@ export const useTabStore = create<TabStore>()(
}),
{
name: "multica_tabs",
version: 4,
version: 5,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
@@ -1002,7 +1292,13 @@ export const useTabStore = create<TabStore>()(
if (version < 4 && state && typeof state === "object") {
state = migrateV3ToV4(state as V3Persisted);
}
return state as V4Persisted;
// v4 → v5: add workspace browsing history. Existing per-tab stacks
// provide a best-effort seed so upgrading users do not lose every
// known destination even though v4 had no cross-tab timestamps.
if (version < 5 && state && typeof state === "object") {
state = migrateV4ToV5(state as V4Persisted);
}
return state as V5Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
@@ -1017,6 +1313,8 @@ export const useTabStore = create<TabStore>()(
// the tab you were last looking at rather than falling back to
// the positional neighbour.
recentTabIds: group.recentTabIds,
browsingHistory: group.browsingHistory,
browsingHistoryTitles: group.browsingHistoryTitles,
tabs: group.tabs.map((t) => ({
id: t.id,
url: t.url,
@@ -1057,7 +1355,7 @@ export function mergePersistedTabs<T extends PersistedTabState>(
persistedState: unknown,
currentState: T,
): T {
const persisted = persistedState as Partial<V4Persisted> | undefined;
const persisted = persistedState as Partial<V5Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
@@ -1114,6 +1412,10 @@ export function mergePersistedTabs<T extends PersistedTabState>(
: tabs[0].id;
// reconcileGroup filters the persisted MRU order down to live tab ids
// (tabs dropped just above), drops the active tab from it, and dedupes.
const browsingHistory = normalizeBrowsingHistory(
pGroup.browsingHistory,
slug,
);
byWorkspace[slug] = reconcileGroup(
{
tabs,
@@ -1121,6 +1423,12 @@ export function mergePersistedTabs<T extends PersistedTabState>(
recentTabIds: Array.isArray(pGroup.recentTabIds)
? pGroup.recentTabIds.filter((id) => typeof id === "string")
: [],
browsingHistory,
browsingHistoryTitles: normalizeBrowsingHistoryTitles(
pGroup.browsingHistoryTitles,
browsingHistory,
tabs,
),
},
tabs,
activeTabId,
@@ -1140,6 +1448,17 @@ function stepHistory(
set: (partial: Partial<TabStore>) => void,
delta: -1 | 1,
) {
const active = getActiveTab(get());
if (!active) return;
setHistoryIndex(get, set, active.history.index + delta);
}
function setHistoryIndex(
get: () => TabStore,
set: (partial: Partial<TabStore>) => void,
historyIndex: number,
) {
if (!Number.isInteger(historyIndex)) return;
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
@@ -1147,21 +1466,33 @@ function stepHistory(
const index = group.tabs.findIndex((t) => t.id === group.activeTabId);
if (index < 0) return;
const current = group.tabs[index];
const nextIndex = current.history.index + delta;
if (nextIndex < 0 || nextIndex >= current.history.stack.length) return;
const url = current.history.stack[nextIndex];
if (
historyIndex === current.history.index ||
historyIndex < 0 ||
historyIndex >= current.history.stack.length
) {
return;
}
const url = current.history.stack[historyIndex];
const next: TabSession = {
...current,
url,
resourceKey: resourceKeyForUrl(url),
history: { ...current.history, index: nextIndex },
history: { ...current.history, index: historyIndex },
};
const nextTabs = [...group.tabs];
nextTabs[index] = next;
const nextGroup = withBrowsingNavigation(
{ ...group, tabs: nextTabs },
activeWorkspaceSlug,
current,
url,
false,
);
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, tabs: nextTabs },
[activeWorkspaceSlug]: nextGroup,
},
});
}
@@ -1253,6 +1584,53 @@ interface V4Persisted {
byWorkspace: Record<string, V4PersistedGroup>;
}
interface V5PersistedGroup extends V4PersistedGroup {
browsingHistory: string[];
browsingHistoryTitles?: Record<string, string>;
}
interface V5Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V5PersistedGroup>;
}
export function migrateV4ToV5(v4: V4Persisted): V5Persisted {
const byWorkspace: Record<string, V5PersistedGroup> = {};
for (const [slug, group] of Object.entries(v4.byWorkspace ?? {})) {
const byId = new Map(group.tabs.map((tab) => [tab.id, tab]));
const orderedTabIds = [
group.activeTabId,
...(group.recentTabIds ?? []),
...group.tabs.map((tab) => tab.id),
];
const seenTabIds = new Set<string>();
const candidates: string[] = [];
for (const tabId of orderedTabIds) {
if (seenTabIds.has(tabId)) continue;
seenTabIds.add(tabId);
const tab = byId.get(tabId);
if (!tab) continue;
candidates.push(tab.url, ...[...tab.history.stack].reverse());
}
byWorkspace[slug] = {
...group,
browsingHistory: normalizeBrowsingHistory(candidates, slug),
browsingHistoryTitles: Object.fromEntries(
group.tabs.flatMap((tab) => {
const title = normalizedBrowsingHistoryTitle(tab.title);
return title
? [[browsingHistoryKeyForUrl(tab.url), title] as const]
: [];
}),
),
};
}
return {
activeWorkspaceSlug: v4.activeWorkspaceSlug ?? null,
byWorkspace,
};
}
export function migrateV3ToV4(v3: V3Persisted): V4Persisted {
const byWorkspace: Record<string, V4PersistedGroup> = {};
for (const [slug, group] of Object.entries(v3.byWorkspace ?? {})) {
@@ -1385,13 +1763,13 @@ export function useActiveTabUrl(): string | null {
}
/**
* History tracking for the active tab as primitives. Subscribers re-render
* only when the numeric index / length change (i.e. on actual navigations),
* not on unrelated store updates.
* History tracking for the active tab. The stack reference changes only on
* actual navigation, so subscribers do not re-render for unrelated updates.
*/
export function useActiveTabHistory(): {
historyIndex: number;
historyLength: number;
historyEntries: string[];
} {
const historyIndex = useTabStore(
(s) => getActiveTab(s)?.history.index ?? 0,
@@ -1399,5 +1777,33 @@ export function useActiveTabHistory(): {
const historyLength = useTabStore(
(s) => getActiveTab(s)?.history.stack.length ?? 1,
);
return { historyIndex, historyLength };
const historyEntries = useTabStore(
(s) => getActiveTab(s)?.history.stack ?? EMPTY_HISTORY_ENTRIES,
);
return { historyIndex, historyLength, historyEntries };
}
const EMPTY_HISTORY_ENTRIES: string[] = [];
const EMPTY_BROWSING_HISTORY_TITLES: Record<string, string> = {};
/** Workspace browsing history shared by all tabs, newest first. */
export function useActiveBrowsingHistory(): string[] {
return useTabStore((s) => {
if (!s.activeWorkspaceSlug) return EMPTY_HISTORY_ENTRIES;
return (
s.byWorkspace[s.activeWorkspaceSlug]?.browsingHistory ??
EMPTY_HISTORY_ENTRIES
);
});
}
/** Persisted display-title fallbacks keyed by browsing-history resource. */
export function useActiveBrowsingHistoryTitles(): Record<string, string> {
return useTabStore((s) => {
if (!s.activeWorkspaceSlug) return EMPTY_BROWSING_HISTORY_TITLES;
return (
s.byWorkspace[s.activeWorkspaceSlug]?.browsingHistoryTitles ??
EMPTY_BROWSING_HISTORY_TITLES
);
});
}
+18 -5
View File
@@ -89,8 +89,9 @@ type SidebarResizeContextProps = {
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
// Width previews are written directly to the two layout shells during drag.
// This context only exposes the one committed state transition on pointer-up.
// Width previews are written directly to the layout shells and opt-in chrome
// consumers during drag. This context only exposes the one committed state
// transition on pointer-up.
const SidebarResizeContext = React.createContext<SidebarResizeContextProps | null>(null)
function useSidebar() {
@@ -433,6 +434,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
wrapperEl: HTMLElement
gapEl: HTMLElement
containerEl: HTMLElement
liveWidthConsumers: HTMLElement[]
} | null>(null)
const cancelActiveDragRef = React.useRef<(() => void) | null>(null)
@@ -450,6 +452,9 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const gapEl = sidebarEl?.querySelector<HTMLElement>("[data-slot='sidebar-gap']")
const containerEl = sidebarEl?.querySelector<HTMLElement>("[data-slot='sidebar-container']")
if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return
const liveWidthConsumers = Array.from(
wrapperEl.querySelectorAll<HTMLElement>("[data-sidebar-resize-consumer]")
)
const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width)
dragRef.current = {
@@ -461,6 +466,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
wrapperEl,
gapEl,
containerEl,
liveWidthConsumers,
}
wrapperEl.setAttribute("data-sidebar-resizing", "true")
@@ -488,6 +494,9 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
}
drag.gapEl.style.removeProperty("width")
drag.containerEl.style.removeProperty("width")
for (const consumer of drag.liveWidthConsumers) {
consumer.style.removeProperty("--sidebar-live-width")
}
drag.wrapperEl.removeAttribute("data-sidebar-resizing")
}
@@ -514,11 +523,15 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
if (nextWidth === drag.latestWidth) return
drag.latestWidth = nextWidth
// Only the two layout shells depend on the live width. Direct writes
// let the browser coalesce layout at paint time without a React commit
// or an inherited custom-property invalidation on the whole app tree.
// Direct writes let the browser coalesce layout at paint time without
// a React commit or an inherited custom-property invalidation on the
// whole app tree. Optional chrome consumers receive the same preview
// through a local custom property.
drag.gapEl.style.width = `${nextWidth}px`
drag.containerEl.style.width = `${nextWidth}px`
for (const consumer of drag.liveWidthConsumers) {
consumer.style.setProperty("--sidebar-live-width", `${nextWidth}px`)
}
}
const onPointerUp = (event: PointerEvent) => {
if (event.pointerId === e.pointerId) finishDrag("commit")
+4 -3
View File
@@ -249,8 +249,8 @@
/* Sidebar resizing follows the same cursor contract as
* react-resizable-panels: the resize cursor survives leaving the narrow rail
* and wins over descendants with their own cursor declarations. Live width is
* written directly to the two layout shells, so their toggle transitions must
* stay disabled until the gesture finishes. */
* written directly to the layout shells and opt-in chrome consumers, so their
* toggle transitions must stay disabled until the gesture finishes. */
html[data-sidebar-resizing="true"],
html[data-sidebar-resizing="true"] * {
cursor: ew-resize !important;
@@ -268,7 +268,8 @@ html[data-dnd-dragging="true"] * {
}
[data-sidebar-resizing="true"] [data-slot="sidebar-gap"],
[data-sidebar-resizing="true"] [data-slot="sidebar-container"] {
[data-sidebar-resizing="true"] [data-slot="sidebar-container"],
[data-sidebar-resizing="true"] [data-sidebar-resize-consumer] {
transition: none !important;
}
+23 -6
View File
@@ -22,7 +22,6 @@ describe("left sidebar resizing", () => {
it("previews width directly and commits only when the pointer is released", () => {
const stableConsumerRender = vi.fn();
const setItem = vi.spyOn(Storage.prototype, "setItem");
function StableSidebarConsumer() {
useSidebar();
@@ -36,6 +35,7 @@ describe("left sidebar resizing", () => {
<StableSidebarConsumer />
<SidebarRail />
</Sidebar>
<div data-sidebar-resize-consumer />
</SidebarProvider>,
);
@@ -43,6 +43,9 @@ describe("left sidebar resizing", () => {
const sidebarContainer = container.querySelector<HTMLElement>("[data-slot='sidebar-container']")!;
const sidebarGap = container.querySelector<HTMLElement>("[data-slot='sidebar-gap']")!;
const sidebar = container.querySelector<HTMLElement>("[data-slot='sidebar']")!;
const liveWidthConsumer = container.querySelector<HTMLElement>(
"[data-sidebar-resize-consumer]",
)!;
const rail = container.querySelector<HTMLButtonElement>("[data-slot='sidebar-rail']")!;
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
@@ -79,17 +82,22 @@ describe("left sidebar resizing", () => {
expect(sidebarGap.style.width).toBe("300px");
expect(sidebarContainer.style.width).toBe("300px");
expect(
liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
).toBe("300px");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
expect(setItem).not.toHaveBeenCalled();
expect(localStorage.getItem("sidebar_width")).toBeNull();
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(document, { pointerId: 7 });
expect(sidebarGap.style.width).toBe("");
expect(sidebarContainer.style.width).toBe("");
expect(
liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
).toBe("");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("300px");
expect(setItem).toHaveBeenCalledTimes(1);
expect(setItem).toHaveBeenCalledWith("sidebar_width", "300");
expect(localStorage.getItem("sidebar_width")).toBe("300");
expect(releasePointerCapture).toHaveBeenCalledWith(7);
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
@@ -100,18 +108,21 @@ describe("left sidebar resizing", () => {
});
it("restores the committed width and cursor state when pointer capture is cancelled", () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const { container } = renderWithI18n(
<SidebarProvider>
<Sidebar>
<SidebarRail />
</Sidebar>
<div data-sidebar-resize-consumer />
</SidebarProvider>,
);
const wrapper = container.querySelector<HTMLElement>("[data-slot='sidebar-wrapper']")!;
const sidebarContainer = container.querySelector<HTMLElement>("[data-slot='sidebar-container']")!;
const sidebarGap = container.querySelector<HTMLElement>("[data-slot='sidebar-gap']")!;
const liveWidthConsumer = container.querySelector<HTMLElement>(
"[data-sidebar-resize-consumer]",
)!;
const rail = container.querySelector<HTMLButtonElement>("[data-slot='sidebar-rail']")!;
rail.setPointerCapture = vi.fn();
rail.hasPointerCapture = vi.fn(() => true);
@@ -136,12 +147,18 @@ describe("left sidebar resizing", () => {
pointerId: 8,
});
fireEvent.pointerMove(document, { buttons: 1, clientX: 320, pointerId: 8 });
expect(
liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
).toBe("320px");
fireEvent.pointerCancel(document, { pointerId: 8 });
expect(sidebarGap.style.width).toBe("");
expect(sidebarContainer.style.width).toBe("");
expect(
liveWidthConsumer.style.getPropertyValue("--sidebar-live-width"),
).toBe("");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
expect(setItem).not.toHaveBeenCalled();
expect(localStorage.getItem("sidebar_width")).toBeNull();
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
});