mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -0,0 +1,376 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
LayoutTemplate,
|
||||
Palette,
|
||||
Power,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
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 {
|
||||
ShellConfirmCapture,
|
||||
useShellController,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
import { TabStage } from "@/shell/stage/TabStage";
|
||||
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,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
installOpen: boolean;
|
||||
setInstallOpen: (open: boolean) => 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>
|
||||
)}
|
||||
<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 shell = useShellController();
|
||||
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 [settingsOpen, setSettingsOpen] = 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;
|
||||
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 }),
|
||||
);
|
||||
|
||||
if (settingsOpen) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-screen bg-background"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
{t("looks.repair.back")}
|
||||
</Button>
|
||||
</div>
|
||||
<SurfaceScope kind="tab" className="overflow-hidden">
|
||||
<ShellConfirmCapture />
|
||||
<TabStage />
|
||||
</SurfaceScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
shell.tabs.openSingleton("settings", undefined, undefined, {
|
||||
section: "appearance",
|
||||
});
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
{t("looks.repair.settings")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<AdminTools
|
||||
status={status}
|
||||
installOpen={installOpen}
|
||||
setInstallOpen={setInstallOpen}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user