Files
Termix/src/ui/settings/SettingsTab.tsx
T
62ac931468 v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
2026-10-12 11:35:48 -05:00

432 lines
15 KiB
TypeScript

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 } 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<UserPreferences>) => void;
onOpenHostTab?: (host: Host) => void;
}
const BAND_LABEL: Record<SettingsBand, string> = {
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<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
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: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
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<HTMLElement>(
`#${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 = (
<div className="flex flex-col gap-3 py-2.5">
<div className="px-2.5">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("settings.searchPlaceholder")}
fill
/>
</div>
{query.trim() && hits.length === 0 && (
<p className="px-3 text-xs text-muted-foreground">
{t("settings.noResults")}
</p>
)}
{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 (
<div key={band} className="flex flex-col">
<GroupHeading
title={t(BAND_LABEL[band])}
className="px-2.5 pb-1.5"
/>
{items.map(({ page: item, match }) => {
const on = item.id === page.id;
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => go(item.id)}
aria-current={on ? "page" : undefined}
className={cn(
"flex w-full flex-col border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="flex items-center gap-2">
{item.pluginId ? (
<PluginIcon
name={
[...userFeatures, ...adminFeatures].find(
(p) => p.id === item.pluginId,
)?.icon
}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{pageLabel(item)}
</span>
</span>
{match && (
<span className="truncate pl-5.5 text-[10px] text-muted-foreground">
{match}
</span>
)}
</button>
);
})}
{reportBug && (
<a
href={reportCoreIssueUrl(
import.meta.env.VITE_APP_VERSION || undefined,
)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 border-l-2 border-transparent py-1.5 pl-2 pr-2 text-left text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring"
>
<Bug className="size-3.5 shrink-0" />
<span className="truncate text-xs font-medium">
{t("dashboard.reportBug")}
</span>
<ExternalLink className="ml-auto size-3 shrink-0" />
</a>
)}
</div>
);
})}
</div>
);
const showProfile = !!page.profileSection && page.id !== "keybindings";
const showAdmin = !!page.adminSection;
return (
<PanelShell
icon={<SettingsIcon className="size-4" />}
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
docs={pageDocs}
actions={
<button
type="button"
onClick={() => setNavOpen((v) => !v)}
aria-expanded={navOpen}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground md:hidden"
>
{t("settings.pages")}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
navOpen && "rotate-180",
)}
/>
</button>
}
>
<div className="relative flex min-h-0 flex-1">
<nav
aria-label={t("settings.pages")}
className={cn(
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
{nav}
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={pageQuery}
onChange={setPageQuery}
placeholder={t("settings.searchPage")}
fill
/>
</div>
</div>
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!pageHasMatches && (
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
)}
<div
ref={pageRef}
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
<div className={showProfile ? "contents" : "hidden"}>
<UserProfilePanel
section={page.profileSection ?? "__none__"}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
onOpenSettingsPage={go}
/>
</div>
{isAdmin && (
<div className={showAdmin ? "contents" : "hidden"}>
<AdminSettingsPanel
section={page.adminSection ?? ("__none__" as never)}
onOpenHostTab={onOpenHostTab}
/>
</div>
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
<FeatureSettingsForm
plugin={pluginFeature.user}
scope="user"
fields={
pluginFeature.user.contributes?.settings?.user ?? []
}
/>
)}
{pluginFeature.admin && (
<>
<GroupHeading
title={t("settings.instanceSettings")}
className="pt-2"
/>
<FeatureSettingsForm
plugin={pluginFeature.admin}
scope="admin"
fields={
pluginFeature.admin.contributes?.settings?.admin ?? []
}
/>
</>
)}
{!pluginFeature.user && !pluginFeature.admin && (
<EmptyState
icon={SettingsIcon}
title={t("settings.nothingHere")}
/>
)}
</>
)}
</div>
</div>
</SurfaceScope>
</div>
</PanelShell>
);
}