* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Select2 } from "@/components/select2";
import {
chooseDefaultLook,
chooseLayout,
useLookState,
} from "@/look/look-store";
import { layoutLabel, useLayouts } from "@/look/layout-registry";
import { themeLabel, useThemes } from "@/look/theme-registry";
const selectClass =
"w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7";
/**
* The layout picker, plus the default layout and theme for everyone when an
* admin is looking. Lists what the layout and theme registries hold, so
* plugin layouts and themes show up as soon as they are installed.
*/
export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation();
const look = useLookState();
const layouts = useLayouts();
const themes = useThemes();
const response = look.response;
const failed = () => toast.error(t("looks.saveFailed"));
return (
<div className="flex flex-col gap-2">
<Select2
value={response?.layoutId ?? ""}
disabled={!response}
onChange={(e) =>
void chooseLayout(e.target.value || null).catch(failed)
}
className={selectClass}
>
<option value="">{t("looks.followDefault")}</option>
{layouts.map((layout) => (
<option key={layout.id} value={layout.id}>
{layoutLabel(layout, t)}
</option>
))}
</Select2>
<p className="text-[11px] text-muted-foreground">
{t("looks.layoutHint")}
</p>
{isAdmin && response && (
<div className="flex flex-col gap-1.5 border border-border p-2">
<span className="text-[11px] font-medium text-foreground">
{t("looks.defaultsLabel")}
</span>
<p className="text-[11px] text-muted-foreground">
{t("looks.defaultsHint")}
</p>
<Select2
value={response.defaults.layout ?? ""}
onChange={(e) =>
void chooseDefaultLook({ layout: e.target.value || null })
.then(() => toast.success(t("looks.defaultSaved")))
.catch(failed)
}
className={selectClass}
>
<option value="">{t("looks.followDefault")}</option>
{layouts.map((layout) => (
<option key={layout.id} value={layout.id}>
{layoutLabel(layout, t)}
</option>
))}
</Select2>
<Select2
value={response.defaults.theme ?? ""}
onChange={(e) =>
void chooseDefaultLook({ theme: e.target.value || null })
.then(() => toast.success(t("looks.defaultSaved")))
.catch(failed)
}
className={selectClass}
>
<option value="">{t("looks.followDefault")}</option>
{themes.map((theme) => (
<option key={theme.id} value={theme.id}>
{themeLabel(theme, t)}
</option>
))}
</Select2>
</div>
)}
</div>
);
}
+353
View File
@@ -0,0 +1,353 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Check,
Copy,
Download,
LayoutTemplate,
Palette,
History,
Power,
RefreshCw,
RotateCcw,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
import { ListRow } from "@/components/list-kit";
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { useTheme } from "@/components/theme-provider";
import { usePermissions } from "@/hooks/use-permissions";
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
import { usePluginsManager } from "@/plugins/use-plugins-manager";
import { PluginIconBox } from "@/plugins/plugin-bits";
import { getPluginRecord } from "@/plugin-host/plugin-store";
import { restoreBundledPlugin } from "@/api/plugins-api";
import { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
import {
chooseDefaultLook,
chooseLayout,
chooseThemeForAccount,
} from "./look-store";
import { layoutLabel, useLayouts } from "./layout-registry";
import { themeLabel, useThemes } from "./theme-registry";
import { useChooseTheme } from "./use-look-theme";
import type { LayoutStatus } from "./layout-status";
type RepairStatus = Extract<LayoutStatus, { kind: "repair" }>;
const LOOK_CAPABILITIES = ["ui:layout", "ui:theme"];
/**
* What an admin can do about a broken layout: update or disable its plugin,
* and install another layout or theme. Uses the Plugins tab's own flows.
*/
function AdminTools({
status,
installOpen,
setInstallOpen,
onRetry,
}: {
status: RepairStatus;
installOpen: boolean;
setInstallOpen: (open: boolean) => void;
onRetry: () => void;
}) {
const { t } = useTranslation();
const manager = usePluginsManager();
const entry = status.pluginId
? manager.plugins.find((plugin) => plugin.id === status.pluginId)
: undefined;
const installable = manager.plugins.filter(
(plugin) =>
!plugin.installed &&
plugin.versions[0]?.capabilities?.some((capability) =>
LOOK_CAPABILITIES.includes(capability),
),
);
return (
<>
{entry?.installed && entry.updateAvailable && entry.latestVersion && (
<Button
size="sm"
onClick={() => void manager.requestUpdate(entry)}
disabled={manager.busy.has(entry.id)}
>
<Download />
{t("looks.repair.update", {
name: entry.name,
version: entry.latestVersion,
})}
</Button>
)}
{entry?.installed && entry.enabled && !entry.managedByServer && (
<Button
size="sm"
variant="outline"
onClick={() => void manager.toggle(entry)}
disabled={manager.busy.has(entry.id)}
>
<Power />
{t("looks.repair.disable", { name: entry.name })}
</Button>
)}
{entry?.installed && entry.bundled && !entry.managedByServer && (
<Button
size="sm"
variant="outline"
onClick={() => {
void restoreBundledPlugin(entry.id)
.then(onRetry)
.catch(() => toast.error(t("looks.repair.restoreFailed")));
}}
disabled={manager.busy.has(entry.id)}
>
<History />
{t("looks.repair.restore", { name: entry.name })}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
<Download />
{t("looks.repair.install")}
</Button>
<InlineView
open={installOpen}
onOpenChange={setInstallOpen}
title={t("looks.repair.installTitle")}
icon={<LayoutTemplate className="size-3.5" />}
>
<div className="flex flex-col">
{manager.loading && (
<p className="p-3 text-xs text-muted-foreground">
{t("common.loading")}
</p>
)}
{!manager.loading && installable.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">
{manager.registryError
? t("looks.repair.registryUnreachable")
: t("looks.repair.nothingToInstall")}
</p>
)}
{installable.map((plugin, index) => (
<ListRow
key={`${plugin.registryId ?? ""}:${plugin.id}`}
stripe={index}
icon={<PluginIconBox name={plugin.icon} size="sm" />}
title={plugin.name}
meta={plugin.description}
trailing={
<Button
size="xs"
variant="outline"
disabled={manager.busy.has(plugin.id)}
onClick={() => manager.requestInstall(plugin)}
>
{t("looks.repair.installButton")}
</Button>
}
/>
))}
</div>
</InlineView>
<PluginConsentPrompt
request={manager.consent}
onCancel={() => manager.setConsent(null)}
onConfirm={() => void manager.confirmConsent()}
/>
</>
);
}
/**
* Shown instead of a layout that cannot be drawn. Small on purpose: it has
* to work when everything else is broken, and gets people back to a working
* look in one click.
*/
export function RepairView({
status,
onRetry,
}: {
status: RepairStatus;
onRetry: () => void;
}) {
const { t } = useTranslation();
const { isAdmin } = usePermissions();
const { theme } = useTheme();
const chooseTheme = useChooseTheme();
const layouts = useLayouts();
const themes = useThemes();
const [copied, setCopied] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
const layoutName =
layouts.find((layout) => layout.id === status.layoutId) ?? null;
const name = layoutName
? layoutLabel(layoutName, t)
: status.layoutId || t("looks.repair.noLayout");
const details = [
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
status.pluginId
? `${t("looks.repair.plugin")}: ${record?.summary.name ?? status.pluginId} ${status.version ?? ""}`.trim()
: null,
`${t("looks.repair.reason")}: ${t(`looks.repair.reasons.${status.reason}`)}`,
status.error ? `${t("looks.repair.error")}: ${status.error}` : null,
]
.filter(Boolean)
.join("\n");
const copy = async () => {
try {
await navigator.clipboard.writeText(details);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("looks.repair.copyFailed"));
}
};
const run = (action: () => Promise<void>) => () =>
void action().catch(() => toast.error(t("looks.repair.saveFailed")));
const resetOwn = run(async () => {
try {
localStorage.removeItem("vite-ui-theme");
} catch {
// Private mode.
}
await chooseThemeForAccount(null);
await chooseLayout(null);
});
const resetForEveryone = run(() =>
chooseDefaultLook({ layout: null, theme: null }),
);
const safe = status.reason === "safe-url" || status.reason === "safe-env";
return (
<SurfaceScope kind="tab" className="h-dvh w-screen bg-background">
<ShellConfirmCapture />
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-4 py-10">
<div className="flex w-full max-w-xl flex-col gap-5">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center border border-border bg-muted">
<TriangleAlert className="size-4 text-warning" />
</div>
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-sm font-semibold text-foreground">
{safe ? t("looks.repair.safeTitle") : t("looks.repair.title")}
</h1>
<p className="text-xs text-muted-foreground">
{t(`looks.repair.reasons.${status.reason}`)}
</p>
</div>
</div>
<div className="flex flex-col border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2">
<span className="truncate text-xs text-foreground">
{name}
{status.pluginId && (
<span className="text-muted-foreground">
{" | "}
{record?.summary.name ?? status.pluginId}
{status.version ? ` ${status.version}` : ""}
</span>
)}
</span>
<Button size="xs" variant="ghost" onClick={() => void copy()}>
{copied ? <Check /> : <Copy />}
{copied ? t("looks.repair.copied") : t("looks.repair.copy")}
</Button>
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[11px] text-muted-foreground">
{status.error ?? t("looks.repair.noError")}
</pre>
</div>
<div className="flex flex-wrap gap-2">
{!safe && (
<Button size="sm" onClick={onRetry}>
<RefreshCw />
{t("looks.repair.retry")}
</Button>
)}
<Button size="sm" variant="outline" onClick={resetOwn}>
<RotateCcw />
{t("looks.repair.reset")}
</Button>
{isAdmin && (
<Button size="sm" variant="outline" onClick={resetForEveryone}>
<RotateCcw />
{t("looks.repair.resetEveryone")}
</Button>
)}
{isAdmin && (
<AdminTools
status={status}
installOpen={installOpen}
setInstallOpen={setInstallOpen}
onRetry={onRetry}
/>
)}
</div>
{!isAdmin && (
<p className="border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("looks.repair.askAdmin")}
</p>
)}
<section className="flex flex-col gap-1.5">
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<LayoutTemplate className="size-3" />
{t("looks.repair.switchLayout")}
</h2>
<div className="flex flex-col border border-border">
{layouts.map((layout, index) => (
<ListRow
key={layout.id}
stripe={index}
title={layoutLabel(layout, t)}
meta={layout.id}
selected={layout.id === status.layoutId}
onClick={run(() => chooseLayout(layout.id))}
/>
))}
</div>
</section>
<section className="flex flex-col gap-1.5">
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<Palette className="size-3" />
{t("looks.repair.switchTheme")}
</h2>
<div className="flex flex-wrap gap-1">
{themes.map((entry) => (
<button
key={entry.id}
type="button"
title={themeLabel(entry, t)}
aria-pressed={theme === entry.id}
onClick={() => chooseTheme(entry.id)}
className={`flex items-center gap-1.5 border px-2 py-1 text-[11px] ${theme === entry.id ? "border-accent-brand text-foreground" : "border-border text-muted-foreground hover:border-foreground/40"}`}
>
<span
className="size-3 border border-border/60"
style={{ background: entry.swatch.background }}
/>
{themeLabel(entry, t)}
</button>
))}
</div>
</section>
</div>
</div>
</SurfaceScope>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useSyncExternalStore, type ComponentType } from "react";
import type { TFunction } from "i18next";
import { createRegistry } from "@/lib/registry";
/** A layout someone can choose, known from a manifest before its code loads. */
export interface LayoutDescriptor {
id: string;
/** i18n key in the plugin namespace, or plain text. */
label: string;
description?: string;
defaultTheme?: string;
/** Set for a plugin layout. */
pluginId?: string;
}
/** A layout whose code is running, from app.registerLayout. */
export interface RegisteredLayout {
id: string;
pluginId?: string;
component: ComponentType;
/** Shown on phones and narrow windows. Never in the desktop app. */
mobileComponent?: ComponentType;
defaultTheme?: string;
}
let descriptorSnapshot: LayoutDescriptor[] = [];
const descriptorListeners = new Set<() => void>();
const registered = createRegistry<RegisteredLayout>();
export const registerLayout = registered.register;
export const getRegisteredLayout = registered.get;
export const useRegisteredLayouts = registered.useList;
export const resetRegisteredLayouts = registered.reset;
/** Every layout that can be chosen, from the installed plugins. */
export function listLayouts(): LayoutDescriptor[] {
return descriptorSnapshot;
}
export function getLayoutDescriptor(id: string): LayoutDescriptor | undefined {
return descriptorSnapshot.find((layout) => layout.id === id);
}
/** Replaces the plugin layouts at once, from the plugin list. */
export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
const key = (list: LayoutDescriptor[]) =>
JSON.stringify(
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
);
if (key(layouts) === key(descriptorSnapshot)) return;
descriptorSnapshot = layouts;
for (const listener of descriptorListeners) listener();
}
function subscribeDescriptors(listener: () => void): () => void {
descriptorListeners.add(listener);
return () => {
descriptorListeners.delete(listener);
};
}
export function useLayouts(): LayoutDescriptor[] {
return useSyncExternalStore(subscribeDescriptors, listLayouts, listLayouts);
}
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
if (!layout.pluginId) return layout.label;
return t(`${layout.pluginId}:${layout.label}`, {
defaultValue: layout.label,
});
}
/** Test seam. */
export function resetPluginLayouts(): void {
setPluginLayouts([]);
}
+99
View File
@@ -0,0 +1,99 @@
import type { UnavailableLook } from "@/api/look-api";
import type { PluginRecord } from "@/plugin-host/plugin-store";
import type { RegisteredLayout } from "./layout-registry";
/** Why the repair view shows instead of the chosen layout. */
export type RepairReason =
/** ?safe-layout=1 in the address. */
| "safe-url"
/** TERMIX_SAFE_LAYOUT on the server. */
| "safe-env"
/** No installed plugin provides the layout. */
| "missing"
/** Its plugin is switched off. */
| "disabled"
/** Its plugin waits on a missing dependency or was refused. */
| "blocked"
/** Its plugin does not fit this Termix or plugin API. */
| "incompatible"
/** Its code threw while starting. */
| "failed"
/** It crashed while drawing, twice. */
| "crashed";
export type LayoutStatus =
| { kind: "ok"; layout: RegisteredLayout }
| { kind: "loading" }
| {
kind: "repair";
reason: RepairReason;
layoutId: string;
pluginId?: string;
version?: string | null;
error?: string | null;
};
/** A crash this many times in a row hands over to the repair view. */
export const MAX_LAYOUT_CRASHES = 2;
export interface LayoutStatusInput {
layoutId: string;
safeFromUrl: boolean;
safeFromEnv: boolean;
/** The look and the plugin list have both been read. */
ready: boolean;
registered: RegisteredLayout | undefined;
/** The installed plugin whose manifest declares the layout, if any. */
owner: PluginRecord | undefined;
/** A plugin the server skipped that declares the layout, if any. */
unavailable: UnavailableLook | undefined;
crashes: number;
lastCrash: string | null;
}
/** Whether the chosen layout can be drawn, and why not when it cannot. */
export function layoutStatus(input: LayoutStatusInput): LayoutStatus {
const repair = (
reason: RepairReason,
extra: { error?: string | null } = {},
): LayoutStatus => ({
kind: "repair",
reason,
layoutId: input.layoutId,
pluginId: input.owner?.summary.id ?? input.unavailable?.id,
version: input.owner?.summary.version ?? input.unavailable?.version ?? null,
error: extra.error ?? null,
});
if (input.safeFromUrl) return repair("safe-url");
if (input.safeFromEnv) return repair("safe-env");
if (input.crashes >= MAX_LAYOUT_CRASHES) {
return repair("crashed", { error: input.lastCrash });
}
if (input.registered) return { kind: "ok", layout: input.registered };
if (!input.ready) return { kind: "loading" };
if (input.unavailable && !input.owner) {
return repair("incompatible", { error: input.unavailable.error });
}
const owner = input.owner;
if (!owner) return repair("missing");
const summary = owner.summary;
if (!summary.enabled) return repair("disabled");
if (owner.frontend === "blocked" || summary.state === "blocked") {
return repair("blocked", { error: owner.error ?? summary.lastError });
}
if (owner.frontend === "failed" || summary.state === "failed") {
return repair("failed", { error: owner.error ?? summary.lastError });
}
if (owner.frontend === "loading") return { kind: "loading" };
// Just chosen: the runtime is about to load its code.
if (
summary.frontend &&
(owner.frontend === "inactive" || owner.frontend === "none")
) {
return { kind: "loading" };
}
// Running, but it never registered the layout it declares.
return repair("missing");
}
+133
View File
@@ -0,0 +1,133 @@
import { useSyncExternalStore } from "react";
import {
getLook,
saveDefaultLook,
saveUserLook,
type LookResponse,
} from "@/api/look-api";
import { resolveLayoutId, safeLayoutInUrl } from "./resolve-look";
const LAST_LAYOUT_KEY = "termix-layout";
interface LookState {
/** The server answered, or failed to; the look is decided either way. */
loaded: boolean;
response: LookResponse | null;
}
let state: LookState = { loaded: false, response: null };
let request: Promise<LookResponse | null> | null = null;
const listeners = new Set<() => void>();
function setState(next: LookState) {
state = next;
for (const listener of listeners) listener();
}
function readLastLayout(): string | null {
try {
return localStorage.getItem(LAST_LAYOUT_KEY);
} catch {
return null;
}
}
/** Fetches the look once per session. Never rejects: no answer means defaults. */
export function loadLook(): Promise<LookResponse | null> {
request ??= getLook()
.then((response) => {
setState({ loaded: true, response });
return response;
})
.catch(() => {
setState({ loaded: true, response: null });
return null;
});
return request;
}
/** Fetches again, e.g. after an admin changed the defaults. */
export function reloadLook(): Promise<LookResponse | null> {
request = null;
return loadLook();
}
/** On logout: the next user has their own look. */
export function resetLook(): void {
request = null;
setState({ loaded: false, response: null });
}
export function getLookState(): LookState {
return state;
}
export function subscribeLook(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function useLookState(): LookState {
return useSyncExternalStore(subscribeLook, getLookState, getLookState);
}
/** The layout to show now. Remembered so a failed fetch next time still knows. */
export function chosenLayoutId(): string {
const id = resolveLayoutId(state.response, readLastLayout());
if (state.response) {
try {
localStorage.setItem(LAST_LAYOUT_KEY, id);
} catch {
// Private mode; the server is asked every time anyway.
}
}
return id;
}
/** The URL or the server asks for the repair view. */
export function safeLayoutRequested(): boolean {
return (
(typeof window !== "undefined" &&
safeLayoutInUrl(window.location.search)) ||
state.response?.safeLayout === true
);
}
function patchResponse(patch: Partial<LookResponse>) {
if (!state.response) return;
setState({ ...state, response: { ...state.response, ...patch } });
}
/** Tells the plugin runtime to load the chosen layout's code. */
function announceLayoutChange() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("termix:plugins-changed"));
}
}
/** The user's own layout; null goes back to the default. */
export async function chooseLayout(layoutId: string | null): Promise<void> {
await saveUserLook({ layoutId });
patchResponse({ layoutId });
announceLayoutChange();
}
/** The user's own theme; null goes back to the default. */
export async function chooseThemeForAccount(
themeId: string | null,
): Promise<void> {
await saveUserLook({ themeId });
patchResponse({ themeId });
}
/** Admin only: what everyone sees until they choose their own. */
export async function chooseDefaultLook(defaults: {
layout?: string | null;
theme?: string | null;
}): Promise<void> {
const saved = await saveDefaultLook(defaults);
patchResponse({ defaults: saved });
announceLayoutChange();
}
+44
View File
@@ -0,0 +1,44 @@
import type { LookResponse } from "@/api/look-api";
/** The theme a fresh install starts in when nothing else says otherwise. */
export const FALLBACK_THEME = "dark";
/**
* The layout to show: the user's choice, else the admin default, else the
* default look. Empty when none of them names one, which the repair view
* answers. Without an answer from the server the last layout this browser
* showed stands in.
*/
export function resolveLayoutId(
look: LookResponse | null,
lastShown: string | null = null,
): string {
if (!look) return lastShown ?? "";
return look.layoutId ?? look.defaults.layout ?? look.defaultLook.layout ?? "";
}
/**
* The theme to show: the user's choice, else the one this browser kept
* (today's per-device setting, so existing users see no change), else the
* admin default, else the layout's own, else the default look's.
*/
export function resolveThemeId(
look: LookResponse | null,
localTheme: string | null,
layoutDefaultTheme?: string,
): string {
return (
look?.themeId ??
localTheme ??
look?.defaults.theme ??
layoutDefaultTheme ??
look?.defaultLook.theme ??
FALLBACK_THEME
);
}
/** ?safe-layout=1 in the address bar opens the repair view. */
export function safeLayoutInUrl(search: string): boolean {
const value = new URLSearchParams(search).get("safe-layout");
return value === "1" || value === "true";
}
+65
View File
@@ -0,0 +1,65 @@
import { arePluginThemesKnown, type ThemeDef } from "./theme-registry";
const LINK_ID = "termix-theme-css";
/** The last plugin theme's stylesheet, so a reload shows it before login too. */
const CACHE_KEY = "termix-theme-css";
function readCache(): { id: string; href: string } | null {
try {
const raw = localStorage.getItem(CACHE_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (
parsed &&
typeof (parsed as { id?: unknown }).id === "string" &&
typeof (parsed as { href?: unknown }).href === "string"
) {
return parsed as { id: string; href: string };
}
} catch {
// Unreadable or private mode.
}
return null;
}
function writeCache(value: { id: string; href: string } | null) {
try {
if (value) localStorage.setItem(CACHE_KEY, JSON.stringify(value));
else localStorage.removeItem(CACHE_KEY);
} catch {
// Private mode; the theme still applies once plugins load.
}
}
/**
* Loads the one plugin theme stylesheet the shown theme needs, and only that
* one. Built-in themes need none. A theme whose plugin has not loaded yet
* keeps the stylesheet it had last time.
*/
export function applyThemeCss(id: string, theme: ThemeDef | undefined): void {
if (typeof document === "undefined") return;
const cached = readCache();
let href: string | null = null;
if (theme?.cssHref) {
href = theme.cssHref;
writeCache({ id, href });
} else if (cached?.id === id && !arePluginThemesKnown()) {
// Its plugin has not said yet whether it still provides this theme.
href = cached.href;
} else if (cached && arePluginThemesKnown()) {
writeCache(null);
}
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
if (!href) {
link?.remove();
return;
}
if (!link) {
link = document.createElement("link");
link.id = LINK_ID;
link.rel = "stylesheet";
document.head.appendChild(link);
}
const absolute = new URL(href, window.location.href).href;
if (link.href !== absolute) link.href = absolute;
}
+142
View File
@@ -0,0 +1,142 @@
import { useSyncExternalStore } from "react";
import type { TFunction } from "i18next";
/** Follows the OS between the light and dark theme. Not a real theme. */
export const SYSTEM_THEME = "system";
export interface ThemeSwatch {
background: string;
panel: string;
text: string;
}
export interface ThemeDef {
/** Also the class put on <html> while the theme is active. */
id: string;
/** i18n key: core's for built-ins, the plugin namespace's for plugins. */
label: string;
mode: "light" | "dark";
swatch: ThemeSwatch;
/** Set for a plugin theme. */
pluginId?: string;
/** Where a plugin theme's CSS is served. Built-ins live in index.css. */
cssHref?: string;
}
/**
* The two themes core keeps as a fallback, with their tokens in index.css.
* The rest come from theme plugins. A plugin theme with the same id takes
* the built-in's place while it is installed.
*/
export const BUILTIN_THEMES: readonly ThemeDef[] = [
{
id: "light",
label: "newUi.sidebar.userProfile.themeLight",
mode: "light",
swatch: { background: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
},
{
id: "dark",
label: "newUi.sidebar.userProfile.themeDark",
mode: "dark",
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
},
];
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
let pluginThemes = new Map<string, ThemeDef>();
/** False until the plugin list has been read once this session. */
let pluginThemesKnown = false;
const listeners = new Set<() => void>();
let snapshot: ThemeDef[] | null = null;
function emit() {
snapshot = null;
for (const listener of listeners) listener();
}
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest as declared. */
export function listThemes(): ThemeDef[] {
if (!snapshot) {
const replaced = BUILTIN_THEMES.map(
(theme) => pluginThemes.get(theme.id) ?? theme,
);
const extra = [...pluginThemes.values()].filter(
(theme) => !builtins.has(theme.id),
);
snapshot = [...replaced, ...extra];
}
return snapshot;
}
export function getTheme(id: string): ThemeDef | undefined {
return pluginThemes.get(id) ?? builtins.get(id);
}
/** Replaces every plugin theme at once, from the plugin list. */
export function setPluginThemes(themes: ThemeDef[]): void {
const first = !pluginThemesKnown;
pluginThemesKnown = true;
const next = new Map(themes.map((theme) => [theme.id, theme]));
const same =
next.size === pluginThemes.size &&
[...next].every(([id, theme]) => {
const current = pluginThemes.get(id);
return (
current?.cssHref === theme.cssHref &&
current?.pluginId === theme.pluginId &&
current?.label === theme.label &&
current?.mode === theme.mode
);
});
if (same && !first) return;
pluginThemes = next;
emit();
}
export function subscribeThemes(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function useThemes(): ThemeDef[] {
return useSyncExternalStore(subscribeThemes, listThemes, listThemes);
}
/** The light or dark mode a theme id stands for, following the OS for system. */
export function themeMode(id: string): "light" | "dark" {
if (id === SYSTEM_THEME) {
return typeof window !== "undefined" &&
window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const theme = getTheme(id);
if (theme) return theme.mode;
// A theme that went away follows the OS, once the plugins have said so.
return pluginThemesKnown ? themeMode(SYSTEM_THEME) : "dark";
}
/** The theme to show for a saved id: itself, or System when it is gone. */
export function effectiveThemeId(id: string): string {
if (id === SYSTEM_THEME || getTheme(id) || !pluginThemesKnown) return id;
return SYSTEM_THEME;
}
export function themeLabel(theme: ThemeDef, t: TFunction): string {
if (!theme.pluginId) return t(theme.label);
return t(`${theme.pluginId}:${theme.label}`, { defaultValue: theme.label });
}
export function arePluginThemesKnown(): boolean {
return pluginThemesKnown;
}
/** On logout, and a test seam. */
export function resetPluginThemes(): void {
pluginThemes = new Map();
pluginThemesKnown = false;
emit();
}
+65
View File
@@ -0,0 +1,65 @@
import { useCallback, useLayoutEffect } from "react";
import { useTheme } from "@/components/theme-provider";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import { chooseThemeForAccount, useLookState } from "./look-store";
import { getLayoutDescriptor, getRegisteredLayout } from "./layout-registry";
import { resolveThemeId } from "./resolve-look";
const LOCAL_THEME_KEY = "vite-ui-theme";
function readLocalTheme(): string | null {
try {
return localStorage.getItem(LOCAL_THEME_KEY);
} catch {
return null;
}
}
/**
* Shows the theme the look settles on once it is known. A theme someone
* picked themselves is kept in this browser too; a default is only shown,
* so a later default change still reaches them.
*/
export function useLookTheme(layoutId: string): void {
const { theme, setTheme } = useTheme();
const look = useLookState();
const layoutDefault =
getRegisteredLayout(layoutId)?.defaultTheme ??
getLayoutDescriptor(layoutId)?.defaultTheme;
useLayoutEffect(() => {
if (!look.loaded) return;
const next = resolveThemeId(look.response, readLocalTheme(), layoutDefault);
if (next === theme) return;
setTheme(next, !!look.response?.themeId);
// Only when the look itself changes, not on every theme switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [look, layoutDefault]);
}
/**
* Picks a theme for this user: shown now and kept in this browser. Unless
* they keep settings on this device only, it is saved to their account too,
* which is what other devices follow.
*/
export function useChooseTheme(): (themeId: string) => void {
const { setTheme } = useTheme();
return useCallback(
(themeId: string) => {
setTheme(themeId);
void syncThemeChoice(themeId);
},
[setTheme],
);
}
async function syncThemeChoice(themeId: string): Promise<void> {
const prefs = await getUserPreferences().catch(() => null);
// A device-only profile must not be overridden by an account choice.
const local = prefs?.storageMode === "local";
await chooseThemeForAccount(local ? null : themeId).catch(() => {});
// Older clients still read the profile field.
if (prefs?.storageMode === "cloud") {
await saveUserPreferences({ theme: themeId }).catch(() => {});
}
}