1import { Outlet, createFileRoute, redirect, useLocation } from "@tanstack/react-router";
2import { useState, type ReactNode } from "react";
3import { RotateCcwIcon } from "lucide-react";
4import { Button } from "../components/ui/button";
5import { useSettingsRestore } from "../components/settings/SettingsPanels";
6
7import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
8import { SidebarInset } from "../components/ui/sidebar";
9import { useNavigateToMainApp } from "../components/sidebar/mainAppLocation";
10import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
11import { isElectron } from "../env";
12import { useEscapeToGoBack } from "../hooks/useNavigateBack";
13import {
14 SettingsScopeProvider,
15 useSettingsScope,
16} from "../components/settings/SettingsScopeContext";
17import { useEnvironments } from "../state/environments";
18import { SettingsScopeNotice } from "../components/settings/SettingsScopeNotice";
19import { SETTINGS_DEVICE_ONLY_PATHS } from "../components/settings/SettingsScopeSentence";
20import { SettingsPageContainer } from "../components/settings/settingsLayout";
21import {
22 retainSettingsScope,
23 validateSettingsRouteSearch,
24} from "../components/settings/settingsScopeNavigation";
25import {
26 getSettingsSearchTargetScope,
27 getThreadAutoSettlementSearchAvailability,
28 isSettingsSearchScopeAvailable,
29} from "../components/settings/settingsSearch";
30
31function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) {
32 const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored);
33 return (
34 <Button
35 size="xs"
36 variant="ghost"
37 disabled={changedSettingLabels.length === 0}
38 onClick={() => void restoreDefaults()}
39 >
40 <RotateCcwIcon className="mx-1 size-3.5" />
41 Restore device defaults
42 </Button>
43 );
44}
45
46function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) {
47 const { scope, connectedEnvironments } = useSettingsScope();
48 const { environments } = useEnvironments();
49 const hash = useLocation({ select: (location) => location.hash });
50 const searchTarget = getSettingsSearchTargetScope(hash);
51 const autoSettlementAvailability = searchTarget?.requiresThreadAutoSettlement
52 ? getThreadAutoSettlementSearchAvailability(environments, scope)
53 : null;
54 if (
55 scope.kind !== "unavailable" &&
56 searchTarget &&
57 autoSettlementAvailability &&
58 !autoSettlementAvailability.isTargetAvailable
59 ) {
60 return (
61 <SettingsScopeNotice
62 target="environment"
63 targetId={hash}
64 eligibleEnvironmentIds={autoSettlementAvailability.eligibleEnvironmentIds}
65 >
66 {autoSettlementAvailability.eligibleEnvironmentIds.length > 0
67 ? `${searchTarget.title} requires a supporting environment. Choose one to continue.`
68 : `${searchTarget.title} requires a supporting environment. Connect or update an environment to continue.`}
69 </SettingsScopeNotice>
70 );
71 }
72 if (
73 scope.kind !== "unavailable" &&
74 searchTarget &&
75 !isSettingsSearchScopeAvailable(searchTarget.scope, scope.kind)
76 ) {
77 const target =
78 searchTarget.scope === "environment" ||
79 searchTarget.scope === "project" ||
80 searchTarget.scope === "checkout"
81 ? searchTarget.scope
82 : "all";
83 return (
84 <SettingsScopeNotice target={target} targetId={hash}>
85 {`${searchTarget.title} is not available for the selected target. Choose its owning scope to continue.`}
86 </SettingsScopeNotice>
87 );
88 }
89 // Device-local pages ignore the scope entirely; the project page follows
90 // remembered members while a grouping change replaces its URL key.
91 if (SETTINGS_DEVICE_ONLY_PATHS.has(pathname) || pathname === "/settings/projects") {
92 return children;
93 }
94 // Keep the scope sentence on screen so the selection can be changed back.
95 if (scope.kind === "unavailable")
96 return (
97 <SettingsPageContainer>
98 <p className="text-sm text-muted-foreground">{scope.message}</p>
99 </SettingsPageContainer>
100 );
101 if (scope.kind === "environment" && connectedEnvironments.length === 0) {
102 return (
103 <SettingsPageContainer>
104 <p className="text-sm text-muted-foreground">
105 Reconnect {scope.label} to change its settings.
106 </p>
107 </SettingsPageContainer>
108 );
109 }
110 return children;
111}
112
113function SettingsContentLayout() {
114 const location = useLocation();
115 const navigateToMainApp = useNavigateToMainApp();
116 useEscapeToGoBack(navigateToMainApp);
117 const { search, environment } = useSettingsScope();
118 const contentScopeKey =
119 location.pathname === "/settings/diagnostics" || location.pathname === "/settings/providers"
120 ? environment?.environmentId
121 : JSON.stringify(search);
122 const [restoreSignal, setRestoreSignal] = useState(0);
123
124 return (
125 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none isolate">
126 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
127 <WorkspacePageHeader electron={isElectron}>
128 <div className="flex w-full items-center gap-3">
129 <SettingsBreadcrumb pathname={location.pathname} />
130 {location.pathname === "/settings/general" ? (
131 <div className="ms-auto flex shrink-0 items-center">
132 <RestoreDeviceDefaultsButton
133 onRestored={() => setRestoreSignal((value) => value + 1)}
134 />
135 </div>
136 ) : null}
137 </div>
138 </WorkspacePageHeader>
139
140 <div key={`${contentScopeKey}:${restoreSignal}`} className="min-h-0 flex flex-1 flex-col">
141 <SettingsScopeBoundary pathname={location.pathname}>
142 <Outlet />
143 </SettingsScopeBoundary>
144 </div>
145 </div>
146 </SidebarInset>
147 );
148}
149
150function SettingsRouteLayout() {
151 const rawSearch = Route.useSearch();
152 const navigate = Route.useNavigate();
153 const pathname = useLocation({ select: (location) => location.pathname });
154 return (
155 <SettingsScopeProvider
156 search={rawSearch}
157 singleEnvironment={pathname === "/settings/providers"}
158 onChange={(next) => {
159 // Send every axis so the retain middleware sees an explicit target
160 // even when the choice is "all", which is the absence of a key.
161 void navigate({
162 to: pathname,
163 search: () => ({
164 project: next.project,
165 machine: next.machine,
166 checkout: next.checkout,
167 }),
168 hash: "",
169 resetScroll: false,
170 });
171 }}
172 >
173 <SettingsContentLayout />
174 </SettingsScopeProvider>
175 );
176}
177
178export const Route = createFileRoute("/settings")({
179 validateSearch: validateSettingsRouteSearch,
180 search: { middlewares: [retainSettingsScope] },
181 beforeLoad: async ({ context, location }) => {
182 if (
183 context.authGateState.status !== "authenticated" &&
184 context.authGateState.status !== "hosted-static"
185 ) {
186 throw redirect({ to: "/pair", replace: true });
187 }
188
189 if (location.pathname === "/settings") {
190 throw redirect({ to: "/settings/general", replace: true });
191 }
192 },
193 component: SettingsRouteLayout,
194});