fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

This commit is contained in:
LukeGus committed 2026-10-01 13:14:11 -05:00
1 parent 3b58415f8f
commit 30da0850da
13 files changed
+364 -101

No files matched your search

+125 -49
View File
@@ -24,6 +24,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
createRef, createRef,
lazy, lazy,
Suspense, Suspense,
@@ -1149,13 +1150,20 @@ export function AppShell({
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
useEffect(() => { useEffect(() => {
if (allHosts.length === 0) return; if (allHosts.length === 0) return;
setTabs((prev) => const byId = new Map(allHosts.map((h) => [h.id, h]));
prev.map((t) => setTabs((prev) => {
t.host let changed = false;
? { ...t, host: allHosts.find((h) => h.id === t.host!.id) ?? t.host } const next = prev.map((t) => {
: t, const fresh = t.host ? byId.get(t.host.id) : undefined;
), if (!fresh || fresh === t.host) return t;
); // A reload hands back new objects; only replace hosts that changed so
// untouched tabs keep their identity and skip a re-render.
if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t;
changed = true;
return { ...t, host: fresh };
});
return changed ? next : prev;
});
}, [allHosts]); }, [allHosts]);
function buildWorkspacePayload(): WorkspacePayload { function buildWorkspacePayload(): WorkspacePayload {
@@ -1197,13 +1205,13 @@ export function AppShell({
} }
if (target.kind === "singleton") { if (target.kind === "singleton") {
openSingletonTab( const newTabId = openSingletonTab(
snapshot.type, snapshot.type,
undefined, undefined,
target.host, target.host,
snapshotData(snapshot), snapshotData(snapshot),
); );
slotIdToNewTabId.set(snapshot.slotId, snapshot.type); slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type);
continue; continue;
} }
@@ -1500,8 +1508,10 @@ export function AppShell({
return existing.id; return existing.id;
} }
} }
const tabId = `${host.name}-${type}-${Date.now()}`;
const instanceId = restore?.instanceId ?? createId(); const instanceId = restore?.instanceId ?? createId();
// Unique per open; a timestamp collides when a workspace opens several
// tabs in the same millisecond.
const tabId = `${type}-${createId()}`;
const openedAt = Date.now(); const openedAt = Date.now();
const ref = isSessionTabType(type) ? createRef() : undefined; const ref = isSessionTabType(type) ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref); if (ref) terminalRefs.current.set(tabId, ref);
@@ -1612,11 +1622,14 @@ export function AppShell({
); );
/** A tab type that opens a fresh tab every time (a local shell). */ /** A tab type that opens a fresh tab every time (a local shell). */
function openMultiInstanceTab(type: TabType): string { function openMultiInstanceTab(
type: TabType,
options?: { host?: Host; data?: Record<string, unknown>; label?: string },
): string {
const instanceId = createId(); const instanceId = createId();
const id = `${type}-${instanceId}`; const id = `${type}-${instanceId}`;
const titleKey = getTabType(type)?.titleKey; const titleKey = getTabType(type)?.titleKey;
const title = titleKey ? t(titleKey) : type; const title = options?.label ?? (titleKey ? t(titleKey) : type);
setTabs((current) => { setTabs((current) => {
const count = current.filter((tab) => tab.type === type).length; const count = current.filter((tab) => tab.type === type).length;
return [ return [
@@ -1625,8 +1638,11 @@ export function AppShell({
id, id,
instanceId, instanceId,
type, type,
label: count === 0 ? title : `${title} (${count + 1})`, label:
count === 0 || options?.label ? title : `${title} (${count + 1})`,
openedAt: Date.now(), openedAt: Date.now(),
...(options?.host ? { host: options.host } : {}),
...(options?.data !== undefined ? { data: options.data } : {}),
}, },
]; ];
}); });
@@ -1641,10 +1657,11 @@ export function AppShell({
pendingEvent?: string, pendingEvent?: string,
host?: Host, host?: Host,
data?: Record<string, unknown>, data?: Record<string, unknown>,
) { tabLabel?: string,
): string | undefined {
// A local shell is never a singleton: each open is its own session. // A local shell is never a singleton: each open is its own session.
if (getTabType(type)?.multiInstance) { if (getTabType(type)?.multiInstance) {
return openMultiInstanceTab(type); return openMultiInstanceTab(type, { host, data, label: tabLabel });
} }
if (type === "host-manager") { if (type === "host-manager") {
if (pendingEvent === "host-manager:add-credential") { if (pendingEvent === "host-manager:add-credential") {
@@ -1726,6 +1743,7 @@ export function AppShell({
tabOrder: 0, tabOrder: 0,
}).catch(() => {}); }).catch(() => {});
} }
return id;
}, },
[t], [t],
); );
@@ -1830,27 +1848,21 @@ export function AppShell({
if (tab && confirmEnabled && isActiveConnectionTab(tab)) { if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
const closeLabel = getTabCloseLabel(tab); const closeLabel = getTabCloseLabel(tab);
const toastId = `close-tab-${id}`; const toastId = `close-tab-${id}`;
toast( toast(t("nav.confirmCloseHost", { host: closeLabel }), {
t("nav.confirmCloseHost", { id: toastId,
host: closeLabel, duration: 8000,
defaultValue: `Close ${closeLabel}?`, action: {
}), label: t("nav.close"),
{ onClick: () => {
id: toastId, toast.dismiss(toastId);
duration: 8000, doCloseTab(id);
action: {
label: t("nav.close"),
onClick: () => {
toast.dismiss(toastId);
doCloseTab(id);
},
},
cancel: {
label: t("nav.cancel"),
onClick: () => toast.dismiss(toastId),
}, },
}, },
); cancel: {
label: t("nav.cancel"),
onClick: () => toast.dismiss(toastId),
},
});
return; return;
} }
@@ -2279,12 +2291,24 @@ export function AppShell({
const startW = sidebarWidth; const startW = sidebarWidth;
// Widths are kept in Normal-size pixels, so a drag is scaled back. // Widths are kept in Normal-size pixels, so a drag is scaled back.
const scale = remScale(); const scale = remScale();
// One width update per frame, not per mousemove.
let frame = 0;
let clientX = startX;
function onMove(ev: MouseEvent) { function onMove(ev: MouseEvent) {
setSidebarWidth( clientX = ev.clientX;
Math.max(160, Math.min(480, startW + (ev.clientX - startX) / scale)), if (frame) return;
); frame = requestAnimationFrame(() => {
frame = 0;
setSidebarWidth(
Math.max(160, Math.min(480, startW + (clientX - startX) / scale)),
);
});
} }
function onUp() { function onUp() {
cancelAnimationFrame(frame);
setSidebarWidth(
Math.max(160, Math.min(480, startW + (clientX - startX) / scale)),
);
setSidebarDragging(false); setSidebarDragging(false);
window.removeEventListener("mousemove", onMove); window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp); window.removeEventListener("mouseup", onUp);
@@ -2303,12 +2327,23 @@ export function AppShell({
const startX = e.clientX; const startX = e.clientX;
const startW = rightSidebarWidth; const startW = rightSidebarWidth;
const scale = remScale(); const scale = remScale();
let frame = 0;
let clientX = startX;
function onMove(ev: MouseEvent) { function onMove(ev: MouseEvent) {
setRightSidebarWidth( clientX = ev.clientX;
Math.max(160, Math.min(480, startW - (ev.clientX - startX) / scale)), if (frame) return;
); frame = requestAnimationFrame(() => {
frame = 0;
setRightSidebarWidth(
Math.max(160, Math.min(480, startW - (clientX - startX) / scale)),
);
});
} }
function onUp() { function onUp() {
cancelAnimationFrame(frame);
setRightSidebarWidth(
Math.max(160, Math.min(480, startW - (clientX - startX) / scale)),
);
setRightSidebarDragging(false); setRightSidebarDragging(false);
window.removeEventListener("mousemove", onMove); window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp); window.removeEventListener("mouseup", onUp);
@@ -2350,6 +2385,9 @@ export function AppShell({
: ""; : "";
useEffect(() => { useEffect(() => {
// Refitting every terminal on each drag frame is what made sidebar drags
// stutter; fit once when the drag ends instead.
if (sidebarDragging || rightSidebarDragging) return;
const id = resizeAllTerminals(); const id = resizeAllTerminals();
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [ }, [
@@ -2358,6 +2396,8 @@ export function AppShell({
sidebarOpen, sidebarOpen,
rightSidebarWidth, rightSidebarWidth,
rightRailView, rightRailView,
sidebarDragging,
rightSidebarDragging,
]); ]);
// A tab inside a split is never shown on its own: activating one (tab jump, // A tab inside a split is never shown on its own: activating one (tab jump,
@@ -2543,10 +2583,16 @@ export function AppShell({
* What plugins may ask of the shell. Rebuilt every render so it always * What plugins may ask of the shell. Rebuilt every render so it always
* closes over current state, and published to the plugin runtime. * closes over current state, and published to the plugin runtime.
*/ */
const shellCallbacks: TabShellCallbacks = { const shellCallbacksImpl: TabShellCallbacks = {
openTab: (host, type, options) => { openTab: (host, type, options) => {
if (!host || getTabType(type)?.singleton) { if (!host || getTabType(type)?.singleton) {
openSingletonTab(type, undefined, host ?? undefined, options?.data); openSingletonTab(
type,
undefined,
host ?? undefined,
options?.data,
options?.label,
);
return; return;
} }
openTab(host, type, undefined, options); openTab(host, type, undefined, options);
@@ -2579,15 +2625,42 @@ export function AppShell({
saveQuickConnect: saveQuickConnectHost, saveQuickConnect: saveQuickConnectHost,
}; };
// Tabs get one stable bag that forwards to the latest callbacks, so a shell
// render does not re-render every open tab.
const shellImplRef = useRef(shellCallbacksImpl);
useLayoutEffect(() => {
shellImplRef.current = shellCallbacksImpl;
});
const shellCallbacks = useMemo<TabShellCallbacks>(
() => ({
openTab: (...args) => shellImplRef.current.openTab(...args),
openSingletonTab: (...args) =>
shellImplRef.current.openSingletonTab(...args),
connectHost: (...args) => shellImplRef.current.connectHost(...args),
closeTab: (...args) => shellImplRef.current.closeTab(...args),
renameTab: (...args) => shellImplRef.current.renameTab(...args),
openRailView: (...args) => shellImplRef.current.openRailView(...args),
closeRailView: (...args) => shellImplRef.current.closeRailView(...args),
openHostEditor: (...args) =>
shellImplRef.current.openHostEditor?.(...args),
saveQuickConnect: (...args) =>
shellImplRef.current.saveQuickConnect!(...args),
}),
[],
);
// Panels close the sidebar on mobile after opening something, the same as // Panels close the sidebar on mobile after opening something, the same as
// the hosts panel does. // the hosts panel does.
const panelShell: TabShellCallbacks = { const panelShell = useMemo<TabShellCallbacks>(
...shellCallbacks, () => ({
openTab: (...args) => { ...shellCallbacks,
shellCallbacks.openTab(...args); openTab: (...args) => {
if (isMobile) setSidebarOpen(false); shellCallbacks.openTab(...args);
}, if (isMobile) setSidebarOpen(false);
}; },
}),
[shellCallbacks, isMobile],
);
useEffect(() => { useEffect(() => {
setShellCallbacks(shellCallbacks); setShellCallbacks(shellCallbacks);
@@ -3075,6 +3148,9 @@ export function AppShell({
return createPortal( return createPortal(
renderTabContent(tab, { renderTabContent(tab, {
shell: shellCallbacks, shell: shellCallbacks,
panelTargetTab: terminalTabs.find(
(t) => t.id === targetTerminalTabId,
),
isVisible: inPane || activeInline, isVisible: inPane || activeInline,
isFocusedPane, isFocusedPane,
inSplit: inPane, inSplit: inPane,
+1 -4
View File
@@ -1685,10 +1685,7 @@ export function Auth({ onLogin }: AuthProps) {
id="reg-pass" id="reg-pass"
value={password} value={password}
onChange={setPassword} onChange={setPassword}
placeholder={t("auth.minChars", { placeholder={t("auth.minChars", { min: 6 })}
min: 6,
defaultValue: "min. 6 characters",
})}
disabled={loading} disabled={loading}
/> />
</Field> </Field>
+28 -23
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols"; import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
import { ComponentSlot } from "@/shell/ActionSlot"; import { ComponentSlot } from "@/shell/ActionSlot";
import { useActionSlot } from "@/hooks/use-action-slot"; import { useActionSlot } from "@/hooks/use-action-slot";
@@ -57,7 +57,7 @@ import {
useStatusColorScheme, useStatusColorScheme,
getStatusClasses, getStatusClasses,
} from "@/hooks/use-status-color-scheme"; } from "@/hooks/use-status-color-scheme";
import { useServerStatus } from "@/lib/ServerStatusContext"; import { useServerStatusMeta } from "@/lib/ServerStatusContext";
import { sshHostToHost } from "@/sidebar/HostManagerData"; import { sshHostToHost } from "@/sidebar/HostManagerData";
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs"; import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
@@ -137,6 +137,13 @@ export function PluginCardSlot({
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) { }) {
useRegisteredDashboardCards(); useRegisteredDashboardCards();
const cardShell = useMemo(
() => ({
...shell,
openSingletonTab: (type: string) => onOpenSingletonTab(type as TabType),
}),
[onOpenSingletonTab],
);
const card = getRegisteredDashboardCard(id); const card = getRegisteredDashboardCard(id);
if (!card) { if (!card) {
return ( return (
@@ -146,15 +153,7 @@ export function PluginCardSlot({
); );
} }
const Component = card.component; const Component = card.component;
return ( return <Component isVisible={isVisible} shell={cardShell} />;
<Component
isVisible={isVisible}
shell={{
...shell,
openSingletonTab: (type) => onOpenSingletonTab(type),
}}
/>
);
} }
// ─── Card components ────────────────────────────────────────────────────────── // ─── Card components ──────────────────────────────────────────────────────────
@@ -213,9 +212,7 @@ function StatsBarCard({
</div> </div>
<div className="flex flex-col justify-center px-4 py-2 gap-1"> <div className="flex flex-col justify-center px-4 py-2 gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("dashboardTab.hostsAvailable", { {t("dashboardTab.hostsAvailable")}
defaultValue: "Hosts Available",
})}
</span> </span>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-xl font-bold leading-none">{online}</span> <span className="text-xl font-bold leading-none">{online}</span>
@@ -1011,7 +1008,7 @@ export function DashboardTab({
}) { }) {
const registeredCards = useRegisteredDashboardCards(); const registeredCards = useRegisteredDashboardCards();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { initialLoadComplete } = useServerStatus(); const { initialLoadComplete } = useServerStatusMeta();
const statusLoading = !initialLoadComplete; const statusLoading = !initialLoadComplete;
const [slots, setSlots] = useState<CardSlot[]>(() => { const [slots, setSlots] = useState<CardSlot[]>(() => {
@@ -1228,15 +1225,23 @@ export function DashboardTab({
e.preventDefault(); e.preventDefault();
const startX = e.clientX; const startX = e.clientX;
const startPct = mainWidthPct; const startPct = mainWidthPct;
const totalW = bodyRef.current?.getBoundingClientRect().width ?? 0;
if (!totalW) return;
// One update per frame, not per mousemove.
let frame = 0;
let clientX = startX;
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
if (!bodyRef.current) return; clientX = ev.clientX;
const totalW = bodyRef.current.getBoundingClientRect().width; if (frame) return;
setMainWidthPct( frame = requestAnimationFrame(() => {
Math.min( frame = 0;
85, setMainWidthPct(
Math.max(25, startPct + ((ev.clientX - startX) / totalW) * 100), Math.min(
), 85,
); Math.max(25, startPct + ((clientX - startX) / totalW) * 100),
),
);
});
}; };
const onUp = () => { const onUp = () => {
window.removeEventListener("mousemove", onMove); window.removeEventListener("mousemove", onMove);
+10 -4
View File
@@ -306,7 +306,10 @@
"fullscreenFailed": "Could not toggle fullscreen", "fullscreenFailed": "Could not toggle fullscreen",
"resetSidebarWidth": "Reset width", "resetSidebarWidth": "Reset width",
"collapseSidebar": "Collapse sidebar", "collapseSidebar": "Collapse sidebar",
"openSidebar": "Open sidebar" "openSidebar": "Open sidebar",
"confirmCloseHost": "Close {{host}}?",
"enterFullscreen": "Enter fullscreen (Ctrl+Shift+F)",
"exitFullscreen": "Exit fullscreen (Ctrl+Shift+F)"
}, },
"hosts": { "hosts": {
"hosts": "Hosts", "hosts": "Hosts",
@@ -1054,7 +1057,8 @@
"password": "Password", "password": "Password",
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.", "secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.", "secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
"serverAuthentication": "Server sign-in" "serverAuthentication": "Server sign-in",
"minChars": "min. {{min}} characters"
}, },
"hostKey": { "hostKey": {
"verifyNewHost": "Verify SSH Host Key", "verifyNewHost": "Verify SSH Host Key",
@@ -1188,7 +1192,8 @@
"empty": "Empty", "empty": "Empty",
"clear": "Clear", "clear": "Clear",
"viewServerDetails": "View server details", "viewServerDetails": "View server details",
"dragToResizeColumns": "Drag to resize columns" "dragToResizeColumns": "Drag to resize columns",
"hostsAvailable": "Hosts Available"
}, },
"admin": { "admin": {
"sectionGeneral": "General", "sectionGeneral": "General",
@@ -1495,7 +1500,8 @@
"sslRenewedBy": "Renewed automatically by {{plugin}}", "sslRenewedBy": "Renewed automatically by {{plugin}}",
"sslIssuer": "Issued by {{issuer}}", "sslIssuer": "Issued by {{issuer}}",
"sslNames": "Covers {{names}}", "sslNames": "Covers {{names}}",
"sslManualTitle": "Upload a certificate" "sslManualTitle": "Upload a certificate",
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting"
}, },
"newUi": { "newUi": {
"sidebar": { "sidebar": {
+17 -2
View File
@@ -263,12 +263,27 @@ function manifestFor(summary: PluginSummary): PluginManifest {
}; };
} }
const loadedNamespaceVersions = new Map<string, string>();
async function loadNamespaces(summaries: PluginSummary[]): Promise<void> { async function loadNamespaces(summaries: PluginSummary[]): Promise<void> {
const ids = summaries.map((summary) => summary.id); // Only a plugin that is new or was upgraded while the app was open needs
// its strings fetched again.
const ids = summaries
.filter(
(summary) =>
loadedNamespaceVersions.get(summary.id) !== namespaceVersion(summary),
)
.map((summary) => summary.id);
if (ids.length === 0) return; if (ids.length === 0) return;
// Reloading picks up a plugin that was upgraded while the app was open.
await i18n.reloadResources(undefined, ids).catch(() => {}); await i18n.reloadResources(undefined, ids).catch(() => {});
await i18n.loadNamespaces(ids).catch(() => {}); await i18n.loadNamespaces(ids).catch(() => {});
for (const summary of summaries) {
loadedNamespaceVersions.set(summary.id, namespaceVersion(summary));
}
}
function namespaceVersion(summary: PluginSummary): string {
return `${summary.version}|${summary.assetVersion ?? ""}`;
} }
async function reconcile(summaries: PluginSummary[]): Promise<void> { async function reconcile(summaries: PluginSummary[]): Promise<void> {
+12
View File
@@ -60,14 +60,26 @@ export function usePluginStore(): StoreState {
/** Replaces the summaries, keeping each plugin's frontend state. */ /** Replaces the summaries, keeping each plugin's frontend state. */
export function setPluginSummaries(summaries: PluginSummary[]): void { export function setPluginSummaries(summaries: PluginSummary[]): void {
const records = new Map<string, PluginRecord>(); const records = new Map<string, PluginRecord>();
let changed = !state.loaded || summaries.length !== state.records.size;
for (const summary of summaries) { for (const summary of summaries) {
const previous = state.records.get(summary.id); const previous = state.records.get(summary.id);
// A periodic re-sync usually returns the same list; keeping the old
// records lets subscribers skip a re-render.
if (
previous &&
JSON.stringify(previous.summary) === JSON.stringify(summary)
) {
records.set(summary.id, previous);
continue;
}
changed = true;
records.set(summary.id, { records.set(summary.id, {
summary, summary,
frontend: previous?.frontend ?? "none", frontend: previous?.frontend ?? "none",
error: previous?.error, error: previous?.error,
}); });
} }
if (!changed) return;
commit({ ...state, loaded: true, records }); commit({ ...state, loaded: true, records });
} }
+5 -3
View File
@@ -651,11 +651,13 @@ export function TabBar({
className="h-full w-12.5 rounded-none border-y-0 border-border text-muted-foreground hover:text-foreground" className="h-full w-12.5 rounded-none border-y-0 border-border text-muted-foreground hover:text-foreground"
title={ title={
isAppFullscreen isAppFullscreen
? "Exit fullscreen (Ctrl+Shift+F)" ? t("nav.exitFullscreen")
: "Enter fullscreen (Ctrl+Shift+F)" : t("nav.enterFullscreen")
} }
aria-label={ aria-label={
isAppFullscreen ? "Exit fullscreen" : "Enter fullscreen" isAppFullscreen
? t("nav.exitFullscreen")
: t("nav.enterFullscreen")
} }
onClick={onToggleAppFullscreen} onClick={onToggleAppFullscreen}
> >
+5 -3
View File
@@ -42,9 +42,11 @@ export function isCapturableTabType(type: string): boolean {
return !!def && def.inLayouts !== false; return !!def && def.inLayouts !== false;
} }
function opensAsSingleton(type: string): boolean { // A hostless type opened for a host (a tunnel tab) is one tab per host, the
// same as when the shell opens it live.
function opensAsSingleton(type: string, hasHost: boolean): boolean {
const def = getTabType(type); const def = getTabType(type);
return !!def && (!!def.singleton || !!def.hostless); return !!def && (!!def.singleton || (!!def.hostless && !hasHost));
} }
/** /**
@@ -107,7 +109,7 @@ export function resolveLayoutTabTarget(
if (!host) return { kind: "skip" }; if (!host) return { kind: "skip" };
} }
if (opensAsSingleton(snapshot.type)) { if (opensAsSingleton(snapshot.type, !!host)) {
return { kind: "singleton", host }; return { kind: "singleton", host };
} }
+62 -10
View File
@@ -6,7 +6,7 @@ import {
Settings, Settings,
User, User,
} from "lucide-react"; } from "lucide-react";
import { lazy, Suspense } from "react"; import { lazy, memo, Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Tab, TabType } from "@/types/ui-types"; import type { Tab, TabType } from "@/types/ui-types";
import { hostToSSHHost } from "@/lib/host-to-ssh-host"; import { hostToSSHHost } from "@/lib/host-to-ssh-host";
@@ -16,6 +16,7 @@ import {
type TabShellCallbacks, type TabShellCallbacks,
type TabTypeDef, type TabTypeDef,
} from "./tab-registry"; } from "./tab-registry";
import { getPanel, type PanelDef } from "./panel-registry";
import { import {
markAdaptiveResourceUsed, markAdaptiveResourceUsed,
runAdaptiveBackgroundTask, runAdaptiveBackgroundTask,
@@ -108,7 +109,7 @@ export function tabIcon(type: TabType) {
* A plugin's tab: its registered component with the generic render props. * A plugin's tab: its registered component with the generic render props.
* Unregistered types get the ownership placeholder in renderTabContent. * Unregistered types get the ownership placeholder in renderTabContent.
*/ */
function RegisteredTab({ const RegisteredTab = memo(function RegisteredTab({
def, def,
tab, tab,
isVisible, isVisible,
@@ -153,10 +154,56 @@ function RegisteredTab({
return withTabSuspense( return withTabSuspense(
def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content, def.panelFrame ? <PanelTabFrame>{content}</PanelTabFrame> : content,
); );
} });
const DashboardTabHost = memo(function DashboardTabHost({
shell,
isVisible,
}: {
shell: TabShellCallbacks;
isVisible: boolean;
}) {
return withTabSuspense(
<DashboardTab
onOpenSingletonTab={(type) => shell.openSingletonTab(type)}
onOpenTab={(host, type) => shell.openTab(host, type)}
isVisible={isVisible}
/>,
);
});
/** 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 { export interface TabRenderContext {
shell: TabShellCallbacks; shell: TabShellCallbacks;
/** The terminal a panel shown as a tab sends commands to. */
panelTargetTab?: Tab;
isVisible?: boolean; isVisible?: boolean;
isFocusedPane?: boolean; isFocusedPane?: boolean;
inSplit?: boolean; inSplit?: boolean;
@@ -172,13 +219,7 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
switch (tab.type) { switch (tab.type) {
case "dashboard": case "dashboard":
return withTabSuspense( return <DashboardTabHost shell={shell} isVisible={isVisible} />;
<DashboardTab
onOpenSingletonTab={(type) => shell.openSingletonTab(type)}
onOpenTab={(host, type) => shell.openTab(host, type)}
isVisible={isVisible}
/>,
);
case "split-screen": case "split-screen":
return null; return null;
@@ -191,6 +232,17 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
default: { default: {
const def = getTabType(tab.type); const def = getTabType(tab.type);
if (!def) { 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 <PluginViewPlaceholder kind="tab" viewId={tab.type} />;
} }
return ( return (
+6 -2
View File
@@ -13,6 +13,8 @@ import { usePermissions } from "@/hooks/use-permissions";
import { useSyncAttentionCount } from "@/hooks/use-sync-status"; import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry"; import { createRegistry } from "@/lib/registry";
import { getPanel } from "@/shell/panel-registry";
import { getTabType } from "@/shell/tab-registry";
/** /**
* The one list of navigation destinations. * The one list of navigation destinations.
@@ -217,10 +219,12 @@ export function rightDockableIds(): string[] {
.map((item) => item.id); .map((item) => item.id);
} }
/** Ids that may be opened as a full-width tab. */ /** Ids that may be opened as a full-width tab: a tab type or a panel to show. */
export function promotableIds(): string[] { export function promotableIds(): string[] {
return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS] return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS]
.filter((item) => item.promotable) .filter(
(item) => item.promotable && (getTabType(item.id) || getPanel(item.id)),
)
.map((item) => item.id); .map((item) => item.id);
} }
+13
View File
@@ -194,6 +194,19 @@ describe("resolveWorkspaceTabTarget", () => {
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01"); expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
}); });
it("reopens a hostless, non-singleton type with a host as its own tab", () => {
registerTabType({ id: "tunnel", component: () => null, hostless: true });
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "tunnel",
label: "Tunnels",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("host");
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
});
it("skips a non-singleton, non-plugin type with no resolvable host", () => { it("skips a non-singleton, non-plugin type with no resolvable host", () => {
const snapshot: WorkspaceTabSnapshot = { const snapshot: WorkspaceTabSnapshot = {
slotId: "s1", slotId: "s1",
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderTabContent } from "@/shell/tabUtils";
import { registerPanel, resetPanels } from "@/shell/panel-registry";
import {
registerTabType,
resetTabTypes,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import {
promotableIds,
registerRailItem,
resetRegisteredRailItems,
} from "@/sidebar/rail-items";
import type { Tab } from "@/types/ui-types";
const shell = {} as TabShellCallbacks;
function tabOf(type: string): Tab {
return { id: type, instanceId: type, type, label: type } as Tab;
}
afterEach(() => {
cleanup();
resetPanels();
resetTabTypes();
resetRegisteredRailItems();
});
describe("renderTabContent", () => {
it("shows a rail panel as a tab when no tab type is registered", () => {
const Panel = vi.fn(({ placement }: { placement: string }) => (
<div>panel in {placement}</div>
));
registerPanel({ id: "history", component: Panel });
render(<>{renderTabContent(tabOf("history"), { shell })}</>);
expect(screen.getByText("panel in tab")).toBeInTheDocument();
});
it("prefers a registered tab type over the panel", () => {
registerPanel({ id: "ai", component: () => <div>panel</div> });
registerTabType({ id: "ai", component: () => <div>tab</div> });
render(<>{renderTabContent(tabOf("ai"), { shell })}</>);
expect(screen.getByText("tab")).toBeInTheDocument();
expect(screen.queryByText("panel")).toBeNull();
});
});
describe("promotableIds", () => {
it("only offers items that have a tab or a panel to show", () => {
registerRailItem({
id: "history",
icon: () => null,
labelKey: "x",
promotable: true,
} as never);
registerRailItem({
id: "orphan",
icon: () => null,
labelKey: "x",
promotable: true,
} as never);
registerPanel({ id: "history", component: () => null });
expect(promotableIds()).toContain("history");
expect(promotableIds()).not.toContain("orphan");
});
});
+7 -1
View File
@@ -13,9 +13,13 @@ import {
visibleRailItems, visibleRailItems,
} from "@/sidebar/rail-items"; } from "@/sidebar/rail-items";
import { isCapturableTabType } from "@/shell/shell-layout"; import { isCapturableTabType } from "@/shell/shell-layout";
import { registerPanel, resetPanels } from "@/shell/panel-registry";
import en from "@/locales/en.json"; import en from "@/locales/en.json";
afterEach(() => resetRegisteredRailItems()); afterEach(() => {
resetRegisteredRailItems();
resetPanels();
});
function lookup(key: string): unknown { function lookup(key: string): unknown {
return key return key
@@ -144,6 +148,7 @@ describe("registered rail items", () => {
it("feeds its flags into the promotable, dock and hideable lists", () => { it("feeds its flags into the promotable, dock and hideable lists", () => {
registerRailItem(item("docked", { promotable: true, rightDockable: true })); registerRailItem(item("docked", { promotable: true, rightDockable: true }));
registerPanel({ id: "docked", component: () => null });
registerRailItem(item("pinned", { hideable: false })); registerRailItem(item("pinned", { hideable: false }));
expect(promotableIds()).toContain("docked"); expect(promotableIds()).toContain("docked");
expect(rightDockableIds()).toContain("docked"); expect(rightDockableIds()).toContain("docked");
@@ -153,6 +158,7 @@ describe("registered rail items", () => {
it("drops a hidden item everywhere without unregistering it", () => { it("drops a hidden item everywhere without unregistering it", () => {
registerRailItem(item("off", { hidden: true, promotable: true })); registerRailItem(item("off", { hidden: true, promotable: true }));
registerPanel({ id: "off", component: () => null });
expect(visibleRailItems().map((entry) => entry.id)).not.toContain("off"); expect(visibleRailItems().map((entry) => entry.id)).not.toContain("off");
expect(hideableRailIds()).not.toContain("off"); expect(hideableRailIds()).not.toContain("off");
expect(promotableIds()).not.toContain("off"); expect(promotableIds()).not.toContain("off");