diff --git a/apps/web/src/components/settings/settingsExit.test.ts b/apps/web/src/components/settings/settingsExit.test.ts new file mode 100644 index 000000000000..2ae8981ac7dc --- /dev/null +++ b/apps/web/src/components/settings/settingsExit.test.ts @@ -0,0 +1,39 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import { expect, it } from "vite-plus/test"; + +import { exitSettings, recordSettingsEntry } from "./settingsExit"; + +it("leaves settings in one step past every scope change", async () => { + const root = createRootRoute(); + const thread = createRoute({ getParentRoute: () => root, path: "thread" }); + const settings = createRoute({ + getParentRoute: () => root, + path: "settings", + validateSearch: (raw: Record) => ({ machine: String(raw.machine) }), + }); + const router = createRouter({ + routeTree: root.addChildren([thread, settings]), + history: createMemoryHistory({ initialEntries: ["/thread"] }), + }); + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + }; + await router.load(); + await router.navigate({ to: "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/settings", search: { machine: "a" } }); + recordSettingsEntry(router, storage); + await router.navigate({ to: "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/settings", search: { machine: "b" } }); + await router.navigate({ to: "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/settings", search: { machine: "c" } }); + + exitSettings(router, storage); + await router.load(); + + expect(router.state.location.pathname).toBe("/thread"); +}); diff --git a/apps/web/src/components/settings/settingsExit.ts b/apps/web/src/components/settings/settingsExit.ts new file mode 100644 index 000000000000..cae7c4a321d4 --- /dev/null +++ b/apps/web/src/components/settings/settingsExit.ts @@ -0,0 +1,51 @@ +import type { AnyRouter } from "@tanstack/react-router"; + +const SETTINGS_ENTRY_INDEX_KEY = "t3code:settings-entry-index"; + +type EntryStorage = Pick; + +function historyIndex(router: AnyRouter): number { + return router.history.location.state.__TSR_index; +} + +function isSettingsPath(pathname: string): boolean { + return /^\/settings(?:\/|$)/.test(pathname); +} + +function readEntryIndex(storage: EntryStorage): number | null { + const raw = storage.getItem(SETTINGS_ENTRY_INDEX_KEY); + const index = raw === null ? Number.NaN : Number(raw); + return Number.isInteger(index) ? index : null; +} + +export function recordSettingsEntry(router: AnyRouter, storage?: EntryStorage): () => void { + try { + const entryStorage = storage ?? window.sessionStorage; + const current = historyIndex(router); + const stored = readEntryIndex(entryStorage); + if (stored === null || stored > current) { + entryStorage.setItem(SETTINGS_ENTRY_INDEX_KEY, String(current)); + } + return () => { + if (isSettingsPath(router.history.location.pathname)) return; + try { + entryStorage.removeItem(SETTINGS_ENTRY_INDEX_KEY); + } catch {} + }; + } catch { + return () => {}; + } +} + +export function exitSettings(router: AnyRouter, storage?: EntryStorage) { + let entry: number | null = null; + try { + entry = readEntryIndex(storage ?? window.sessionStorage); + } catch {} + const current = historyIndex(router); + if (entry !== null && entry > 0 && entry <= current) { + router.history.go(entry - current - 1); + return; + } + void router.navigate({ to: "/" }); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 0bcc8e07c2b4..e10fcded9873 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,7 +1,7 @@ import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useCanGoBack, useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -26,6 +26,7 @@ import { } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; +import { exitSettings } from "../settings/settingsExit"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -131,6 +132,7 @@ function SidebarUtilityItem({ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); + const router = useRouter(); const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ @@ -177,12 +179,16 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const handleBackClick = useCallback(() => { closeMobileSidebar(); + if (currentFooterPage === "settings") { + exitSettings(router); + return; + } if (canGoBack) { window.history.back(); return; } void navigate({ to: "/" }); - }, [canGoBack, closeMobileSidebar, navigate]); + }, [canGoBack, closeMobileSidebar, currentFooterPage, navigate, router]); return ( diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 5fc6d63f20f6..0d865cc9ca68 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,12 +1,5 @@ -import { - Outlet, - createFileRoute, - redirect, - useCanGoBack, - useLocation, - useNavigate, -} from "@tanstack/react-router"; -import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { Outlet, createFileRoute, redirect, useLocation, useRouter } from "@tanstack/react-router"; +import { useEffect, useState, type ReactNode } from "react"; import { RotateCcwIcon } from "lucide-react"; import { Button } from "../components/ui/button"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; @@ -27,6 +20,7 @@ import { retainSettingsScope, validateSettingsRouteSearch, } from "../components/settings/settingsScopeNavigation"; +import { exitSettings, recordSettingsEntry } from "../components/settings/settingsExit"; import { getSettingsSearchTargetScope, getThreadAutoSettlementSearchAvailability, @@ -117,17 +111,10 @@ function SettingsScopeBoundary({ pathname, children }: { pathname: string; child function SettingsContentLayout() { const location = useLocation(); - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); + const router = useRouter(); const { search } = useSettingsScope(); const [restoreSignal, setRestoreSignal] = useState(0); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); + useEffect(() => recordSettingsEntry(router), [router]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -140,7 +127,7 @@ function SettingsContentLayout() { activeElement.blur(); } - navigateBackWithinApp(); + exitSettings(router); } }; @@ -148,7 +135,7 @@ function SettingsContentLayout() { return () => { window.removeEventListener("keydown", onKeyDown); }; - }, [navigateBackWithinApp]); + }, [router]); return (