fix: home tab, layout switch and missing layout fallbacks for looks

This commit is contained in:
LukeGus committed 2026-10-18 19:08:50 -05:00
1 parent 4c235bb23f
commit ec3cc9cf18
14 files changed
+761 -11

No files matched your search

+8 -3
View File
@@ -13,6 +13,7 @@ import {
LAYOUT_SETTING,
LOOK_ID_PATTERN,
THEME_SETTING,
installedLayoutIds,
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
@@ -35,7 +36,7 @@ async function adminDefaults() {
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults, the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
@@ -101,10 +102,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
const { loader } = getPluginRuntime();
const installed = installedLayoutIds(loader);
const known = (id: string | null | undefined) =>
id && installed.has(id) ? id : null;
const defaults = await adminDefaults();
res.json({
layoutId: preferences?.layoutId ?? null,
layoutId: known(preferences?.layoutId),
themeId: preferences?.themeId ?? null,
defaults: await adminDefaults(),
defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
unavailable: unavailableLooks(loader),
+19
View File
@@ -14,6 +14,7 @@ export interface DefaultLook {
interface LookLoader {
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
list(): Array<Pick<LoadedPlugin, "manifest">>;
skipped(): SkippedPlugin[];
}
@@ -46,6 +47,24 @@ export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
}
/**
* Layout ids some installed plugin brings, whether it runs, is turned off or
* failed to load. A choice outside this set was uninstalled, so the next
* choice down applies instead of the repair view.
*/
export function installedLayoutIds(loader: LookLoader): Set<string> {
const ids = new Set<string>();
for (const plugin of loader.list()) {
for (const layout of plugin.manifest.contributes?.layouts ?? []) {
ids.add(layout.id);
}
}
for (const plugin of loader.skipped()) {
for (const id of plugin.layouts) ids.add(id);
}
return ids;
}
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
export function safeLayoutForced(): boolean {
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
audits: [] as unknown[],
skipped: [] as unknown[],
defaultLookIds: [] as string[],
installed: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => ({
@@ -69,6 +70,15 @@ vi.mock("../../../plugins/index.js", () => ({
},
}
: undefined,
list: () => [
{
manifest: {
contributes: {
layouts: state.installed.map((id) => ({ id, label: id })),
},
},
},
],
skipped: () => state.skipped,
},
}),
@@ -90,6 +100,7 @@ beforeEach(async () => {
state.audits = [];
state.skipped = [];
state.defaultLookIds = [];
state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT;
const app = express();
@@ -133,6 +144,7 @@ describe("GET /user-preferences/look", () => {
it("returns the user's pick, the admin defaults and the default look", async () => {
state.prefs = { layoutId: "mine", themeId: "nord" };
state.installed = ["mine", "admin-layout"];
state.settings.set("ui.layout", "admin-layout");
state.settings.set("ui.theme", "gruvbox");
state.defaultLookIds = ["shipped-look"];
@@ -162,6 +174,31 @@ describe("GET /user-preferences/look", () => {
});
});
describe("GET /user-preferences/look with an uninstalled layout", () => {
it("drops a choice no installed plugin brings, so the next one applies", async () => {
state.prefs = { layoutId: "deleted", themeId: null };
state.settings.set("ui.layout", "also-deleted");
state.installed = ["kept"];
const { body } = await getLook();
expect(body.layoutId).toBeNull();
expect(body.defaults.layout).toBeNull();
});
it("keeps a choice whose plugin failed to load, for the repair view", async () => {
state.prefs = { layoutId: "b", themeId: null };
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
];
expect((await getLook()).body.layoutId).toBe("b");
});
});
describe("PUT /user-preferences/look/defaults", () => {
it("is for admins only", async () => {
const { status } = await putDefaults({ layout: "x" });
+16
View File
@@ -6,6 +6,7 @@ vi.mock("../../plugins/bundled-index.js", () => ({
}));
import {
installedLayoutIds,
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
@@ -23,6 +24,10 @@ function loaderWith(
plugins[id]
? ({ manifest: { contributes: plugins[id] } } as never)
: undefined,
list: () =>
Object.values(plugins).map(
(contributes) => ({ manifest: { contributes } }) as never,
),
skipped: () => skipped,
};
}
@@ -103,3 +108,14 @@ describe("safeLayoutForced", () => {
expect(safeLayoutForced()).toBe(false);
});
});
describe("installedLayoutIds", () => {
it("lists layouts of loaded and skipped plugins", () => {
const ids = installedLayoutIds(
loaderWith({ a: { layouts: [{ id: "one" }], themes: [{ id: "t" }] } }, [
{ id: "b", version: null, layouts: ["two"], themes: [], error: "x" },
]),
);
expect([...ids].sort()).toEqual(["one", "two"]);
});
});
@@ -68,9 +68,10 @@ export function useTabStageStore(): TabStageStore {
* renders it inside the scope its tab-close questions should show in.
*/
export function ShellConfirmCapture(): null {
const { confirmRef } = useShellController();
// Optional, so the repair view still draws when the shell itself is broken.
const controller = useOptionalShellController();
const confirm = useConfirm();
confirmRef.current = confirm;
if (controller) controller.confirmRef.current = confirm;
return null;
}
+11 -2
View File
@@ -51,13 +51,22 @@ export function usePluginShell(pluginId: string): PluginShellController | null {
() => (hosts ?? []).map((host) => toPluginHostRecord(host, pluginId)),
[hosts, pluginId],
);
// The shell always keeps a home tab. A layout that registers no tab type
// for it never sees it, so it has no dead tab to draw.
const hasHome = useTabTypes().some((type) => type.id === "dashboard");
if (!shell) return null;
const { tabs, split, regions } = shell;
const shown = (list: typeof tabs.list) =>
(hasHome
? list
: list.filter(
(tab) => tab.type !== "dashboard",
)) as unknown as ShellTabState[];
return {
tabs: {
list: tabs.list as unknown as ShellTabState[],
topLevel: tabs.topLevel as unknown as ShellTabState[],
list: shown(tabs.list),
topLevel: shown(tabs.topLevel),
activeId: tabs.activeId,
ready: tabs.ready,
showNumbers: tabs.showNumbers,
+46 -1
View File
@@ -1,4 +1,11 @@
import { Component, useMemo, useState, type ReactNode } from "react";
import {
Component,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { isElectron } from "@/lib/electron";
import { usePluginStore } from "@/plugin-host/plugin-store";
@@ -12,6 +19,7 @@ import { layoutStatus } from "@/look/layout-status";
import { safeLayoutInUrl } from "@/look/resolve-look";
import { useLookTheme } from "@/look/use-look-theme";
import { RepairView } from "@/look/RepairView";
import { useOptionalShellController } from "@/shell/controller/ShellControllerProvider";
/** Catches a crash while a layout draws, so the repair view can take over. */
class LayoutErrorBoundary extends Component<
@@ -34,6 +42,39 @@ class LayoutErrorBoundary extends Component<
}
}
/**
* Only the chosen layout plugin runs, so the tabs the old one brought (its
* settings screen, say) cannot draw after a switch. Close them; the home tab
* stays, since core keeps it in every layout.
*/
function useCloseLeftoverTabs(layoutId: string, tabTypes: string[]) {
const shell = useOptionalShellController();
const previous = useRef<{ layoutId: string; tabTypes: string[] } | null>(
null,
);
const current = { layoutId, tabTypes };
const latest = useRef(current);
latest.current = current;
const shellRef = useRef(shell);
shellRef.current = shell;
useEffect(() => {
const before = previous.current;
previous.current = latest.current;
if (!before || before.layoutId === layoutId || !shellRef.current) return;
const kept = new Set(latest.current.tabTypes);
for (const tab of shellRef.current.tabs.list) {
if (
tab.id !== "dashboard" &&
before.tabTypes.includes(tab.type) &&
!kept.has(tab.type)
) {
void shellRef.current.tabs.close(tab.id);
}
}
}, [layoutId]);
}
interface CrashState {
layoutId: string | null;
count: number;
@@ -64,6 +105,10 @@ export function LayoutHost() {
const owner = [...store.records.values()].find((record) =>
record.summary.contributes?.layouts?.some((l) => l.id === layoutId),
);
useCloseLeftoverTabs(
layoutId,
owner?.summary.contributes?.tabs?.map((tab) => tab.id) ?? [],
);
const status = layoutStatus({
layoutId,
safeFromUrl: safeLayoutInUrl(window.location.search),
+2
View File
@@ -206,6 +206,8 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
default: {
const def = getTabType(tab.type);
// The home tab is empty until the layout gives it a tab type.
if (!def && tab.type === "dashboard") return null;
if (!def) {
const panel = getPanel(tab.type);
if (panel) {
@@ -30,8 +30,12 @@ vi.mock("@/hooks/use-permissions", () => ({
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
import { usePluginShell } from "@/shell/controller/plugin-shell";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
afterEach(cleanup);
afterEach(() => {
cleanup();
resetTabTypes();
});
let shell: PluginShell | null = null;
function Capture() {
@@ -67,4 +71,22 @@ describe("usePluginShell", () => {
act(() => shell!.palette.open());
expect(shell!.palette.isOpen).toBe(true);
});
it("hides the home tab from a layout that gives it no tab type", async () => {
render(
<ShellControllerProvider username="ada" onLogout={() => {}}>
<Capture />
</ShellControllerProvider>,
);
await waitFor(() => expect(shell).not.toBeNull());
expect(shell!.tabs.list.map((tab) => tab.type)).not.toContain("dashboard");
expect(shell!.tabs.topLevel).toEqual([]);
act(() => {
registerTabType({ id: "dashboard", component: () => null });
});
await waitFor(() =>
expect(shell!.tabs.topLevel.map((tab) => tab.id)).toEqual(["dashboard"]),
);
});
});
@@ -0,0 +1,292 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
// The repair drill: a broken layout, the real repair view, the real look
// store. Only the server, the plugin loader and the Plugins tab flows are fakes.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
options && typeof options === "object" && !("defaultValue" in options)
? `${key}:${JSON.stringify(options)}`
: ((options?.defaultValue as string | undefined) ?? key),
i18n: { language: "en" },
}),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
const permissions = vi.hoisted(() => ({ isAdmin: true }));
vi.mock("@/hooks/use-permissions", () => ({
usePermissions: () => ({
isAdmin: permissions.isAdmin,
permissions: [],
loaded: true,
has: () => permissions.isAdmin,
}),
}));
const lookApi = vi.hoisted(() => ({
getLook: vi.fn(),
saveUserLook: vi.fn(async (_patch: Record<string, unknown>) => {}),
saveDefaultLook: vi.fn(async (defaults: Record<string, unknown>) => ({
layout: null,
theme: null,
...defaults,
})),
}));
vi.mock("@/api/look-api", () => lookApi);
const loader = vi.hoisted(() => ({
retryPluginFrontend: vi.fn(async () => {}),
syncPlugins: vi.fn(async () => {}),
}));
vi.mock("@/plugin-host/loader", () => loader);
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
const manager = vi.hoisted(() => ({
requestUpdate: vi.fn(),
requestInstall: vi.fn(),
toggle: vi.fn(),
setConsent: vi.fn(),
confirmConsent: vi.fn(),
}));
vi.mock("@/plugins/use-plugins-manager", () => ({
usePluginsManager: () => ({
...manager,
loading: false,
registryError: null,
consent: null,
busy: new Set<string>(),
plugins: [
{
id: "fancy-layout",
name: "Fancy",
installed: true,
enabled: true,
updateAvailable: true,
latestVersion: "1.1.0",
managedByServer: false,
bundled: false,
versions: [],
},
{
id: "paper-themes",
name: "Paper themes",
description: "Light themes",
installed: false,
enabled: false,
updateAvailable: false,
latestVersion: "1.0.0",
versions: [{ version: "1.0.0", capabilities: ["ui:theme"] }],
},
],
}),
}));
import { LayoutHost } from "@/shell/layout/LayoutHost";
import {
registerLayout,
resetRegisteredLayouts,
setPluginLayouts,
} from "@/look/layout-registry";
import { loadLook, resetLook } from "@/look/look-store";
import {
markPluginsSettled,
resetPluginStore,
setFrontendState,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
import type { PluginSummary } from "@/api/plugins-api";
const fancyPlugin: PluginSummary = {
id: "fancy-layout",
name: "Fancy",
version: "1.0.0",
enabled: true,
state: "active",
frontend: true,
contributes: { layouts: [{ id: "fancy", label: "Fancy" }] },
};
async function start(look: {
layoutId?: string | null;
defaultLayout?: string | null;
summaries?: PluginSummary[];
}) {
lookApi.getLook.mockResolvedValue({
layoutId: look.layoutId ?? null,
themeId: null,
defaults: { layout: look.defaultLayout ?? null, theme: null },
defaultLook: { layout: "classic", theme: "dark" },
safeLayout: false,
unavailable: [],
});
await loadLook();
setPluginSummaries(look.summaries ?? []);
markPluginsSettled();
}
function Broken(): never {
throw new Error("draw exploded");
}
// React reports crashes it caught; these are expected here.
const expectedCrash = (event: ErrorEvent) => event.preventDefault();
beforeEach(() => {
permissions.isAdmin = true;
resetLook();
resetPluginStore();
resetRegisteredLayouts();
for (const fn of [
...Object.values(manager),
lookApi.saveUserLook,
lookApi.saveDefaultLook,
]) {
fn.mockClear();
}
setPluginLayouts([
{ id: "fancy", label: "Fancy", pluginId: "fancy-layout" },
{ id: "classic", label: "Classic", pluginId: "classic-layout" },
]);
registerLayout({ id: "classic", component: () => <p>classic layout</p> });
vi.spyOn(console, "error").mockImplementation(() => {});
window.addEventListener("error", expectedCrash);
});
afterEach(() => {
cleanup();
window.removeEventListener("error", expectedCrash);
vi.restoreAllMocks();
});
describe("layout repair drill", () => {
it("a layout that throws while drawing ends in the repair view, and switching gets out", async () => {
registerLayout({ id: "fancy", component: Broken });
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
render(<LayoutHost />);
expect(await screen.findByText("draw exploded")).toBeTruthy();
fireEvent.click(screen.getByText("Classic"));
await waitFor(() =>
expect(lookApi.saveUserLook).toHaveBeenCalledWith({
layoutId: "classic",
}),
);
expect(await screen.findByText("classic layout")).toBeTruthy();
});
it("a layout whose activate threw shows the error, and reset brings the default look back", async () => {
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
setFrontendState("fancy-layout", "failed", "activate exploded");
render(<LayoutHost />);
expect(await screen.findByText("activate exploded")).toBeTruthy();
fireEvent.click(screen.getByText("looks.repair.reset"));
await waitFor(() =>
expect(lookApi.saveUserLook).toHaveBeenCalledWith({ layoutId: null }),
);
expect(lookApi.saveUserLook).toHaveBeenCalledWith({ themeId: null });
expect(await screen.findByText("classic layout")).toBeTruthy();
});
it("an admin updates the broken plugin when the registry has a newer one", async () => {
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
setFrontendState("fancy-layout", "failed", "activate exploded");
render(<LayoutHost />);
fireEvent.click(await screen.findByText(/looks\.repair\.update/));
expect(manager.requestUpdate).toHaveBeenCalledWith(
expect.objectContaining({ id: "fancy-layout" }),
);
});
it("an admin installs a layout or theme from the registries", async () => {
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
setFrontendState("fancy-layout", "failed", "activate exploded");
render(<LayoutHost />);
fireEvent.click(await screen.findByText("looks.repair.install"));
expect(await screen.findByText("Paper themes")).toBeTruthy();
fireEvent.click(screen.getByText("looks.repair.installButton"));
expect(manager.requestInstall).toHaveBeenCalledWith(
expect.objectContaining({ id: "paper-themes" }),
);
});
it("someone who is not an admin gets switch and reset, and a note to ask an admin", async () => {
permissions.isAdmin = false;
await start({ layoutId: "fancy", summaries: [fancyPlugin] });
setFrontendState("fancy-layout", "failed", "activate exploded");
render(<LayoutHost />);
expect(await screen.findByText("looks.repair.askAdmin")).toBeTruthy();
expect(screen.queryByText(/looks\.repair\.update/)).toBeNull();
expect(screen.queryByText("looks.repair.install")).toBeNull();
expect(screen.getByText("looks.repair.reset")).toBeTruthy();
expect(screen.getByText("Classic")).toBeTruthy();
});
it("the user's own layout wins over the admin default", async () => {
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
await start({
layoutId: "fancy",
defaultLayout: "classic",
summaries: [fancyPlugin],
});
render(<LayoutHost />);
expect(await screen.findByText("fancy layout")).toBeTruthy();
});
it("a layout the server dropped as uninstalled falls back to the admin default", async () => {
// The server answers null for a choice no installed plugin brings.
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
await start({ layoutId: null, defaultLayout: "fancy" });
render(<LayoutHost />);
expect(await screen.findByText("fancy layout")).toBeTruthy();
cleanup();
resetLook();
await start({ layoutId: null, defaultLayout: null });
render(<LayoutHost />);
expect(await screen.findByText("classic layout")).toBeTruthy();
});
it("?safe-layout=1 opens the repair view over a working layout, and leaving it draws the layout", async () => {
window.history.replaceState(null, "", "/?safe-layout=1");
await start({ layoutId: "classic" });
render(<LayoutHost />);
expect(await screen.findByText("looks.repair.safeTitle")).toBeTruthy();
expect(screen.queryByText("looks.repair.retry")).toBeNull();
cleanup();
window.history.replaceState(null, "", "/");
render(<LayoutHost />);
expect(await screen.findByText("classic layout")).toBeTruthy();
});
it("TERMIX_SAFE_LAYOUT on the server opens the repair view for everyone", async () => {
lookApi.getLook.mockResolvedValue({
layoutId: "classic",
themeId: null,
defaults: { layout: null, theme: null },
defaultLook: { layout: "classic", theme: "dark" },
safeLayout: true,
unavailable: [],
});
await act(async () => {
await loadLook();
});
setPluginSummaries([]);
markPluginsSettled();
render(<LayoutHost />);
expect(await screen.findByText("looks.repair.safeTitle")).toBeTruthy();
});
});
+87 -2
View File
@@ -1,5 +1,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
vi.mock("@/main-axios", () => ({
getUserInfo: vi.fn(async () => ({ is_admin: false })),
getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })),
saveUserPreferences: vi.fn(async () => {}),
getSSHHosts: vi.fn(async () => []),
getSSHFolders: vi.fn(async () => []),
getOpenTabs: vi.fn(async () => []),
getActiveSessions: vi.fn(async () => []),
addOpenTab: vi.fn(async () => {}),
deleteOpenTab: vi.fn(async () => {}),
patchOpenTab: vi.fn(async () => {}),
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
const lookApi = vi.hoisted(() => ({
getLook: vi.fn(),
@@ -36,7 +54,13 @@ vi.mock("@/look/RepairView", () => ({
import { LayoutHost } from "@/shell/layout/LayoutHost";
import { registerLayout, resetRegisteredLayouts } from "@/look/layout-registry";
import { loadLook, resetLook } from "@/look/look-store";
import { chooseLayout, loadLook, resetLook } from "@/look/look-store";
import {
ShellControllerProvider,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
import type { ShellController } from "@/shell/controller/types";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
import {
markPluginsSettled,
resetPluginStore,
@@ -196,4 +220,65 @@ describe("LayoutHost", () => {
expect(loader.retryPluginFrontend).toHaveBeenCalledWith("fancy-layout"),
);
});
it("closes the tabs the old layout plugin brought when the layout changes", async () => {
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
registerLayout({ id: "plain", component: () => <p>plain layout</p> });
registerTabType({
id: "fancy-settings",
component: () => null,
hostless: true,
singleton: true,
});
registerTabType({
id: "notes",
component: () => null,
hostless: true,
multiInstance: true,
});
await start("fancy", [
plugin({
contributes: {
layouts: [{ id: "fancy", label: "Fancy" }],
tabs: [
{
id: "fancy-settings",
titleKey: "x",
icon: "Settings",
openFrom: [],
},
],
},
}),
plugin({
id: "plain-layout",
contributes: { layouts: [{ id: "plain", label: "Plain" }] },
}),
]);
let shell: ShellController | null = null;
function Capture() {
shell = useShellController();
return null;
}
render(
<ShellControllerProvider username="ada" onLogout={() => {}}>
<Capture />
<LayoutHost />
</ShellControllerProvider>,
);
await screen.findByText("fancy layout");
act(() => {
shell!.tabs.openSingleton("fancy-settings");
shell!.tabs.openMultiInstance("notes");
});
const types = () => shell!.tabs.list.map((tab) => tab.type).sort();
await waitFor(() =>
expect(types()).toEqual(["dashboard", "fancy-settings", "notes"]),
);
await act(() => chooseLayout("plain"));
await screen.findByText("plain layout");
await waitFor(() => expect(types()).toEqual(["dashboard", "notes"]));
resetTabTypes();
});
});
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { useEffect, useRef, useState } from "react";
vi.mock("@/main-axios", () => ({
getUserInfo: vi.fn(async () => ({ is_admin: false })),
getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })),
saveUserPreferences: vi.fn(async () => {}),
getSSHHosts: vi.fn(async () => []),
getSSHFolders: vi.fn(async () => []),
getOpenTabs: vi.fn(async () => []),
getActiveSessions: vi.fn(async () => []),
addOpenTab: vi.fn(async () => {}),
deleteOpenTab: vi.fn(async () => {}),
patchOpenTab: vi.fn(async () => {}),
createSSHHost: vi.fn(async () => ({})),
}));
vi.mock("@/hooks/use-sync-status", () => ({
useSyncStatus: () => undefined,
useSyncAttentionCount: () => 0,
}));
const lookApi = vi.hoisted(() => ({
getLook: vi.fn(),
saveUserLook: vi.fn(async () => {}),
saveDefaultLook: vi.fn(),
}));
vi.mock("@/api/look-api", () => lookApi);
vi.mock("@/plugin-host/loader", () => ({
retryPluginFrontend: vi.fn(async () => {}),
syncPlugins: vi.fn(async () => {}),
}));
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false }));
import type { TabRenderProps } from "@/shell/tab-registry";
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
import {
ShellControllerProvider,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
import type { ShellController } from "@/shell/controller/types";
import { TabPortals } from "@/shell/stage/TabPortals";
import { TabStage } from "@/shell/stage/TabStage";
import { LayoutHost } from "@/shell/layout/LayoutHost";
import { registerLayout, resetRegisteredLayouts } from "@/look/layout-registry";
import { chooseLayout, loadLook, resetLook } from "@/look/look-store";
import {
markPluginsSettled,
resetPluginStore,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
const mounts = { count: 0 };
/** Stands in for a terminal: a session id and scrollback that a remount would lose. */
function FakeTerminal({ tab }: TabRenderProps) {
const session = useRef(`session-${Math.random().toString(36).slice(2)}`);
const [lines, setLines] = useState<string[]>([]);
useEffect(() => {
mounts.count += 1;
}, []);
return (
<div data-testid={`term-${tab.id}`} data-session={session.current}>
<button type="button" onClick={() => setLines((l) => [...l, "line"])}>
write {tab.id}
</button>
<pre>{lines.join("\n")}</pre>
</div>
);
}
let shell: ShellController | null = null;
function Capture() {
shell = useShellController();
return null;
}
// Two layouts that put the stage in very different places.
function RailLayout() {
return (
<div data-testid="rail-layout" className="flex">
<nav>rail</nav>
<main>
<TabStage />
</main>
</div>
);
}
function TopBarLayout() {
return (
<div data-testid="topbar-layout">
<header>bar</header>
<section>
<div>
<TabStage />
</div>
</section>
</div>
);
}
function App() {
return (
<ShellControllerProvider username="ada" onLogout={() => {}}>
<Capture />
<LayoutHost />
<TabPortals />
</ShellControllerProvider>
);
}
beforeEach(async () => {
mounts.count = 0;
shell = null;
resetLook();
resetPluginStore();
resetRegisteredLayouts();
registerTabType({
id: "fake-terminal",
component: FakeTerminal,
hostless: true,
multiInstance: true,
});
registerLayout({ id: "rail", component: RailLayout });
registerLayout({ id: "topbar", component: TopBarLayout });
lookApi.getLook.mockResolvedValue({
layoutId: "rail",
themeId: null,
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
unavailable: [],
});
await loadLook();
setPluginSummaries([]);
markPluginsSettled();
});
afterEach(() => {
cleanup();
resetTabTypes();
});
describe("TabPortals", () => {
it("keeps three sessions and their scrollback through a split and a layout switch", async () => {
render(<App />);
await screen.findByTestId("rail-layout");
await waitFor(() => expect(shell).not.toBeNull());
const ids: string[] = [];
for (let i = 0; i < 3; i += 1) {
act(() => {
ids.push(shell!.tabs.openMultiInstance("fake-terminal"));
});
}
const terms = await Promise.all(
ids.map((id) => screen.findByTestId(`term-${id}`)),
);
const sessions = terms.map((el) => el.dataset.session);
expect(mounts.count).toBe(3);
// Some output in every terminal.
for (const id of ids) act(() => screen.getByText(`write ${id}`).click());
const scrollback = () =>
terms.map((el) => el.querySelector("pre")!.textContent);
expect(scrollback()).toEqual(["line", "line", "line"]);
const check = () => {
ids.forEach((id, index) => {
const el = screen.getByTestId(`term-${id}`);
expect(el).toBe(terms[index]);
expect(el.dataset.session).toBe(sessions[index]);
});
expect(scrollback()).toEqual(["line", "line", "line"]);
expect(mounts.count).toBe(3);
};
// The second one into a split, then back out.
const moved = ids[1];
act(() => {
shell!.split.handleAction({ kind: "split", tabId: moved, edge: "right" });
});
await waitFor(() =>
expect(terms[1].closest("[data-split-pane-id]")).not.toBeNull(),
);
check();
const splitId = shell!.split.active!.id;
act(() => {
shell!.split.handleAction({ kind: "unsplit", splitTabId: splitId });
});
await waitFor(() =>
expect(terms[1].closest("[data-split-pane-id]")).toBeNull(),
);
check();
// Switch layouts and back.
await act(() => chooseLayout("topbar"));
const topbar = await screen.findByTestId("topbar-layout");
await waitFor(() =>
terms.forEach((el) => expect(topbar.contains(el)).toBe(true)),
);
expect(screen.queryByTestId("rail-layout")).toBeNull();
check();
await act(() => chooseLayout("rail"));
const rail = await screen.findByTestId("rail-layout");
await waitFor(() =>
terms.forEach((el) => expect(rail.contains(el)).toBe(true)),
);
check();
});
});
+4
View File
@@ -49,6 +49,10 @@ describe("renderTabContent", () => {
expect(screen.getByText("tab")).toBeInTheDocument();
expect(screen.queryByText("panel")).toBeNull();
});
it("draws nothing for the home tab when no layout gave it a type", () => {
expect(renderTabContent(tabOf("dashboard"), { shell })).toBeNull();
});
});
describe("promotableIds", () => {