mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
This commit is contained in:
1 parent
3b58415f8f
commit
30da0850da
13 files changed
+364
-101
No files matched your search
+125
-49
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user