import { InfoIcon, Undo2Icon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { createContext, type ComponentPropsWithoutRef, type ReactNode, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import { PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, usePrimarySettingsAvailable, } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; declare module "@tanstack/react-router" { interface HistoryState { settingsTargetHighlight?: boolean; } } interface SettingsSearchTargetContextValue { readonly targetId: string | null; readonly highlightTarget: boolean; readonly onTargetHandled: () => void; } const noop = () => undefined; const SettingsSearchTargetContext = createContext({ targetId: null, highlightTarget: true, onTargetHandled: noop, }); export function SettingsSearchTargetProvider({ targetId, highlightTarget = true, onTargetHandled = noop, children, }: { targetId: string | null; highlightTarget?: boolean; onTargetHandled?: () => void; children: ReactNode; }) { const value = useMemo( () => ({ targetId, highlightTarget, onTargetHandled }), [highlightTarget, onTargetHandled, targetId], ); return {children}; } function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const markedScrollTarget = typeof target.querySelector === "function" ? target.querySelector(":scope > [data-settings-scroll-target]") : null; const scrollTarget = markedScrollTarget ?? (target.tagName === "SECTION" && target.firstElementChild ? (target.firstElementChild as HTMLElement) : target); scrollTarget.scrollIntoView({ behavior: prefersReducedMotion ? "auto" : "smooth", block: "center", }); target.focus({ preventScroll: true }); target.classList.remove("settings-search-target-pulse"); if (!highlight || prefersReducedMotion) return; void target.offsetWidth; target.classList.add("settings-search-target-pulse"); // The class also suppresses the focus outline (the pulse is the destination // indicator), so drop it once the element is no longer the destination. target.addEventListener("blur", () => target.classList.remove("settings-search-target-pulse"), { once: true, }); } /** The row id a settings-search jump is currently trying to reach, if any. */ export function useSettingsSearchTargetId(): string | null { return useContext(SettingsSearchTargetContext).targetId; } export function useSettingsSearchTarget(id: string | undefined) { const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext); const isSearchTarget = id !== undefined && id === targetId; const targetRef = useCallback( (target: T | null) => { if (target && isSearchTarget) { scrollAndFocusSettingsTarget(target, highlightTarget); onTargetHandled(); } }, [highlightTarget, isSearchTarget, onTargetHandled], ); return targetRef; } export function SettingsSearchTarget({ children, ...targetProps }: ComponentPropsWithoutRef<"div">) { const targetRef = useSettingsSearchTarget(targetProps.id); return (
{children}
); } /** * Trigger classes for the composer model/traits pickers when they sit in a * settings row: match the `sm` control box (the composer pins them to 28px at * every breakpoint) and drop the composer's max-width. */ export const SETTINGS_PICKER_TRIGGER_CLASSNAME = "h-8 min-h-8 min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground sm:h-7 sm:min-h-7"; /** Info affordance explaining how a setting interacts with the shared background policy. */ export function PolicyTooltip({ children }: { readonly children: string }) { return ( } /> {children} ); } /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ export function useRelativeTimeTick(intervalMs = 1_000) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNowMs(Date.now()), intervalMs); return () => clearInterval(id); }, [intervalMs]); return nowMs; } /** Muted section headings have no descriptions; explanatory copy belongs to individual settings. */ export function SettingsSection({ title, hideTitle = false, icon, headerAction, variant = "grouped", children, className, ...sectionProps }: ComponentPropsWithoutRef<"section"> & { title: string; hideTitle?: boolean; icon?: ReactNode; headerAction?: ReactNode; variant?: "grouped" | "plain"; children: ReactNode; }) { const targetRef = useSettingsSearchTarget(sectionProps.id); return (
{hideTitle ? (

{title}

) : (

{icon} {title}

{headerAction}
)}
*+*]:border-t [&>*+*]:border-border/50 [&>[data-slot=settings-row]]:rounded-none" : "space-y-1", )} > {children}
); } export function SettingsUnavailableGroup({ children, message, }: { children: ReactNode; message?: ReactNode; }) { if (message === undefined) return children; return (

{message}

{children}
); } /** * One setting. `serverScoped` marks rows whose value lives in the primary * environment's settings.json; where there is no primary (the hosted app) * the control goes inert with a tooltip instead of showing an editable * default that would never save. * * Keep descriptions short enough for one line where possible. Allow wrapping * for clarity or narrow screens instead of truncating or forcing no-wrap. * * Control sizing across settings follows three tiers so rows share a baseline: * - `control` slot: `size="sm"` (Button, Select, Input, NumberField) or `icon-sm`. * - Section `headerAction`s and buttons inside list items, cards, toolbars: `xs` / `icon-xs`. * - Inline affordances (reset arrows, info tooltips, table-cell buttons): `icon-micro`. * Dialog footers keep the app-wide default button size. */ export function SettingsRow({ title, description, status, resetAction, control, serverScoped = false, children, className, ...rowProps }: Omit, "title"> & { title: ReactNode; description?: ReactNode; status?: ReactNode; resetAction?: ReactNode; control?: ReactNode; serverScoped?: boolean; children?: ReactNode; }) { const targetRef = useSettingsSearchTarget(rowProps.id); const primarySettingsAvailable = usePrimarySettingsAvailable(); const unavailable = serverScoped && !primarySettingsAvailable; const renderedReset = unavailable ? null : resetAction; const renderedControl = unavailable && control ? ( } >
{control}
{PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE}
) : ( control ); return (

{title}

{renderedReset}
{description ? (

{description}

) : null} {status ?
{status}
: null}
{renderedControl ? (
{renderedControl}
) : null}
{unavailable && children ? (
{children}
) : ( children )}
); } export function SettingResetButton({ label, tooltip = "Reset to default", disabled = false, onClick, }: { label: string; tooltip?: string; disabled?: boolean; onClick: () => void; }) { return ( { event.stopPropagation(); onClick(); }} > } /> {tooltip} ); } export function SettingsPageContainer({ children, className, width = "readable", }: { children: ReactNode; className?: string; width?: WorkspacePageWidth; }) { const navigate = useNavigate(); const hash = useLocation({ select: (location) => location.hash }); const highlightTarget = useLocation({ select: (location) => location.state.settingsTargetHighlight !== false, }); const targetId = hash.replace(/^#/, "") || null; const clearTargetHash = useCallback(() => { void navigate({ hash: "", replace: true, resetScroll: false, hashScrollIntoView: false, state: { settingsTargetHighlight: true }, }); }, [navigate]); return (
{children}
); } export function scrollToSettingsTarget( targetId: string, { highlight = true }: { readonly highlight?: boolean } = {}, ): boolean { const target = document.getElementById(targetId); if (!target) return false; scrollAndFocusSettingsTarget(target, highlight); return true; }