| 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | import { AuthSettingsWriteScope } from "@t3tools/contracts"; |
| 3 | import { usePrimaryEnvironmentId, usePrimaryEnvironment } from "../../state/environments"; |
| 4 | import { useEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; |
| 5 | import { InfoIcon, Undo2Icon } from "lucide-react"; |
| 6 | import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; |
| 7 | import * as Equal from "effect/Equal"; |
| 8 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 9 | import { |
| 10 | createContext, |
| 11 | type ComponentPropsWithoutRef, |
| 12 | type ReactNode, |
| 13 | useCallback, |
| 14 | useContext, |
| 15 | useEffect, |
| 16 | useMemo, |
| 17 | useState, |
| 18 | } from "react"; |
| 19 | |
| 20 | import { |
| 21 | PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, |
| 22 | usePrimarySettingsAvailable, |
| 23 | } from "../../hooks/useSettings"; |
| 24 | import { cn } from "../../lib/utils"; |
| 25 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; |
| 26 | import { Button } from "../ui/button"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 28 | import { useOptionalSettingsScope } from "./SettingsScopeContext"; |
| 29 | import { SettingsScopeSentence } from "./SettingsScopeSentence"; |
| 30 | import { |
| 31 | isProjectScopedSettingKey, |
| 32 | listProjectOverrides, |
| 33 | type ProjectOverrideEntry, |
| 34 | scopedSettingsAreMixed, |
| 35 | scopedSettingsSource, |
| 36 | } from "./scopedSettings"; |
| 37 | import { useClearProjectOverrides, useClearScopedSettings } from "./useScopedSettings"; |
| 38 | import { |
| 39 | SettingInheritance, |
| 40 | type SettingInheritanceState, |
| 41 | type SettingOverridingProject, |
| 42 | } from "./SettingInheritance"; |
| 43 | |
| 44 | const EMPTY_SETTING_KEYS: readonly (keyof ServerSettings)[] = []; |
| 45 | |
| 46 | declare module "@tanstack/react-router" { |
| 47 | interface HistoryState { |
| 48 | settingsTargetHighlight?: boolean; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | interface SettingsSearchTargetContextValue { |
| 53 | readonly targetId: string | null; |
| 54 | readonly highlightTarget: boolean; |
| 55 | readonly onTargetHandled: () => void; |
| 56 | } |
| 57 | |
| 58 | const noop = () => undefined; |
| 59 | const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({ |
| 60 | targetId: null, |
| 61 | highlightTarget: true, |
| 62 | onTargetHandled: noop, |
| 63 | }); |
| 64 | |
| 65 | function SettingsSearchTargetProvider({ |
| 66 | targetId, |
| 67 | highlightTarget = true, |
| 68 | onTargetHandled = noop, |
| 69 | children, |
| 70 | }: { |
| 71 | targetId: string | null; |
| 72 | highlightTarget?: boolean; |
| 73 | onTargetHandled?: () => void; |
| 74 | children: ReactNode; |
| 75 | }) { |
| 76 | const value = useMemo( |
| 77 | () => ({ targetId, highlightTarget, onTargetHandled }), |
| 78 | [highlightTarget, onTargetHandled, targetId], |
| 79 | ); |
| 80 | return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>; |
| 81 | } |
| 82 | |
| 83 | function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void { |
| 84 | const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 85 | const markedScrollTarget = |
| 86 | typeof target.querySelector === "function" |
| 87 | ? target.querySelector<HTMLElement>(":scope > [data-settings-scroll-target]") |
| 88 | : null; |
| 89 | const scrollTarget = |
| 90 | markedScrollTarget ?? |
| 91 | (target.tagName === "SECTION" && target.firstElementChild |
| 92 | ? (target.firstElementChild as HTMLElement) |
| 93 | : target); |
| 94 | |
| 95 | scrollTarget.scrollIntoView({ |
| 96 | behavior: prefersReducedMotion ? "auto" : "smooth", |
| 97 | block: "center", |
| 98 | }); |
| 99 | target.focus({ preventScroll: true }); |
| 100 | target.classList.remove("settings-search-target-pulse"); |
| 101 | if (!highlight || prefersReducedMotion) return; |
| 102 | void target.offsetWidth; |
| 103 | target.classList.add("settings-search-target-pulse"); |
| 104 | // The class also suppresses the focus outline (the pulse is the destination |
| 105 | // indicator), so drop it once the element is no longer the destination. |
| 106 | target.addEventListener("blur", () => target.classList.remove("settings-search-target-pulse"), { |
| 107 | once: true, |
| 108 | }); |
| 109 | } |
| 110 | |
| 111 | /** The row id a settings-search jump is currently trying to reach, if any. */ |
| 112 | export function useSettingsSearchTargetId(): string | null { |
| 113 | return useContext(SettingsSearchTargetContext).targetId; |
| 114 | } |
| 115 | |
| 116 | export function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) { |
| 117 | const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext); |
| 118 | const isSearchTarget = id !== undefined && id === targetId; |
| 119 | const targetRef = useCallback( |
| 120 | (target: T | null) => { |
| 121 | if (target && isSearchTarget) { |
| 122 | scrollAndFocusSettingsTarget(target, highlightTarget); |
| 123 | onTargetHandled(); |
| 124 | } |
| 125 | }, |
| 126 | [highlightTarget, isSearchTarget, onTargetHandled], |
| 127 | ); |
| 128 | |
| 129 | return targetRef; |
| 130 | } |
| 131 | |
| 132 | export function SettingsSearchTarget({ |
| 133 | children, |
| 134 | ...targetProps |
| 135 | }: ComponentPropsWithoutRef<"div">) { |
| 136 | const targetRef = useSettingsSearchTarget<HTMLDivElement>(targetProps.id); |
| 137 | return ( |
| 138 | <div {...targetProps} ref={targetRef} tabIndex={targetProps.id ? -1 : targetProps.tabIndex}> |
| 139 | {children} |
| 140 | </div> |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | /** Layout for the composer model/traits pickers in a settings row: drop the composer's max-width. */ |
| 145 | export const SETTINGS_PICKER_TRIGGER_CLASSNAME = "min-w-0 max-w-none shrink-0"; |
| 146 | |
| 147 | /** Info affordance explaining how a setting interacts with the shared background policy. */ |
| 148 | export function PolicyTooltip({ children }: { readonly children: string }) { |
| 149 | return ( |
| 150 | <Tooltip> |
| 151 | <TooltipTrigger |
| 152 | delay={200} |
| 153 | render={ |
| 154 | <Button size="icon-micro" variant="ghost-muted" aria-label="Background policy details"> |
| 155 | <InfoIcon className="size-3.5" /> |
| 156 | </Button> |
| 157 | } |
| 158 | /> |
| 159 | <TooltipPopup side="top">{children}</TooltipPopup> |
| 160 | </Tooltip> |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ |
| 165 | export function useRelativeTimeTick(intervalMs = 1_000) { |
| 166 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| 167 | useEffect(() => { |
| 168 | const id = setInterval(() => setNowMs(Date.now()), intervalMs); |
| 169 | return () => clearInterval(id); |
| 170 | }, [intervalMs]); |
| 171 | return nowMs; |
| 172 | } |
| 173 | |
| 174 | /** Muted section headings have no descriptions; explanatory copy belongs to individual settings. */ |
| 175 | export function SettingsSection({ |
| 176 | title, |
| 177 | hideTitle = false, |
| 178 | icon, |
| 179 | titleAction, |
| 180 | headerAction, |
| 181 | variant = "grouped", |
| 182 | children, |
| 183 | className, |
| 184 | ...sectionProps |
| 185 | }: ComponentPropsWithoutRef<"section"> & { |
| 186 | title: string; |
| 187 | hideTitle?: boolean; |
| 188 | icon?: ReactNode; |
| 189 | /** Small control shown right after the title, such as an add button. */ |
| 190 | titleAction?: ReactNode; |
| 191 | headerAction?: ReactNode; |
| 192 | variant?: "grouped" | "plain"; |
| 193 | children: ReactNode; |
| 194 | }) { |
| 195 | const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id); |
| 196 | |
| 197 | return ( |
| 198 | <section |
| 199 | {...sectionProps} |
| 200 | ref={targetRef} |
| 201 | tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex} |
| 202 | className={cn(!hideTitle && "space-y-2.5", className)} |
| 203 | > |
| 204 | {hideTitle ? ( |
| 205 | <h2 className="sr-only">{title}</h2> |
| 206 | ) : ( |
| 207 | <div |
| 208 | data-settings-scroll-target |
| 209 | className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" |
| 210 | > |
| 211 | <div className="flex min-w-0 items-center gap-1"> |
| 212 | <h2 className="flex min-h-7 items-center gap-2 text-sm font-normal text-foreground/70"> |
| 213 | {icon} |
| 214 | {title} |
| 215 | </h2> |
| 216 | {titleAction} |
| 217 | </div> |
| 218 | <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div> |
| 219 | </div> |
| 220 | )} |
| 221 | <SettingsGroup data-settings-scroll-target={hideTitle ? "" : undefined} variant={variant}> |
| 222 | {children} |
| 223 | </SettingsGroup> |
| 224 | </section> |
| 225 | ); |
| 226 | } |
| 227 | |
| 228 | export function SettingsUnavailableGroup({ |
| 229 | children, |
| 230 | message, |
| 231 | }: { |
| 232 | children: ReactNode; |
| 233 | message?: ReactNode; |
| 234 | }) { |
| 235 | if (message === undefined) return children; |
| 236 | |
| 237 | return ( |
| 238 | <div className="border-border/60 bg-muted/20 py-1.5"> |
| 239 | <div className="flex items-start gap-2 px-3 py-2 text-xs leading-relaxed text-muted-foreground sm:px-4"> |
| 240 | <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" /> |
| 241 | <p>{message}</p> |
| 242 | </div> |
| 243 | <div className="[&_h3]:opacity-64 [&_p]:opacity-64">{children}</div> |
| 244 | </div> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * One setting. `serverScoped` marks rows whose value lives in the primary |
| 250 | * environment's settings.json; where there is no primary (the hosted app) |
| 251 | * the control goes inert with a tooltip instead of showing an editable |
| 252 | * default that would never save. |
| 253 | * |
| 254 | * Keep descriptions short enough for one line where possible. Allow wrapping |
| 255 | * for clarity or narrow screens instead of truncating or forcing no-wrap. |
| 256 | * |
| 257 | * Control sizing across settings follows three tiers so rows share a baseline: |
| 258 | * - `control` slot: `size="sm"` (Button, Select, Input, NumberField) or `icon-sm`. |
| 259 | * - Section `headerAction`s and buttons inside list items, cards, toolbars: `xs` / `icon-xs`. |
| 260 | * - Inline affordances (reset arrows, info tooltips, table-cell buttons): `icon-micro`. |
| 261 | * Dialog footers keep the app-wide default button size. |
| 262 | */ |
| 263 | export function SettingsRow({ |
| 264 | title, |
| 265 | description, |
| 266 | status, |
| 267 | resetAction, |
| 268 | onResetOverride, |
| 269 | control, |
| 270 | serverScoped = false, |
| 271 | settingKeys = EMPTY_SETTING_KEYS, |
| 272 | mixed: mixedOverride, |
| 273 | children, |
| 274 | className, |
| 275 | ...rowProps |
| 276 | }: Omit<ComponentPropsWithoutRef<"div">, "title"> & { |
| 277 | title: ReactNode; |
| 278 | description?: ReactNode; |
| 279 | status?: ReactNode; |
| 280 | resetAction?: ReactNode; |
| 281 | /** Replaces the default override clear for rows with side effects beyond the settings key. */ |
| 282 | onResetOverride?: () => void; |
| 283 | control?: ReactNode; |
| 284 | serverScoped?: boolean; |
| 285 | settingKeys?: readonly (keyof ServerSettings)[]; |
| 286 | mixed?: boolean; |
| 287 | children?: ReactNode; |
| 288 | }) { |
| 289 | const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id); |
| 290 | const primarySettingsAvailable = usePrimarySettingsAvailable(); |
| 291 | const context = useOptionalSettingsScope(); |
| 292 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 293 | const primaryEnvironment = usePrimaryEnvironment(); |
| 294 | const primaryCanWrite = useEnvironmentScope(primaryEnvironmentId, AuthSettingsWriteScope); |
| 295 | const writableIds = useEnvironmentsWithScope( |
| 296 | context?.connectedEnvironments ?? [], |
| 297 | AuthSettingsWriteScope, |
| 298 | ); |
| 299 | const canWriteSettings = context |
| 300 | ? context.connectedEnvironments.length > 0 && |
| 301 | context.connectedEnvironments.every((target) => writableIds.has(target.environmentId)) |
| 302 | : primaryCanWrite; |
| 303 | const clearOverrides = useClearScopedSettings(); |
| 304 | const clearProjectOverrides = useClearProjectOverrides(); |
| 305 | const isProjectScope = |
| 306 | context !== null && (context.scope.kind === "project" || context.scope.kind === "checkout"); |
| 307 | const scopedKeys = settingKeys.filter(isProjectScopedSettingKey); |
| 308 | // A project scope can only edit keys that support overrides; the rest stay |
| 309 | // visible so the user sees the inherited value, but cannot change it here. |
| 310 | const environmentWide = isProjectScope && serverScoped && scopedKeys.length === 0; |
| 311 | const mixed = |
| 312 | mixedOverride ?? (context !== null && scopedSettingsAreMixed(context.targets, settingKeys)); |
| 313 | const source = |
| 314 | context && isProjectScope ? scopedSettingsSource(context.targets, scopedKeys) : null; |
| 315 | const unavailable = |
| 316 | serverScoped && |
| 317 | (!canWriteSettings || |
| 318 | !(context ? context.connectedEnvironments.length > 0 : primarySettingsAvailable)); |
| 319 | const inheritedFrom = |
| 320 | source === "environment" && context?.scope.environmentIds.length === 1 |
| 321 | ? (context.environments.find( |
| 322 | (environment) => environment.environmentId === context.scope.environmentIds[0], |
| 323 | )?.label ?? "environment") |
| 324 | : "environment"; |
| 325 | const environmentSettingsById = useMemo( |
| 326 | () => |
| 327 | new Map( |
| 328 | (context?.connectedEnvironments ?? []).flatMap((environment) => |
| 329 | environment.serverConfig |
| 330 | ? [[environment.environmentId, environment.serverConfig.settings] as const] |
| 331 | : [], |
| 332 | ), |
| 333 | ), |
| 334 | [context?.connectedEnvironments], |
| 335 | ); |
| 336 | // At environment scope, projects with their own value keep it when the |
| 337 | // environment default changes; the chain names them and can reset them. |
| 338 | const overridingProjects = useMemo((): SettingOverridingProject[] => { |
| 339 | if (context === null || isProjectScope || scopedKeys.length === 0) return []; |
| 340 | return listProjectOverrides(context.connectedEnvironments, scopedKeys).flatMap((entry) => { |
| 341 | const group = context.groups.find((candidate) => |
| 342 | candidate.memberProjects.some( |
| 343 | (member) => member.environmentId === entry.environmentId && member.id === entry.projectId, |
| 344 | ), |
| 345 | ); |
| 346 | if (!group) return []; |
| 347 | return [ |
| 348 | { |
| 349 | ...entry, |
| 350 | label: group.displayName, |
| 351 | open: () => |
| 352 | context.selectScope({ |
| 353 | project: group.projectKey, |
| 354 | ...(context.search.machine ? { machine: context.search.machine } : {}), |
| 355 | }), |
| 356 | }, |
| 357 | ]; |
| 358 | }); |
| 359 | }, [context, isProjectScope, scopedKeys]); |
| 360 | const renderedReset = unavailable ? null : isProjectScope && scopedKeys.length > 0 ? ( |
| 361 | source === "project" || source === "mixed" ? ( |
| 362 | <SettingResetButton |
| 363 | label={typeof title === "string" ? title : "override"} |
| 364 | tooltip="Reset to inherited value" |
| 365 | onClick={() => (onResetOverride ? onResetOverride() : clearOverrides(scopedKeys))} |
| 366 | /> |
| 367 | ) : null |
| 368 | ) : ( |
| 369 | resetAction |
| 370 | ); |
| 371 | const inertControl = (message: string) => ( |
| 372 | <Tooltip> |
| 373 | <TooltipTrigger |
| 374 | render={ |
| 375 | // Focusable so keyboard users can still reach the explanation. |
| 376 | <span |
| 377 | tabIndex={0} |
| 378 | className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring @min-[32rem]/settings-row:w-auto" |
| 379 | /> |
| 380 | } |
| 381 | > |
| 382 | <div |
| 383 | inert |
| 384 | className="flex w-full items-center gap-2 opacity-50 @min-[32rem]/settings-row:w-auto" |
| 385 | > |
| 386 | {control} |
| 387 | </div> |
| 388 | </TooltipTrigger> |
| 389 | <TooltipPopup side="top">{message}</TooltipPopup> |
| 390 | </Tooltip> |
| 391 | ); |
| 392 | // A mixed selection keeps the real control with "Mixed" as its placeholder |
| 393 | // (the multi-selection inspector convention): the popover shows who has |
| 394 | // what, and picking a value applies it to every target. |
| 395 | const renderedControl = |
| 396 | unavailable && control |
| 397 | ? inertControl( |
| 398 | !canWriteSettings |
| 399 | ? `This connection lacks permission to change settings on ${ |
| 400 | context |
| 401 | ? context.connectedEnvironments |
| 402 | .filter((target) => !writableIds.has(target.environmentId)) |
| 403 | .map((target) => target.label) |
| 404 | .join(", ") || "the selected environment" |
| 405 | : (primaryEnvironment?.label ?? "the primary environment") |
| 406 | }.` |
| 407 | : context |
| 408 | ? "Reconnect the selected environment to change this setting." |
| 409 | : PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, |
| 410 | ) |
| 411 | : environmentWide && control |
| 412 | ? inertControl("Environment-wide setting. Select an environment to change it.") |
| 413 | : control; |
| 414 | // Server rows get an indicator beside the title that opens the resolution |
| 415 | // chain per target at every scope; client rows keep a plain status only. |
| 416 | const customized = |
| 417 | context !== null && |
| 418 | settingKeys.some((key) => |
| 419 | context.targets.some((candidate) => { |
| 420 | const environmentSettings = environmentSettingsById.get(candidate.environmentId); |
| 421 | return ( |
| 422 | environmentSettings !== undefined && |
| 423 | !Equal.equals(environmentSettings[key], DEFAULT_SERVER_SETTINGS[key]) |
| 424 | ); |
| 425 | }), |
| 426 | ); |
| 427 | const inheritance: { state: SettingInheritanceState; summary: string } = mixed |
| 428 | ? { state: "mixed", summary: "Mixed across selected environments" } |
| 429 | : source === "project" |
| 430 | ? { state: "overridden", summary: "Overridden for this project" } |
| 431 | : source === "t3.json" |
| 432 | ? { state: "inherited", summary: "Inherited from the repository's t3.json" } |
| 433 | : source === "environment" && scopedKeys.length > 0 |
| 434 | ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` } |
| 435 | : customized |
| 436 | ? { state: "environment", summary: "Set on the environment" } |
| 437 | : { state: "default", summary: "Built-in default" }; |
| 438 | const renderedInheritance = |
| 439 | context && serverScoped && settingKeys.length > 0 ? ( |
| 440 | <SettingInheritance |
| 441 | state={inheritance.state} |
| 442 | summary={inheritance.summary} |
| 443 | targets={context.targets} |
| 444 | environments={context.connectedEnvironments} |
| 445 | keys={settingKeys} |
| 446 | overridingProjects={overridingProjects} |
| 447 | {...(canWriteSettings |
| 448 | ? { |
| 449 | onClearOverrides: (entries: readonly ProjectOverrideEntry[]) => |
| 450 | clearProjectOverrides(entries, scopedKeys), |
| 451 | } |
| 452 | : {})} |
| 453 | /> |
| 454 | ) : null; |
| 455 | const renderedStatus = status; |
| 456 | |
| 457 | return ( |
| 458 | <div |
| 459 | {...rowProps} |
| 460 | ref={targetRef} |
| 461 | tabIndex={rowProps.id ? -1 : rowProps.tabIndex} |
| 462 | data-slot="settings-row" |
| 463 | className={cn( |
| 464 | "@container/settings-row rounded-xl px-3 sm:px-4 aria-disabled:opacity-64 aria-disabled:[&_*]:text-muted-foreground", |
| 465 | children ? "pt-3 pb-1" : "py-3", |
| 466 | className, |
| 467 | )} |
| 468 | > |
| 469 | <div className="flex flex-col gap-3 @min-[32rem]/settings-row:grid @min-[32rem]/settings-row:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] @min-[32rem]/settings-row:items-center @min-[32rem]/settings-row:gap-8"> |
| 470 | <div className="min-w-0 flex-1 space-y-1"> |
| 471 | <div className="flex min-h-5 items-center gap-1.5"> |
| 472 | <h3 className="text-sm font-medium text-foreground">{title}</h3> |
| 473 | {renderedInheritance ? ( |
| 474 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> |
| 475 | {renderedInheritance} |
| 476 | </span> |
| 477 | ) : null} |
| 478 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> |
| 479 | {renderedReset} |
| 480 | </span> |
| 481 | </div> |
| 482 | {description ? ( |
| 483 | <p className="max-w-xl text-xs leading-normal text-muted-foreground/80"> |
| 484 | {description} |
| 485 | </p> |
| 486 | ) : null} |
| 487 | {renderedStatus ? ( |
| 488 | <div className="pt-0.5 text-xs text-muted-foreground">{renderedStatus}</div> |
| 489 | ) : null} |
| 490 | </div> |
| 491 | {renderedControl ? ( |
| 492 | <div className="flex w-full min-w-0 shrink-0 items-center gap-2 @min-[32rem]/settings-row:w-auto @min-[32rem]/settings-row:justify-end"> |
| 493 | {renderedControl} |
| 494 | </div> |
| 495 | ) : null} |
| 496 | </div> |
| 497 | {unavailable && children ? ( |
| 498 | <div inert className="opacity-50"> |
| 499 | {children} |
| 500 | </div> |
| 501 | ) : ( |
| 502 | children |
| 503 | )} |
| 504 | </div> |
| 505 | ); |
| 506 | } |
| 507 | |
| 508 | export function SettingResetButton({ |
| 509 | label, |
| 510 | tooltip = "Reset to default", |
| 511 | disabled = false, |
| 512 | onClick, |
| 513 | }: { |
| 514 | label: string; |
| 515 | tooltip?: string; |
| 516 | disabled?: boolean; |
| 517 | onClick: () => void; |
| 518 | }) { |
| 519 | return ( |
| 520 | <Tooltip> |
| 521 | <TooltipTrigger |
| 522 | render={ |
| 523 | <Button |
| 524 | size="icon-micro" |
| 525 | variant="ghost-muted" |
| 526 | aria-label={`Reset ${label} to default`} |
| 527 | disabled={disabled} |
| 528 | onClick={(event) => { |
| 529 | event.stopPropagation(); |
| 530 | onClick(); |
| 531 | }} |
| 532 | > |
| 533 | <Undo2Icon className="size-3" /> |
| 534 | </Button> |
| 535 | } |
| 536 | /> |
| 537 | <TooltipPopup side="top">{tooltip}</TooltipPopup> |
| 538 | </Tooltip> |
| 539 | ); |
| 540 | } |
| 541 | |
| 542 | export function SettingsPageContainer({ |
| 543 | children, |
| 544 | className, |
| 545 | width = "readable", |
| 546 | }: { |
| 547 | children: ReactNode; |
| 548 | className?: string; |
| 549 | width?: WorkspacePageWidth; |
| 550 | }) { |
| 551 | const navigate = useNavigate(); |
| 552 | const hash = useLocation({ select: (location) => location.hash }); |
| 553 | const highlightTarget = useLocation({ |
| 554 | select: (location) => location.state.settingsTargetHighlight !== false, |
| 555 | }); |
| 556 | const targetId = hash.replace(/^#/, "") || null; |
| 557 | const clearTargetHash = useCallback(() => { |
| 558 | void navigate({ |
| 559 | hash: "", |
| 560 | replace: true, |
| 561 | resetScroll: false, |
| 562 | hashScrollIntoView: false, |
| 563 | state: { settingsTargetHighlight: true }, |
| 564 | }); |
| 565 | }, [navigate]); |
| 566 | |
| 567 | return ( |
| 568 | <SettingsSearchTargetProvider |
| 569 | targetId={targetId} |
| 570 | highlightTarget={highlightTarget} |
| 571 | onTargetHandled={clearTargetHash} |
| 572 | > |
| 573 | <div |
| 574 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto" |
| 575 | data-settings-page-scroll |
| 576 | data-scroll-restoration-id="settings-page" |
| 577 | > |
| 578 | <WorkspacePageContainer width={width} className={cn("gap-8", className)}> |
| 579 | <SettingsScopeSentence /> |
| 580 | {children} |
| 581 | </WorkspacePageContainer> |
| 582 | </div> |
| 583 | </SettingsSearchTargetProvider> |
| 584 | ); |
| 585 | } |
| 586 | |
| 587 | export function scrollToSettingsTarget( |
| 588 | targetId: string, |
| 589 | { highlight = true }: { readonly highlight?: boolean } = {}, |
| 590 | ): boolean { |
| 591 | const target = document.getElementById(targetId); |
| 592 | if (!target) return false; |
| 593 | scrollAndFocusSettingsTarget(target, highlight); |
| 594 | return true; |
| 595 | } |