mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -10,7 +10,15 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { relevantSteps, type OnboardingContext } from "./onboarding-steps";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import {
|
||||
relevantSteps,
|
||||
type OnboardingContext,
|
||||
type OnboardingStep,
|
||||
} from "./onboarding-steps";
|
||||
|
||||
/** Plugin steps go in before this one. */
|
||||
const PLUGIN_STEPS_BEFORE = "security";
|
||||
|
||||
export function OnboardingDialog({
|
||||
open,
|
||||
@@ -23,7 +31,22 @@ export function OnboardingDialog({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ctx = useUiPreferencesContext();
|
||||
const steps = useMemo(() => relevantSteps(context), [context]);
|
||||
const pluginSteps = useActionSlot("onboarding.steps");
|
||||
const steps = useMemo(() => {
|
||||
const core = relevantSteps(context);
|
||||
const extra: OnboardingStep[] = pluginSteps
|
||||
.filter((contribution) => contribution.component)
|
||||
.map((contribution) => ({
|
||||
id: contribution.actionId,
|
||||
titleKey: contribution.titleKey,
|
||||
Component:
|
||||
contribution.component as unknown as OnboardingStep["Component"],
|
||||
}));
|
||||
const at = core.findIndex((step) => step.id === PLUGIN_STEPS_BEFORE);
|
||||
return at < 0
|
||||
? [...core, ...extra]
|
||||
: [...core.slice(0, at), ...extra, ...core.slice(at)];
|
||||
}, [context, pluginSteps]);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
// A reopened run always starts from the top.
|
||||
|
||||
@@ -5,13 +5,10 @@ import { AppearanceStep } from "./steps/AppearanceStep";
|
||||
import { FeaturesStep } from "./steps/FeaturesStep";
|
||||
import { WorkflowStep } from "./steps/WorkflowStep";
|
||||
import { SecurityStep } from "./steps/SecurityStep";
|
||||
import { AiAssistantStep } from "./steps/AiAssistantStep";
|
||||
import { DoneStep } from "./steps/DoneStep";
|
||||
|
||||
export interface OnboardingContext {
|
||||
/** Whether an admin has enabled the AI assistant for this instance. */
|
||||
aiGloballyEnabled: boolean;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface OnboardingContext {}
|
||||
|
||||
export interface OnboardingStepProps {
|
||||
context: OnboardingContext;
|
||||
@@ -27,7 +24,8 @@ export interface OnboardingStep {
|
||||
|
||||
/**
|
||||
* Onboarding as data rather than hardcoded JSX, so steps can be added,
|
||||
* reordered or made conditional without touching the dialog shell.
|
||||
* reordered or made conditional without touching the dialog shell. Plugins
|
||||
* add steps through the "onboarding.steps" slot; they land before security.
|
||||
*/
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
@@ -51,14 +49,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
titleKey: "onboarding.workflowTitle",
|
||||
Component: WorkflowStep,
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
titleKey: "onboarding.aiTitle",
|
||||
Component: AiAssistantStep,
|
||||
// Never shown when the admin has the assistant switched off, so users on
|
||||
// an instance without it are not told about a feature they cannot use.
|
||||
isRelevant: (context) => context.aiGloballyEnabled,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
titleKey: "onboarding.securityTitle",
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
|
||||
/**
|
||||
* Asks once, plainly.
|
||||
*
|
||||
* "Leave it off" is preselected and applied on mount, so someone who clicks
|
||||
* straight through ends up without the assistant rather than with it. Both
|
||||
* options are described in the same flat register and neither is marked
|
||||
* recommended: this is a choice, not a pitch.
|
||||
*/
|
||||
export function AiAssistantStep() {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
function apply(next: boolean) {
|
||||
setEnabled(next);
|
||||
|
||||
const hidden = readHiddenRailTabs();
|
||||
if (next) hidden.delete("ai");
|
||||
else hidden.add("ai");
|
||||
|
||||
const serialized = JSON.stringify([...hidden]);
|
||||
localStorage.setItem("hiddenRailTabs", serialized);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
|
||||
saveUserPreferences({
|
||||
aiAssistantEnabled: next,
|
||||
hiddenRailTabs: serialized,
|
||||
}).catch(() => {
|
||||
// The local choice still applies if the write fails.
|
||||
});
|
||||
}
|
||||
|
||||
// Skipping the step entirely still has to mean off, not unset.
|
||||
useEffect(() => {
|
||||
apply(false);
|
||||
}, []);
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: false,
|
||||
title: t("onboarding.aiSkipTitle"),
|
||||
description: t("onboarding.aiSkipDesc"),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
title: t("onboarding.aiEnableTitle"),
|
||||
description: t("onboarding.aiEnableDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">{t("onboarding.aiIntro")}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
onClick={() => apply(option.value)}
|
||||
className={`flex items-start gap-2.5 border p-2.5 text-left ${
|
||||
enabled === option.value
|
||||
? "border-accent-brand bg-accent-brand/10"
|
||||
: "border-border bg-card"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">{option.title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
{/*
|
||||
Always rendered and only made visible when selected, so picking an
|
||||
option never reflows the text beside it.
|
||||
*/}
|
||||
<Check
|
||||
size={14}
|
||||
className={`ml-auto mt-0.5 shrink-0 ${
|
||||
enabled === option.value ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
{t("onboarding.aiNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Boxes,
|
||||
FolderTree,
|
||||
MonitorSmartphone,
|
||||
Network,
|
||||
Play,
|
||||
Plug,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { FolderTree, Play, Plug, type LucideIcon } from "lucide-react";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
|
||||
/**
|
||||
* A tour of the things people miss because they live behind a rail icon.
|
||||
* Terminal and hosts are covered by the welcome step, so this is deliberately
|
||||
* the "there is more than SSH here" list.
|
||||
* the "there is more than SSH here" list. Plugins add their own entries
|
||||
* through the "onboarding.features" slot.
|
||||
*/
|
||||
const FEATURES: { icon: LucideIcon; key: string }[] = [
|
||||
{ icon: FolderTree, key: "files" },
|
||||
{ icon: MonitorSmartphone, key: "desktop" },
|
||||
{ icon: Plug, key: "tunnels" },
|
||||
{ icon: Boxes, key: "docker" },
|
||||
{ icon: Play, key: "snippets" },
|
||||
{ icon: Workflow, key: "automations" },
|
||||
{ icon: Network, key: "metrics" },
|
||||
];
|
||||
|
||||
export function FeaturesStep() {
|
||||
const { t } = useTranslation();
|
||||
const pluginFeatures = useActionSlot("onboarding.features");
|
||||
const features = [
|
||||
...FEATURES.map(({ icon, key }) => ({
|
||||
id: key,
|
||||
icon,
|
||||
title: t(`onboarding.feature_${key}`),
|
||||
description: t(`onboarding.feature_${key}_desc`),
|
||||
})),
|
||||
...pluginFeatures.map((feature) => ({
|
||||
id: feature.actionId,
|
||||
icon: feature.icon as LucideIcon | undefined,
|
||||
title: t(feature.titleKey),
|
||||
description: feature.descriptionKey ? t(feature.descriptionKey) : "",
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -35,18 +39,18 @@ export function FeaturesStep() {
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{FEATURES.map(({ icon: Icon, key }) => (
|
||||
{features.map(({ icon: Icon, id, title, description }) => (
|
||||
<div
|
||||
key={key}
|
||||
key={id}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
{Icon && (
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t(`onboarding.feature_${key}`)}
|
||||
</span>
|
||||
<span className="text-xs font-medium">{title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(`onboarding.feature_${key}_desc`)}
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Columns2, LayoutTemplate, PanelRight, Search } from "lucide-react";
|
||||
import { Columns2, PanelRight, Search, type LucideIcon } from "lucide-react";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
|
||||
/**
|
||||
* The handful of navigation habits that make the app feel fast. Each row shows
|
||||
@@ -9,6 +10,24 @@ import { Kbd } from "@/components/kbd";
|
||||
*/
|
||||
export function WorkflowStep() {
|
||||
const { t } = useTranslation();
|
||||
const pluginTips = useActionSlot("onboarding.workflow");
|
||||
const tips = [
|
||||
...[
|
||||
{ icon: Columns2, key: "split" },
|
||||
{ icon: PanelRight, key: "dock" },
|
||||
].map(({ icon, key }) => ({
|
||||
id: key,
|
||||
icon: icon as LucideIcon | undefined,
|
||||
title: t(`onboarding.workflow_${key}`),
|
||||
description: t(`onboarding.workflow_${key}_desc`),
|
||||
})),
|
||||
...pluginTips.map((tip) => ({
|
||||
id: tip.actionId,
|
||||
icon: tip.icon as LucideIcon | undefined,
|
||||
title: t(tip.titleKey),
|
||||
description: tip.descriptionKey ? t(tip.descriptionKey) : "",
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -33,22 +52,18 @@ export function WorkflowStep() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ icon: Columns2, key: "split" },
|
||||
{ icon: PanelRight, key: "dock" },
|
||||
{ icon: LayoutTemplate, key: "workspaces" },
|
||||
].map(({ icon: Icon, key }) => (
|
||||
{tips.map(({ icon: Icon, id, title, description }) => (
|
||||
<div
|
||||
key={key}
|
||||
key={id}
|
||||
className="flex items-start gap-2.5 border border-border bg-card p-2.5"
|
||||
>
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
{Icon && (
|
||||
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t(`onboarding.workflow_${key}`)}
|
||||
</span>
|
||||
<span className="text-xs font-medium">{title}</span>
|
||||
<span className="text-[10px] leading-snug text-muted-foreground">
|
||||
{t(`onboarding.workflow_${key}_desc`)}
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user