feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import {
useTranslation,
type SshAuthEditorProps,
} from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { getTailscaleDevices } from "./tailscale-api";
interface Device {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}
function tailnetAddress(device: Device): string {
return (
device.addresses.find((address) => address.startsWith("100.")) ??
device.addresses[0] ??
""
);
}
/**
* The host editor's "tailscale" auth method: pick a tailnet device and its
* address fills the host's IP.
*/
export function TailscaleAuthEditor({ form, setField }: SshAuthEditorProps) {
const { t } = useTranslation();
const [devices, setDevices] = useState<Device[]>([]);
const [hasApiKey, setHasApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setFailed(false);
getTailscaleDevices()
.then((result) => {
if (cancelled) return;
setDevices(result?.devices ?? []);
setHasApiKey(result?.hasApiKey ?? false);
setFailed(!!result?.error);
})
.catch(() => {
if (!cancelled) setFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const ip = String(form.ip ?? "");
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.tailscaleDeviceSelect")}
</label>
<a
href="https://docs.termix.site/features/networking/tailscale"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.tailscaleDocsLink")}
</a>
</div>
{loading ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleLoadingDevices")}
</p>
) : failed ? (
<p className="text-[10px] text-destructive">
{t("hosts.tailscaleDeviceLoadFailed")}
</p>
) : !hasApiKey ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoApiKey")}
</p>
) : devices.length === 0 ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoDevices")}
</p>
) : (
<>
<Select2
className="w-full border border-border bg-background text-foreground text-xs px-2 py-1.5 focus:outline-none focus:border-accent-brand/50"
value={
devices.find((device) => device.addresses.includes(ip))?.id ?? ""
}
onChange={(event) => {
const device = devices.find(
(item) => item.id === event.target.value,
);
if (device) setField("ip", tailnetAddress(device));
}}
>
<option value="" disabled>
{t("hosts.tailscaleDeviceSelectPlaceholder")}
</option>
{devices.map((device) => (
<option key={device.id} value={device.id}>
{device.hostname} ({tailnetAddress(device)})
</option>
))}
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleDeviceAutoFill")}
</p>
</>
)}
</div>
);
}
@@ -1,7 +1,7 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Shield, ExternalLink, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface TailscaleCheckDialogProps {
isOpen: boolean;
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { TerminalOverlayProps } from "@/features/terminal/terminal-slots";
import { TailscaleCheckDialog } from "./TailscaleCheckDialog";
/** How long Tailscale may hold a connection open waiting on approval. */
const CHECK_TIMEOUT_MS = 30 * 60 * 1000;
interface CheckState {
authUrl: string;
message?: string;
stage: "prompt" | "waiting";
}
/**
* Tailscale SSH can hold a connection open until the user approves it in a
* browser. The terminal's server messages say when, and this pauses the
* connect timeout and shows the approval link meanwhile.
*/
export function TailscaleCheckOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [check, setCheck] = useState<CheckState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const heldRef = useRef(false);
useEffect(() => {
const release = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
setCheck(null);
};
const unsubscribe = subscribe((message) => {
if (message.type === "tailscale_check_required") {
if (!heldRef.current) {
heldRef.current = true;
holdConnectTimeout(true);
}
setCheck({
authUrl: typeof message.url === "string" ? message.url : "",
message:
typeof message.message === "string" ? message.message : undefined,
stage: "prompt",
});
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
release();
fail(t("terminal.tailscaleCheckTimeout"));
}, CHECK_TIMEOUT_MS);
} else if (
message.type === "tailscale_check_completed" ||
message.type === "session_closed"
) {
release();
}
});
return () => {
unsubscribe();
release();
};
}, [subscribe, holdConnectTimeout, fail, t]);
if (!check) return null;
return (
<TailscaleCheckDialog
isOpen
authUrl={check.authUrl}
message={check.message}
stage={check.stage}
onCancel={() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
setCheck(null);
disconnect();
}}
onOpenUrl={() => {
window.open(check.authUrl, "_blank");
setCheck((current) =>
current ? { ...current, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
);
}
@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, RefreshCw, Terminal } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getTailscaleDevices } from "./tailscale-api";
import { createQuickConnectHost } from "../../../../src/ui/sidebar/quick-connect-host";
import { createQuickConnectHost } from "@/sidebar/quick-connect-host";
interface TailscaleDevice {
id: string;
@@ -0,0 +1,89 @@
/**
* The tailnet status shown on the Tailscale settings page.
*
* Confirms the saved key actually works, which is the question anyone has just
* after entering one. Connecting to a device stays in the Tailscale rail panel;
* a settings page is for configuring, not for operating.
*
* Registered by legacy-settings-components.ts until A7 lets the plugin's own
* bundle register it.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, RefreshCw } from "lucide-react";
import { Button } from "@/components/button";
import type { SettingsComponentProps } from "@termix/plugin-sdk/frontend";
import { getTailscaleDevices } from "./tailscale-api";
type Status =
| { kind: "loading" }
| { kind: "no-key" }
| { kind: "error" }
| { kind: "ok"; count: number };
export function TailscaleDevicesStatus({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>({ kind: "loading" });
const load = useCallback(async () => {
if (!running) {
setStatus({ kind: "no-key" });
return;
}
setStatus({ kind: "loading" });
try {
const result = await getTailscaleDevices();
if (!result.hasApiKey) {
setStatus({ kind: "no-key" });
return;
}
if (result.error) {
setStatus({ kind: "error" });
return;
}
setStatus({ kind: "ok", count: result.devices?.length ?? 0 });
} catch {
setStatus({ kind: "error" });
}
}, [running]);
useEffect(() => {
void load();
}, [load]);
const label = (key: string) => t(key);
return (
<div className="flex items-center justify-between gap-3 py-3">
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium leading-snug">
{label("settings.devices.label")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{status.kind === "loading" && label("settings.devices.checking")}
{status.kind === "no-key" && label("settings.devices.noKey")}
{status.kind === "error" && label("settings.devices.error")}
{status.kind === "ok" &&
t("settings.devices.count", {
count: status.count,
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
disabled={!running || status.kind === "loading"}
onClick={() => void load()}
>
{status.kind === "loading" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1.5">{label("settings.devices.refresh")}</span>
</Button>
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
/**
* Frontend half of the tailscale plugin.
*
* TailscaleDevicesPanel is a left-sidebar railView panel (opened from the
* rail, not a Tab), the same shape as FleetsPanel/QuickConnectPanel. None of
* those go through registerRailItem/registerTabComponent either -- per
* src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like this is
* rendered directly by AppShell.tsx rather than registered at runtime.
* TailscaleDevicesPanel is reached today through a direct relative import in
* AppShell.tsx (../../plugins/tailscale/frontend/TailscaleDevicesPanel), not
* through register() below.
*
* register()/unregister() still follow the same shape every other plugin's
* frontend entry does, so this is ready the moment a real frontend plugin
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
* "there is no plugin loader yet".
*
* TailscaleCheckDialog is not registered here at all: it is rendered
* directly by src/ui/features/terminal/Terminal.tsx (owned by ssh-terminal),
* driven by raw WS message types on the terminal socket. There is no
* dialog-registration extension point for that today, so this plugin ships
* the component as a plain module ssh-terminal imports by path.
*/
export const id = "tailscale";
export const railViewId = "tailscale";
export async function register({ registerRailItem, icons }) {
registerRailItem?.({
id: railViewId,
icon: icons.Radar,
labelKey: "nav.tailscale",
separatorAfter: true,
});
}
export async function unregister({ unregisterRailItem }) {
unregisterRailItem?.(railViewId);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ComponentType } from "react";
import { Radar } from "lucide-react";
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
import type { Host } from "@/types/ui-types";
import { TailscaleDevicesPanel } from "./TailscaleDevicesPanel";
import { TailscaleDevicesStatus } from "./TailscaleDevicesStatus";
import { TailscaleAuthEditor } from "./TailscaleAuthEditor";
import { TailscaleCheckOverlay } from "./TailscaleCheckOverlay";
function Panel({ shell }: PanelProps) {
return (
<TailscaleDevicesPanel
onConnect={(host: Host, type) =>
shell.openTab(
host as unknown as Parameters<typeof shell.openTab>[0],
type,
)
}
/>
);
}
export function activate(app: TermixApp): void {
app.registerRailItem({
id: "tailscale",
icon: Radar,
titleKey: "nav.tailscale",
after: "macros",
order: 20,
});
app.registerPanel("tailscale", Panel);
app.registerSettingsComponent("devices", TailscaleDevicesStatus);
// "tailscale" as a host's SSH auth method: pick a device from the tailnet.
app.registerSshAuthEditor({
authType: "tailscale",
titleKey: "nav.tailscale",
hintKey: "hosts.tailscaleUsernameHint",
component: TailscaleAuthEditor,
});
// Approval prompts while Tailscale SSH holds a connection open.
app.registerSlotContribution("terminal.overlay", {
actionId: "tailscale.check",
titleKey: "terminal.tailscaleCheckRequired",
kind: "component",
component: TailscaleCheckOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
}