diff --git a/CHANGELOG.md b/CHANGELOG.md index bd584a29c..a08ddfc41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1 - Hovering a theme in Settings previews it - Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version +- Switching layouts closes the tabs only the old layout could show, like its Settings tab +- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen ### Changed diff --git a/src/backend/database/routes/look.ts b/src/backend/database/routes/look.ts index 6d5df4fb7..c008dc9ed 100644 --- a/src/backend/database/routes/look.ts +++ b/src/backend/database/routes/look.ts @@ -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), diff --git a/src/backend/plugins/looks.ts b/src/backend/plugins/looks.ts index 0d7f7816b..5db3c6b43 100644 --- a/src/backend/plugins/looks.ts +++ b/src/backend/plugins/looks.ts @@ -14,6 +14,7 @@ export interface DefaultLook { interface LookLoader { get(pluginId: string): Pick | undefined; + list(): Array>; 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 { + const ids = new Set(); + 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(); diff --git a/src/backend/tests/database/routes/look.test.ts b/src/backend/tests/database/routes/look.test.ts index 648768667..bc7432564 100644 --- a/src/backend/tests/database/routes/look.test.ts +++ b/src/backend/tests/database/routes/look.test.ts @@ -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" }); diff --git a/src/backend/tests/plugins/looks.test.ts b/src/backend/tests/plugins/looks.test.ts index 096fb2f23..6e7f7a6dc 100644 --- a/src/backend/tests/plugins/looks.test.ts +++ b/src/backend/tests/plugins/looks.test.ts @@ -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"]); + }); +}); diff --git a/src/ui/shell/controller/ShellControllerProvider.tsx b/src/ui/shell/controller/ShellControllerProvider.tsx index 0ee19474a..a329dbb88 100644 --- a/src/ui/shell/controller/ShellControllerProvider.tsx +++ b/src/ui/shell/controller/ShellControllerProvider.tsx @@ -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; } diff --git a/src/ui/shell/controller/plugin-shell.ts b/src/ui/shell/controller/plugin-shell.ts index dbb8af4ab..f7a390248 100644 --- a/src/ui/shell/controller/plugin-shell.ts +++ b/src/ui/shell/controller/plugin-shell.ts @@ -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, diff --git a/src/ui/shell/layout/LayoutHost.tsx b/src/ui/shell/layout/LayoutHost.tsx index cbe75f296..26a315df6 100644 --- a/src/ui/shell/layout/LayoutHost.tsx +++ b/src/ui/shell/layout/LayoutHost.tsx @@ -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), diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index 5b1c2e47e..82a1b25a4 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -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) { diff --git a/src/ui/tests/shell/controller/plugin-shell.test.tsx b/src/ui/tests/shell/controller/plugin-shell.test.tsx index 4a2fdbab0..a0542310d 100644 --- a/src/ui/tests/shell/controller/plugin-shell.test.tsx +++ b/src/ui/tests/shell/controller/plugin-shell.test.tsx @@ -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( + {}}> + + , + ); + 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"]), + ); + }); }); diff --git a/src/ui/tests/shell/layout/LayoutHost.repair.test.tsx b/src/ui/tests/shell/layout/LayoutHost.repair.test.tsx new file mode 100644 index 000000000..67ab508e9 --- /dev/null +++ b/src/ui/tests/shell/layout/LayoutHost.repair.test.tsx @@ -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) => + 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) => {}), + saveDefaultLook: vi.fn(async (defaults: Record) => ({ + 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(), + 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: () =>

classic layout

}); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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: () =>

fancy layout

}); + await start({ + layoutId: "fancy", + defaultLayout: "classic", + summaries: [fancyPlugin], + }); + render(); + 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: () =>

fancy layout

}); + await start({ layoutId: null, defaultLayout: "fancy" }); + render(); + expect(await screen.findByText("fancy layout")).toBeTruthy(); + + cleanup(); + resetLook(); + await start({ layoutId: null, defaultLayout: null }); + render(); + 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(); + expect(await screen.findByText("looks.repair.safeTitle")).toBeTruthy(); + expect(screen.queryByText("looks.repair.retry")).toBeNull(); + + cleanup(); + window.history.replaceState(null, "", "/"); + render(); + 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(); + expect(await screen.findByText("looks.repair.safeTitle")).toBeTruthy(); + }); +}); diff --git a/src/ui/tests/shell/layout/LayoutHost.test.tsx b/src/ui/tests/shell/layout/LayoutHost.test.tsx index 3cfeb6967..254130785 100644 --- a/src/ui/tests/shell/layout/LayoutHost.test.tsx +++ b/src/ui/tests/shell/layout/LayoutHost.test.tsx @@ -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: () =>

fancy layout

}); + registerLayout({ id: "plain", component: () =>

plain layout

}); + 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( + {}}> + + + , + ); + 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(); + }); }); diff --git a/src/ui/tests/shell/stage/TabPortals.test.tsx b/src/ui/tests/shell/stage/TabPortals.test.tsx new file mode 100644 index 000000000..1cd5948a7 --- /dev/null +++ b/src/ui/tests/shell/stage/TabPortals.test.tsx @@ -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([]); + useEffect(() => { + mounts.count += 1; + }, []); + return ( +
+ +
{lines.join("\n")}
+
+ ); +} + +let shell: ShellController | null = null; +function Capture() { + shell = useShellController(); + return null; +} + +// Two layouts that put the stage in very different places. +function RailLayout() { + return ( +
+ +
+ +
+
+ ); +} +function TopBarLayout() { + return ( +
+
bar
+
+
+ +
+
+
+ ); +} + +function App() { + return ( + {}}> + + + + + ); +} + +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(); + 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(); + }); +}); diff --git a/src/ui/tests/shell/tabUtils.test.tsx b/src/ui/tests/shell/tabUtils.test.tsx index 9761877ae..d3f3dab44 100644 --- a/src/ui/tests/shell/tabUtils.test.tsx +++ b/src/ui/tests/shell/tabUtils.test.tsx @@ -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", () => {