diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 918d28845068..0383bb3d0530 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,7 +7,14 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { + useCanGoBack, + useLocation, + useNavigate, + useParams, + useRouter, +} from "@tanstack/react-router"; +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; @@ -24,6 +31,7 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; +import { useCanGoForward } from "../navigationHistory"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; import { @@ -55,6 +63,7 @@ import { useSidebar, useSidebarVisibility, } from "./ui/sidebar"; +import { Button } from "./ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; @@ -92,6 +101,9 @@ function SidebarControl() { const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); + // Over the stage artwork the controls sit on imagery, like the media viewer's + // arrows; that variant positions itself, so the layout is reset here. + const onStageBackdrop = isSidebarVisible && stageBackdropVariant !== null; useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -139,12 +151,10 @@ function SidebarControl() { @@ -154,6 +164,62 @@ function SidebarControl() { Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + {/* Browsers bring their own Back and Forward buttons; the desktop window has none. */} + {isElectron ? : null} + + ); +} + +// Titlebar Back and Forward buttons, the clickable form of navigation.back/forward. +function NavigationHistoryControls({ onStageBackdrop }: { onStageBackdrop: boolean }) { + const { history } = useRouter(); + const canGoBack = useCanGoBack(); + const canGoForward = useCanGoForward(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const controls = [ + { + label: "Back", + command: "navigation.back", + icon: , + enabled: canGoBack, + go: () => history.back(), + }, + { + label: "Forward", + command: "navigation.forward", + icon: , + enabled: canGoForward, + go: () => history.forward(), + }, + ] as const; + + return ( +
+ {controls.map(({ label, command, icon, enabled, go }) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, command); + return ( + + + {icon} + + } + /> + + {label} + {shortcutLabel ? ` (${shortcutLabel})` : ""} + + + ); + })}
); } @@ -258,6 +324,13 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), + // Titlebar content clears the sidebar toggle plus the Back and Forward controls. + ...(isElectron + ? { + "--workspace-titlebar-content-left": + "calc(var(--workspace-controls-left) + 3 * var(--workspace-titlebar-control-size) + 2 * 0.125rem + var(--workspace-titlebar-control-gap))", + } + : {}), } as CSSProperties; useEffect(() => { diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ce2ef2629549..77f219d34f33 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -112,7 +112,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { (); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => void values.delete(key), + setItem: (key, value) => void values.set(key, value), + }; +} + +function trackedHistory(storage = memoryStorage()) { + const history = createMemoryHistory({ initialEntries: ["/"] }); + trackNavigationHistory(history, () => storage); + return history; +} + +describe("canGoForward", () => { + it("allows Forward only after going back", () => { + const history = trackedHistory(); + history.push("/a"); + history.push("/b"); + expect(canGoForward(history)).toBe(false); + + history.back(); + history.back(); + expect(canGoForward(history)).toBe(true); + + history.forward(); + expect(canGoForward(history)).toBe(true); + history.forward(); + expect(canGoForward(history)).toBe(false); + }); + + it("drops Forward entries when a new page is pushed", () => { + const history = trackedHistory(); + history.push("/a"); + history.push("/b"); + history.back(); + history.push("/c"); + expect(canGoForward(history)).toBe(false); + }); + + it("keeps Forward entries across a replace", () => { + const history = trackedHistory(); + history.push("/a"); + history.back(); + history.replace("/home"); + expect(canGoForward(history)).toBe(true); + }); + + it("does not offer Forward for an untracked history", () => { + const history = createMemoryHistory({ initialEntries: ["/"] }); + history.push("/a"); + history.back(); + expect(canGoForward(history)).toBe(false); + }); + + it("restores Forward after a reload of the same entry", () => { + const storage = memoryStorage(); + const history = createMemoryHistory({ initialEntries: ["/"] }); + storage.setItem( + "t3code:navigation-furthest-index", + JSON.stringify({ key: history.location.state.__TSR_key, furthest: 2 }), + ); + trackNavigationHistory(history, () => storage); + expect(canGoForward(history)).toBe(true); + }); + + it("ignores a stored position from a different entry", () => { + const storage = memoryStorage(); + storage.setItem( + "t3code:navigation-furthest-index", + JSON.stringify({ key: "another-entry", furthest: 2 }), + ); + expect(canGoForward(trackedHistory(storage))).toBe(false); + }); +}); diff --git a/apps/web/src/navigationHistory.ts b/apps/web/src/navigationHistory.ts new file mode 100644 index 000000000000..237563588251 --- /dev/null +++ b/apps/web/src/navigationHistory.ts @@ -0,0 +1,69 @@ +import { useRouter, type RouterHistory } from "@tanstack/react-router"; +import { useSyncExternalStore } from "react"; + +// Browsers expose how far back history goes (TanStack's `__TSR_index`) but not +// how far forward it goes. Each history remembers the furthest entry reached +// since its latest push, so the Forward control knows whether it has anywhere +// to go. Start tracking before the first navigation with `trackNavigationHistory`. +// The position lives in sessionStorage because a reload keeps the window's +// forward entries. +const FURTHEST_INDEX_KEY = "t3code:navigation-furthest-index"; + +const furthestIndexByHistory = new WeakMap(); + +function currentIndex(history: RouterHistory): number | null { + const index = history.location.state.__TSR_index; + return Number.isInteger(index) ? index : null; +} + +// Stored with the current entry's key, so only a reload of that same entry reuses it. +function readStoredIndex(history: RouterHistory, getStorage: () => Storage): number | null { + try { + const stored = JSON.parse(getStorage().getItem(FURTHEST_INDEX_KEY) ?? "{}") as { + key?: unknown; + furthest?: unknown; + }; + const { key, furthest } = stored; + return key === history.location.state.__TSR_key && Number.isInteger(furthest) + ? Number(furthest) + : null; + } catch { + return null; + } +} + +function storeIndex(history: RouterHistory, getStorage: () => Storage, furthest: number) { + try { + const key = history.location.state.__TSR_key; + getStorage().setItem(FURTHEST_INDEX_KEY, JSON.stringify({ key, furthest })); + } catch { + // Without storage, Forward still works until the next reload. + } +} + +export function trackNavigationHistory( + history: RouterHistory, + getStorage: () => Storage = () => window.sessionStorage, +) { + if (furthestIndexByHistory.has(history)) return; + const index = currentIndex(history) ?? 0; + const tracked = { furthest: Math.max(index, readStoredIndex(history, getStorage) ?? index) }; + furthestIndexByHistory.set(history, tracked); + history.subscribe(({ action }) => { + const index = currentIndex(history); + if (index === null) return; + tracked.furthest = action.type === "PUSH" ? index : Math.max(tracked.furthest, index); + storeIndex(history, getStorage, tracked.furthest); + }); +} + +export function canGoForward(history: RouterHistory): boolean { + const tracked = furthestIndexByHistory.get(history); + const index = currentIndex(history); + return tracked !== undefined && index !== null && index < tracked.furthest; +} + +export function useCanGoForward(): boolean { + const { history } = useRouter(); + return useSyncExternalStore(history.subscribe, () => canGoForward(history)); +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 4362dd3d70ba..a26b7d869b21 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,8 +1,10 @@ import { createRouter, RouterHistory } from "@tanstack/react-router"; +import { trackNavigationHistory } from "./navigationHistory"; import { routeTree } from "./routeTree.gen"; export function getRouter(history: RouterHistory) { + trackNavigationHistory(history); return createRouter({ routeTree, history,