mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
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:
co-authored by
multica-agent
Lambda
parent
0590011318
commit
7e1dfba124
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user