mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: home tab, layout switch and missing layout fallbacks for looks
This commit is contained in:
1 parent
4c235bb23f
commit
ec3cc9cf18
14 files changed
+761
-11
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user