authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 17:06:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 17:06:27-07:00
log4d3907f63d71644e1918d76cd104b036535a1173
treea84cb1eec3be94dc5e847c353f4f7fdcd5278661
parent2e61301b1325e30cb9464e193299a7e469f7ec08
signature Signed by PGP key B5690EEEBB952194

feat(web): highlight visible settings sections (#9812)


4 files changed, 482 insertions(+), 3 deletions(-)

apps/web/src/components/settings/SettingsSidebarNav.tsx+47-2
......@@ -22,12 +22,13 @@ import {
2222 Settings2Icon,
2323 XIcon,
2424} from "lucide-react";
25import { useLocation, useNavigate } from "@tanstack/react-router";
25import { useLocation, useNavigate, useRouterState } from "@tanstack/react-router";
2626
2727import { Button } from "../ui/button";
2828import { Collapsible, CollapsiblePanel } from "../ui/collapsible";
2929import { Input } from "../ui/input";
3030import { Kbd } from "../ui/kbd";
31import { cn } from "../../lib/utils";
3132import {
3233 SidebarContent,
3334 SidebarFooter,
......@@ -42,6 +43,11 @@ import {
4243} from "../ui/sidebar";
4344import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
4445import { scrollToSettingsTarget } from "./settingsLayout";
46import {
47 getVisibleSettingsSectionIds,
48 observeSettingsSectionVisibility,
49 type SettingsSectionVisibilityState,
50} from "./settingsSectionVisibility";
4551import {
4652 searchSettings,
4753 SETTINGS_SECTION_LABELS,
......@@ -99,6 +105,7 @@ const SETTINGS_PAGE_SECTIONS: Partial<
99105 "/settings/appearance": [
100106 { label: "Colors & themes", targetId: "appearance" },
101107 { label: "Interface", targetId: "appearance-interface" },
108 { label: "Motion", targetId: "motion" },
102109 { label: "Typography", targetId: "typography" },
103110 ],
104111 "/settings/source-control": [
......@@ -135,10 +142,16 @@ function SettingsSubmenuCollapse({
135142export function SettingsSidebarNav({ pathname }: { pathname: string }) {
136143 const navigate = useNavigate();
137144 const currentHash = useLocation({ select: (location) => location.hash });
145 const resolvedPathname = useRouterState({
146 select: (state) => state.resolvedLocation?.pathname,
147 });
138148 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
139149 const searchInputRef = useRef<HTMLInputElement>(null);
140150 const [query, setQuery] = useState("");
141151 const [activeResultIndex, setActiveResultIndex] = useState(0);
152 const [sectionVisibility, setSectionVisibility] = useState<SettingsSectionVisibilityState | null>(
153 null,
154 );
142155 const searchableItems = useAvailableSettingsSearchItems();
143156 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
144157 const isSearching = query.trim().length > 0;
......@@ -146,6 +159,33 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
146159 const activeSettingsPath = SETTINGS_NAV_ITEMS.find(
147160 (item) => pathname === item.to || pathname.startsWith(`${item.to}/`),
148161 )?.to;
162 const observedVisibilityScope = useMemo(() => {
163 const path = SETTINGS_NAV_ITEMS.find(
164 (item) =>
165 resolvedPathname === item.to || resolvedPathname?.startsWith(`${item.to}/`) === true,
166 )?.to;
167 const pageSections = path ? SETTINGS_PAGE_SECTIONS[path] : undefined;
168 return path && pageSections ? { path, pageSections } : null;
169 }, [resolvedPathname]);
170 const visiblePageSectionIds = getVisibleSettingsSectionIds({
171 activePath: activeSettingsPath,
172 scope: observedVisibilityScope,
173 visibility: sectionVisibility,
174 });
175
176 useEffect(() => {
177 if (!observedVisibilityScope) return;
178 const container = document.querySelector<HTMLElement>("[data-settings-page-layout]");
179 if (!container) return;
180
181 return observeSettingsSectionVisibility({
182 container,
183 targetIds: observedVisibilityScope.pageSections.map((section) => section.targetId),
184 onChange(targetIds) {
185 setSectionVisibility({ scope: observedVisibilityScope, targetIds: new Set(targetIds) });
186 },
187 });
188 }, [observedVisibilityScope]);
149189
150190 useEffect(() => {
151191 setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));
......@@ -387,7 +427,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
387427 <SidebarMenuSubButton
388428 render={<button type="button" />}
389429 size="sm"
390 className="w-full text-sidebar-muted-foreground/65"
430 data-visible={visiblePageSectionIds.has(section.targetId)}
431 className={cn(
432 "w-full text-sidebar-muted-foreground/65",
433 visiblePageSectionIds.has(section.targetId) &&
434 "font-medium text-sidebar-foreground",
435 )}
391436 onClick={() => handlePageSectionClick(item.to, section.targetId)}
392437 >
393438 <span className="ms-0.5">{section.label}</span>
apps/web/src/components/settings/settingsSectionVisibility.test.ts created+245
......@@ -0,0 +1,245 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 getVisibleSettingsSectionIds,
5 observeSettingsSectionVisibility,
6 type SettingsSectionVisibilityEnvironment,
7} from "./settingsSectionVisibility";
8
9type VisibilityEntry = Pick<
10 IntersectionObserverEntry,
11 "intersectionRatio" | "isIntersecting" | "target"
12>;
13
14function createHarness(
15 targetIds: ReadonlyArray<string>,
16 initialRoot: Element | null = { name: "initial-root" } as unknown as Element,
17) {
18 const targets = new Map(
19 targetIds.map((targetId) => [targetId, { targetId } as unknown as Element]),
20 );
21 const observed = new Set<Element>();
22 const unobserve = vi.fn((target: Element) => observed.delete(target));
23 const disconnectIntersections = vi.fn();
24 const disconnectMutations = vi.fn();
25 const intersectionCallbacks: Array<(entries: ReadonlyArray<VisibilityEntry>) => void> = [];
26 const intersectionRoots: Element[] = [];
27 let root = initialRoot;
28 let onMutation = () => {};
29
30 const environment: SettingsSectionVisibilityEnvironment = {
31 findRoot: () => root,
32 findTarget: (_root, targetId) => targets.get(targetId) ?? null,
33 createIntersectionObserver(callback, observedRoot) {
34 intersectionCallbacks.push(callback);
35 intersectionRoots.push(observedRoot);
36 return {
37 observe: (target) => observed.add(target),
38 unobserve,
39 disconnect: disconnectIntersections,
40 };
41 },
42 createMutationObserver(callback) {
43 onMutation = callback;
44 return { disconnect: disconnectMutations };
45 },
46 };
47
48 return {
49 environment,
50 targets,
51 observed,
52 unobserve,
53 disconnectIntersections,
54 disconnectMutations,
55 intersectionCallbacks,
56 intersectionRoots,
57 intersect(entries: ReadonlyArray<VisibilityEntry>) {
58 intersectionCallbacks.at(-1)?.(entries);
59 },
60 mutate() {
61 onMutation();
62 },
63 replaceRoot(nextRoot: Element | null) {
64 root = nextRoot;
65 onMutation();
66 },
67 };
68}
69
70function visibleEntry(
71 target: Element,
72 { isIntersecting = true, intersectionRatio = 1 } = {},
73): VisibilityEntry {
74 return { target, isIntersecting, intersectionRatio };
75}
76
77describe("settings section visibility", () => {
78 it("does not reuse visibility when returning to the same sectioned route", () => {
79 const firstGeneralVisit = { path: "/settings/general" };
80 const firstVisibility = {
81 scope: firstGeneralVisit,
82 targetIds: new Set(["text-generation"]),
83 };
84
85 expect(
86 getVisibleSettingsSectionIds({
87 activePath: "/settings/general",
88 scope: firstGeneralVisit,
89 visibility: firstVisibility,
90 }),
91 ).toEqual(new Set(["text-generation"]));
92 expect(
93 getVisibleSettingsSectionIds({
94 activePath: "/settings/providers",
95 scope: null,
96 visibility: firstVisibility,
97 }),
98 ).toEqual(new Set());
99
100 const secondGeneralVisit = { path: "/settings/general" };
101 expect(
102 getVisibleSettingsSectionIds({
103 activePath: "/settings/general",
104 scope: secondGeneralVisit,
105 visibility: firstVisibility,
106 }),
107 ).toEqual(new Set());
108 });
109
110 it("accumulates visible sections and emits them in sidebar order", () => {
111 const harness = createHarness(["one", "two", "three"]);
112 const emissions: ReadonlyArray<string>[] = [];
113 observeSettingsSectionVisibility({
114 container: {} as Element,
115 targetIds: ["one", "two", "three"],
116 onChange: (visible) => emissions.push(visible),
117 environment: harness.environment,
118 });
119
120 harness.intersect([visibleEntry(harness.targets.get("two")!)]);
121 harness.intersect([visibleEntry(harness.targets.get("one")!)]);
122
123 expect(emissions).toEqual([[], ["two"], ["one", "two"]]);
124 });
125
126 it("treats zero-ratio and non-intersecting entries as hidden", () => {
127 const harness = createHarness(["one", "two"]);
128 const emissions: ReadonlyArray<string>[] = [];
129 observeSettingsSectionVisibility({
130 container: {} as Element,
131 targetIds: ["one", "two"],
132 onChange: (visible) => emissions.push(visible),
133 environment: harness.environment,
134 });
135
136 const one = harness.targets.get("one")!;
137 const two = harness.targets.get("two")!;
138 harness.intersect([visibleEntry(one), visibleEntry(two)]);
139 harness.intersect([visibleEntry(one, { intersectionRatio: 0 })]);
140 harness.intersect([visibleEntry(two, { isIntersecting: false })]);
141
142 expect(emissions).toEqual([[], ["one", "two"], ["two"], []]);
143 });
144
145 it("resyncs replaced and removed targets without retaining stale visibility", () => {
146 const harness = createHarness(["dynamic"]);
147 const emissions: ReadonlyArray<string>[] = [];
148 observeSettingsSectionVisibility({
149 container: {} as Element,
150 targetIds: ["dynamic"],
151 onChange: (visible) => emissions.push(visible),
152 environment: harness.environment,
153 });
154
155 const firstTarget = harness.targets.get("dynamic")!;
156 harness.intersect([visibleEntry(firstTarget)]);
157 const replacementTarget = { targetId: "dynamic-replacement" } as unknown as Element;
158 harness.targets.set("dynamic", replacementTarget);
159 harness.mutate();
160
161 expect(harness.unobserve).toHaveBeenCalledWith(firstTarget);
162 expect(harness.observed.has(replacementTarget)).toBe(true);
163 expect(emissions.at(-1)).toEqual([]);
164
165 harness.intersect([visibleEntry(firstTarget)]);
166 expect(emissions.at(-1)).toEqual([]);
167 harness.intersect([visibleEntry(replacementTarget)]);
168 expect(emissions.at(-1)).toEqual(["dynamic"]);
169
170 harness.targets.delete("dynamic");
171 harness.mutate();
172 expect(harness.unobserve).toHaveBeenCalledWith(replacementTarget);
173 expect(emissions.at(-1)).toEqual([]);
174 });
175
176 it("rebinds when navigation replaces the settings scroll root", () => {
177 const harness = createHarness(["section"]);
178 const emissions: ReadonlyArray<string>[] = [];
179 observeSettingsSectionVisibility({
180 container: {} as Element,
181 targetIds: ["section"],
182 onChange: (visible) => emissions.push(visible),
183 environment: harness.environment,
184 });
185
186 const firstTarget = harness.targets.get("section")!;
187 harness.intersect([visibleEntry(firstTarget)]);
188 const firstObserverCallback = harness.intersectionCallbacks[0]!;
189 const nextRoot = { name: "next-root" } as unknown as Element;
190 const nextTarget = { targetId: "next-section" } as unknown as Element;
191 harness.targets.set("section", nextTarget);
192 harness.replaceRoot(nextRoot);
193
194 expect(harness.disconnectIntersections).toHaveBeenCalledOnce();
195 expect(harness.intersectionRoots.at(-1)).toBe(nextRoot);
196 expect(harness.observed.has(nextTarget)).toBe(true);
197 expect(emissions.at(-1)).toEqual([]);
198
199 firstObserverCallback([visibleEntry(firstTarget)]);
200 expect(emissions.at(-1)).toEqual([]);
201 harness.intersect([visibleEntry(nextTarget)]);
202 expect(emissions.at(-1)).toEqual(["section"]);
203 });
204
205 it("starts observing when the settings scroll root mounts later", () => {
206 const harness = createHarness(["section"], null);
207 const emissions: ReadonlyArray<string>[] = [];
208 observeSettingsSectionVisibility({
209 container: {} as Element,
210 targetIds: ["section"],
211 onChange: (visible) => emissions.push(visible),
212 environment: harness.environment,
213 });
214
215 expect(emissions).toEqual([[]]);
216 expect(harness.intersectionRoots).toEqual([]);
217
218 const root = { name: "mounted-root" } as unknown as Element;
219 harness.replaceRoot(root);
220 harness.intersect([visibleEntry(harness.targets.get("section")!)]);
221
222 expect(harness.intersectionRoots).toEqual([root]);
223 expect(emissions.at(-1)).toEqual(["section"]);
224 });
225
226 it("disconnects both observers and ignores callbacks after cleanup", () => {
227 const harness = createHarness(["one"]);
228 const onChange = vi.fn();
229 const cleanup = observeSettingsSectionVisibility({
230 container: {} as Element,
231 targetIds: ["one"],
232 onChange,
233 environment: harness.environment,
234 });
235
236 cleanup();
237 harness.intersect([visibleEntry(harness.targets.get("one")!)]);
238 harness.mutate();
239
240 expect(harness.disconnectIntersections).toHaveBeenCalledOnce();
241 expect(harness.disconnectMutations).toHaveBeenCalledOnce();
242 expect(onChange).toHaveBeenCalledTimes(1);
243 expect(onChange).toHaveBeenLastCalledWith([]);
244 });
245});
apps/web/src/components/settings/settingsSectionVisibility.ts created+186
......@@ -0,0 +1,186 @@
1type VisibilityEntry = Pick<
2 IntersectionObserverEntry,
3 "intersectionRatio" | "isIntersecting" | "target"
4>;
5
6export type SettingsSectionVisibilityScope = {
7 readonly path: string;
8};
9
10export type SettingsSectionVisibilityState = {
11 readonly scope: SettingsSectionVisibilityScope;
12 readonly targetIds: ReadonlySet<string>;
13};
14
15const EMPTY_VISIBLE_SETTINGS_SECTION_IDS: ReadonlySet<string> = new Set();
16
17export function getVisibleSettingsSectionIds({
18 activePath,
19 scope,
20 visibility,
21}: {
22 readonly activePath: string | undefined;
23 readonly scope: SettingsSectionVisibilityScope | null;
24 readonly visibility: SettingsSectionVisibilityState | null;
25}): ReadonlySet<string> {
26 if (!scope || activePath !== scope.path || visibility?.scope !== scope) {
27 return EMPTY_VISIBLE_SETTINGS_SECTION_IDS;
28 }
29 return visibility.targetIds;
30}
31
32type ElementObserver = {
33 observe(target: Element): void;
34 unobserve(target: Element): void;
35 disconnect(): void;
36};
37
38type MutationSubscription = {
39 disconnect(): void;
40};
41
42export type SettingsSectionVisibilityEnvironment = {
43 findRoot(container: Element): Element | null;
44 findTarget(root: Element, targetId: string): Element | null;
45 createIntersectionObserver(
46 onEntries: (entries: ReadonlyArray<VisibilityEntry>) => void,
47 root: Element,
48 ): ElementObserver;
49 createMutationObserver(onMutation: () => void, container: Element): MutationSubscription;
50};
51
52function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment {
53 return {
54 findRoot(container) {
55 return container.querySelector("[data-settings-page-scroll]");
56 },
57 findTarget(root, targetId) {
58 const target = root.ownerDocument.getElementById(targetId);
59 return target && root.contains(target) ? target : null;
60 },
61 createIntersectionObserver(onEntries, scrollRoot) {
62 const observer = new IntersectionObserver(onEntries, {
63 root: scrollRoot,
64 threshold: 0,
65 });
66 return observer;
67 },
68 createMutationObserver(onMutation, container) {
69 const observer = new MutationObserver(onMutation);
70 observer.observe(container, { childList: true, subtree: true });
71 return observer;
72 },
73 };
74}
75
76export function observeSettingsSectionVisibility({
77 container,
78 targetIds,
79 onChange,
80 environment = createBrowserEnvironment(),
81}: {
82 readonly container: Element;
83 readonly targetIds: ReadonlyArray<string>;
84 readonly onChange: (visibleTargetIds: ReadonlyArray<string>) => void;
85 readonly environment?: SettingsSectionVisibilityEnvironment;
86}): () => void {
87 const orderedTargetIds = [...new Set(targetIds)];
88 const targetsById = new Map<string, Element>();
89 const targetIdsByElement = new Map<Element, string>();
90 const visibleTargetIds = new Set<string>();
91 let lastEmission: string | null = null;
92 let stopped = false;
93 let root: Element | null = null;
94 let intersectionObserver: ElementObserver | null = null;
95 let observerGeneration = 0;
96
97 const emit = () => {
98 const visibleInOrder = orderedTargetIds.filter((targetId) => visibleTargetIds.has(targetId));
99 const emissionKey = visibleInOrder.join("\0");
100 if (emissionKey === lastEmission) return;
101 lastEmission = emissionKey;
102 onChange(visibleInOrder);
103 };
104
105 const handleEntries = (entries: ReadonlyArray<VisibilityEntry>, generation: number) => {
106 if (stopped || generation !== observerGeneration) return;
107 let changed = false;
108 for (const entry of entries) {
109 const targetId = targetIdsByElement.get(entry.target);
110 if (!targetId || targetsById.get(targetId) !== entry.target) continue;
111 const visible = entry.isIntersecting && entry.intersectionRatio > 0;
112 if (visible === visibleTargetIds.has(targetId)) continue;
113 changed = true;
114 if (visible) {
115 visibleTargetIds.add(targetId);
116 } else {
117 visibleTargetIds.delete(targetId);
118 }
119 }
120 if (changed) emit();
121 };
122
123 const syncTargets = () => {
124 if (stopped) return;
125 let changed = false;
126 const nextRoot = environment.findRoot(container);
127
128 if (nextRoot !== root) {
129 observerGeneration += 1;
130 intersectionObserver?.disconnect();
131 intersectionObserver = null;
132 root = nextRoot;
133 targetsById.clear();
134 targetIdsByElement.clear();
135 changed = visibleTargetIds.size > 0;
136 visibleTargetIds.clear();
137
138 if (root) {
139 const generation = observerGeneration;
140 intersectionObserver = environment.createIntersectionObserver(
141 (entries) => handleEntries(entries, generation),
142 root,
143 );
144 }
145 }
146
147 if (!root || !intersectionObserver) {
148 if (changed) emit();
149 return;
150 }
151
152 for (const targetId of orderedTargetIds) {
153 const previousTarget = targetsById.get(targetId) ?? null;
154 const nextTarget = environment.findTarget(root, targetId);
155 if (previousTarget === nextTarget) continue;
156
157 if (previousTarget) {
158 intersectionObserver.unobserve(previousTarget);
159 targetsById.delete(targetId);
160 targetIdsByElement.delete(previousTarget);
161 changed = visibleTargetIds.delete(targetId) || changed;
162 }
163 if (nextTarget) {
164 targetsById.set(targetId, nextTarget);
165 targetIdsByElement.set(nextTarget, targetId);
166 intersectionObserver.observe(nextTarget);
167 }
168 }
169
170 if (changed) emit();
171 };
172
173 const mutationObserver = environment.createMutationObserver(syncTargets, container);
174 syncTargets();
175 emit();
176
177 return () => {
178 stopped = true;
179 observerGeneration += 1;
180 intersectionObserver?.disconnect();
181 mutationObserver.disconnect();
182 targetsById.clear();
183 targetIdsByElement.clear();
184 visibleTargetIds.clear();
185 };
186}
apps/web/src/routes/settings.tsx+4-1
......@@ -69,7 +69,10 @@ function SettingsContentLayout() {
6969 }, [navigateBackWithinApp]);
7070
7171 return (
72 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
72 <SidebarInset
73 className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"
74 data-settings-page-layout
75 >
7376 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
7477 <WorkspacePageHeader electron={isElectron}>
7578 <div className="flex w-full items-center gap-3">