Open History to see the last commit
1import {
2 lazy,
3 Suspense,
4 useEffect,
5 useRef,
6 useState,
7 type ComponentType,
8 type KeyboardEvent,
9} from "react";
10import {
11 ArchiveIcon,
12 FlaskConicalIcon,
13 BotIcon,
14 createLucideIcon,
15 CalendarClockIcon,
16 FileDiffIcon,
17 FolderTreeIcon,
18 GitBranchIcon,
19 GlobeIcon,
20 InfoIcon,
21 MessageSquareTextIcon,
22 MessagesSquareIcon,
23 PanelsTopLeftIcon,
24 KeyboardIcon,
25 KeyRoundIcon,
26 MicIcon,
27 PaletteIcon,
28 SearchIcon,
29 ServerIcon,
30 Settings2Icon,
31 ShieldCheckIcon,
32 SmartphoneIcon,
33 WrenchIcon,
34 XIcon,
35} from "lucide-react";
36import { resolveEventKeys } from "@t3tools/shared/keybindings";
37import { useLocation, useNavigate } from "@tanstack/react-router";
38import { T, useGT, useMessages } from "gt-react";
39
40import { cn } from "../../lib/utils";
41import { Button } from "../ui/button";
42import { Kbd } from "../ui/kbd";
43import {
44 SidebarContent,
45 SidebarFooter,
46 SidebarGroup,
47 SidebarMenu,
48 SidebarMenuButton,
49 SidebarMenuItem,
50 SidebarInput,
51} from "../ui/sidebar";
52import { scrollToSettingsTarget } from "./settingsLayout";
53import {
54 searchSettings,
55 revealSettingsSearchTarget,
56 SETTINGS_NAV_GROUPS,
57 SETTINGS_SECTION_LABELS,
58 type SettingsPath,
59 type SettingsSearchItem,
60} from "./settingsSearch";
61import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
62import { handleSettingsSectionKeyDown } from "./settingsSectionNavigation";
63import { workspaceAreaProps } from "../../lib/workspaceFocus";
64
65const SnapShotIcon = createLucideIcon("snap-shot", [
66 [
67 "path",
68 {
69 d: "M8 3H6a3 3 0 0 0-3 3v2M16 3h2a3 3 0 0 1 3 3v2M21 16v2a3 3 0 0 1-3 3h-2M8 21H6a3 3 0 0 1-3-3v-2",
70 key: "capture-frame",
71 },
72 ],
73 ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }],
74 ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }],
75]);
76
77const T3ConnectSidebarSignIn = lazy(() =>
78 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({
79 default: module.T3ConnectSidebarSignIn,
80 })),
81);
82const T3ConnectSidebarAvatar = lazy(() =>
83 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({
84 default: module.T3ConnectSidebarAvatar,
85 })),
86);
87
88const SETTINGS_SECTION_ICONS: Readonly<
89 Record<SettingsPath, ComponentType<{ className?: string }>>
90> = {
91 "/settings/general": Settings2Icon,
92 "/settings/diagnostics": WrenchIcon,
93 "/settings/voice": MicIcon,
94 "/settings/appearance": PaletteIcon,
95 "/settings/chat": MessageSquareTextIcon,
96 "/settings/diffs": FileDiffIcon,
97 "/settings/projects": PanelsTopLeftIcon,
98 "/settings/keybindings": KeyboardIcon,
99 "/settings/snap-shot": SnapShotIcon,
100 "/settings/providers": BotIcon,
101 "/settings/threads": MessagesSquareIcon,
102 "/settings/browser": GlobeIcon,
103 "/settings/devices": SmartphoneIcon,
104 "/settings/scheduled-tasks": CalendarClockIcon,
105 "/settings/source-control": GitBranchIcon,
106 "/settings/worktrees": FolderTreeIcon,
107 "/settings/connections": ServerIcon,
108 "/settings/access": ShieldCheckIcon,
109 "/settings/secrets": KeyRoundIcon,
110 "/settings/maintenance": WrenchIcon,
111 "/settings/labs": FlaskConicalIcon,
112 "/settings/about": InfoIcon,
113 "/settings/archived": ArchiveIcon,
114};
115
116// Detail pages without a nav entry highlight the page that links to them.
117const SETTINGS_DETAIL_PAGE_PARENTS: Readonly<Record<string, SettingsPath>> = {
118 "/settings/diagnostics": "/settings/maintenance",
119 "/settings/open-source-licenses": "/settings/about",
120};
121
122// Attached only to the highlighted result, so it runs whenever the highlight lands on a row.
123function scrollResultIntoView(row: HTMLElement | null) {
124 row?.scrollIntoView({ block: "nearest" });
125}
126
127function SettingsSectionIcon({ to }: { to: SettingsPath }) {
128 const Icon = SETTINGS_SECTION_ICONS[to];
129 return <Icon className="size-4 shrink-0 text-(--sidebar-icon-color)" />;
130}
131
132export function SettingsSidebarNav({ pathname }: { pathname: string }) {
133 const gt = useGT();
134 const m = useMessages();
135 const navArea = workspaceAreaProps("settings-nav", 0, gt("Settings sections"));
136 const navigate = useNavigate();
137 const currentHash = useLocation({ select: (location) => location.hash });
138 const activePath = SETTINGS_DETAIL_PAGE_PARENTS[pathname] ?? pathname;
139 const searchInputRef = useRef<HTMLInputElement>(null);
140 const [query, setQuery] = useState("");
141 // Below md the nav is an icon rail; searching expands it over the page.
142 const [compactSearchOpen, setCompactSearchOpen] = useState(false);
143 const [activeResultIndex, setActiveResultIndex] = useState(0);
144 const searchableItems = useAvailableSettingsSearchItems();
145 const results = searchSettings(query, searchableItems, m);
146 const isSearching = query.trim().length > 0;
147 const hasResults = results.length > 0;
148 const maxResultIndex = Math.max(results.length - 1, 0);
149 // Persist the clamp so a shrink-then-recover of results keeps the clamped highlight.
150 if (activeResultIndex > maxResultIndex) {
151 setActiveResultIndex(maxResultIndex);
152 }
153
154 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window keydown listener for the find and slash search shortcuts
155 useEffect(() => {
156 const handleKeyDown = (event: globalThis.KeyboardEvent) => {
157 if (event.defaultPrevented || event.isComposing) return;
158 const eventKeys = resolveEventKeys(event);
159 const isFindShortcut =
160 eventKeys.has("f") && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
161 // Shift is allowed only when the layout types "/" with it (German Shift+7); Shift on
162 // the physical Slash key types "?" or "," instead.
163 const isSlashShortcut =
164 eventKeys.has("/") &&
165 (event.key === "/" || !event.shiftKey) &&
166 !event.metaKey &&
167 !event.ctrlKey &&
168 !event.altKey;
169 if (!isFindShortcut && !isSlashShortcut) return;
170
171 const target = event.target;
172 if (
173 target instanceof HTMLElement &&
174 ((isSlashShortcut &&
175 (target.tagName === "INPUT" ||
176 target.tagName === "TEXTAREA" ||
177 target.isContentEditable)) ||
178 target.closest(
179 '[role="dialog"]:not([data-app-modal]), [aria-modal="true"]:not([data-app-modal]), [data-slot$="popup"]:not([data-app-modal]), [data-keybinding-capture]',
180 ) !== null)
181 ) {
182 return;
183 }
184
185 event.preventDefault();
186 setCompactSearchOpen(true);
187 searchInputRef.current?.focus();
188 searchInputRef.current?.select();
189 };
190 window.addEventListener("keydown", handleKeyDown);
191 return () => window.removeEventListener("keydown", handleKeyDown);
192 }, []);
193
194 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the search input DOM node after the compact search opens
195 useEffect(() => {
196 if (compactSearchOpen) searchInputRef.current?.focus();
197 }, [compactSearchOpen]);
198
199 const handleSectionClick = (to: SettingsPath) => {
200 void navigate({
201 to,
202 hash: "",
203 replace: true,
204 hashScrollIntoView: false,
205 });
206 };
207
208 const clearSearch = () => {
209 setQuery("");
210 setActiveResultIndex(0);
211 };
212 const handleSearchResultClick = (item: SettingsSearchItem) => {
213 clearSearch();
214 setCompactSearchOpen(false);
215 const targetId = item.targetId ?? item.id;
216 revealSettingsSearchTarget(targetId);
217 if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
218 scrollToSettingsTarget(targetId);
219 return;
220 }
221 void navigate({
222 to: item.to,
223 hash: targetId,
224 replace: true,
225 hashScrollIntoView: false,
226 state: { settingsTargetHighlight: true },
227 });
228 };
229
230 const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
231 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
232 if (event.key === "Escape" && isSearching) {
233 event.preventDefault();
234 event.stopPropagation();
235 clearSearch();
236 return;
237 }
238 if (results.length === 0) return;
239 if (event.key === "ArrowDown") {
240 event.preventDefault();
241 setActiveResultIndex((activeResultIndex + 1) % results.length);
242 return;
243 }
244 if (event.key === "ArrowUp") {
245 event.preventDefault();
246 setActiveResultIndex((activeResultIndex - 1 + results.length) % results.length);
247 return;
248 }
249 if (event.key === "Enter") {
250 event.preventDefault();
251 const result = results[activeResultIndex];
252 if (result) handleSearchResultClick(result);
253 }
254 };
255
256 return (
257 <nav
258 {...navArea}
259 role="navigation"
260 className={cn(
261 "flex w-60 shrink-0 flex-col border-e bg-sidebar text-sidebar-foreground max-md:w-14",
262 compactSearchOpen &&
263 "max-md:absolute max-md:inset-y-0 max-md:start-0 max-md:z-10 max-md:w-full",
264 )}
265 >
266 <SidebarContent className="overflow-x-hidden">
267 <SidebarGroup>
268 <div className="flex flex-col gap-2">
269 <div
270 className={cn(
271 "flex h-8 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
272 !compactSearchOpen && "max-md:hidden",
273 )}
274 >
275 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
276 {/* Native search inputs clear on Escape even during IME composition. */}
277 <SidebarInput
278 ref={searchInputRef}
279 nativeInput
280 type="text"
281 value={query}
282 onChange={(event) => {
283 setQuery(event.currentTarget.value);
284 setActiveResultIndex(0);
285 }}
286 onKeyDown={handleSearchKeyDown}
287 onFocus={() => setCompactSearchOpen(true)}
288 onBlur={() => {
289 if (!isSearching) setCompactSearchOpen(false);
290 }}
291 placeholder={gt("Search")}
292 aria-label={gt("Search settings")}
293 role="combobox"
294 aria-autocomplete="list"
295 aria-expanded={isSearching && hasResults}
296 aria-controls={isSearching && hasResults ? "settings-search-results" : undefined}
297 aria-activedescendant={
298 isSearching && results[activeResultIndex]
299 ? `settings-search-result-${results[activeResultIndex].id}`
300 : undefined
301 }
302 className="min-w-0 flex-1"
303 />
304 {isSearching ? (
305 <Button
306 type="button"
307 size="icon-micro"
308 variant="ghost-muted"
309 className="shrink-0"
310 aria-label={gt("Clear settings search")}
311 onClick={() => {
312 clearSearch();
313 searchInputRef.current?.focus();
314 }}
315 >
316 <XIcon className="size-3" />
317 </Button>
318 ) : (
319 <Kbd>/</Kbd>
320 )}
321 </div>
322 {isSearching && results.length === 0 ? (
323 <p
324 role="status"
325 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
326 >
327 <T>No settings found</T>
328 </p>
329 ) : null}
330 {isSearching ? (
331 <SidebarMenu
332 id={hasResults ? "settings-search-results" : undefined}
333 role={hasResults ? "listbox" : undefined}
334 aria-label={hasResults ? gt("Settings search results") : undefined}
335 >
336 {results.map((item, index) => (
337 <SidebarMenuItem
338 key={item.id}
339 role="presentation"
340 ref={index === activeResultIndex ? scrollResultIntoView : undefined}
341 >
342 <SidebarMenuButton
343 id={`settings-search-result-${item.id}`}
344 role="option"
345 aria-selected={index === activeResultIndex}
346 tabIndex={-1}
347 size="sm"
348 isActive={index === activeResultIndex}
349 className="h-auto min-h-10 items-start"
350 onMouseMove={() => setActiveResultIndex(index)}
351 onClick={() => handleSearchResultClick(item)}
352 >
353 <SettingsSectionIcon to={item.to} />
354 <span className="min-w-0 flex-1">
355 <span className="block truncate text-sm font-medium text-sidebar-foreground">
356 {m(item.title)}
357 </span>
358 <span className="block truncate text-2xs text-sidebar-muted-foreground/75">
359 {m(SETTINGS_SECTION_LABELS[item.to])}
360 </span>
361 </span>
362 </SidebarMenuButton>
363 </SidebarMenuItem>
364 ))}
365 </SidebarMenu>
366 ) : (
367 <div className="flex flex-col gap-4">
368 {compactSearchOpen ? null : (
369 <SidebarMenu className="md:hidden">
370 <SidebarMenuItem>
371 <SidebarMenuButton
372 aria-label={gt("Search settings")}
373 onClick={() => setCompactSearchOpen(true)}
374 >
375 <SearchIcon />
376 </SidebarMenuButton>
377 </SidebarMenuItem>
378 </SidebarMenu>
379 )}
380 <div onKeyDown={handleSettingsSectionKeyDown} className="flex flex-col gap-4">
381 {SETTINGS_NAV_GROUPS.map((group, index) => (
382 <div
383 key={group.heading}
384 className="flex flex-col gap-1 max-md:border-t max-md:border-sidebar-border max-md:pt-4 max-md:first:border-t-0 max-md:first:pt-0"
385 >
386 <h3
387 id={`settings-nav-group-${index}`}
388 className={cn(
389 "px-2 text-xs font-medium text-sidebar-muted-foreground/80",
390 group.headingHidden ? "sr-only" : !compactSearchOpen && "max-md:sr-only",
391 )}
392 >
393 {m(group.heading)}
394 </h3>
395 {/* The heading stays outside the group it names so NVDA does not read the name twice. */}
396 <div role="group" aria-labelledby={`settings-nav-group-${index}`}>
397 <SidebarMenu>
398 {group.paths.map((to) => {
399 const Icon = SETTINGS_SECTION_ICONS[to];
400 const isActive = activePath === to || activePath.startsWith(`${to}/`);
401 return (
402 <SidebarMenuItem key={to}>
403 <SidebarMenuButton
404 data-settings-section={to}
405 aria-current={isActive ? "page" : undefined}
406 isActive={isActive}
407 onClick={() => handleSectionClick(to)}
408 >
409 <Icon />
410 <span
411 className={cn(
412 "truncate",
413 !compactSearchOpen && "max-md:sr-only",
414 )}
415 >
416 {m(SETTINGS_SECTION_LABELS[to])}
417 </span>
418 </SidebarMenuButton>
419 </SidebarMenuItem>
420 );
421 })}
422 </SidebarMenu>
423 </div>
424 </div>
425 ))}
426 </div>
427 </div>
428 )}
429 </div>
430 </SidebarGroup>
431 </SidebarContent>
432 <SidebarFooter className="max-md:hidden">
433 <Suspense fallback={null}>
434 <T3ConnectSidebarSignIn />
435 </Suspense>
436 <Suspense fallback={null}>
437 <T3ConnectSidebarAvatar />
438 </Suspense>
439 </SidebarFooter>
440 </nav>
441 );
442}