diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx index aea75a987..844baf10f 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx @@ -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" }); }); }); diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index 327637539..37501db13 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -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 ( -
-
- -
- - -
-
-
- ); -} - function SidebarTopSpacer() { return
; } @@ -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 ( - - -
+
- + ); } diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx index e8c223841..2b28b7af8 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx @@ -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: () => {}, })); diff --git a/apps/desktop/src/renderer/src/components/window-toolbar.test.tsx b/apps/desktop/src/renderer/src/components/window-toolbar.test.tsx new file mode 100644 index 000000000..40327d38d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/window-toolbar.test.tsx @@ -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: () => , +})); + +vi.mock("@multica/ui/components/ui/sidebar", () => ({ + useSidebar: () => sidebarState, + SidebarTrigger: (props: ComponentProps<"button">) => ( + + + +
+ + { + if (!open) setMenu(null); + }} + onOpenChangeComplete={(open) => { + if (!open) menuAnchorRef.current?.focus(); + }} + > + + + {menuLabel} + {menu?.mode === "all" + ? browsingMenuEntries.map((entry) => ( + selectBrowsingHistory(entry.url)} + /> + )) + : menuIndices.map((index) => ( + selectHistoryIndex(index)} + /> + ))} + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx b/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx index a1a57761d..5365618eb 100644 --- a/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx +++ b/apps/desktop/src/renderer/src/hooks/use-navigation-input-bindings.test.tsx @@ -31,6 +31,8 @@ function seedHistory() { ], activeTabId: "t1", recentTabIds: ["t1"], + browsingHistory: ["/acme/issues/abc", "/acme/issues"], + browsingHistoryTitles: {}, }, }, }); diff --git a/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx b/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx index 760e4883e..442abe3e9 100644 --- a/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx +++ b/apps/desktop/src/renderer/src/hooks/use-open-settings-shortcut.test.tsx @@ -32,6 +32,8 @@ function seedTabs() { ], activeTabId: "t1", recentTabIds: ["t1"], + browsingHistory: ["/acme/issues"], + browsingHistoryTitles: {}, }, }, }); diff --git a/apps/desktop/src/renderer/src/hooks/use-tab-history.ts b/apps/desktop/src/renderer/src/hooks/use-tab-history.ts index 0c3cd5d14..4271b6b1b 100644 --- a/apps/desktop/src/renderer/src/hooks/use-tab-history.ts +++ b/apps/desktop/src/renderer/src/hooks/use-tab-history.ts @@ -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( + () => + 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 { diff --git a/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx b/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx index 9982d6cdb..04d85381a 100644 --- a/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx +++ b/apps/desktop/src/renderer/src/hooks/use-tab-selection-shortcut.test.tsx @@ -39,6 +39,8 @@ function seedTabs(count: number, activeIndex = 1): void { tabs, activeTabId: `t${activeIndex}`, recentTabIds: [], + browsingHistory: tabs.map((tab) => tab.url).reverse(), + browsingHistoryTitles: {}, }, }, }); diff --git a/apps/desktop/src/renderer/src/stores/tab-store.test.ts b/apps/desktop/src/renderer/src/stores/tab-store.test.ts index 8058b947b..84d5fec69 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.test.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.test.ts @@ -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", + }); + }); }); diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index 02eee0b81..0df8289f5 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -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; /** * 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, + right: Record, +): 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 = {}; + 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(); + 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 { + const persistedTitles = + value && typeof value === "object" + ? (value as Record) + : {}; + const tabTitles = new Map( + tabs.flatMap((tab) => { + const title = normalizedBrowsingHistoryTitle(tab.title); + return title + ? [[browsingHistoryKeyForUrl(tab.url), title] as const] + : []; + }), + ); + const result: Record = {}; + 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()( 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()( 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()( 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()( 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()( 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()( // 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()( 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()( }; 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()( 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()( 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()( }), { 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()( 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()( // 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( persistedState: unknown, currentState: T, ): T { - const persisted = persistedState as Partial | undefined; + const persisted = persistedState as Partial | undefined; if (!persisted?.byWorkspace) return currentState; const byWorkspace: Record = {}; @@ -1114,6 +1412,10 @@ export function mergePersistedTabs( : 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( 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) => 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) => 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; } +interface V5PersistedGroup extends V4PersistedGroup { + browsingHistory: string[]; + browsingHistoryTitles?: Record; +} + +interface V5Persisted { + activeWorkspaceSlug: string | null; + byWorkspace: Record; +} + +export function migrateV4ToV5(v4: V4Persisted): V5Persisted { + const byWorkspace: Record = {}; + 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(); + 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 = {}; 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 = {}; + +/** 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 { + return useTabStore((s) => { + if (!s.activeWorkspaceSlug) return EMPTY_BROWSING_HISTORY_TITLES; + return ( + s.byWorkspace[s.activeWorkspaceSlug]?.browsingHistoryTitles ?? + EMPTY_BROWSING_HISTORY_TITLES + ); + }); } diff --git a/packages/ui/components/ui/sidebar.tsx b/packages/ui/components/ui/sidebar.tsx index 47108d04c..8db120379 100644 --- a/packages/ui/components/ui/sidebar.tsx +++ b/packages/ui/components/ui/sidebar.tsx @@ -89,8 +89,9 @@ type SidebarResizeContextProps = { } const SidebarContext = React.createContext(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(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("[data-slot='sidebar-gap']") const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']") if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return + const liveWidthConsumers = Array.from( + wrapperEl.querySelectorAll("[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") diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index 80ecd3c33..11030983c 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -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; } diff --git a/packages/views/layout/sidebar-resize.test.tsx b/packages/views/layout/sidebar-resize.test.tsx index 06796b4f6..81977df2e 100644 --- a/packages/views/layout/sidebar-resize.test.tsx +++ b/packages/views/layout/sidebar-resize.test.tsx @@ -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", () => { +
, ); @@ -43,6 +43,9 @@ describe("left sidebar resizing", () => { const sidebarContainer = container.querySelector("[data-slot='sidebar-container']")!; const sidebarGap = container.querySelector("[data-slot='sidebar-gap']")!; const sidebar = container.querySelector("[data-slot='sidebar']")!; + const liveWidthConsumer = container.querySelector( + "[data-sidebar-resize-consumer]", + )!; const rail = container.querySelector("[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( +
, ); const wrapper = container.querySelector("[data-slot='sidebar-wrapper']")!; const sidebarContainer = container.querySelector("[data-slot='sidebar-container']")!; const sidebarGap = container.querySelector("[data-slot='sidebar-gap']")!; + const liveWidthConsumer = container.querySelector( + "[data-sidebar-resize-consumer]", + )!; const rail = container.querySelector("[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"); });