From 30da0850dac5e2f9c5df3b1a821b41e538471eb3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 1 Oct 2026 13:14:11 -0500 Subject: [PATCH] fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag --- src/ui/AppShell.tsx | 174 +++++++++++++++++------- src/ui/auth/Auth.tsx | 5 +- src/ui/dashboard/DashboardTab.tsx | 51 +++---- src/ui/locales/en.json | 14 +- src/ui/plugin-host/loader.ts | 19 ++- src/ui/plugin-host/plugin-store.ts | 12 ++ src/ui/shell/TabBar.tsx | 8 +- src/ui/shell/shell-layout.ts | 8 +- src/ui/shell/tabUtils.tsx | 72 ++++++++-- src/ui/sidebar/rail-items.ts | 8 +- src/ui/tests/shell/shell-layout.test.ts | 13 ++ src/ui/tests/shell/tabUtils.test.tsx | 73 ++++++++++ src/ui/tests/sidebar/rail-items.test.ts | 8 +- 13 files changed, 364 insertions(+), 101 deletions(-) create mode 100644 src/ui/tests/shell/tabUtils.test.tsx diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index e40525fad..cdb92313c 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -24,6 +24,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, createRef, lazy, Suspense, @@ -1149,13 +1150,20 @@ export function AppShell({ // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) useEffect(() => { if (allHosts.length === 0) return; - setTabs((prev) => - prev.map((t) => - t.host - ? { ...t, host: allHosts.find((h) => h.id === t.host!.id) ?? t.host } - : t, - ), - ); + const byId = new Map(allHosts.map((h) => [h.id, h])); + setTabs((prev) => { + let changed = false; + const next = prev.map((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]); function buildWorkspacePayload(): WorkspacePayload { @@ -1197,13 +1205,13 @@ export function AppShell({ } if (target.kind === "singleton") { - openSingletonTab( + const newTabId = openSingletonTab( snapshot.type, undefined, target.host, snapshotData(snapshot), ); - slotIdToNewTabId.set(snapshot.slotId, snapshot.type); + slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type); continue; } @@ -1500,8 +1508,10 @@ export function AppShell({ return existing.id; } } - const tabId = `${host.name}-${type}-${Date.now()}`; 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 ref = isSessionTabType(type) ? createRef() : undefined; 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). */ - function openMultiInstanceTab(type: TabType): string { + function openMultiInstanceTab( + type: TabType, + options?: { host?: Host; data?: Record; label?: string }, + ): string { const instanceId = createId(); const id = `${type}-${instanceId}`; const titleKey = getTabType(type)?.titleKey; - const title = titleKey ? t(titleKey) : type; + const title = options?.label ?? (titleKey ? t(titleKey) : type); setTabs((current) => { const count = current.filter((tab) => tab.type === type).length; return [ @@ -1625,8 +1638,11 @@ export function AppShell({ id, instanceId, type, - label: count === 0 ? title : `${title} (${count + 1})`, + label: + count === 0 || options?.label ? title : `${title} (${count + 1})`, openedAt: Date.now(), + ...(options?.host ? { host: options.host } : {}), + ...(options?.data !== undefined ? { data: options.data } : {}), }, ]; }); @@ -1641,10 +1657,11 @@ export function AppShell({ pendingEvent?: string, host?: Host, data?: Record, - ) { + tabLabel?: string, + ): string | undefined { // A local shell is never a singleton: each open is its own session. if (getTabType(type)?.multiInstance) { - return openMultiInstanceTab(type); + return openMultiInstanceTab(type, { host, data, label: tabLabel }); } if (type === "host-manager") { if (pendingEvent === "host-manager:add-credential") { @@ -1726,6 +1743,7 @@ export function AppShell({ tabOrder: 0, }).catch(() => {}); } + return id; }, [t], ); @@ -1830,27 +1848,21 @@ export function AppShell({ if (tab && confirmEnabled && isActiveConnectionTab(tab)) { const closeLabel = getTabCloseLabel(tab); const toastId = `close-tab-${id}`; - toast( - t("nav.confirmCloseHost", { - host: closeLabel, - defaultValue: `Close ${closeLabel}?`, - }), - { - id: toastId, - duration: 8000, - action: { - label: t("nav.close"), - onClick: () => { - toast.dismiss(toastId); - doCloseTab(id); - }, - }, - cancel: { - label: t("nav.cancel"), - onClick: () => toast.dismiss(toastId), + toast(t("nav.confirmCloseHost", { host: closeLabel }), { + id: toastId, + duration: 8000, + action: { + label: t("nav.close"), + onClick: () => { + toast.dismiss(toastId); + doCloseTab(id); }, }, - ); + cancel: { + label: t("nav.cancel"), + onClick: () => toast.dismiss(toastId), + }, + }); return; } @@ -2279,12 +2291,24 @@ export function AppShell({ const startW = sidebarWidth; // Widths are kept in Normal-size pixels, so a drag is scaled back. const scale = remScale(); + // One width update per frame, not per mousemove. + let frame = 0; + let clientX = startX; function onMove(ev: MouseEvent) { - setSidebarWidth( - Math.max(160, Math.min(480, startW + (ev.clientX - startX) / scale)), - ); + clientX = ev.clientX; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setSidebarWidth( + Math.max(160, Math.min(480, startW + (clientX - startX) / scale)), + ); + }); } function onUp() { + cancelAnimationFrame(frame); + setSidebarWidth( + Math.max(160, Math.min(480, startW + (clientX - startX) / scale)), + ); setSidebarDragging(false); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); @@ -2303,12 +2327,23 @@ export function AppShell({ const startX = e.clientX; const startW = rightSidebarWidth; const scale = remScale(); + let frame = 0; + let clientX = startX; function onMove(ev: MouseEvent) { - setRightSidebarWidth( - Math.max(160, Math.min(480, startW - (ev.clientX - startX) / scale)), - ); + clientX = ev.clientX; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setRightSidebarWidth( + Math.max(160, Math.min(480, startW - (clientX - startX) / scale)), + ); + }); } function onUp() { + cancelAnimationFrame(frame); + setRightSidebarWidth( + Math.max(160, Math.min(480, startW - (clientX - startX) / scale)), + ); setRightSidebarDragging(false); window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); @@ -2350,6 +2385,9 @@ export function AppShell({ : ""; 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(); return () => cancelAnimationFrame(id); }, [ @@ -2358,6 +2396,8 @@ export function AppShell({ sidebarOpen, rightSidebarWidth, rightRailView, + sidebarDragging, + rightSidebarDragging, ]); // 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 * closes over current state, and published to the plugin runtime. */ - const shellCallbacks: TabShellCallbacks = { + const shellCallbacksImpl: TabShellCallbacks = { openTab: (host, type, options) => { if (!host || getTabType(type)?.singleton) { - openSingletonTab(type, undefined, host ?? undefined, options?.data); + openSingletonTab( + type, + undefined, + host ?? undefined, + options?.data, + options?.label, + ); return; } openTab(host, type, undefined, options); @@ -2579,15 +2625,42 @@ export function AppShell({ 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( + () => ({ + 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 // the hosts panel does. - const panelShell: TabShellCallbacks = { - ...shellCallbacks, - openTab: (...args) => { - shellCallbacks.openTab(...args); - if (isMobile) setSidebarOpen(false); - }, - }; + const panelShell = useMemo( + () => ({ + ...shellCallbacks, + openTab: (...args) => { + shellCallbacks.openTab(...args); + if (isMobile) setSidebarOpen(false); + }, + }), + [shellCallbacks, isMobile], + ); useEffect(() => { setShellCallbacks(shellCallbacks); @@ -3075,6 +3148,9 @@ export function AppShell({ return createPortal( renderTabContent(tab, { shell: shellCallbacks, + panelTargetTab: terminalTabs.find( + (t) => t.id === targetTerminalTabId, + ), isVisible: inPane || activeInline, isFocusedPane, inSplit: inPane, diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index ada612a5e..df1fed65a 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -1685,10 +1685,7 @@ export function Auth({ onLogin }: AuthProps) { id="reg-pass" value={password} onChange={setPassword} - placeholder={t("auth.minChars", { - min: 6, - defaultValue: "min. 6 characters", - })} + placeholder={t("auth.minChars", { min: 6 })} disabled={loading} /> diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 3777166cc..0ebb3bc4e 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -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 { ComponentSlot } from "@/shell/ActionSlot"; import { useActionSlot } from "@/hooks/use-action-slot"; @@ -57,7 +57,7 @@ import { useStatusColorScheme, getStatusClasses, } from "@/hooks/use-status-color-scheme"; -import { useServerStatus } from "@/lib/ServerStatusContext"; +import { useServerStatusMeta } from "@/lib/ServerStatusContext"; import { sshHostToHost } from "@/sidebar/HostManagerData"; import { getDefaultConnectionTab } from "@/lib/host-connection-tabs"; @@ -137,6 +137,13 @@ export function PluginCardSlot({ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; }) { useRegisteredDashboardCards(); + const cardShell = useMemo( + () => ({ + ...shell, + openSingletonTab: (type: string) => onOpenSingletonTab(type as TabType), + }), + [onOpenSingletonTab], + ); const card = getRegisteredDashboardCard(id); if (!card) { return ( @@ -146,15 +153,7 @@ export function PluginCardSlot({ ); } const Component = card.component; - return ( - onOpenSingletonTab(type), - }} - /> - ); + return ; } // ─── Card components ────────────────────────────────────────────────────────── @@ -213,9 +212,7 @@ function StatsBarCard({
- {t("dashboardTab.hostsAvailable", { - defaultValue: "Hosts Available", - })} + {t("dashboardTab.hostsAvailable")}
{online} @@ -1011,7 +1008,7 @@ export function DashboardTab({ }) { const registeredCards = useRegisteredDashboardCards(); const { t, i18n } = useTranslation(); - const { initialLoadComplete } = useServerStatus(); + const { initialLoadComplete } = useServerStatusMeta(); const statusLoading = !initialLoadComplete; const [slots, setSlots] = useState(() => { @@ -1228,15 +1225,23 @@ export function DashboardTab({ e.preventDefault(); const startX = e.clientX; 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) => { - if (!bodyRef.current) return; - const totalW = bodyRef.current.getBoundingClientRect().width; - setMainWidthPct( - Math.min( - 85, - Math.max(25, startPct + ((ev.clientX - startX) / totalW) * 100), - ), - ); + clientX = ev.clientX; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setMainWidthPct( + Math.min( + 85, + Math.max(25, startPct + ((clientX - startX) / totalW) * 100), + ), + ); + }); }; const onUp = () => { window.removeEventListener("mousemove", onMove); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 641d5e198..7ccfe9f14 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -306,7 +306,10 @@ "fullscreenFailed": "Could not toggle fullscreen", "resetSidebarWidth": "Reset width", "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", @@ -1054,7 +1057,8 @@ "password": "Password", "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.", - "serverAuthentication": "Server sign-in" + "serverAuthentication": "Server sign-in", + "minChars": "min. {{min}} characters" }, "hostKey": { "verifyNewHost": "Verify SSH Host Key", @@ -1188,7 +1192,8 @@ "empty": "Empty", "clear": "Clear", "viewServerDetails": "View server details", - "dragToResizeColumns": "Drag to resize columns" + "dragToResizeColumns": "Drag to resize columns", + "hostsAvailable": "Hosts Available" }, "admin": { "sectionGeneral": "General", @@ -1495,7 +1500,8 @@ "sslRenewedBy": "Renewed automatically by {{plugin}}", "sslIssuer": "Issued by {{issuer}}", "sslNames": "Covers {{names}}", - "sslManualTitle": "Upload a certificate" + "sslManualTitle": "Upload a certificate", + "updateExternalAutoProvisionFailed": "Failed to update the auto-create setting" }, "newUi": { "sidebar": { diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index d4fdc2fdc..d20811444 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -263,12 +263,27 @@ function manifestFor(summary: PluginSummary): PluginManifest { }; } +const loadedNamespaceVersions = new Map(); + async function loadNamespaces(summaries: PluginSummary[]): Promise { - 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; - // Reloading picks up a plugin that was upgraded while the app was open. await i18n.reloadResources(undefined, 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 { diff --git a/src/ui/plugin-host/plugin-store.ts b/src/ui/plugin-host/plugin-store.ts index c62b796e1..f2cb7a827 100644 --- a/src/ui/plugin-host/plugin-store.ts +++ b/src/ui/plugin-host/plugin-store.ts @@ -60,14 +60,26 @@ export function usePluginStore(): StoreState { /** Replaces the summaries, keeping each plugin's frontend state. */ export function setPluginSummaries(summaries: PluginSummary[]): void { const records = new Map(); + let changed = !state.loaded || summaries.length !== state.records.size; for (const summary of summaries) { 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, { summary, frontend: previous?.frontend ?? "none", error: previous?.error, }); } + if (!changed) return; commit({ ...state, loaded: true, records }); } diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index f4441239e..b6e4b057a 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -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" title={ isAppFullscreen - ? "Exit fullscreen (Ctrl+Shift+F)" - : "Enter fullscreen (Ctrl+Shift+F)" + ? t("nav.exitFullscreen") + : t("nav.enterFullscreen") } aria-label={ - isAppFullscreen ? "Exit fullscreen" : "Enter fullscreen" + isAppFullscreen + ? t("nav.exitFullscreen") + : t("nav.enterFullscreen") } onClick={onToggleAppFullscreen} > diff --git a/src/ui/shell/shell-layout.ts b/src/ui/shell/shell-layout.ts index 611eefed5..8d096c2b3 100644 --- a/src/ui/shell/shell-layout.ts +++ b/src/ui/shell/shell-layout.ts @@ -42,9 +42,11 @@ export function isCapturableTabType(type: string): boolean { 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); - 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 (opensAsSingleton(snapshot.type)) { + if (opensAsSingleton(snapshot.type, !!host)) { return { kind: "singleton", host }; } diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 7b899f143..8bfec9562 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -6,7 +6,7 @@ import { Settings, User, } from "lucide-react"; -import { lazy, Suspense } from "react"; +import { lazy, memo, Suspense } from "react"; import { useTranslation } from "react-i18next"; import type { Tab, TabType } from "@/types/ui-types"; import { hostToSSHHost } from "@/lib/host-to-ssh-host"; @@ -16,6 +16,7 @@ import { type TabShellCallbacks, type TabTypeDef, } from "./tab-registry"; +import { getPanel, type PanelDef } from "./panel-registry"; import { markAdaptiveResourceUsed, runAdaptiveBackgroundTask, @@ -108,7 +109,7 @@ export function tabIcon(type: TabType) { * A plugin's tab: its registered component with the generic render props. * Unregistered types get the ownership placeholder in renderTabContent. */ -function RegisteredTab({ +const RegisteredTab = memo(function RegisteredTab({ def, tab, isVisible, @@ -153,10 +154,56 @@ function RegisteredTab({ return withTabSuspense( def.panelFrame ? {content} : content, ); -} +}); + +const DashboardTabHost = memo(function DashboardTabHost({ + shell, + isVisible, +}: { + shell: TabShellCallbacks; + isVisible: boolean; +}) { + return withTabSuspense( + 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( + + + , + ); +}); + +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; @@ -172,13 +219,7 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) { switch (tab.type) { case "dashboard": - return withTabSuspense( - shell.openSingletonTab(type)} - onOpenTab={(host, type) => shell.openTab(host, type)} - isVisible={isVisible} - />, - ); + return ; case "split-screen": return null; @@ -191,6 +232,17 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) { default: { const def = getTabType(tab.type); if (!def) { + const panel = getPanel(tab.type); + if (panel) { + return ( + + ); + } return ; } return ( diff --git a/src/ui/sidebar/rail-items.ts b/src/ui/sidebar/rail-items.ts index 4a0d8aefc..32e9edf44 100644 --- a/src/ui/sidebar/rail-items.ts +++ b/src/ui/sidebar/rail-items.ts @@ -13,6 +13,8 @@ import { usePermissions } from "@/hooks/use-permissions"; import { useSyncAttentionCount } from "@/hooks/use-sync-status"; import { isElectron } from "@/lib/electron"; import { createRegistry } from "@/lib/registry"; +import { getPanel } from "@/shell/panel-registry"; +import { getTabType } from "@/shell/tab-registry"; /** * The one list of navigation destinations. @@ -217,10 +219,12 @@ export function rightDockableIds(): string[] { .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[] { return [...mergedRailItems(), ...RAIL_UTILITY_ITEMS] - .filter((item) => item.promotable) + .filter( + (item) => item.promotable && (getTabType(item.id) || getPanel(item.id)), + ) .map((item) => item.id); } diff --git a/src/ui/tests/shell/shell-layout.test.ts b/src/ui/tests/shell/shell-layout.test.ts index 352f4d872..4059c6791 100644 --- a/src/ui/tests/shell/shell-layout.test.ts +++ b/src/ui/tests/shell/shell-layout.test.ts @@ -194,6 +194,19 @@ describe("resolveWorkspaceTabTarget", () => { 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", () => { const snapshot: WorkspaceTabSnapshot = { slotId: "s1", diff --git a/src/ui/tests/shell/tabUtils.test.tsx b/src/ui/tests/shell/tabUtils.test.tsx new file mode 100644 index 000000000..9761877ae --- /dev/null +++ b/src/ui/tests/shell/tabUtils.test.tsx @@ -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 }) => ( +
panel in {placement}
+ )); + 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: () =>
panel
}); + registerTabType({ id: "ai", component: () =>
tab
}); + + 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"); + }); +}); diff --git a/src/ui/tests/sidebar/rail-items.test.ts b/src/ui/tests/sidebar/rail-items.test.ts index 93381d98f..ae4f2f53c 100644 --- a/src/ui/tests/sidebar/rail-items.test.ts +++ b/src/ui/tests/sidebar/rail-items.test.ts @@ -13,9 +13,13 @@ import { visibleRailItems, } from "@/sidebar/rail-items"; import { isCapturableTabType } from "@/shell/shell-layout"; +import { registerPanel, resetPanels } from "@/shell/panel-registry"; import en from "@/locales/en.json"; -afterEach(() => resetRegisteredRailItems()); +afterEach(() => { + resetRegisteredRailItems(); + resetPanels(); +}); function lookup(key: string): unknown { return key @@ -144,6 +148,7 @@ describe("registered rail items", () => { it("feeds its flags into the promotable, dock and hideable lists", () => { registerRailItem(item("docked", { promotable: true, rightDockable: true })); + registerPanel({ id: "docked", component: () => null }); registerRailItem(item("pinned", { hideable: false })); expect(promotableIds()).toContain("docked"); expect(rightDockableIds()).toContain("docked"); @@ -153,6 +158,7 @@ describe("registered rail items", () => { it("drops a hidden item everywhere without unregistering it", () => { registerRailItem(item("off", { hidden: true, promotable: true })); + registerPanel({ id: "off", component: () => null }); expect(visibleRailItems().map((entry) => entry.id)).not.toContain("off"); expect(hideableRailIds()).not.toContain("off"); expect(promotableIds()).not.toContain("off");