mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -22,6 +22,8 @@ interface ActionSlotProps {
|
||||
) => React.ReactNode;
|
||||
/** Icon-only, for toolbars that collapse their labels. */
|
||||
hideLabels?: boolean;
|
||||
/** Passed to each contribution's `when`. */
|
||||
when?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,9 +41,12 @@ export function ActionSlot({
|
||||
enabled = true,
|
||||
renderItem,
|
||||
hideLabels,
|
||||
when,
|
||||
}: ActionSlotProps) {
|
||||
const { t } = useTranslation();
|
||||
const contributions = useActionSlot(slotId);
|
||||
const contributions = useActionSlot(slotId, when).filter(
|
||||
(contribution) => contribution.kind !== "component",
|
||||
);
|
||||
|
||||
if (!enabled || contributions.length === 0) return null;
|
||||
|
||||
@@ -80,3 +85,32 @@ export function ActionSlot({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the component contributions in a slot, each with the owner's
|
||||
* props. Same permission and `when` filtering as buttons, and the same
|
||||
* nothing-at-all output when there is nothing to show.
|
||||
*/
|
||||
export function ComponentSlot({
|
||||
slotId,
|
||||
props,
|
||||
when,
|
||||
}: {
|
||||
slotId: string;
|
||||
props?: Record<string, unknown>;
|
||||
when?: Record<string, unknown>;
|
||||
}) {
|
||||
const contributions = useActionSlot(slotId, when).filter(
|
||||
(contribution) =>
|
||||
contribution.kind === "component" && contribution.component,
|
||||
);
|
||||
if (contributions.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{contributions.map((contribution) => {
|
||||
const Contributed = contribution.component!;
|
||||
return <Contributed key={contribution.actionId} {...(props ?? {})} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+91
-115
@@ -14,22 +14,16 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
Box,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Plus,
|
||||
MessagesSquare,
|
||||
LifeBuoy,
|
||||
Search,
|
||||
Activity,
|
||||
Network,
|
||||
User,
|
||||
KeyRound,
|
||||
Layers, // --- tmux-monitor ---
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Clock,
|
||||
Folder,
|
||||
Pencil,
|
||||
@@ -43,10 +37,17 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { Host, TabType, Tab, Snippet } from "@/types/ui-types";
|
||||
import { canEditHost } from "@/sidebar/host-permissions";
|
||||
import { RAIL_ITEMS, RAIL_UTILITY_ITEMS } from "@/sidebar/rail-items";
|
||||
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
||||
import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
|
||||
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
|
||||
import { isTabTypeAvailable } from "@/shell/pluginLoader";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
runHostAction,
|
||||
useHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import { paletteEntriesFor, usePaletteEntries } from "./palette-registry";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
@@ -59,47 +60,18 @@ interface CommandPaletteProps {
|
||||
onOpenPanel?: (view: string) => void;
|
||||
}
|
||||
|
||||
const ACTIVITY_ICONS: Record<string, React.ReactNode> = {
|
||||
terminal: <Terminal className="size-3.5" />,
|
||||
file_manager: <FolderOpen className="size-3.5" />,
|
||||
server_stats: <Activity className="size-3.5" />,
|
||||
tunnel: <Network className="size-3.5" />,
|
||||
docker: <Box className="size-3.5" />,
|
||||
telnet: <MessagesSquare className="size-3.5" />,
|
||||
vnc: <MousePointerClick className="size-3.5" />,
|
||||
rdp: <Monitor className="size-3.5" />,
|
||||
};
|
||||
|
||||
const ACTIVITY_TAB_TYPE: Record<string, TabType> = {
|
||||
terminal: "terminal",
|
||||
file_manager: "files",
|
||||
server_stats: "host-metrics",
|
||||
tunnel: "tunnel",
|
||||
docker: "docker",
|
||||
telnet: "telnet",
|
||||
vnc: "vnc",
|
||||
rdp: "rdp",
|
||||
};
|
||||
|
||||
/** Core per-host tools. Plugins add theirs as host actions. */
|
||||
function getSshActions(host: Host): {
|
||||
type: TabType;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
}[] {
|
||||
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
|
||||
return [
|
||||
host.enableTerminal !== false &&
|
||||
isTabTypeAvailable("terminal") && {
|
||||
type: "terminal",
|
||||
icon: Terminal,
|
||||
label: "Terminal",
|
||||
},
|
||||
host.enableFileManager && {
|
||||
type: "files",
|
||||
icon: FolderSearch,
|
||||
label: "Files",
|
||||
},
|
||||
host.enableDocker && { type: "docker", icon: Box, label: "Docker" },
|
||||
// --- tmux-monitor --- opt-in per host, off by default
|
||||
host.enableTerminal !== false &&
|
||||
host.enableTmuxMonitor && {
|
||||
@@ -108,16 +80,6 @@ function getSshActions(host: Host): {
|
||||
label: "Tmux Monitor",
|
||||
},
|
||||
host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" },
|
||||
metricsEnabled && {
|
||||
type: "host-metrics",
|
||||
icon: Activity,
|
||||
label: "Host Metrics",
|
||||
},
|
||||
host.enableProxmoxStats === true && {
|
||||
type: "proxmox-stats",
|
||||
icon: HardDrive,
|
||||
label: "Proxmox Stats",
|
||||
},
|
||||
].filter(Boolean) as {
|
||||
type: TabType;
|
||||
icon: React.ElementType;
|
||||
@@ -135,7 +97,9 @@ export function CommandPalette({
|
||||
onOpenPanel,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const { globallyEnabled: aiGloballyEnabled } = useAiAvailability();
|
||||
const railItems = useRailItems();
|
||||
const hostActions = useHostActions();
|
||||
const paletteEntries = usePaletteEntries();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
@@ -396,9 +360,8 @@ export function CommandPalette({
|
||||
heading={t("commandPalette.navigation")}
|
||||
className="px-2"
|
||||
>
|
||||
{[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||
{[...railItems, ...RAIL_UTILITY_ITEMS]
|
||||
.filter((item) => item.kind !== "tab")
|
||||
.filter((item) => item.id !== "ai" || aiGloballyEnabled)
|
||||
.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
@@ -471,6 +434,37 @@ export function CommandPalette({
|
||||
</>
|
||||
)}
|
||||
|
||||
{paletteEntriesFor(paletteEntries, "global").length > 0 && (
|
||||
<>
|
||||
<CommandSeparator className="my-2" />
|
||||
<CommandGroup
|
||||
heading={t("commandPalette.pluginActions")}
|
||||
className="px-2"
|
||||
>
|
||||
{paletteEntriesFor(paletteEntries, "global").map((entry) => {
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`plugin-${entry.id}`}
|
||||
value={`plugin-${entry.id} ${t(entry.titleKey)} ${(entry.keywords ?? []).join(" ")}`}
|
||||
onSelect={() => handleAction(() => entry.run(shell))}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors">
|
||||
{Icon && (
|
||||
<Icon className="size-4 text-accent-brand" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-semibold flex-1">
|
||||
{t(entry.titleKey)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recentActivity.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator className="my-2" />
|
||||
@@ -483,17 +477,18 @@ export function CommandPalette({
|
||||
key={item.id}
|
||||
value={`recent-activity-${item.id}`}
|
||||
onSelect={() =>
|
||||
handleAction(() =>
|
||||
onOpenTab(
|
||||
ACTIVITY_TAB_TYPE[item.type],
|
||||
item.hostName,
|
||||
),
|
||||
)
|
||||
handleAction(() => {
|
||||
const target = activityTarget(item.type);
|
||||
if (target) onOpenTab(target.tab, item.hostName);
|
||||
})
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-7 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors text-muted-foreground group-hover:text-accent-brand">
|
||||
{ACTIVITY_ICONS[item.type]}
|
||||
{(() => {
|
||||
const Icon = activityTarget(item.type)?.icon;
|
||||
return Icon ? <Icon className="size-3.5" /> : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold truncate">
|
||||
@@ -537,17 +532,11 @@ export function CommandPalette({
|
||||
value={`host-${host.id}`}
|
||||
onSelect={() =>
|
||||
handleAction(() => {
|
||||
const type = host.enableSsh
|
||||
? "terminal"
|
||||
: host.enableRdp && isTabTypeAvailable("rdp")
|
||||
? "rdp"
|
||||
: host.enableVnc && isTabTypeAvailable("vnc")
|
||||
? "vnc"
|
||||
: host.enableTelnet &&
|
||||
isTabTypeAvailable("telnet")
|
||||
? "telnet"
|
||||
: "terminal";
|
||||
onOpenTab(type, host.name);
|
||||
const action = defaultConnectAction(
|
||||
hostActions,
|
||||
host,
|
||||
);
|
||||
if (action) runHostAction(action, host, shell);
|
||||
})
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
@@ -599,59 +588,46 @@ export function CommandPalette({
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{host.enableSsh &&
|
||||
((host.enableRdp && isTabTypeAvailable("rdp")) ||
|
||||
(host.enableVnc && isTabTypeAvailable("vnc")) ||
|
||||
(host.enableTelnet &&
|
||||
isTabTypeAvailable("telnet"))) && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && isTabTypeAvailable("rdp") && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab("rdp", host.name),
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Monitor className="size-3" />
|
||||
RDP
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && isTabTypeAvailable("vnc") && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab("vnc", host.name),
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<MousePointerClick className="size-3" />
|
||||
VNC
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet &&
|
||||
isTabTypeAvailable("telnet") && (
|
||||
{hostActionsFor(hostActions, host).map((action) => {
|
||||
const Icon = action.icon;
|
||||
const label =
|
||||
action.label?.(host) ?? t(action.titleKey);
|
||||
return (
|
||||
<button
|
||||
title="Telnet"
|
||||
key={action.id}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab("telnet", host.name),
|
||||
runHostAction(action, host, shell),
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
Telnet
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
{paletteEntriesFor(
|
||||
paletteEntries,
|
||||
"host",
|
||||
host,
|
||||
).map((entry) => {
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
title={t(entry.titleKey)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() => entry.run(shell, host));
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
{Icon && <Icon className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canEditHost(host) && (
|
||||
<>
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { RailView } from "@/sidebar/AppRail";
|
||||
import { visibleRailItems } from "@/sidebar/rail-items";
|
||||
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
||||
import { useRailItems } from "@/sidebar/rail-items";
|
||||
import type { SplitMode } from "@/types/ui-types";
|
||||
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
|
||||
|
||||
@@ -28,7 +27,7 @@ export function MobileBottomBar({
|
||||
const { t } = useTranslation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [hidden, setHidden] = useState<Set<string>>(readHiddenRailTabs);
|
||||
const { userEnabled: aiEnabled } = useAiAvailability();
|
||||
const railItems = useRailItems();
|
||||
|
||||
// The rail's visibility toggles apply on mobile too; this used to ignore
|
||||
// them, so hiding a tab did nothing on a phone.
|
||||
@@ -40,11 +39,8 @@ export function MobileBottomBar({
|
||||
|
||||
const { primaryItems, moreItems } = useMemo(() => {
|
||||
// Tab-opening entries (network graph) have no sidebar panel to show here.
|
||||
const visible = visibleRailItems().filter(
|
||||
(item) =>
|
||||
item.kind !== "tab" &&
|
||||
!hidden.has(item.id) &&
|
||||
(item.id !== "ai" || aiEnabled),
|
||||
const visible = railItems.filter(
|
||||
(item) => item.kind !== "tab" && !hidden.has(item.id),
|
||||
);
|
||||
const preferred = visible.filter((item) => item.mobilePrimary);
|
||||
// Keep four primary slots filled even when the user hides the defaults,
|
||||
@@ -58,7 +54,7 @@ export function MobileBottomBar({
|
||||
primaryItems: primary,
|
||||
moreItems: visible.filter((item) => !primaryIds.has(item.id)),
|
||||
};
|
||||
}, [hidden, aiEnabled]);
|
||||
}, [hidden, railItems]);
|
||||
|
||||
const moreActive =
|
||||
sidebarOpen &&
|
||||
|
||||
+13
-10
@@ -30,17 +30,20 @@ import {
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import { isSessionTabType } from "@/shell/tab-registry";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { Tab, TabType, SplitMode } from "@/types/ui-types";
|
||||
import { SPLIT_MODES, PANE_COUNTS } from "@/lib/theme";
|
||||
|
||||
const CONNECTION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
"local-terminal",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
];
|
||||
/**
|
||||
* Tabs holding a live connection that can be refreshed and shared: local
|
||||
* terminals and registered session tabs. Serial sessions are not shareable.
|
||||
*/
|
||||
function isConnectionTab(type: TabType): boolean {
|
||||
return (
|
||||
type === "local-terminal" || (type !== "serial" && isSessionTabType(type))
|
||||
);
|
||||
}
|
||||
|
||||
export function TabBar({
|
||||
tabs,
|
||||
@@ -453,7 +456,7 @@ export function TabBar({
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ml-1 ${active ? "opacity-100" : "opacity-0 group-hover/tab:opacity-100"}`}
|
||||
>
|
||||
{CONNECTION_TAB_TYPES.includes(tab.type) && (
|
||||
{isConnectionTab(tab.type) && (
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
@@ -466,7 +469,7 @@ export function TabBar({
|
||||
<RefreshCw className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{CONNECTION_TAB_TYPES.includes(tab.type) && onOpenShare && (
|
||||
{isConnectionTab(tab.type) && onOpenShare && (
|
||||
<button
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
@@ -674,7 +677,7 @@ export function TabBar({
|
||||
{ctxTab.label}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1" />
|
||||
{CONNECTION_TAB_TYPES.includes(ctxTab.type) && (
|
||||
{isConnectionTab(ctxTab.type) && (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
|
||||
@@ -111,15 +111,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const computeUniqueTitle = useCallback(
|
||||
(tabType: Tab["type"], desiredTitle: string | undefined): string => {
|
||||
const defaultTitle =
|
||||
tabType === "server_stats"
|
||||
? t("nav.hostMetrics")
|
||||
: tabType === "file_manager"
|
||||
? t("nav.fileManager")
|
||||
: tabType === "tunnel"
|
||||
? t("nav.tunnels")
|
||||
: tabType === "docker"
|
||||
? t("nav.docker")
|
||||
: t("nav.terminal");
|
||||
tabType === "file_manager"
|
||||
? t("nav.fileManager")
|
||||
: tabType === "tunnel"
|
||||
? t("nav.tunnels")
|
||||
: t("nav.terminal");
|
||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||
const root = match ? match[1] : baseTitle;
|
||||
@@ -185,10 +181,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const instanceId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const needsUniqueTitle =
|
||||
tabData.type === "terminal" ||
|
||||
tabData.type === "server_stats" ||
|
||||
tabData.type === "file_manager" ||
|
||||
tabData.type === "tunnel" ||
|
||||
tabData.type === "docker";
|
||||
tabData.type === "tunnel";
|
||||
const effectiveTitle = needsUniqueTitle
|
||||
? computeUniqueTitle(tabData.type, tabData.title)
|
||||
: tabData.type === "tmux_monitor" // --- tmux-monitor ---
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* subscribe/getSnapshot plumbing for useSyncExternalStore.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
/**
|
||||
* Args are unknown[] because the registry never inspects them: a slot owner
|
||||
@@ -49,9 +49,18 @@ export interface SlotContribution {
|
||||
/** Action invoked when this contribution is activated. */
|
||||
actionId: string;
|
||||
titleKey: string;
|
||||
icon?: LucideIcon;
|
||||
/** Secondary text, for slots that show one (onboarding cards). */
|
||||
descriptionKey?: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
/** Checked against the slot's accepts. Defaults to "button". */
|
||||
kind?: string;
|
||||
/**
|
||||
* For kind "component": rendered by the slot owner with its own props,
|
||||
* e.g. a panel docked into the terminal.
|
||||
*/
|
||||
component?: ComponentType<Record<string, unknown>>;
|
||||
/** Extra condition, evaluated against the slot owner's context. */
|
||||
when?: (context: Record<string, unknown>) => boolean;
|
||||
pluginId?: string;
|
||||
/** Lower sorts first. Ties break on actionId so ordering is stable. */
|
||||
order?: number;
|
||||
@@ -83,15 +92,32 @@ export function registerAction(
|
||||
id: string,
|
||||
handler: ActionHandler,
|
||||
options: { permission?: string; pluginId?: string } = {},
|
||||
): void {
|
||||
actions.set(id, { id, handler, ...options });
|
||||
): () => void {
|
||||
const action = { id, handler, ...options };
|
||||
actions.set(id, action);
|
||||
emit();
|
||||
return () => {
|
||||
if (actions.get(id) === action) {
|
||||
actions.delete(id);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterAction(id: string): void {
|
||||
if (actions.delete(id)) emit();
|
||||
}
|
||||
|
||||
/** Every registered action. */
|
||||
export function listActions(): RegisteredAction[] {
|
||||
return [...actions.values()];
|
||||
}
|
||||
|
||||
/** Whether a handler is registered, i.e. its plugin is running. */
|
||||
export function isActionRegistered(id: string): boolean {
|
||||
return actions.has(id);
|
||||
}
|
||||
|
||||
/** The permission gating an action, if it declared one. */
|
||||
export function getActionPermission(id: string): string | undefined {
|
||||
return actions.get(id)?.permission;
|
||||
@@ -114,9 +140,20 @@ export async function invokeAction(
|
||||
return (action.handler as (...a: unknown[]) => unknown)(...args);
|
||||
}
|
||||
|
||||
export function declareActionSlot(slot: ActionSlotDefinition): void {
|
||||
export function declareActionSlot(slot: ActionSlotDefinition): () => void {
|
||||
slots.set(slot.id, slot);
|
||||
emit();
|
||||
return () => {
|
||||
if (slots.get(slot.id) === slot) {
|
||||
slots.delete(slot.id);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a slot is declared, i.e. its owner is running. */
|
||||
export function isActionSlotDeclared(slotId: string): boolean {
|
||||
return slots.has(slotId);
|
||||
}
|
||||
|
||||
export function undeclareActionSlot(slotId: string): void {
|
||||
@@ -133,7 +170,7 @@ export function undeclareActionSlot(slotId: string): void {
|
||||
export function registerSlotContribution(
|
||||
slotId: string,
|
||||
contribution: SlotContribution,
|
||||
): void {
|
||||
): () => void {
|
||||
const slot = slots.get(slotId);
|
||||
const kind = contribution.kind ?? "button";
|
||||
|
||||
@@ -141,7 +178,7 @@ export function registerSlotContribution(
|
||||
console.warn(
|
||||
`[actions] slot "${slotId}" does not accept "${kind}" (accepts: ${slot.accepts.join(", ")})`,
|
||||
);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let forSlot = contributions.get(slotId);
|
||||
@@ -149,8 +186,14 @@ export function registerSlotContribution(
|
||||
forSlot = new Map();
|
||||
contributions.set(slotId, forSlot);
|
||||
}
|
||||
forSlot.set(contribution.actionId, { ...contribution, kind });
|
||||
const stored = { ...contribution, kind };
|
||||
forSlot.set(contribution.actionId, stored);
|
||||
emit();
|
||||
return () => {
|
||||
if (contributions.get(slotId)?.get(contribution.actionId) === stored) {
|
||||
unregisterSlotContribution(slotId, contribution.actionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterSlotContribution(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||
import type { TabShellCallbacks } from "./tab-registry";
|
||||
|
||||
/** A command palette entry a plugin contributes. */
|
||||
export interface PaletteEntryDef {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
keywords?: string[];
|
||||
scope: "global" | "host";
|
||||
when?: (host?: Host) => boolean;
|
||||
run: (shell: TabShellCallbacks, host?: Host) => void;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
const registry = createRegistry<PaletteEntryDef>(byOrderThenId);
|
||||
|
||||
export const registerPaletteEntry = registry.register;
|
||||
export const usePaletteEntries = registry.useList;
|
||||
export const listPaletteEntries = registry.list;
|
||||
export const resetPaletteEntries = registry.reset;
|
||||
|
||||
export function paletteEntriesFor(
|
||||
all: PaletteEntryDef[],
|
||||
scope: PaletteEntryDef["scope"],
|
||||
host?: Host,
|
||||
): PaletteEntryDef[] {
|
||||
return all.filter((entry) => {
|
||||
if (entry.scope !== scope) return false;
|
||||
if (!entry.when) return true;
|
||||
try {
|
||||
return entry.when(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
import type { TabShellCallbacks } from "./tab-registry";
|
||||
|
||||
export interface PanelRenderProps {
|
||||
active: boolean;
|
||||
shell: TabShellCallbacks;
|
||||
setEditing: (editing: boolean) => void;
|
||||
activeTabType?: string;
|
||||
placement: "left" | "right" | "tab";
|
||||
}
|
||||
|
||||
/** Sidebar content for a rail view a plugin contributes. */
|
||||
export interface PanelDef {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
component: ComponentType<PanelRenderProps>;
|
||||
/** Stays mounted once opened, so its state survives switching away. */
|
||||
keepMounted?: boolean;
|
||||
}
|
||||
|
||||
const registry = createRegistry<PanelDef>();
|
||||
|
||||
export const registerPanel = registry.register;
|
||||
export const unregisterPanel = registry.unregister;
|
||||
export const getPanel = registry.get;
|
||||
export const listPanels = registry.list;
|
||||
export const usePanels = registry.useList;
|
||||
export const resetPanels = registry.reset;
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Applies backend plugin state to the shell.
|
||||
*
|
||||
* Two kinds of contributed surface exist, and they are removed differently:
|
||||
*
|
||||
* - A tab that only exists because a plugin registered it goes away with
|
||||
* unregisterTabComponent / unregisterRailItem.
|
||||
* - A tab that ships as a built-in code path but is OWNED by a plugin (the
|
||||
* terminal) cannot, because it was never registered. Callers ask
|
||||
* isTabTypeAvailable() before offering it instead.
|
||||
*
|
||||
* The terminal is in the second group because its tab takes around a dozen
|
||||
* props (host, split-pane state, file-manager and editor callbacks) that the
|
||||
* generic { tab } plugin surface has no way to supply. Rewiring that was a
|
||||
* bigger regression risk to a working terminal than gating the entry points.
|
||||
*/
|
||||
|
||||
import { Bot } from "lucide-react";
|
||||
import { unregisterRailItem } from "@/sidebar/rail-items";
|
||||
import { unregisterTabComponent } from "@/shell/tabUtils";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
|
||||
import {
|
||||
declareActionSlot,
|
||||
registerAction,
|
||||
registerSlotContribution,
|
||||
unregisterAction,
|
||||
unregisterSlotContribution,
|
||||
} from "@/shell/action-registry";
|
||||
|
||||
/**
|
||||
* Built-in tab types owned by a plugin, keyed by plugin id. The terminal has
|
||||
* no rail item: it opens from a host, the command palette or quick connect.
|
||||
*
|
||||
* ai is a rail-view/right-dock panel rather than a tab opened via
|
||||
* registerTabComponent (AiPanel is rendered directly by AppShell.tsx and
|
||||
* tabUtils.tsx, the same way TerminalTabContent is for the terminal), so it
|
||||
* belongs here for the same reason: a built-in surface gated by whether its
|
||||
* owning plugin is installed and enabled, not something registered at
|
||||
* runtime.
|
||||
*/
|
||||
const BUILT_IN_TABS_BY_PLUGIN: Record<string, string[]> = {
|
||||
"ssh-terminal": ["terminal"],
|
||||
"remote-desktop": ["rdp", "vnc", "telnet"],
|
||||
ai: ["ai"],
|
||||
fleets: ["fleet-inventory"],
|
||||
"network-topology": ["network_graph"],
|
||||
"web-endpoint": ["web-endpoint"],
|
||||
};
|
||||
|
||||
let pluginEnabled = new Map<string, boolean>();
|
||||
let disabledTabTypes = new Set<string>();
|
||||
|
||||
const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
|
||||
const AI_OPEN_WITH_CONTEXT = "ai.openWithContext";
|
||||
|
||||
/**
|
||||
* Applies the UI action contributions the first-party plugins declare.
|
||||
*
|
||||
* These live in plugins/*_/frontend/index.mjs, but nothing imports those yet:
|
||||
* there is no frontend plugin loader. Until there is, the registration is
|
||||
* mirrored here so the contributed UI is actually reachable in the running
|
||||
* app rather than only in tests. The two must be kept in step, and this block
|
||||
* goes away when a real loader lands.
|
||||
*/
|
||||
function applyFirstPartyActions(enabled: Map<string, boolean>): void {
|
||||
if (enabled.get("ssh-terminal") !== false) {
|
||||
declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
|
||||
}
|
||||
|
||||
if (enabled.get("ai") === false) {
|
||||
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
|
||||
unregisterAction(AI_OPEN_WITH_CONTEXT);
|
||||
return;
|
||||
}
|
||||
|
||||
registerAction(
|
||||
AI_OPEN_WITH_CONTEXT,
|
||||
(context: unknown) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:ai:openWithContext", {
|
||||
detail: { context: typeof context === "string" ? context : "" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ permission: "ai.services.use", pluginId: "ai" },
|
||||
);
|
||||
|
||||
registerSlotContribution(TERMINAL_TOOLBAR_SLOT, {
|
||||
actionId: AI_OPEN_WITH_CONTEXT,
|
||||
titleKey: "ai.assistant",
|
||||
icon: Bot,
|
||||
kind: "button",
|
||||
pluginId: "ai",
|
||||
});
|
||||
}
|
||||
|
||||
export function applyPluginState(plugins: PluginSummary[]): void {
|
||||
const nextEnabled = new Map<string, boolean>();
|
||||
const nextDisabledTabs = new Set<string>();
|
||||
|
||||
for (const plugin of plugins) {
|
||||
nextEnabled.set(plugin.id, plugin.enabled);
|
||||
|
||||
const builtInTabs = BUILT_IN_TABS_BY_PLUGIN[plugin.id];
|
||||
if (builtInTabs) {
|
||||
if (!plugin.enabled) {
|
||||
for (const tabId of builtInTabs) nextDisabledTabs.add(tabId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plugin-registered surfaces: only removal happens here. Registration is
|
||||
// the plugin's own frontend entry being imported.
|
||||
if (!plugin.enabled) {
|
||||
for (const tab of plugin.contributes?.tabs ?? []) {
|
||||
unregisterRailItem(tab.id);
|
||||
unregisterTabComponent(tab.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pluginEnabled = nextEnabled;
|
||||
disabledTabTypes = nextDisabledTabs;
|
||||
applyFirstPartyActions(nextEnabled);
|
||||
|
||||
// The host editor's Plugins tab exists only while something fills it, so it
|
||||
// is re-synced whenever the plugin list changes rather than declared once.
|
||||
syncHostPluginsTab(plugins);
|
||||
}
|
||||
|
||||
export async function refreshPluginState(): Promise<PluginSummary[]> {
|
||||
const plugins = await getPlugins();
|
||||
applyPluginState(plugins);
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a built-in tab type may still be opened. Call sites that offer a
|
||||
* connection action (host item, command palette, quick connect) check this so
|
||||
* a disabled plugin stops offering the tab it owns.
|
||||
*/
|
||||
export function isTabTypeAvailable(tabType: string): boolean {
|
||||
return !disabledTabTypes.has(tabType);
|
||||
}
|
||||
|
||||
export function isPluginEnabled(pluginId: string): boolean {
|
||||
// Unknown means not yet loaded; assume enabled so the UI does not blank out
|
||||
// a working feature while the first request is in flight.
|
||||
return pluginEnabled.get(pluginId) ?? true;
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginState(): void {
|
||||
pluginEnabled = new Map();
|
||||
disabledTabTypes = new Set();
|
||||
unregisterSlotContribution(TERMINAL_TOOLBAR_SLOT, AI_OPEN_WITH_CONTEXT);
|
||||
unregisterAction(AI_OPEN_WITH_CONTEXT);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
Host,
|
||||
Tab,
|
||||
WorkspacePayload,
|
||||
WorkspaceTabSnapshot,
|
||||
} from "@/types/ui-types";
|
||||
import { getTabType } from "./tab-registry";
|
||||
|
||||
/**
|
||||
* Serializing and restoring the shell's arrangement: which tabs are open,
|
||||
* how they are split, and what the docks show. This is the shell's own
|
||||
* state, so it lives here; the workspaces plugin only stores and names these
|
||||
* snapshots, through app.tabs.getLayout and applyLayout.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Core tab types worth saving in a layout. "dashboard" is left out on
|
||||
* purpose: the shell always keeps one alive as the fallback tab, so it is
|
||||
* not a meaningful part of an arrangement.
|
||||
*/
|
||||
const CORE_CAPTURABLE = new Set([
|
||||
"files",
|
||||
"tunnel",
|
||||
"tmux_monitor",
|
||||
"serial",
|
||||
"homepage",
|
||||
"termix-id",
|
||||
"session-logs",
|
||||
"snippets",
|
||||
"macros",
|
||||
"history",
|
||||
"ssh-tools",
|
||||
]);
|
||||
|
||||
/** Core types reopened as singletons, with an optional preselected host. */
|
||||
const CORE_SINGLETON = new Set([
|
||||
"tunnel",
|
||||
"tmux_monitor",
|
||||
"homepage",
|
||||
"termix-id",
|
||||
"session-logs",
|
||||
"snippets",
|
||||
"macros",
|
||||
"history",
|
||||
"ssh-tools",
|
||||
]);
|
||||
|
||||
export function isCapturableTabType(type: string): boolean {
|
||||
if (CORE_CAPTURABLE.has(type)) return true;
|
||||
const def = getTabType(type);
|
||||
return !!def && def.inLayouts !== false;
|
||||
}
|
||||
|
||||
function opensAsSingleton(type: string): boolean {
|
||||
if (CORE_SINGLETON.has(type)) return true;
|
||||
const def = getTabType(type);
|
||||
return !!def && (!!def.singleton || !!def.hostless);
|
||||
}
|
||||
|
||||
function createSlotId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps live tabs to snapshots, with a fresh slotId per tab. slotId is a
|
||||
* stable key within the saved payload, distinct from Tab.id, which is
|
||||
* regenerated every time a tab opens.
|
||||
*/
|
||||
export function buildLayoutTabSnapshots(
|
||||
tabs: Tab[],
|
||||
genSlotId: () => string = createSlotId,
|
||||
): { snapshots: WorkspaceTabSnapshot[]; slotIdByTabId: Map<string, string> } {
|
||||
const capturable = tabs.filter((tab) => isCapturableTabType(tab.type));
|
||||
const slotIdByTabId = new Map(capturable.map((tab) => [tab.id, genSlotId()]));
|
||||
|
||||
const snapshots: WorkspaceTabSnapshot[] = capturable.map((tab) => ({
|
||||
slotId: slotIdByTabId.get(tab.id)!,
|
||||
type: tab.type,
|
||||
hostSyncId: tab.type === "serial" ? null : (tab.host?.syncId ?? null),
|
||||
hostNameSnapshot: tab.host?.name ?? null,
|
||||
label: tab.label,
|
||||
customLabel: tab.customLabel,
|
||||
initialFilePath: tab.initialFilePath,
|
||||
initialPath: tab.initialPath,
|
||||
data: tab.data,
|
||||
serialConfig: tab.type === "serial" ? tab.serialConfig : undefined,
|
||||
}));
|
||||
|
||||
return { snapshots, slotIdByTabId };
|
||||
}
|
||||
|
||||
/** Remaps a slotId-keyed array (paneTabIds shape) to live tab ids. */
|
||||
export function remapSlotIds(
|
||||
slotIds: (string | null)[],
|
||||
slotIdToTabId: Map<string, string>,
|
||||
): (string | null)[] {
|
||||
return slotIds.map((slotId) =>
|
||||
slotId != null ? (slotIdToTabId.get(slotId) ?? null) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's plugin payload. Payloads saved before tabs carried `data` stored
|
||||
* the fleet a fleet-inventory tab showed as a field of its own.
|
||||
*/
|
||||
export function snapshotData(
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (snapshot.data) return snapshot.data;
|
||||
return snapshot.fleetId !== undefined
|
||||
? { fleetId: snapshot.fleetId }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to reopen one saved tab. "singleton" types go through
|
||||
* openSingletonTab with an optional host; "host" types need a resolved host
|
||||
* to open at all.
|
||||
*/
|
||||
export function resolveLayoutTabTarget(
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
allHosts: Host[],
|
||||
):
|
||||
| { kind: "serial" }
|
||||
| { kind: "singleton"; host?: Host }
|
||||
| { kind: "host"; host: Host }
|
||||
| { kind: "skip" } {
|
||||
if (snapshot.type === "serial") {
|
||||
return snapshot.serialConfig ? { kind: "serial" } : { kind: "skip" };
|
||||
}
|
||||
|
||||
let host: Host | undefined;
|
||||
if (snapshot.hostSyncId) {
|
||||
host = allHosts.find((h) => h.syncId === snapshot.hostSyncId);
|
||||
if (!host) return { kind: "skip" };
|
||||
}
|
||||
|
||||
if (opensAsSingleton(snapshot.type)) {
|
||||
return { kind: "singleton", host };
|
||||
}
|
||||
|
||||
return host ? { kind: "host", host } : { kind: "skip" };
|
||||
}
|
||||
|
||||
export function buildLayoutPayload(input: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
splitMode: WorkspacePayload["splitMode"];
|
||||
paneTabIds: (string | null)[];
|
||||
rowSizes: number[];
|
||||
rowColSizes: number[][];
|
||||
genSlotId?: () => string;
|
||||
sidebar?: WorkspacePayload["sidebar"];
|
||||
}): WorkspacePayload {
|
||||
const { snapshots, slotIdByTabId } = buildLayoutTabSnapshots(
|
||||
input.tabs,
|
||||
input.genSlotId,
|
||||
);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
tabs: snapshots,
|
||||
activeSlotId: slotIdByTabId.get(input.activeTabId) ?? null,
|
||||
splitMode: input.splitMode,
|
||||
paneTabIds: input.paneTabIds.map((tabId) =>
|
||||
tabId != null ? (slotIdByTabId.get(tabId) ?? null) : null,
|
||||
),
|
||||
rowSizes: input.rowSizes,
|
||||
rowColSizes: input.rowColSizes,
|
||||
...(input.sidebar ? { sidebar: input.sidebar } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ComponentType, Ref } from "react";
|
||||
import type { Host, Tab } from "@/types/ui-types";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
import { viewOwner } from "@/plugin-host/view-ownership";
|
||||
|
||||
/**
|
||||
* The shell callbacks a registered tab may use. Same bag every tab gets, so
|
||||
* a plugin tab can do anything a core tab can without new positional props.
|
||||
*/
|
||||
export interface TabShellCallbacks {
|
||||
openTab: (
|
||||
host: Host | null,
|
||||
type: string,
|
||||
options?: {
|
||||
label?: string;
|
||||
forceNewTab?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
},
|
||||
) => void;
|
||||
openSingletonTab: (
|
||||
type: string,
|
||||
options?: { label?: string; data?: Record<string, unknown> },
|
||||
) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
renameTab: (tabId: string, label: string) => void;
|
||||
openFileInEditor: (host: Host, path: string) => void;
|
||||
openFileManager: (host: Host, path?: string) => void;
|
||||
openTerminalTab: (host: Host, path?: string) => void;
|
||||
openRailView: (id: string) => void;
|
||||
/** Closes a rail view wherever it is shown, e.g. when its feature goes away. */
|
||||
closeRailView: (id: string) => void;
|
||||
/** Saves a quick-connect tab's host. Core terminal only. */
|
||||
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TabRenderProps {
|
||||
tab: Tab;
|
||||
host?: Host;
|
||||
/** The host in the shape SSH-facing components take. */
|
||||
sshHost?: Record<string, unknown>;
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
isFocusedPane: boolean;
|
||||
handleRef: Ref<unknown>;
|
||||
shell: TabShellCallbacks;
|
||||
}
|
||||
|
||||
export interface StandaloneViewProps {
|
||||
hostId?: string;
|
||||
view: string;
|
||||
params: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface TabTypeDef {
|
||||
/** The tab type. */
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
component: ComponentType<TabRenderProps>;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
titleKey?: string;
|
||||
requiresHost?: boolean;
|
||||
noHostMessageKey?: string;
|
||||
persistent?: boolean;
|
||||
singleton?: boolean;
|
||||
session?: boolean;
|
||||
hostless?: boolean;
|
||||
restore?: (host: Host) => boolean;
|
||||
activityTypes?: string[];
|
||||
standalone?: ComponentType<StandaloneViewProps>;
|
||||
standaloneViews?: string[];
|
||||
panelFrame?: boolean;
|
||||
/** False keeps this tab out of saved layouts and workspaces. */
|
||||
inLayouts?: boolean;
|
||||
preload?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const registry = createRegistry<TabTypeDef>();
|
||||
|
||||
export const registerTabType = registry.register;
|
||||
export const unregisterTabType = registry.unregister;
|
||||
export const getTabType = registry.get;
|
||||
export const listTabTypes = registry.list;
|
||||
export const subscribeToTabTypes = registry.subscribe;
|
||||
export const useTabTypes = registry.useList;
|
||||
export const resetTabTypes = registry.reset;
|
||||
|
||||
/** Core tab types that are saved and reopened after login. */
|
||||
const CORE_PERSISTENT = new Set(["files", "tunnel"]);
|
||||
/** Core tab types that hold a live session (close confirm, refresh). */
|
||||
const CORE_SESSION = new Set(["serial"]);
|
||||
/** Core tab types that restore without a host. */
|
||||
const CORE_HOSTLESS = new Set(["dashboard", "tunnel"]);
|
||||
|
||||
/**
|
||||
* A plugin tab type nobody registered right now: its plugin is off, failed or
|
||||
* still loading. It keeps the behaviour it had, so a saved tab survives the
|
||||
* plugin being disabled instead of being dropped by the next sync.
|
||||
*/
|
||||
function isOwnedButAbsent(type: string): boolean {
|
||||
return !registry.get(type) && !!viewOwner("tab", type);
|
||||
}
|
||||
|
||||
export function isPersistentTabType(type: string): boolean {
|
||||
if (CORE_PERSISTENT.has(type)) return true;
|
||||
const def = registry.get(type);
|
||||
if (def) return !!def.persistent;
|
||||
return isOwnedButAbsent(type);
|
||||
}
|
||||
|
||||
export function isSessionTabType(type: string): boolean {
|
||||
return CORE_SESSION.has(type) || !!registry.get(type)?.session;
|
||||
}
|
||||
|
||||
export function isHostlessTabType(type: string): boolean {
|
||||
return CORE_HOSTLESS.has(type) || !!registry.get(type)?.hostless;
|
||||
}
|
||||
|
||||
/** Whether a saved tab of this type may be restored for this host. */
|
||||
export function canRestoreTabType(
|
||||
type: string,
|
||||
host: Host | undefined,
|
||||
): boolean {
|
||||
const def = registry.get(type);
|
||||
if (!host) return isHostlessTabType(type) || isOwnedButAbsent(type);
|
||||
return def?.restore ? def.restore(host) : true;
|
||||
}
|
||||
|
||||
/** The tab type a recent-activity entry opens, if a registered tab claims it. */
|
||||
export function tabTypeForActivity(
|
||||
activityType: string,
|
||||
): TabTypeDef | undefined {
|
||||
return registry
|
||||
.list()
|
||||
.find(
|
||||
(def) =>
|
||||
def.id === activityType || def.activityTypes?.includes(activityType),
|
||||
);
|
||||
}
|
||||
|
||||
/** The registered tab serving a `?view=` full-screen link. */
|
||||
export function standaloneViewFor(view: string): TabTypeDef | undefined {
|
||||
return registry
|
||||
.list()
|
||||
.find(
|
||||
(def) =>
|
||||
def.standalone &&
|
||||
(def.id === view || def.standaloneViews?.includes(view)),
|
||||
);
|
||||
}
|
||||
+72
-446
@@ -1,24 +1,16 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
Box,
|
||||
Boxes,
|
||||
Braces,
|
||||
FolderSearch,
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
LayoutPanelLeft,
|
||||
MessagesSquare,
|
||||
Monitor,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
ArrowLeftRight,
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
Usb,
|
||||
User,
|
||||
Activity,
|
||||
TerminalSquare,
|
||||
Layers, // --- tmux-monitor ---
|
||||
Clock,
|
||||
@@ -26,43 +18,25 @@ import {
|
||||
Hammer,
|
||||
Play,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Presentation,
|
||||
Workflow,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SerialHandle } from "@/features/serial/serial-types";
|
||||
import type {
|
||||
TerminalHandle,
|
||||
TerminalHostConfig,
|
||||
} from "@/features/terminal/Terminal";
|
||||
import type { GuacamoleAppHandle } from "../../../plugins/remote-desktop/src/frontend/GuacamoleApp";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { Tab, TabType, Host } from "@/types/ui-types";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
import {
|
||||
isQuickConnectHost,
|
||||
quickConnectGuacHost,
|
||||
} from "@/sidebar/quick-connect-host";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
getTabType,
|
||||
type TabShellCallbacks,
|
||||
type TabTypeDef,
|
||||
} from "./tab-registry";
|
||||
import {
|
||||
markAdaptiveResourceUsed,
|
||||
runAdaptiveBackgroundTask,
|
||||
} from "@/lib/adaptive-resource-budget";
|
||||
|
||||
// Heavy tab surfaces — keep out of the AppShell critical path.
|
||||
const CommandHistoryProvider = lazy(() =>
|
||||
import("@/features/terminal/command-history/CommandHistoryContext").then(
|
||||
(m) => ({ default: m.CommandHistoryProvider }),
|
||||
),
|
||||
);
|
||||
const loadTerminalFeature = () =>
|
||||
import("@/features/terminal/Terminal").then((m) => ({
|
||||
default: m.Terminal,
|
||||
}));
|
||||
const TerminalFeature = lazy(loadTerminalFeature);
|
||||
const CollabRoomTab = lazy(() =>
|
||||
import("@/features/collab/CollabRoomTab").then((m) => ({
|
||||
default: m.CollabRoomTab,
|
||||
@@ -73,52 +47,16 @@ const LocalTerminal = lazy(() =>
|
||||
default: m.LocalTerminal,
|
||||
})),
|
||||
);
|
||||
const MobileTerminalKeyboard = lazy(() =>
|
||||
import("@/features/terminal/MobileTerminalKeyboard").then((m) => ({
|
||||
default: m.MobileTerminalKeyboard,
|
||||
})),
|
||||
);
|
||||
const loadFileManager = () =>
|
||||
import("@/features/file-manager/FileManager").then((m) => ({
|
||||
default: m.FileManager,
|
||||
}));
|
||||
const FileManager = lazy(loadFileManager);
|
||||
const loadDockerManager = () =>
|
||||
import("../../../plugins/docker/src/frontend/DockerManager").then((m) => ({
|
||||
default: m.DockerManager,
|
||||
}));
|
||||
const DockerManager = lazy(loadDockerManager);
|
||||
const loadWebEndpointTab = () =>
|
||||
import("../../../plugins/web-endpoint/src/frontend/WebEndpointTab").then(
|
||||
(m) => ({
|
||||
default: m.WebEndpointTab,
|
||||
}),
|
||||
);
|
||||
const WebEndpointTab = lazy(loadWebEndpointTab);
|
||||
const loadHostMetricsTab = () =>
|
||||
import("../../../plugins/host-metrics/src/frontend/HostMetricsTab").then(
|
||||
(m) => ({
|
||||
default: m.HostMetricsTab,
|
||||
}),
|
||||
);
|
||||
const HostMetricsTab = lazy(loadHostMetricsTab);
|
||||
const loadProxmoxStatsTab = () =>
|
||||
import("@/features/proxmox-stats/ProxmoxStatsTab").then((m) => ({
|
||||
default: m.ProxmoxStatsTab,
|
||||
}));
|
||||
const ProxmoxStatsTab = lazy(loadProxmoxStatsTab);
|
||||
const loadTmuxMonitor = () =>
|
||||
import("@/features/tmux-monitor/TmuxMonitor").then((m) => ({
|
||||
default: m.TmuxMonitor,
|
||||
}));
|
||||
const TmuxMonitor = lazy(loadTmuxMonitor);
|
||||
const loadGuacamoleApp = () =>
|
||||
import("../../../plugins/remote-desktop/src/frontend/GuacamoleApp").then(
|
||||
(m) => ({
|
||||
default: m.default,
|
||||
}),
|
||||
);
|
||||
const GuacamoleApp = lazy(loadGuacamoleApp);
|
||||
const DashboardTab = lazy(() =>
|
||||
import("@/dashboard/DashboardTab").then((m) => ({
|
||||
default: m.DashboardTab,
|
||||
@@ -139,26 +77,11 @@ const SftpTransferTab = lazy(() =>
|
||||
default: m.SftpTransferTab,
|
||||
})),
|
||||
);
|
||||
const NetworkGraphCard = lazy(() =>
|
||||
import("../../../plugins/network-topology/src/frontend/NetworkGraphCard").then(
|
||||
(m) => ({
|
||||
default: m.NetworkGraphCard,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const Serial = lazy(() =>
|
||||
import("@/features/serial/Serial").then((m) => ({
|
||||
default: m.Serial,
|
||||
})),
|
||||
);
|
||||
const FleetInventoryTab = lazy(() =>
|
||||
import("../../../plugins/fleets/src/frontend/FleetInventoryTab").then(
|
||||
(m) => ({
|
||||
default: m.FleetInventoryTab,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Rail panels promoted to full tabs.
|
||||
const TermixIdPanel = lazy(() =>
|
||||
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
|
||||
@@ -180,72 +103,15 @@ const HistoryPanel = lazy(() =>
|
||||
const SshToolsPanel = lazy(() =>
|
||||
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
||||
);
|
||||
const AutomationsPanel = lazy(() =>
|
||||
import("../../../plugins/automations/src/frontend/AutomationsPanel").then(
|
||||
(m) => ({
|
||||
default: m.AutomationsPanel,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const AiPanel = lazy(() =>
|
||||
import("@/features/ai/AiPanel").then((m) => ({
|
||||
default: m.AiPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
const tabSurfaceLoaders: Partial<Record<TabType, () => Promise<unknown>>> = {
|
||||
terminal: loadTerminalFeature,
|
||||
files: loadFileManager,
|
||||
docker: loadDockerManager,
|
||||
"host-metrics": loadHostMetricsTab,
|
||||
"proxmox-stats": loadProxmoxStatsTab,
|
||||
tmux_monitor: loadTmuxMonitor,
|
||||
tunnel: loadTunnelTab,
|
||||
rdp: loadGuacamoleApp,
|
||||
vnc: loadGuacamoleApp,
|
||||
telnet: loadGuacamoleApp,
|
||||
};
|
||||
|
||||
/**
|
||||
* A plugin-contributed tab surface. `component` is the loader's resolved
|
||||
* default export, rendered with the same Tab it would have received from the
|
||||
* switch in renderTabContent -- plugins get the raw tab record and decide
|
||||
* for themselves what they need out of it.
|
||||
*/
|
||||
export type PluginTabComponent = React.ComponentType<{ tab: Tab }>;
|
||||
export type PluginTabLoader = () => Promise<{ default: PluginTabComponent }>;
|
||||
|
||||
interface PluginTabEntry {
|
||||
loader: PluginTabLoader;
|
||||
icon?: React.ReactNode;
|
||||
// Built lazily on first render and cached here, so every render of the same
|
||||
// plugin tab reuses one lazy component instead of remounting it each time.
|
||||
LazyComponent?: PluginTabComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime registry for plugin tab ids that don't exist in the built-in
|
||||
* TabType union. Registered once a plugin loads, looked up by tabIcon and
|
||||
* renderTabContent instead of a switch case.
|
||||
*/
|
||||
const registeredTabComponents = new Map<string, PluginTabEntry>();
|
||||
|
||||
export function registerTabComponent(
|
||||
id: string,
|
||||
loader: PluginTabLoader,
|
||||
icon?: React.ReactNode,
|
||||
): void {
|
||||
registeredTabComponents.set(id, { loader, icon });
|
||||
}
|
||||
|
||||
export function unregisterTabComponent(id: string): void {
|
||||
registeredTabComponents.delete(id);
|
||||
}
|
||||
|
||||
/** Download a likely next tab without starting a connection or mounting UI. */
|
||||
export function preloadTabSurface(type: TabType): void {
|
||||
const loader = tabSurfaceLoaders[type];
|
||||
const loader = tabSurfaceLoaders[type] ?? getTabType(type)?.preload;
|
||||
if (loader) runAdaptiveBackgroundTask("module", `tab:${type}`, loader);
|
||||
}
|
||||
|
||||
@@ -253,54 +119,6 @@ export function markTabSurfaceUsed(type: TabType): void {
|
||||
markAdaptiveResourceUsed("module", `tab:${type}`);
|
||||
}
|
||||
|
||||
function hostToSSHHost(h: Host): SSHHost {
|
||||
return {
|
||||
id: parseInt(h.id, 10),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
hasPassword: h.hasPassword,
|
||||
key: h.key,
|
||||
keyPassword: h.keyPassword,
|
||||
hasKey: h.hasKey,
|
||||
hasKeyPassword: h.hasKeyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
|
||||
terminalConfig: h.terminalConfig,
|
||||
hasSudoPassword: h.hasSudoPassword,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
|
||||
enableAiAssistant: h.enableAiAssistant ?? false,
|
||||
dockerConfig: h.dockerConfig ?? null,
|
||||
enableWebUi: h.enableWebUi ?? false,
|
||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
showDockerInSidebar: true,
|
||||
showServerStatsInSidebar: true,
|
||||
defaultPath: h.defaultPath ?? "",
|
||||
tunnelConnections: [],
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
isShared: h.isShared ?? false,
|
||||
// Carries the host's identity to a delegated backend. Without it the
|
||||
// remote side resolves our local row id against its own table.
|
||||
syncId: h.syncId ?? null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as unknown as SSHHost;
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
messageKey,
|
||||
@@ -352,20 +170,8 @@ export function tabIcon(type: TabType) {
|
||||
switch (type) {
|
||||
case "dashboard":
|
||||
return <LayoutDashboard className="size-3.5" />;
|
||||
case "terminal":
|
||||
return <Terminal className="size-3.5" />;
|
||||
case "local-terminal":
|
||||
return <TerminalSquare className="size-3.5" />;
|
||||
case "rdp":
|
||||
return <Monitor className="size-3.5" />;
|
||||
case "vnc":
|
||||
return <MousePointerClick className="size-3.5" />;
|
||||
case "telnet":
|
||||
return <MessagesSquare className="size-3.5" />;
|
||||
case "host-metrics":
|
||||
return <Server className="size-3.5" />;
|
||||
case "proxmox-stats":
|
||||
return <HardDrive className="size-3.5" />;
|
||||
case "files":
|
||||
return <FolderSearch className="size-3.5" />;
|
||||
case "host-manager":
|
||||
@@ -374,16 +180,10 @@ export function tabIcon(type: TabType) {
|
||||
return <User className="size-3.5" />;
|
||||
case "admin-settings":
|
||||
return <Settings className="size-3.5" />;
|
||||
case "docker":
|
||||
return <Box className="size-3.5" />;
|
||||
case "web-endpoint":
|
||||
return <Globe className="size-3.5" />;
|
||||
case "tunnel":
|
||||
return <Network className="size-3.5" />;
|
||||
case "sftp":
|
||||
return <ArrowLeftRight className="size-3.5" />;
|
||||
case "network_graph":
|
||||
return <Network className="size-3.5" />;
|
||||
// --- tmux-monitor ---
|
||||
case "tmux_monitor":
|
||||
return <Layers className="size-3.5" />;
|
||||
@@ -391,8 +191,6 @@ export function tabIcon(type: TabType) {
|
||||
return <Usb className="size-3.5" />;
|
||||
case "homepage":
|
||||
return <LayoutGrid className="size-3.5" />;
|
||||
case "fleet-inventory":
|
||||
return <Boxes className="size-3.5" />;
|
||||
case "collab":
|
||||
return <Presentation className="size-3.5" />;
|
||||
case "termix-id":
|
||||
@@ -407,90 +205,60 @@ export function tabIcon(type: TabType) {
|
||||
return <Clock className="size-3.5" />;
|
||||
case "ssh-tools":
|
||||
return <Hammer className="size-3.5" />;
|
||||
case "automations":
|
||||
return <Workflow className="size-3.5" />;
|
||||
case "ai":
|
||||
return <Sparkles className="size-3.5" />;
|
||||
case "split-screen":
|
||||
return <LayoutPanelLeft className="size-3.5" />;
|
||||
default:
|
||||
return registeredTabComponents.get(type)?.icon;
|
||||
default: {
|
||||
const Icon = getTabType(type)?.icon;
|
||||
return Icon ? <Icon className="size-3.5" /> : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function TerminalTabContent({
|
||||
/**
|
||||
* A plugin's tab: its registered component with the generic render props.
|
||||
* Unregistered types get the ownership placeholder in renderTabContent.
|
||||
*/
|
||||
function RegisteredTab({
|
||||
def,
|
||||
tab,
|
||||
host,
|
||||
label,
|
||||
isVisible,
|
||||
isFocusedPane,
|
||||
onCloseTab,
|
||||
onRenameTab,
|
||||
onOpenFileInEditor,
|
||||
onOpenFileManager,
|
||||
onOpenTab,
|
||||
onSaveQuickConnect,
|
||||
shell,
|
||||
}: {
|
||||
def: TabTypeDef;
|
||||
tab: Tab;
|
||||
host: Host;
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
isFocusedPane: boolean;
|
||||
onCloseTab?: (id: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onOpenFileInEditor?: (filePath: string) => void;
|
||||
onOpenFileManager?: (path?: string) => void;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onSaveQuickConnect?: () => Promise<void>;
|
||||
shell: TabShellCallbacks;
|
||||
}) {
|
||||
const { previewTerminalTheme } = useTabsSafe();
|
||||
const isMobile = useIsMobile();
|
||||
const { host, label } = tab;
|
||||
if (def.requiresHost && !host) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
messageKey={def.noHostMessageKey ?? "hosts.noHostSelected"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Component = def.component;
|
||||
const content = (
|
||||
<Component
|
||||
tab={tab}
|
||||
host={host}
|
||||
sshHost={
|
||||
host
|
||||
? (hostToSSHHost(host) as unknown as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
label={label}
|
||||
isVisible={isVisible}
|
||||
isFocusedPane={isFocusedPane}
|
||||
handleRef={tab.terminalRef as React.Ref<unknown>}
|
||||
shell={shell}
|
||||
/>
|
||||
);
|
||||
return withTabSuspense(
|
||||
<CommandHistoryProvider>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalFeature
|
||||
ref={tab.terminalRef as React.Ref<TerminalHandle>}
|
||||
hostConfig={
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
joinSharedSessionId: tab.joinSharedSessionId ?? null,
|
||||
joinShareId: tab.joinShareId ?? null,
|
||||
} as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
initialPath={tab.initialFilePath}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => onCloseTab?.(tab.id)}
|
||||
onTitleChange={
|
||||
onRenameTab && host.terminalConfig?.useSSHTitle
|
||||
? (title) => onRenameTab(tab.id, title)
|
||||
: undefined
|
||||
}
|
||||
previewTheme={previewTerminalTheme}
|
||||
onOpenFileInEditor={onOpenFileInEditor}
|
||||
onOpenFileManager={onOpenFileManager}
|
||||
isQuickConnect={host.id.startsWith("quick-connect-")}
|
||||
onSaveQuickConnect={onSaveQuickConnect}
|
||||
host={host}
|
||||
onOpenTab={onOpenTab}
|
||||
isFocusedPane={isFocusedPane}
|
||||
/>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileTerminalKeyboard
|
||||
terminalRef={
|
||||
tab.terminalRef as React.RefObject<TerminalHandle | null>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandHistoryProvider>,
|
||||
def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,64 +273,27 @@ export type PromotedPanelProps = {
|
||||
storageMode?: "local" | "cloud";
|
||||
};
|
||||
|
||||
export function renderTabContent(
|
||||
tab: Tab,
|
||||
onOpenSingletonTab?: (type: TabType) => void,
|
||||
onOpenTab?: (host: Host, type: TabType) => void,
|
||||
onCloseTab?: (id: string) => void,
|
||||
isVisible = true,
|
||||
onOpenFileInEditor?: (host: Host, filePath: string) => void,
|
||||
onOpenFileManager?: (host: Host, path?: string) => void,
|
||||
onOpenTerminalTab?: (host: Host, path?: string) => void,
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void,
|
||||
onSaveQuickConnect?: (tab: Tab, host: Host) => Promise<void>,
|
||||
isFocusedPane = true,
|
||||
panelProps?: PromotedPanelProps,
|
||||
) {
|
||||
export interface TabRenderContext {
|
||||
shell: TabShellCallbacks;
|
||||
isVisible?: boolean;
|
||||
isFocusedPane?: boolean;
|
||||
panelProps?: PromotedPanelProps;
|
||||
}
|
||||
|
||||
export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
||||
const { shell, isVisible = true, isFocusedPane = true, panelProps } = context;
|
||||
const { host, label } = tab;
|
||||
|
||||
switch (tab.type) {
|
||||
case "dashboard":
|
||||
return withTabSuspense(
|
||||
<DashboardTab
|
||||
onOpenSingletonTab={onOpenSingletonTab!}
|
||||
onOpenTab={onOpenTab!}
|
||||
onOpenSingletonTab={(type) => shell.openSingletonTab(type)}
|
||||
onOpenTab={(host, type) => shell.openTab(host, type)}
|
||||
isVisible={isVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
case "terminal":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={TerminalSquare}
|
||||
messageKey="terminal.noHostSelected"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<TerminalTabContent
|
||||
tab={tab}
|
||||
host={host}
|
||||
label={label}
|
||||
isVisible={isVisible}
|
||||
isFocusedPane={isFocusedPane}
|
||||
onCloseTab={onCloseTab}
|
||||
onRenameTab={onRenameTab}
|
||||
onOpenFileInEditor={
|
||||
onOpenFileInEditor
|
||||
? (fp) => onOpenFileInEditor(host, fp)
|
||||
: undefined
|
||||
}
|
||||
onOpenFileManager={
|
||||
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
|
||||
}
|
||||
onOpenTab={onOpenTab ? (type) => onOpenTab(host, type) : undefined}
|
||||
onSaveQuickConnect={
|
||||
onSaveQuickConnect ? () => onSaveQuickConnect(tab, host) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case "local-terminal":
|
||||
return withTabSuspense(
|
||||
<LocalTerminal instanceId={tab.instanceId} isVisible={isVisible} />,
|
||||
@@ -582,69 +313,7 @@ export function renderTabContent(
|
||||
initialFilePath={tab.initialFilePath}
|
||||
initialPath={tab.initialPath}
|
||||
isVisible={isVisible}
|
||||
onOpenTerminalTab={
|
||||
onOpenTerminalTab
|
||||
? (path) => onOpenTerminalTab(host, path)
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
case "web-endpoint":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState icon={Globe} messageKey="webEndpoint.noHostSelected" />
|
||||
);
|
||||
// Passed the endpoint ID, not the endpoint object: the tab resolves it
|
||||
// against the host on every render, so an endpoint deleted while its tab
|
||||
// is open shows a plain message instead of throwing.
|
||||
return withTabSuspense(
|
||||
<WebEndpointTab host={host} endpointId={tab.endpointId} />,
|
||||
);
|
||||
|
||||
case "docker":
|
||||
if (!host)
|
||||
return <EmptyState icon={Box} messageKey="docker.noHostSelected" />;
|
||||
return withTabSuspense(
|
||||
<DockerManager
|
||||
hostConfig={hostToSSHHost(host)}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
case "host-metrics":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState icon={Activity} messageKey="hostMetrics.noHostSelected" />
|
||||
);
|
||||
return withTabSuspense(
|
||||
<HostMetricsTab
|
||||
hostConfig={hostToSSHHost(host)}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
case "proxmox-stats":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={HardDrive}
|
||||
messageKey="proxmoxStats.noHostSelected"
|
||||
/>
|
||||
);
|
||||
return withTabSuspense(
|
||||
<ProxmoxStatsTab
|
||||
hostConfig={hostToSSHHost(host)}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
onOpenTerminalTab={(path) => shell.openTerminalTab(host, path)}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -656,34 +325,6 @@ export function renderTabContent(
|
||||
case "sftp":
|
||||
return withTabSuspense(<SftpTransferTab />);
|
||||
|
||||
case "rdp":
|
||||
case "vnc":
|
||||
case "telnet":
|
||||
if (!host)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Monitor}
|
||||
messageKey="remoteDesktop.noHostSelected"
|
||||
/>
|
||||
);
|
||||
return withTabSuspense(
|
||||
<GuacamoleApp
|
||||
ref={tab.terminalRef as React.Ref<GuacamoleAppHandle>}
|
||||
hostId={host.id}
|
||||
tabId={tab.id}
|
||||
protocol={tab.type as "rdp" | "vnc" | "telnet"}
|
||||
isVisible={isVisible}
|
||||
quickConnectHost={
|
||||
isQuickConnectHost(host) ? quickConnectGuacHost(host) : undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
case "network_graph":
|
||||
return withTabSuspense(
|
||||
<NetworkGraphCard embedded={false} isVisible={isVisible} />,
|
||||
);
|
||||
|
||||
// --- tmux-monitor ---
|
||||
case "tmux_monitor":
|
||||
return withTabSuspense(
|
||||
@@ -708,11 +349,6 @@ export function renderTabContent(
|
||||
case "homepage":
|
||||
return withTabSuspense(<HomepageCanvas />);
|
||||
|
||||
case "fleet-inventory":
|
||||
return withTabSuspense(
|
||||
<FleetInventoryTab fleetId={tab.fleetId} isVisible={isVisible} />,
|
||||
);
|
||||
|
||||
case "collab":
|
||||
return withTabSuspense(
|
||||
<CollabRoomTab roomId={tab.collabRoomId} isVisible={isVisible} />,
|
||||
@@ -732,20 +368,6 @@ export function renderTabContent(
|
||||
</PanelTabFrame>,
|
||||
);
|
||||
|
||||
case "automations":
|
||||
return withTabSuspense(
|
||||
<PanelTabFrame>
|
||||
<AutomationsPanel active={isVisible} />
|
||||
</PanelTabFrame>,
|
||||
);
|
||||
|
||||
case "ai":
|
||||
return withTabSuspense(
|
||||
<PanelTabFrame>
|
||||
<AiPanel />
|
||||
</PanelTabFrame>,
|
||||
);
|
||||
|
||||
case "split-screen":
|
||||
return null;
|
||||
|
||||
@@ -797,15 +419,19 @@ export function renderTabContent(
|
||||
return null;
|
||||
|
||||
default: {
|
||||
const entry = registeredTabComponents.get(tab.type);
|
||||
if (!entry) return null;
|
||||
if (!entry.LazyComponent) {
|
||||
entry.LazyComponent = lazy(() =>
|
||||
entry.loader().then((m) => ({ default: m.default })),
|
||||
) as unknown as PluginTabComponent;
|
||||
const def = getTabType(tab.type);
|
||||
if (!def) {
|
||||
return <PluginViewPlaceholder kind="tab" viewId={tab.type} />;
|
||||
}
|
||||
const PluginTab = entry.LazyComponent;
|
||||
return withTabSuspense(<PluginTab tab={tab} />);
|
||||
return (
|
||||
<RegisteredTab
|
||||
def={def}
|
||||
tab={tab}
|
||||
isVisible={isVisible}
|
||||
isFocusedPane={isFocusedPane}
|
||||
shell={shell}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user