Files
Termix/src/ui/shell/tabUtils.tsx
T

241 lines
6.3 KiB
TypeScript

/* eslint-disable react-refresh/only-export-components */
import { LayoutPanelLeft, Puzzle, Server, Settings } from "lucide-react";
import { lazy, memo, Suspense } from "react";
import { useTranslation } from "react-i18next";
import type { Tab, TabType } from "@/types/ui-types";
import { trackTabHandle } from "@/shell/tab-handles";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
import {
getTabType,
type TabShellCallbacks,
type TabTypeDef,
} from "./tab-registry";
import { getPanel, type PanelDef } from "./panel-registry";
import { CompanionFrame } from "./CompanionFrame";
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
// Heavy tab surfaces, kept out of the AppShell critical path.
const PluginsTab = lazy(() =>
import("@/plugins/PluginsTab").then((m) => ({ default: m.PluginsTab })),
);
/** Download a likely next tab without starting a connection or mounting UI. */
export function preloadTabSurface(type: TabType): void {
const loader = getTabType(type)?.preload;
if (loader) runAdaptiveBackgroundTask("module", `tab:${type}`, loader);
}
export function markTabSurfaceUsed(type: TabType): void {
markAdaptiveResourceUsed("module", `tab:${type}`);
}
function EmptyState({
icon: Icon,
messageKey,
}: {
icon: React.ElementType;
messageKey: string;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Icon className="size-5 text-muted-foreground/30" />
</div>
<span className="text-sm font-semibold text-muted-foreground/60">
{t(messageKey)}
</span>
</div>
);
}
function TabChunkFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
function withTabSuspense(node: React.ReactNode) {
return <Suspense fallback={<TabChunkFallback />}>{node}</Suspense>;
}
/**
* Host frame for rail panels opened as tabs. Panels expect a full-height flex
* column like the sidebar gives them. The max width keeps forms readable on a
* wide monitor instead of stretching them edge to edge.
*/
function PanelTabFrame({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full w-full justify-center overflow-y-auto bg-background">
<div className="flex flex-col flex-1 min-h-0 w-full max-w-5xl">
{children}
</div>
</div>
);
}
export function tabIcon(type: TabType) {
switch (type) {
case "user-profile":
case "admin-settings":
return <Settings className="size-3.5" />;
case "split-screen":
return <LayoutPanelLeft className="size-3.5" />;
case "plugins":
return <Puzzle className="size-3.5" />;
default: {
const Icon = getTabType(type)?.icon;
return Icon ? <Icon className="size-3.5" /> : null;
}
}
}
/**
* A plugin's tab: its registered component with the generic render props.
* Unregistered types get the ownership placeholder in renderTabContent.
*/
const RegisteredTab = memo(function RegisteredTab({
def,
tab,
isVisible,
isFocusedPane,
inSplit,
shell,
}: {
def: TabTypeDef;
tab: Tab;
isVisible: boolean;
isFocusedPane: boolean;
inSplit: boolean;
shell: TabShellCallbacks;
}) {
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}
inSplit={inSplit}
handleRef={trackTabHandle(
tab.terminalRef as React.RefObject<unknown> | undefined,
)}
shell={shell}
/>
);
return withTabSuspense(
<CompanionFrame tab={tab} isVisible={isVisible}>
{def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content}
</CompanionFrame>,
);
});
/** A rail panel opened as a tab when its plugin registers no tab of its own. */
const PanelAsTab = memo(function PanelAsTab({
panel,
isVisible,
targetTab,
shell,
}: {
panel: PanelDef;
isVisible: boolean;
targetTab?: Tab;
shell: TabShellCallbacks;
}) {
const Panel = panel.component;
return withTabSuspense(
<PanelTabFrame>
<Panel
targetTab={targetTab}
active={isVisible}
shell={shell}
setEditing={noop}
placement="tab"
/>
</PanelTabFrame>,
);
});
function noop() {}
export interface TabRenderContext {
shell: TabShellCallbacks;
/** The terminal a panel shown as a tab sends commands to. */
panelTargetTab?: Tab;
isVisible?: boolean;
isFocusedPane?: boolean;
inSplit?: boolean;
}
export function renderTabContent(tab: Tab, context: TabRenderContext) {
const {
shell,
isVisible = true,
isFocusedPane = true,
inSplit = false,
} = context;
switch (tab.type) {
case "split-screen":
return null;
case "plugins":
return withTabSuspense(<PluginsTab />);
case "user-profile":
case "admin-settings":
return null;
default: {
const def = getTabType(tab.type);
// The home tab is empty until the layout gives it a tab type.
if (!def && tab.type === "dashboard") return null;
if (!def) {
const panel = getPanel(tab.type);
if (panel) {
return (
<PanelAsTab
panel={panel}
isVisible={isVisible}
targetTab={context.panelTargetTab}
shell={shell}
/>
);
}
return <PluginViewPlaceholder kind="tab" viewId={tab.type} />;
}
return (
<RegisteredTab
def={def}
tab={tab}
isVisible={isVisible}
isFocusedPane={isFocusedPane}
inSplit={inSplit}
shell={shell}
/>
);
}
}
}