* 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

+2
View File
@@ -6,6 +6,7 @@ import { SurfaceScope } from "@/components/surface/surface-scope";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { usePermissions } from "@/hooks/use-permissions";
import { isElectron } from "@/lib/electron";
import { TITLE_BAR_OFFSET } from "@/shell/TitleBar";
import {
getOnboardingPlugins,
type OnboardingPluginList,
@@ -236,6 +237,7 @@ export function OnboardingStage({
<OnboardingStageContext.Provider value={stageValue}>
<div
className="fixed inset-0 z-[60] overflow-y-auto bg-background text-foreground"
style={{ paddingTop: TITLE_BAR_OFFSET }}
role="dialog"
aria-modal="true"
aria-label={t("onboarding.stageLabel")}
+11 -1
View File
@@ -20,12 +20,22 @@ export function isStepRelevant(
return true;
}
/** A step that took over older keys counts those as seen at version 1. */
function effectiveSeen(
step: OnboardingStepDef,
seen: Record<string, number>,
): number {
const own = seen[step.id] ?? 0;
if (own > 0 || !step.replaces) return own;
return step.replaces.some((key) => (seen[key] ?? 0) > 0) ? 1 : 0;
}
export function isStepPending(
step: OnboardingStepDef,
seen: Record<string, number>,
ctx: OnboardingCtx,
): boolean {
if ((seen[step.id] ?? 0) < step.version) return true;
if (effectiveSeen(step, seen) < step.version) return true;
try {
return step.forcePending?.(ctx) === true;
} catch {
+11
View File
@@ -42,22 +42,33 @@ export interface OnboardingStepDef {
forcePending?: (ctx: OnboardingCtx) => boolean;
/** Skip stays hidden until this step has been confirmed. */
required?: boolean;
/** Older step keys this one took over; seeing one counts as seeing this. */
replaces?: string[];
}
/** Where each section of plugin steps sits among the core steps. */
export const SECTION_POSITION: Record<OnboardingSection, number> = {
start: 25,
setup: 50,
explore: 80,
security: 95,
};
/** An explicit position stays inside the flow, before the last core step. */
const MIN_POSITION = 1;
const MAX_POSITION = 99;
export const onboardingSteps = createRegistry<OnboardingStepDef>();
/** Plugin steps sort inside their section by order, then key. */
export function sectionPosition(
section: OnboardingSection | undefined,
order = 0,
position?: number,
): number {
if (typeof position === "number" && Number.isFinite(position)) {
return Math.max(MIN_POSITION, Math.min(MAX_POSITION, position));
}
const base = SECTION_POSITION[section ?? "explore"];
return base + Math.max(-1, Math.min(1, order / 1000)) * 0.4;
}
+1 -39
View File
@@ -1,17 +1,7 @@
import {
LayoutTemplate,
Monitor,
MonitorDown,
Palette,
Puzzle,
Shield,
} from "lucide-react";
import { MonitorDown, Puzzle, Shield } from "lucide-react";
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
import { PluginsStep } from "./steps/PluginsStep";
import { PresetStep } from "./steps/PresetStep";
import { AppearanceStep } from "./steps/AppearanceStep";
import { SecurityStep } from "./steps/SecurityStep";
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
import { desktopUpdates } from "@/lib/desktop-updates";
import type { OnboardingStepDef } from "./onboarding-registry";
@@ -37,16 +27,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
isRelevant: (ctx) => !ctx.pluginsManagedElsewhere && ctx.pluginSetupPending,
forcePending: (ctx) => ctx.pluginSetupPending,
},
{
id: "desktop-sync",
version: CORE_ONBOARDING_STEPS["desktop-sync"],
titleKey: "onboarding.desktopTitle",
icon: Monitor,
Component: DesktopSyncStep,
audience: "all",
position: 20,
isRelevant: (ctx) => ctx.isDesktop,
},
{
id: "desktop-updates",
version: CORE_ONBOARDING_STEPS["desktop-updates"],
@@ -58,24 +38,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
// App Store builds have no updater.
isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null,
},
{
id: "preset",
version: CORE_ONBOARDING_STEPS.preset,
titleKey: "onboarding.presetTitle",
icon: LayoutTemplate,
Component: PresetStep,
audience: "all",
position: 30,
},
{
id: "appearance",
version: CORE_ONBOARDING_STEPS.appearance,
titleKey: "onboarding.appearanceTitle",
icon: Palette,
Component: AppearanceStep,
audience: "all",
position: 40,
},
{
id: "security",
version: CORE_ONBOARDING_STEPS.security,
@@ -1,82 +0,0 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
AccentPicker,
FontSizePicker,
LanguageSelect,
ThemeGrid,
UiFontSelect,
useAppearanceSettings,
} from "@/settings/appearance/appearance-controls";
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</span>
{children}
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
</div>
);
}
/** Every change applies right away, so the page itself is the preview. */
export function AppearanceStep() {
const { t } = useTranslation();
const appearance = useAppearanceSettings();
return (
<div className="flex flex-col gap-5">
<p className="text-xs text-muted-foreground">
{t("onboarding.appearanceIntro")}
</p>
<Field label={t("newUi.sidebar.userProfile.themeLabel")}>
<ThemeGrid value={appearance.theme} onChange={appearance.setTheme} />
</Field>
<Field label={t("newUi.sidebar.userProfile.accentColorLabel")}>
<AccentPicker
value={appearance.accent}
onChange={appearance.setAccent}
/>
</Field>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Field
label={t("newUi.sidebar.userProfile.interfaceFontLabel")}
hint={t("newUi.sidebar.userProfile.interfaceFontDescription")}
>
<UiFontSelect
value={appearance.uiFont}
onChange={appearance.setUiFont}
/>
</Field>
<Field label={t("newUi.sidebar.userProfile.fontSizeLabel")}>
<FontSizePicker
value={appearance.fontSize}
onChange={appearance.setFontSize}
/>
</Field>
</div>
<Field label={t("newUi.sidebar.userProfile.languageLabel")}>
<LanguageSelect
value={appearance.language}
onChange={appearance.setLanguage}
/>
</Field>
</div>
);
}
@@ -1,79 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, Laptop } from "lucide-react";
import { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
/**
* Desktop only: use Termix on this device alone, or link it to a server.
* Either choice can be changed later from Sync.
*/
export function DesktopSyncStep() {
const { t } = useTranslation();
const status = useSyncStatus();
const [linkOpen, setLinkOpen] = useState(false);
const linked = !!status?.linked;
const options = [
{
id: "local",
icon: Laptop,
title: t("onboarding.desktopLocalTitle"),
body: t("onboarding.desktopLocalBody"),
selected: !linked,
onClick: () => {},
},
{
id: "link",
icon: Cloud,
title: t("onboarding.desktopLinkTitle"),
body: linked
? t("onboarding.desktopLinked", {
server: status?.serverName || status?.serverUrl,
})
: t("onboarding.desktopLinkBody"),
selected: linked,
onClick: () => !linked && setLinkOpen(true),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{t("onboarding.desktopBody")}
</p>
<div className="flex flex-col gap-1.5">
{options.map(({ id, icon: Icon, title, body, selected, onClick }) => (
<button
key={id}
type="button"
onClick={onClick}
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
selected
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
<span className="flex flex-col gap-1">
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{body}
</span>
</span>
</button>
))}
</div>
<LinkServerDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onLinked={(next) => {
setSyncStatus(next);
notifySyncChanged();
}}
/>
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
import { useTranslation } from "react-i18next";
import { Gauge, Sparkles, Wrench } from "lucide-react";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
const CHOICES: Exclude<UiPreset, "custom">[] = [
"simple",
"balanced",
"advanced",
];
const ICONS = { simple: Sparkles, balanced: Gauge, advanced: Wrench } as const;
/**
* A miniature of the host list at the given preset. Deliberately drawn rather
* than rendering the real component: the point is to show the shape difference
* (how many rows fit, how much detail each carries) at a glance.
*/
function HostRowPreview({ preset }: { preset: Exclude<UiPreset, "custom"> }) {
const { hostList } = PRESETS[preset];
const rows = hostList.density === "compact" ? 5 : 3;
return (
<div className="flex flex-col gap-[3px] border border-border/60 bg-background/60 p-1.5">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-stretch gap-1">
{hostList.showStatusStripes && (
<div className="w-[2px] shrink-0 bg-accent-brand/70" />
)}
<div className="flex flex-1 flex-col gap-[2px] py-[2px]">
<div className="h-[3px] w-2/3 bg-muted-foreground/40" />
{hostList.density === "comfortable" && (
<div className="h-[2px] w-1/2 bg-muted-foreground/20" />
)}
{hostList.showTags && (
<div className="flex gap-[2px]">
<div className="h-[3px] w-3 bg-muted-foreground/20" />
<div className="h-[3px] w-2 bg-muted-foreground/20" />
</div>
)}
{hostList.showResourceBars && (
<div className="flex gap-[2px]">
<div className="h-[2px] w-4 bg-accent-brand/40" />
<div className="h-[2px] w-3 bg-accent-brand/25" />
</div>
)}
</div>
</div>
))}
</div>
);
}
export function PresetStep() {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const selected = ctx?.preferences.preset ?? "balanced";
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{t("onboarding.presetIntro")}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{CHOICES.map((preset) => {
const Icon = ICONS[preset];
const active = selected === preset;
return (
<button
key={preset}
type="button"
aria-pressed={active}
onClick={() => {
ctx?.setPreset(preset);
ctx?.clearAllOverrides();
void applyPresetSideEffects(preset);
}}
className={`flex flex-col gap-2 border p-2.5 text-left transition-colors ${
active
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<span className="flex items-center gap-1.5 text-xs font-semibold">
<Icon
size={13}
className={
active ? "text-accent-brand" : "text-muted-foreground"
}
/>
{t(`newUi.sidebar.userProfile.preset_${preset}`)}
</span>
<HostRowPreview preset={preset} />
<span className="text-[10px] leading-snug text-muted-foreground">
{t(`newUi.sidebar.userProfile.preset_${preset}_desc`)}
</span>
</button>
);
})}
</div>
<p className="text-[10px] text-muted-foreground/70">
{t("onboarding.presetChangeLater")}
</p>
</div>
);
}