import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Bug, ChevronDown, ExternalLink, Search, Settings as SettingsIcon, } from "lucide-react"; import { GroupHeading, PANEL, PanelSearch, PanelShell, } from "@/components/panel-layout"; import { SurfaceScope } from "@/components/surface/surface-scope"; import { EmptyState } from "@/components/empty-state"; import { isElectron } from "@/lib/electron"; import { reportCoreIssueUrl } from "@/lib/issue-url"; import { cn } from "@/lib/utils"; import { pluginKey, pluginName } from "@/lib/plugin-i18n"; import { PluginIcon } from "@/lib/plugin-icon"; import type { Host } from "@/types/ui-types"; import type { UserPreferences } from "@/main-axios"; import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; import { FeatureSettingsForm, useFeatureSettings, } from "./FeatureSettingsSections"; import { KeybindingsSettings } from "./KeybindingsSettings"; import { UpdatesSettings } from "./UpdatesSettings"; import { SettingsEmbedContext } from "./settings-embed"; import { useSettingsPageFilter } from "./page-filter"; import { CORE_SETTINGS_PAGES, SETTINGS_BAND_ORDER, searchSettingsPages, visibleSettingsPages, type SettingsBand, type SettingsPage, } from "./settings-pages"; import { usePluginDocsUrl } from "@/components/docs-link"; import { docsUrl } from "@/lib/docs"; export interface SettingsTabProps { /** The page to open, from the tab's data. */ section?: string; /** Changes on every open request, so asking for the same page again still goes there. */ sectionAt?: string | number; /** An element id on that page to scroll to, as "id@nonce". */ reveal?: string; username: string; isAdmin: boolean; onLogout?: () => void; userPrefs?: UserPreferences; onPrefsChange?: (prefs: Partial) => void; onOpenHostTab?: (host: Host) => void; } const BAND_LABEL: Record = { you: "settings.bandYou", app: "settings.bandApp", plugins: "settings.bandPlugins", instance: "settings.bandInstance", }; /** Scrolls to the first text on the page containing the query and marks it. */ function revealText(container: HTMLElement, needle: string): boolean { const lower = needle.toLowerCase(); const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.textContent?.toLowerCase().includes(lower)) { const el = node.parentElement; if (el && el.offsetParent !== null) { el.scrollIntoView?.({ block: "center", behavior: "smooth" }); el.classList.add("settings-search-hit"); window.setTimeout( () => el.classList.remove("settings-search-hit"), 1600, ); return true; } } node = walker.nextNode(); } return false; } /** Everything that configures the app, one page at a time. */ export function SettingsTab({ section, sectionAt, reveal, username, isAdmin, onLogout, userPrefs, onPrefsChange, onOpenHostTab, }: SettingsTabProps) { const { t } = useTranslation(); const userFeatures = useFeatureSettings("user"); const adminFeatures = useFeatureSettings("admin"); const [query, setQuery] = useState(""); const [navOpen, setNavOpen] = useState(false); const [pageQuery, setPageQuery] = useState(""); const contentRef = useRef(null); const pageRef = useRef(null); const pages = useMemo(() => { const plugins = new Map(); for (const plugin of [...userFeatures, ...(isAdmin ? adminFeatures : [])]) { if (plugins.has(plugin.id)) continue; const fields = [ ...(plugin.contributes?.settings?.user ?? []), ...(isAdmin ? (plugin.contributes?.settings?.admin ?? []) : []), ]; plugins.set(plugin.id, { id: `plugin:${plugin.id}`, band: "plugins", labelKey: pluginKey(plugin.id, "plugin.name"), label: pluginName(t, plugin), icon: SettingsIcon, pluginId: plugin.id, searchKeys: fields.flatMap((field) => [field.labelKey, field.descriptionKey] .filter((key): key is string => !!key) .map((key) => pluginKey(plugin.id, key)), ), }); } return visibleSettingsPages( [ ...CORE_SETTINGS_PAGES, ...[...plugins.values()].sort((a, b) => (a.label ?? "").localeCompare(b.label ?? ""), ), ], { isAdmin, isElectron: isElectron() }, ); }, [userFeatures, adminFeatures, isAdmin, t]); const [current, setCurrent] = useState(section ?? "account"); useEffect(() => { if (section) setCurrent(section); }, [section, sectionAt]); // The page renders lazily, so the target may take a moment to appear. useEffect(() => { const target = reveal?.split("@")[0]; if (!target) return; let tries = 0; let timer = 0; const attempt = () => { const el = contentRef.current?.querySelector( `#${CSS.escape(target)}`, ); if (el) { el.scrollIntoView?.({ block: "start", behavior: "smooth" }); el.classList.add("settings-search-hit"); timer = window.setTimeout( () => el.classList.remove("settings-search-hit"), 1600, ); return; } if (++tries < 15) timer = window.setTimeout(attempt, 120); }; timer = window.setTimeout(attempt, 60); return () => window.clearTimeout(timer); }, [reveal]); const page = pages.find((p) => p.id === current) ?? pages.find((p) => p.id === "account")!; const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id); const hits = searchSettingsPages(pages, query, (key) => t(key)); const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey); const go = (id: string) => { setCurrent(id); setNavOpen(false); setPageQuery(""); contentRef.current?.scrollTo?.({ top: 0 }); const needle = query.trim(); if (!needle) return; let tries = 0; const attempt = () => { if (!contentRef.current) return; if (revealText(contentRef.current, needle) || ++tries > 12) return; window.setTimeout(attempt, 150); }; window.setTimeout(attempt, 60); }; const pluginDocs = usePluginDocsUrl(page.pluginId); const pageDocs = page.pluginId ? pluginDocs : page.docs ? docsUrl(page.docs) : null; const pluginFeature = page.pluginId ? { user: userFeatures.find((p) => p.id === page.pluginId), admin: isAdmin ? adminFeatures.find((p) => p.id === page.pluginId) : undefined, } : null; const nav = (
{query.trim() && hits.length === 0 && (

{t("settings.noResults")}

)} {SETTINGS_BAND_ORDER.map((band) => { const items = hits.filter((hit) => hit.page.band === band); const reportBug = band === "you" && (!query.trim() || t("dashboard.reportBug") .toLowerCase() .includes(query.trim().toLowerCase())); if (items.length === 0 && !reportBug) return null; return (
{items.map(({ page: item, match }) => { const on = item.id === page.id; const Icon = item.icon; return ( ); })} {reportBug && ( {t("dashboard.reportBug")} )}
); })}
); const showProfile = !!page.profileSection && page.id !== "keybindings"; const showAdmin = !!page.adminSection; return ( } title={t("nav.settings")} status={pageLabel(page)} scroll={false} docs={pageDocs} actions={ } >
{!pageHasMatches && ( )}
{isAdmin && (
)}
{page.id === "keybindings" && } {page.id === "updates" && } {pluginFeature && ( <> {pluginFeature.user && ( )} {pluginFeature.admin && ( <> )} {!pluginFeature.user && !pluginFeature.admin && ( )} )}
); }