mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import lookRoutes from "./routes/look.js";
|
||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
@@ -1616,6 +1617,7 @@ app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences/look", lookRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
|
||||
@@ -985,6 +985,8 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
|
||||
@@ -709,6 +709,8 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -709,6 +709,8 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -703,6 +703,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import {
|
||||
LAYOUT_SETTING,
|
||||
LOOK_ID_PATTERN,
|
||||
THEME_SETTING,
|
||||
resolveDefaultLook,
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
async function adminDefaults() {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
return {
|
||||
layout: await settings.get(LAYOUT_SETTING),
|
||||
theme: await settings.get(THEME_SETTING),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /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.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What the look is chosen from.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* defaults:
|
||||
* type: object
|
||||
* description: Admin defaults, null where unset.
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* defaultLook:
|
||||
* type: object
|
||||
* description: The default-look plugin's layout and theme, null where none is installed.
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* safeLayout:
|
||||
* type: boolean
|
||||
* unavailable:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* version:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* layouts:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* themes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* error:
|
||||
* type: string
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
const { loader } = getPluginRuntime();
|
||||
res.json({
|
||||
layoutId: preferences?.layoutId ?? null,
|
||||
themeId: preferences?.themeId ?? null,
|
||||
defaults: await adminDefaults(),
|
||||
defaultLook: resolveDefaultLook(loader),
|
||||
safeLayout: safeLayoutForced(),
|
||||
unavailable: unavailableLooks(loader),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get the look", error, {
|
||||
operation: "get_look",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get the look" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences/look/defaults:
|
||||
* put:
|
||||
* summary: Set the default layout and theme (admin only)
|
||||
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The defaults now in effect.
|
||||
* 400:
|
||||
* description: Not a layout or theme id.
|
||||
* 403:
|
||||
* description: Not an admin.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await createCurrentUserRepository().findById(userId);
|
||||
if (!actor?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
|
||||
const changes: Array<[string, unknown]> = [
|
||||
[LAYOUT_SETTING, body.layout],
|
||||
[THEME_SETTING, body.theme],
|
||||
];
|
||||
for (const [key, value] of changes) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||
}
|
||||
}
|
||||
const settings = createCurrentSettingsRepository();
|
||||
for (const [key, value] of changes) {
|
||||
if (value === undefined) continue;
|
||||
if (value === null) await settings.delete(key);
|
||||
else await settings.set(key, value as string);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_default_look",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json(await adminDefaults());
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set the default look", error, {
|
||||
operation: "set_default_look",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to set the default look" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -352,6 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
docs,
|
||||
defaultLook: isDefaultLookPlugin(record.id),
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
|
||||
@@ -2306,7 +2308,7 @@ router.post(
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: Other running plugins depend on it, or managed by a linked server.
|
||||
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
|
||||
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
layoutId: row?.layoutId ?? null,
|
||||
themeId: row?.themeId ?? null,
|
||||
});
|
||||
|
||||
/** Layout and theme ids, the same shape the plugin manifest allows. */
|
||||
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: The layout this user chose, overriding the admin default. Null follows the default.
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: The theme this user chose, overriding the admin default. Null follows the default.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: A layout id, or null to follow the admin default.
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: A theme id, or null to follow the admin default.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
customKeybindings,
|
||||
layoutId,
|
||||
themeId,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
layoutId?: string | null;
|
||||
themeId?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||
@@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({ layoutId, themeId })) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
(typeof value !== "string" || !LOOK_ID.test(value))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (layoutId !== undefined) updates.layoutId = layoutId;
|
||||
if (themeId !== undefined) updates.themeId = themeId;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
|
||||
// CSS files its themes declare.
|
||||
//
|
||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||
// bearer header, and nothing here is secret. It only ever serves files from
|
||||
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
|
||||
assetVersion: string | null;
|
||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||
locales: string[];
|
||||
/** Changes whenever a theme's CSS does, for its ?v= link. */
|
||||
themeVersion: string | null;
|
||||
}
|
||||
|
||||
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
|
||||
export const THEME_ASSET_PREFIX = "_themes/";
|
||||
|
||||
/** The declared CSS file of each of the plugin's themes, by theme id. */
|
||||
function themeFiles(plugin: AssetPlugin): Map<string, string> {
|
||||
const files = new Map<string, string>();
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
for (const theme of manifest.contributes?.themes ?? []) {
|
||||
const file = path.resolve(plugin.dir, theme.css);
|
||||
if (isInside(plugin.dir, file)) files.set(theme.id, file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function frontendEntry(
|
||||
@@ -72,9 +89,16 @@ export function describePluginFrontend(
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
themeVersion: null,
|
||||
};
|
||||
if (!plugin) return none;
|
||||
|
||||
const themes = [...themeFiles(plugin).values()].filter(fileExists);
|
||||
const themeVersion =
|
||||
themes.length > 0
|
||||
? contentVersion(themes.sort(), plugin.manifest.version)
|
||||
: null;
|
||||
|
||||
const locales: string[] = [];
|
||||
const localeFiles: string[] = [];
|
||||
const dir = localesDir(plugin);
|
||||
@@ -96,7 +120,8 @@ export function describePluginFrontend(
|
||||
}
|
||||
|
||||
const entry = frontendEntry(plugin);
|
||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
||||
if (!entry || !fileExists(entry.file))
|
||||
return { ...none, locales, themeVersion };
|
||||
|
||||
const cssFile = path.join(entry.dir, "frontend.css");
|
||||
const css = fileExists(cssFile);
|
||||
@@ -111,6 +136,7 @@ export function describePluginFrontend(
|
||||
css,
|
||||
assetVersion,
|
||||
locales: locales.sort(),
|
||||
themeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
|
||||
|
||||
let root: string | null;
|
||||
let rest: string;
|
||||
if (clean.startsWith("locales/")) {
|
||||
if (clean.startsWith(THEME_ASSET_PREFIX)) {
|
||||
// Only a declared theme file, which the loader checked on load.
|
||||
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
|
||||
const file = themeFiles(plugin).get(themeId);
|
||||
if (!file || !clean.endsWith(".css")) return null;
|
||||
root = path.dirname(file);
|
||||
rest = path.basename(file);
|
||||
} else if (clean.startsWith("locales/")) {
|
||||
root = localesDir(plugin);
|
||||
rest = clean.slice("locales/".length);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The onboarding defaults of the bundled plugins, from
|
||||
* The onboarding defaults and the default look of the bundled plugins, from
|
||||
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
||||
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
||||
* fresh install keeps lives in that file and not here.
|
||||
@@ -27,6 +27,7 @@ interface BundledIndex {
|
||||
consent?: boolean;
|
||||
enabledByEnv?: string[];
|
||||
};
|
||||
defaultLook?: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled plugins whose layout and theme a fresh install shows. Core reads
|
||||
* the default look from here so it never has to name a plugin.
|
||||
*/
|
||||
export function defaultLookPluginIds(): string[] {
|
||||
const index = readBundledIndex();
|
||||
if (!index) return [];
|
||||
return Object.entries(index.plugins)
|
||||
.filter(([, entry]) => entry?.defaultLook === true)
|
||||
.map(([id]) => id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function isDefaultLookPlugin(pluginId: string): boolean {
|
||||
return defaultLookPluginIds().includes(pluginId);
|
||||
}
|
||||
|
||||
export function resetBundledIndexCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -96,6 +114,8 @@ export function initialPluginState(
|
||||
): "enabled" | "disabled" {
|
||||
if (source !== "bundled") return "disabled";
|
||||
if (!freshInstall) return "enabled";
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent ? "enabled" : "disabled";
|
||||
}
|
||||
@@ -18,7 +18,11 @@ import path from "node:path";
|
||||
import semver from "semver";
|
||||
import * as tar from "tar";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isTermixCompatible, parseManifest } from "./manifest.js";
|
||||
import {
|
||||
isTermixCompatible,
|
||||
parseManifest,
|
||||
validateThemeCss,
|
||||
} from "./manifest.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
|
||||
signedTier?: KeyTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin folder that did not load. Kept so the layout repair view can say
|
||||
* why a chosen layout or theme is missing instead of only "not installed".
|
||||
*/
|
||||
export interface SkippedPlugin {
|
||||
/** Manifest id when it could be read, else the folder or file name. */
|
||||
id: string;
|
||||
version: string | null;
|
||||
layouts: string[];
|
||||
themes: string[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
errorThreshold?: number;
|
||||
errorWindowMs?: number;
|
||||
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
|
||||
onFailed?: (plugin: LoadedPlugin) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every theme file a manifest declares must sit inside the plugin and hold
|
||||
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
|
||||
* being served.
|
||||
*/
|
||||
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
|
||||
for (const theme of manifest.contributes?.themes ?? []) {
|
||||
const file = path.join(dir, theme.css);
|
||||
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
|
||||
);
|
||||
}
|
||||
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What can still be said about a plugin folder that failed to load. */
|
||||
function describeSkipped(
|
||||
dir: string,
|
||||
name: string,
|
||||
isArtifact: boolean,
|
||||
error: unknown,
|
||||
): SkippedPlugin {
|
||||
const skipped: SkippedPlugin = {
|
||||
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
|
||||
version: null,
|
||||
layouts: [],
|
||||
themes: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
if (isArtifact) return skipped;
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
|
||||
) as {
|
||||
id?: unknown;
|
||||
version?: unknown;
|
||||
contributes?: { layouts?: unknown; themes?: unknown };
|
||||
};
|
||||
const ids = (list: unknown) =>
|
||||
Array.isArray(list)
|
||||
? list
|
||||
.map((entry) => (entry as { id?: unknown })?.id)
|
||||
.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
if (typeof raw.id === "string") skipped.id = raw.id;
|
||||
if (typeof raw.version === "string") skipped.version = raw.version;
|
||||
skipped.layouts = ids(raw.contributes?.layouts);
|
||||
skipped.themes = ids(raw.contributes?.themes);
|
||||
} catch {
|
||||
// An unreadable manifest leaves only the folder name.
|
||||
}
|
||||
return skipped;
|
||||
}
|
||||
|
||||
export class PluginLoader {
|
||||
private readonly plugins = new Map<string, LoadedPlugin>();
|
||||
private skippedPlugins: SkippedPlugin[] = [];
|
||||
/** Version of each bundled plugin as shipped, the floor for any update. */
|
||||
private readonly bundledVersions = new Map<string, string>();
|
||||
/** Activation order, so shutdown can run it backwards. */
|
||||
@@ -113,6 +192,11 @@ export class PluginLoader {
|
||||
return [...this.plugins.values()];
|
||||
}
|
||||
|
||||
/** Plugin folders the last full load skipped, with the reason. */
|
||||
skipped(): SkippedPlugin[] {
|
||||
return [...this.skippedPlugins];
|
||||
}
|
||||
|
||||
get(pluginId: string): LoadedPlugin | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
@@ -210,6 +294,8 @@ export class PluginLoader {
|
||||
);
|
||||
}
|
||||
|
||||
validatePluginThemes(dir, manifest);
|
||||
|
||||
if (this.plugins.has(manifest.id)) {
|
||||
throw new Error(`another plugin already uses the id "${manifest.id}"`);
|
||||
}
|
||||
@@ -258,6 +344,7 @@ export class PluginLoader {
|
||||
async loadAll(): Promise<LoadedPlugin[]> {
|
||||
this.plugins.clear();
|
||||
this.bundledVersions.clear();
|
||||
this.skippedPlugins = [];
|
||||
await removeStaleStaging();
|
||||
const loaded: LoadedPlugin[] = [];
|
||||
// Every bundled folder name, loaded or not: a bundled plugin that fails
|
||||
@@ -322,6 +409,9 @@ export class PluginLoader {
|
||||
}
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
this.skippedPlugins.push(
|
||||
describeSkipped(dir, entry.name, isArtifact, error),
|
||||
);
|
||||
pluginLogger.error(
|
||||
`Skipping plugin directory ${entry.name} in ${root}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Layouts and themes on the server side: which look a fresh install shows,
|
||||
* and which installed looks could not load. Core names no plugin here; the
|
||||
* default look comes from the bundled index's defaultLook flag.
|
||||
*/
|
||||
|
||||
import { defaultLookPluginIds } from "./bundled-index.js";
|
||||
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
|
||||
|
||||
export interface DefaultLook {
|
||||
layout: string | null;
|
||||
theme: string | null;
|
||||
}
|
||||
|
||||
interface LookLoader {
|
||||
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
|
||||
skipped(): SkippedPlugin[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first default-look plugin's default layout (else its first one) and
|
||||
* that layout's theme. A default-look plugin with themes only fills in the
|
||||
* theme. Null where none is installed, and the browser uses its built-in.
|
||||
*/
|
||||
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
|
||||
const look: DefaultLook = { layout: null, theme: null };
|
||||
for (const id of defaultLookPluginIds()) {
|
||||
const contributes = loader.get(id)?.manifest.contributes;
|
||||
const layouts = contributes?.layouts ?? [];
|
||||
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
|
||||
if (layout && !look.layout) {
|
||||
look.layout = layout.id;
|
||||
look.theme ??= layout.defaultTheme ?? null;
|
||||
}
|
||||
if (!look.theme && contributes?.themes?.length) {
|
||||
look.theme = contributes.themes[0].id;
|
||||
}
|
||||
}
|
||||
return look;
|
||||
}
|
||||
|
||||
/** Plugins with a layout or theme that were found but did not load. */
|
||||
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
|
||||
return loader
|
||||
.skipped()
|
||||
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
|
||||
}
|
||||
|
||||
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
|
||||
export function safeLayoutForced(): boolean {
|
||||
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
|
||||
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
export const LAYOUT_SETTING = "ui.layout";
|
||||
export const THEME_SETTING = "ui.theme";
|
||||
@@ -7,7 +7,9 @@
|
||||
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
||||
* image and can still be uninstalled: their files are deleted and the id is
|
||||
* recorded in the settings table, so the shipped copy is ignored from then
|
||||
* on and a reinstall downloads the plugin like any other.
|
||||
* on and a reinstall downloads the plugin like any other. The one exception
|
||||
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
|
||||
* it can be disabled or swapped but never uninstalled.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
@@ -15,6 +17,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
deactivatePlugin,
|
||||
@@ -1154,6 +1157,13 @@ export async function uninstallPluginUnlocked(
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const shipped = loader.bundledIds().has(pluginId);
|
||||
if (shipped && isDefaultLookPlugin(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
"The default look cannot be uninstalled",
|
||||
409,
|
||||
"DEFAULT_LOOK",
|
||||
);
|
||||
}
|
||||
|
||||
const dependents = dependentChain(pluginId, await enabledIds());
|
||||
if (dependents.length > 0) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
validateThemeCss,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
admins: new Set(["admin-1"]),
|
||||
prefs: null as { layoutId: string | null; themeId: string | null } | null,
|
||||
settings: new Map<string, string>(),
|
||||
audits: [] as unknown[],
|
||||
skipped: [] as unknown[],
|
||||
defaultLookIds: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: unknown) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: express.Request, _res: unknown, next: () => void) => {
|
||||
(req as unknown as { userId: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async () => state.prefs,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({
|
||||
id,
|
||||
username: id,
|
||||
isAdmin: state.admins.has(id),
|
||||
}),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
state.settings.delete(key);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
get: (id: string) =>
|
||||
id === "shipped-look"
|
||||
? {
|
||||
manifest: {
|
||||
contributes: {
|
||||
layouts: [
|
||||
{ id: "shipped", label: "Shipped", defaultTheme: "ink" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
skipped: () => state.skipped,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../plugins/bundled-index.js", () => ({
|
||||
defaultLookPluginIds: () => state.defaultLookIds,
|
||||
}));
|
||||
|
||||
const { default: lookRoutes } =
|
||||
await import("../../../database/routes/look.js");
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.userId = "user-1";
|
||||
state.prefs = null;
|
||||
state.settings.clear();
|
||||
state.audits = [];
|
||||
state.skipped = [];
|
||||
state.defaultLookIds = [];
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/user-preferences/look", lookRoutes);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function getLook() {
|
||||
const res = await fetch(`${baseUrl}/user-preferences/look`);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function putDefaults(body: unknown) {
|
||||
const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
describe("GET /user-preferences/look", () => {
|
||||
it("is all empty on a fresh install", async () => {
|
||||
expect((await getLook()).body).toEqual({
|
||||
layoutId: null,
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the user's pick, the admin defaults and the default look", async () => {
|
||||
state.prefs = { layoutId: "mine", themeId: "nord" };
|
||||
state.settings.set("ui.layout", "admin-layout");
|
||||
state.settings.set("ui.theme", "gruvbox");
|
||||
state.defaultLookIds = ["shipped-look"];
|
||||
state.skipped = [
|
||||
{
|
||||
id: "broken",
|
||||
version: "1.0.0",
|
||||
layouts: ["b"],
|
||||
themes: [],
|
||||
error: "x",
|
||||
},
|
||||
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
|
||||
];
|
||||
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||
|
||||
const { body } = await getLook();
|
||||
expect(body).toMatchObject({
|
||||
layoutId: "mine",
|
||||
themeId: "nord",
|
||||
defaults: { layout: "admin-layout", theme: "gruvbox" },
|
||||
defaultLook: { layout: "shipped", theme: "ink" },
|
||||
safeLayout: true,
|
||||
});
|
||||
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
|
||||
["broken"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /user-preferences/look/defaults", () => {
|
||||
it("is for admins only", async () => {
|
||||
const { status } = await putDefaults({ layout: "x" });
|
||||
expect(status).toBe(403);
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
|
||||
it("sets and clears the defaults, and audits it", async () => {
|
||||
state.userId = "admin-1";
|
||||
let res = await putDefaults({ layout: "fancy", theme: "nord" });
|
||||
expect(res).toEqual({
|
||||
status: 200,
|
||||
body: { layout: "fancy", theme: "nord" },
|
||||
});
|
||||
res = await putDefaults({ layout: null });
|
||||
expect(res.body).toEqual({ layout: null, theme: "nord" });
|
||||
expect(state.audits).toHaveLength(2);
|
||||
expect(state.audits[0]).toMatchObject({ action: "update_default_look" });
|
||||
});
|
||||
|
||||
it("refuses something that is not an id", async () => {
|
||||
state.userId = "admin-1";
|
||||
expect((await putDefaults({ theme: "../evil" })).status).toBe(400);
|
||||
expect((await putDefaults({ layout: 5 })).status).toBe(400);
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -433,6 +433,7 @@ describe("plugins route", () => {
|
||||
"name",
|
||||
"optionalDependencies",
|
||||
"state",
|
||||
"themeVersion",
|
||||
"version",
|
||||
].sort(),
|
||||
);
|
||||
@@ -624,10 +625,12 @@ describe("plugins route", () => {
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
defaultLook: false,
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
themeVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
row: null as Record<string, unknown> | null,
|
||||
upserts: [] as Record<string, unknown>[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: express.Request, _res: unknown, next: () => void) => {
|
||||
(req as unknown as { userId: string }).userId = "user-1";
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async () => state.row,
|
||||
upsert: async (_userId: string, updates: Record<string, unknown>) => {
|
||||
state.upserts.push(updates);
|
||||
state.row = { ...(state.row ?? {}), ...updates };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
|
||||
isValidKeybinding: () => true,
|
||||
}));
|
||||
|
||||
const { default: routes } =
|
||||
await import("../../../database/routes/user-preferences.js");
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.row = null;
|
||||
state.upserts = [];
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/user-preferences", routes);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function put(body: unknown) {
|
||||
const res = await fetch(`${baseUrl}/user-preferences`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
describe("user preferences: layout and theme", () => {
|
||||
it("reads null for both until chosen", async () => {
|
||||
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
|
||||
expect(body).toMatchObject({ layoutId: null, themeId: null });
|
||||
});
|
||||
|
||||
it("saves a layout and theme id, and null to follow the default", async () => {
|
||||
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(state.upserts[0]).toMatchObject({
|
||||
layoutId: "fancy",
|
||||
themeId: "nord",
|
||||
});
|
||||
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
|
||||
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
|
||||
|
||||
await put({ layoutId: null });
|
||||
expect(state.upserts[1]).toMatchObject({ layoutId: null });
|
||||
expect(state.upserts[1]).not.toHaveProperty("themeId");
|
||||
});
|
||||
|
||||
it("refuses anything that is not an id", async () => {
|
||||
for (const bad of ["Fancy", "../x", "a", 7]) {
|
||||
expect((await put({ themeId: bad })).status).toBe(400);
|
||||
}
|
||||
expect(state.upserts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
const shell = fs.readFileSync(
|
||||
new URL("../../../ui/AppShell.tsx", import.meta.url),
|
||||
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ describe("describePluginFrontend", () => {
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
themeVersion: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme stylesheets", () => {
|
||||
let themed: { id: string; dir: string; manifest: never };
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = path.join(root, "inks");
|
||||
fs.mkdirSync(path.join(dir, "themes"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}");
|
||||
fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}");
|
||||
themed = {
|
||||
id: "inks",
|
||||
dir,
|
||||
manifest: {
|
||||
id: "inks",
|
||||
version: "1.0.0",
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
});
|
||||
|
||||
it("serves a declared theme's CSS under _themes/<id>.css only", () => {
|
||||
expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe(
|
||||
path.join(themed.dir, "themes", "paper.css"),
|
||||
);
|
||||
expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull();
|
||||
expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull();
|
||||
expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("versions theme files even without a frontend bundle", () => {
|
||||
const info = describePluginFrontend(themed);
|
||||
expect(info.frontend).toBe(false);
|
||||
expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(describePluginFrontend(plugin).themeVersion).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
defaultLookPluginIds,
|
||||
initialPluginState,
|
||||
isDefaultLookPlugin,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
@@ -103,3 +105,28 @@ describe("initialPluginState", () => {
|
||||
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
it("lists the plugins flagged as the default look", () => {
|
||||
writeIndex({
|
||||
"some-layout": { defaultLook: true },
|
||||
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
|
||||
other: { onboarding: { recommended: true } },
|
||||
});
|
||||
expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]);
|
||||
expect(isDefaultLookPlugin("some-themes")).toBe(true);
|
||||
expect(isDefaultLookPlugin("other")).toBe(false);
|
||||
});
|
||||
|
||||
it("has none without an index", () => {
|
||||
expect(defaultLookPluginIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("starts the default look enabled even on a fresh install", () => {
|
||||
writeIndex({
|
||||
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
|
||||
});
|
||||
expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled");
|
||||
expect(initialPluginState("some-themes", "user", true)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader themes and skipped looks", () => {
|
||||
const themed = {
|
||||
capabilities: ["ui:theme", "ui:layout"],
|
||||
manifestOverrides: {
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
layouts: [{ id: "fancy", label: "Fancy" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("loads a plugin whose theme CSS is allowed", async () => {
|
||||
const fixture = createFixturePlugin(themed);
|
||||
cleanups.push(fixture.cleanup);
|
||||
fs.mkdirSync(path.join(fixture.dir, "themes"));
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.dir, "themes", "paper.css"),
|
||||
".paper { --background: #fff; }",
|
||||
);
|
||||
const plugin = await new PluginLoader().load(fixture.dir, "bundled");
|
||||
expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper");
|
||||
});
|
||||
|
||||
it("refuses a plugin whose theme CSS reaches outside", async () => {
|
||||
const fixture = createFixturePlugin(themed);
|
||||
cleanups.push(fixture.cleanup);
|
||||
fs.mkdirSync(path.join(fixture.dir, "themes"));
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.dir, "themes", "paper.css"),
|
||||
'@import url("https://example.com/x.css");',
|
||||
);
|
||||
await expect(
|
||||
new PluginLoader().load(fixture.dir, "bundled"),
|
||||
).rejects.toThrow(/theme paper is not allowed: @import/);
|
||||
});
|
||||
|
||||
it("refuses a plugin whose theme file is missing", async () => {
|
||||
const fixture = createFixturePlugin(themed);
|
||||
cleanups.push(fixture.cleanup);
|
||||
await expect(
|
||||
new PluginLoader().load(fixture.dir, "bundled"),
|
||||
).rejects.toThrow(/theme paper file themes\/paper.css is missing/);
|
||||
});
|
||||
|
||||
it("remembers why a layout plugin was skipped", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
...themed,
|
||||
id: "fancy-layout",
|
||||
root: bundled,
|
||||
manifestOverrides: {
|
||||
...themed.manifestOverrides,
|
||||
version: "2.0.0",
|
||||
engine: { termix: ">=99.0.0", api: "1" },
|
||||
},
|
||||
});
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
const [skipped] = loader.skipped();
|
||||
expect(skipped).toMatchObject({
|
||||
id: "fancy-layout",
|
||||
version: "2.0.0",
|
||||
layouts: ["fancy"],
|
||||
themes: ["paper"],
|
||||
});
|
||||
expect(skipped.error).toMatch(/needs Termix >=99.0.0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader dependency resolution", () => {
|
||||
async function loadAll(
|
||||
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const index = vi.hoisted(() => ({ ids: [] as string[] }));
|
||||
vi.mock("../../plugins/bundled-index.js", () => ({
|
||||
defaultLookPluginIds: () => index.ids,
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveDefaultLook,
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
import type { SkippedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
type Contributes = Record<string, unknown>;
|
||||
|
||||
function loaderWith(
|
||||
plugins: Record<string, Contributes>,
|
||||
skipped: SkippedPlugin[] = [],
|
||||
) {
|
||||
return {
|
||||
get: (id: string) =>
|
||||
plugins[id]
|
||||
? ({ manifest: { contributes: plugins[id] } } as never)
|
||||
: undefined,
|
||||
skipped: () => skipped,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
index.ids = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
});
|
||||
|
||||
describe("resolveDefaultLook", () => {
|
||||
it("is empty without a default-look plugin", () => {
|
||||
expect(resolveDefaultLook(loaderWith({}))).toEqual({
|
||||
layout: null,
|
||||
theme: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the layout marked default and its theme", () => {
|
||||
index.ids = ["look-a"];
|
||||
const loader = loaderWith({
|
||||
"look-a": {
|
||||
layouts: [
|
||||
{ id: "first", label: "First", defaultTheme: "one" },
|
||||
{ id: "main", label: "Main", defaultTheme: "two", default: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(resolveDefaultLook(loader)).toEqual({
|
||||
layout: "main",
|
||||
theme: "two",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the first layout, and a theme from a themes-only plugin", () => {
|
||||
index.ids = ["look-a", "look-b"];
|
||||
const loader = loaderWith({
|
||||
"look-a": { themes: [{ id: "paper", label: "Paper" }] },
|
||||
"look-b": { layouts: [{ id: "solo", label: "Solo" }] },
|
||||
});
|
||||
expect(resolveDefaultLook(loader)).toEqual({
|
||||
layout: "solo",
|
||||
theme: "paper",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a default-look plugin that did not load", () => {
|
||||
index.ids = ["gone"];
|
||||
expect(resolveDefaultLook(loaderWith({}))).toEqual({
|
||||
layout: null,
|
||||
theme: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unavailableLooks", () => {
|
||||
it("lists only skipped plugins that brought a layout or theme", () => {
|
||||
const skipped: SkippedPlugin[] = [
|
||||
{ id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" },
|
||||
{ id: "b", version: null, layouts: [], themes: [], error: "e" },
|
||||
{ id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" },
|
||||
];
|
||||
expect(
|
||||
unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id),
|
||||
).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeLayoutForced", () => {
|
||||
it("reads TERMIX_SAFE_LAYOUT", () => {
|
||||
expect(safeLayoutForced()).toBe(false);
|
||||
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||
expect(safeLayoutForced()).toBe(true);
|
||||
process.env.TERMIX_SAFE_LAYOUT = "true";
|
||||
expect(safeLayoutForced()).toBe(true);
|
||||
process.env.TERMIX_SAFE_LAYOUT = "0";
|
||||
expect(safeLayoutForced()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => {
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
});
|
||||
|
||||
it("refuses to uninstall the default look but lets it be turned off", async () => {
|
||||
createFixturePlugin({ id: "some-look", root: bundled });
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "bundled-index.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
plugins: { "some-look": { defaultLook: true } },
|
||||
}),
|
||||
);
|
||||
const { resetBundledIndexCache } =
|
||||
await import("../../plugins/bundled-index.js");
|
||||
resetBundledIndexCache();
|
||||
try {
|
||||
const loader = await boot();
|
||||
await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({
|
||||
code: "DEFAULT_LOOK",
|
||||
status: 409,
|
||||
});
|
||||
expect(loader.get("some-look")?.state).toBe("active");
|
||||
expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true });
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes a plugin dropped in as a plain folder", async () => {
|
||||
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
||||
const loader = await boot();
|
||||
|
||||
@@ -1002,3 +1002,79 @@ describe("docs and env fields", () => {
|
||||
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layouts and themes", () => {
|
||||
const layouts = [
|
||||
{
|
||||
id: "fancy",
|
||||
label: "layout.fancy",
|
||||
defaultTheme: "paper",
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
const themes = [
|
||||
{
|
||||
id: "paper",
|
||||
label: "theme.paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
|
||||
},
|
||||
];
|
||||
|
||||
it("accepts layouts and themes with their capabilities", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
base({
|
||||
capabilities: ["ui:layout", "ui:theme"],
|
||||
contributes: { layouts, themes },
|
||||
}),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
|
||||
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
|
||||
});
|
||||
|
||||
it("needs ui:layout for layouts and ui:theme for themes", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
|
||||
);
|
||||
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
|
||||
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses bad ids, modes, paths and swatches", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
capabilities: ["ui:layout", "ui:theme"],
|
||||
contributes: {
|
||||
layouts: [
|
||||
{ id: "Fancy", label: "x" },
|
||||
{ id: "two", label: "x", default: true },
|
||||
{ id: "three", label: "x", default: true, extra: 1 },
|
||||
],
|
||||
themes: [
|
||||
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
|
||||
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
|
||||
{
|
||||
id: "dusk",
|
||||
label: "x",
|
||||
mode: "dark",
|
||||
css: "dusk.css",
|
||||
swatch: { background: "red", panel: "#000", text: "#fff" },
|
||||
},
|
||||
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const has = (text: string) => errors.some((e) => e.includes(text));
|
||||
expect(has("contributes.layouts[0].id must match")).toBe(true);
|
||||
expect(has("Only one entry")).toBe(true);
|
||||
expect(has('Unknown field "extra"')).toBe(true);
|
||||
expect(has('mode must be "light" or "dark"')).toBe(true);
|
||||
expect(has("relative path inside the plugin")).toBe(true);
|
||||
expect(has("must be a .css file")).toBe(true);
|
||||
expect(has("swatch.background must be a hex color")).toBe(true);
|
||||
expect(has('duplicates "dusk"')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateThemeCss } from "../../plugins/manifest.js";
|
||||
|
||||
describe("validateThemeCss", () => {
|
||||
it("accepts token rules scoped under the theme class", () => {
|
||||
const css = `
|
||||
/* Paper */
|
||||
.paper { --background: #fff; --foreground: #111; }
|
||||
:root.paper, html.paper .card { --card: #f8f8f8; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.paper * { transition: none; }
|
||||
}
|
||||
@supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } }
|
||||
.paper .logo { background: url("logo.svg") no-repeat; }
|
||||
.paper .dot { background: url(data:image/png;base64,AAAA); }
|
||||
`;
|
||||
expect(validateThemeCss(css, "paper")).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses selectors outside the theme", () => {
|
||||
const errors = validateThemeCss(
|
||||
".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }",
|
||||
"paper",
|
||||
);
|
||||
expect(errors).toHaveLength(3);
|
||||
expect(errors[0]).toContain('"body"');
|
||||
});
|
||||
|
||||
it("refuses anything that loads or runs", () => {
|
||||
const errors = validateThemeCss(
|
||||
`@import url("https://evil.example/x.css");
|
||||
.paper { background: url(https://evil.example/a.png); }
|
||||
.paper { background: url(//evil.example/a.png); }
|
||||
.paper { width: expression(alert(1)); }
|
||||
.paper { background: url(javascript:alert(1)); }
|
||||
.paper { behavior: url(x.htc); }
|
||||
@font-face { font-family: x; src: url(x.woff2); }
|
||||
@keyframes spin { from { opacity: 0 } }`,
|
||||
"paper",
|
||||
);
|
||||
const has = (text: string) => errors.some((e) => e.includes(text));
|
||||
expect(has("@import is not allowed")).toBe(true);
|
||||
expect(has("url(https://evil.example/a.png) points outside")).toBe(true);
|
||||
expect(has("url(//evil.example/a.png) points outside")).toBe(true);
|
||||
expect(has("expression() is not allowed")).toBe(true);
|
||||
expect(has("javascript: is not allowed")).toBe(true);
|
||||
expect(has("Script bindings")).toBe(true);
|
||||
expect(has("@font-face is not allowed")).toBe(true);
|
||||
expect(has("@keyframes is not allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses nested rules and broken braces", () => {
|
||||
expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([
|
||||
'Nested rules are not allowed in ".paper"',
|
||||
]);
|
||||
expect(validateThemeCss(".paper { color: red", "paper")).toEqual([
|
||||
"A rule is missing its closing brace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not trip on braces inside strings", () => {
|
||||
expect(
|
||||
validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
} from "@/plugin-host/loader";
|
||||
import { settledPromise } from "@/plugin-host/plugin-store";
|
||||
import { resetShellBridge } from "@/plugin-host/shell-bridge";
|
||||
import { loadLook, resetLook } from "@/look/look-store";
|
||||
import { resetPluginThemes } from "@/look/theme-registry";
|
||||
import { resetPluginLayouts } from "@/look/layout-registry";
|
||||
import { preloadPermissions } from "@/hooks/use-permissions";
|
||||
import { getUserPreferences } from "@/api/open-tabs-api";
|
||||
import { standaloneViewFor } from "@/shell/tab-registry";
|
||||
@@ -209,6 +212,7 @@ function App() {
|
||||
.then(() => settledPromise()),
|
||||
preloadPermissions(),
|
||||
getUserPreferences().catch(() => undefined),
|
||||
loadLook(),
|
||||
]);
|
||||
await enterApp();
|
||||
})
|
||||
@@ -296,6 +300,7 @@ function App() {
|
||||
.then(() => settledPromise()),
|
||||
preloadPermissions(),
|
||||
getUserPreferences().catch(() => undefined),
|
||||
loadLook(),
|
||||
]);
|
||||
await enterApp();
|
||||
})();
|
||||
@@ -321,6 +326,9 @@ function App() {
|
||||
// should survive into the next session.
|
||||
void stopPluginRuntime();
|
||||
resetShellBridge();
|
||||
resetLook();
|
||||
resetPluginThemes();
|
||||
resetPluginLayouts();
|
||||
clearStoredAuth();
|
||||
localStorage.removeItem("jwt");
|
||||
if (isElectron() && options?.manual) {
|
||||
|
||||
@@ -223,7 +223,8 @@ export type AdminSection =
|
||||
| "api-keys"
|
||||
| "audit-log"
|
||||
| "ssl";
|
||||
export type ThemeId =
|
||||
export type ThemeId = string;
|
||||
export type LegacyThemeId =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "system"
|
||||
|
||||
+3
-3174
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
/** A layout or theme plugin the server found but could not load. */
|
||||
export interface UnavailableLook {
|
||||
id: string;
|
||||
version: string | null;
|
||||
layouts: string[];
|
||||
themes: string[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Everything the browser picks the layout and theme from. */
|
||||
export interface LookResponse {
|
||||
layoutId: string | null;
|
||||
themeId: string | null;
|
||||
/** Admin defaults. */
|
||||
defaults: { layout: string | null; theme: string | null };
|
||||
/** The default-look plugin's layout and theme. */
|
||||
defaultLook: { layout: string | null; theme: string | null };
|
||||
/** TERMIX_SAFE_LAYOUT is set on the server. */
|
||||
safeLayout: boolean;
|
||||
unavailable: UnavailableLook[];
|
||||
}
|
||||
|
||||
export async function getLook(): Promise<LookResponse> {
|
||||
const response = await authApi.get("/user-preferences/look");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** The user's own layout and theme; null follows the default. */
|
||||
export async function saveUserLook(look: {
|
||||
layoutId?: string | null;
|
||||
themeId?: string | null;
|
||||
}): Promise<void> {
|
||||
await authApi.put("/user-preferences", look);
|
||||
}
|
||||
|
||||
/** Admin only. Null clears a default. */
|
||||
export async function saveDefaultLook(defaults: {
|
||||
layout?: string | null;
|
||||
theme?: string | null;
|
||||
}): Promise<{ layout: string | null; theme: string | null }> {
|
||||
const response = await authApi.put(
|
||||
"/user-preferences/look/defaults",
|
||||
defaults,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -116,6 +116,10 @@ export interface UserPreferences {
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
/** Layout this user chose; null follows the admin default. */
|
||||
layoutId?: string | null;
|
||||
/** Theme this user chose; null follows the admin default. */
|
||||
themeId?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomKeybindings(
|
||||
|
||||
@@ -78,6 +78,22 @@ export interface PluginViewContribution {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PluginLayoutContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultTheme?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
css: string;
|
||||
swatch?: { background: string; panel: string; text: string };
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
@@ -87,6 +103,8 @@ export interface PluginContributions {
|
||||
settings?: PluginSettingsContribution;
|
||||
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
||||
uiPresets?: PluginUiPresets;
|
||||
layouts?: PluginLayoutContribution[];
|
||||
themes?: PluginThemeContribution[];
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -236,6 +254,10 @@ export interface PluginSummary {
|
||||
assetVersion?: string | null;
|
||||
/** "en" plus the xx_YY names of shipped translations. */
|
||||
locales?: string[];
|
||||
/** Cache key for the theme CSS files; null without themes. */
|
||||
themeVersion?: string | null;
|
||||
/** The app is drawn with this plugin by default; it cannot be uninstalled. */
|
||||
defaultLook?: boolean;
|
||||
|
||||
tier?: string;
|
||||
source?: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { ThemeId } from "@/types/ui-types";
|
||||
import { SYSTEM_THEME, getTheme, useThemes } from "@/look/theme-registry";
|
||||
import { applyThemeCss } from "@/look/theme-css";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -10,22 +12,11 @@ type ThemeProviderProps = {
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: ThemeId;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
/** Pass persist false to show a theme without keeping it in this browser. */
|
||||
setTheme: (theme: ThemeId, persist?: boolean) => void;
|
||||
setThemePreview: (theme: ThemeId | null) => void;
|
||||
};
|
||||
|
||||
const ALL_THEME_CLASSES = [
|
||||
"light",
|
||||
"dark",
|
||||
"dracula",
|
||||
"catppuccin",
|
||||
"nord",
|
||||
"solarized",
|
||||
"tokyo-night",
|
||||
"one-dark",
|
||||
"gruvbox",
|
||||
];
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "dark",
|
||||
setTheme: () => null,
|
||||
@@ -34,6 +25,9 @@ const initialState: ThemeProviderState = {
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
/** The class last put on <html>, in case its theme has since gone away. */
|
||||
let appliedClass: string | null = null;
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "dark",
|
||||
@@ -44,29 +38,31 @@ export function ThemeProvider({
|
||||
() => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme,
|
||||
);
|
||||
const [previewTheme, setPreviewTheme] = useState<ThemeId | null>(null);
|
||||
// Plugin themes arrive after login; the class and CSS follow them.
|
||||
const themes = useThemes();
|
||||
|
||||
const activeTheme = previewTheme ?? theme;
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove(...ALL_THEME_CLASSES);
|
||||
root.classList.remove(...themes.map((entry) => entry.id));
|
||||
if (appliedClass) root.classList.remove(appliedClass);
|
||||
|
||||
if (activeTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(activeTheme);
|
||||
}, [activeTheme]);
|
||||
const applied =
|
||||
activeTheme === SYSTEM_THEME
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: activeTheme;
|
||||
root.classList.add(applied);
|
||||
appliedClass = applied;
|
||||
applyThemeCss(applied, getTheme(applied));
|
||||
}, [activeTheme, themes]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: ThemeId) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme: (theme: ThemeId, persist = true) => {
|
||||
if (persist) localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
setThemePreview: (preview: ThemeId | null) => {
|
||||
|
||||
@@ -1770,6 +1770,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"looks": {
|
||||
"builtinLayout": "Classic",
|
||||
"layoutLabel": "Layout",
|
||||
"layoutHint": "How the whole app is laid out. Themes and layouts come from plugins.",
|
||||
"defaultsLabel": "Default for everyone",
|
||||
"defaultsHint": "What people see until they choose their own layout or theme.",
|
||||
"followDefault": "Use the default",
|
||||
"defaultSaved": "Default look saved",
|
||||
"saveFailed": "Could not save the look",
|
||||
"repair": {
|
||||
"title": "This layout could not be shown",
|
||||
"safeTitle": "Safe mode",
|
||||
"layout": "Layout",
|
||||
"plugin": "Plugin",
|
||||
"reason": "Reason",
|
||||
"error": "Error",
|
||||
"noError": "No error message.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Could not copy the details",
|
||||
"retry": "Try again",
|
||||
"reset": "Reset to the default look",
|
||||
"resetEveryone": "Reset the default for everyone",
|
||||
"settings": "Open Settings",
|
||||
"back": "Back",
|
||||
"update": "Update {{name}} to {{version}}",
|
||||
"disable": "Turn off {{name}}",
|
||||
"install": "Find layouts and themes",
|
||||
"installTitle": "Layouts and themes",
|
||||
"installButton": "Install",
|
||||
"nothingToInstall": "No other layouts or themes are available right now.",
|
||||
"registryUnreachable": "The plugin registry could not be reached.",
|
||||
"askAdmin": "Ask an admin to update or fix this layout. You can switch to another layout or theme for yourself below.",
|
||||
"switchLayout": "Switch layout",
|
||||
"switchTheme": "Switch theme",
|
||||
"saveFailed": "Could not save your choice",
|
||||
"reasons": {
|
||||
"safe-url": "The address asked for safe mode. Pick a layout below, or remove ?safe-layout=1 from the address.",
|
||||
"safe-env": "The server is started with TERMIX_SAFE_LAYOUT, so everyone sees this screen until it is removed.",
|
||||
"missing": "No installed plugin provides this layout.",
|
||||
"disabled": "The plugin that provides this layout is turned off.",
|
||||
"blocked": "The plugin that provides this layout is waiting on something it needs.",
|
||||
"incompatible": "The plugin that provides this layout does not work with this version of Termix.",
|
||||
"failed": "The layout failed to start.",
|
||||
"crashed": "The layout crashed twice while drawing the app."
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"capabilities": {
|
||||
"credentials:read": {
|
||||
@@ -1887,6 +1935,14 @@
|
||||
"ui:surface": {
|
||||
"title": "Add its own screens",
|
||||
"consequence": "Tabs, panels and settings you can hide later."
|
||||
},
|
||||
"ui:layout": {
|
||||
"title": "Replace the whole app layout",
|
||||
"consequence": "When chosen, its code draws every screen, including your terminals and settings."
|
||||
},
|
||||
"ui:theme": {
|
||||
"title": "Add color themes",
|
||||
"consequence": "Only colors and styles. No code runs for a theme."
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
@@ -2016,6 +2072,7 @@
|
||||
"options": "Could not save options for {{name}}: {{error}}",
|
||||
"deleteData": "Could not delete data for {{name}}: {{error}}",
|
||||
"managedByServer": "This desktop is linked to a server, which decides its plugins.",
|
||||
"defaultLook": "{{name}} is the default look, so it can be turned off or swapped but not uninstalled.",
|
||||
"hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.",
|
||||
"missingDependencies": "{{name}} needs {{list}}, which is not installed.",
|
||||
"consentChanged": "What this plugin asks for changed since you looked. Check the list again.",
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
LayoutTemplate,
|
||||
Palette,
|
||||
Power,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { ListRow } from "@/components/list-kit";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
|
||||
import { usePluginsManager } from "@/plugins/use-plugins-manager";
|
||||
import { PluginIconBox } from "@/plugins/plugin-bits";
|
||||
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
ShellConfirmCapture,
|
||||
useShellController,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
import { TabStage } from "@/shell/stage/TabStage";
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
chooseThemeForAccount,
|
||||
} from "./look-store";
|
||||
import { layoutLabel, useLayouts } from "./layout-registry";
|
||||
import { themeLabel, useThemes } from "./theme-registry";
|
||||
import { useChooseTheme } from "./use-look-theme";
|
||||
import type { LayoutStatus } from "./layout-status";
|
||||
|
||||
type RepairStatus = Extract<LayoutStatus, { kind: "repair" }>;
|
||||
|
||||
const LOOK_CAPABILITIES = ["ui:layout", "ui:theme"];
|
||||
|
||||
/**
|
||||
* What an admin can do about a broken layout: update or disable its plugin,
|
||||
* and install another layout or theme. Uses the Plugins tab's own flows.
|
||||
*/
|
||||
function AdminTools({
|
||||
status,
|
||||
installOpen,
|
||||
setInstallOpen,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
installOpen: boolean;
|
||||
setInstallOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const manager = usePluginsManager();
|
||||
const entry = status.pluginId
|
||||
? manager.plugins.find((plugin) => plugin.id === status.pluginId)
|
||||
: undefined;
|
||||
const installable = manager.plugins.filter(
|
||||
(plugin) =>
|
||||
!plugin.installed &&
|
||||
plugin.versions[0]?.capabilities?.some((capability) =>
|
||||
LOOK_CAPABILITIES.includes(capability),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{entry?.installed && entry.updateAvailable && entry.latestVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void manager.requestUpdate(entry)}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<Download />
|
||||
{t("looks.repair.update", {
|
||||
name: entry.name,
|
||||
version: entry.latestVersion,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{entry?.installed && entry.enabled && !entry.managedByServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void manager.toggle(entry)}
|
||||
disabled={manager.busy.has(entry.id)}
|
||||
>
|
||||
<Power />
|
||||
{t("looks.repair.disable", { name: entry.name })}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
|
||||
<Download />
|
||||
{t("looks.repair.install")}
|
||||
</Button>
|
||||
|
||||
<InlineView
|
||||
open={installOpen}
|
||||
onOpenChange={setInstallOpen}
|
||||
title={t("looks.repair.installTitle")}
|
||||
icon={<LayoutTemplate className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{manager.loading && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
)}
|
||||
{!manager.loading && installable.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{manager.registryError
|
||||
? t("looks.repair.registryUnreachable")
|
||||
: t("looks.repair.nothingToInstall")}
|
||||
</p>
|
||||
)}
|
||||
{installable.map((plugin, index) => (
|
||||
<ListRow
|
||||
key={`${plugin.registryId ?? ""}:${plugin.id}`}
|
||||
stripe={index}
|
||||
icon={<PluginIconBox name={plugin.icon} size="sm" />}
|
||||
title={plugin.name}
|
||||
meta={plugin.description}
|
||||
trailing={
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={manager.busy.has(plugin.id)}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
>
|
||||
{t("looks.repair.installButton")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InlineView>
|
||||
|
||||
<PluginConsentPrompt
|
||||
request={manager.consent}
|
||||
onCancel={() => manager.setConsent(null)}
|
||||
onConfirm={() => void manager.confirmConsent()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown instead of a layout that cannot be drawn. Small on purpose: it has
|
||||
* to work when everything else is broken, and gets people back to a working
|
||||
* look in one click.
|
||||
*/
|
||||
export function RepairView({
|
||||
status,
|
||||
onRetry,
|
||||
}: {
|
||||
status: RepairStatus;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const shell = useShellController();
|
||||
const { isAdmin } = usePermissions();
|
||||
const { theme } = useTheme();
|
||||
const chooseTheme = useChooseTheme();
|
||||
const layouts = useLayouts();
|
||||
const themes = useThemes();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
|
||||
const layoutName =
|
||||
layouts.find((layout) => layout.id === status.layoutId) ?? null;
|
||||
const name = layoutName ? layoutLabel(layoutName, t) : status.layoutId;
|
||||
const details = [
|
||||
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
|
||||
status.pluginId
|
||||
? `${t("looks.repair.plugin")}: ${record?.summary.name ?? status.pluginId} ${status.version ?? ""}`.trim()
|
||||
: null,
|
||||
`${t("looks.repair.reason")}: ${t(`looks.repair.reasons.${status.reason}`)}`,
|
||||
status.error ? `${t("looks.repair.error")}: ${status.error}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(details);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t("looks.repair.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const run = (action: () => Promise<void>) => () =>
|
||||
void action().catch(() => toast.error(t("looks.repair.saveFailed")));
|
||||
|
||||
const resetOwn = run(async () => {
|
||||
try {
|
||||
localStorage.removeItem("vite-ui-theme");
|
||||
} catch {
|
||||
// Private mode.
|
||||
}
|
||||
await chooseThemeForAccount(null);
|
||||
await chooseLayout(null);
|
||||
});
|
||||
|
||||
const resetForEveryone = run(() =>
|
||||
chooseDefaultLook({ layout: null, theme: null }),
|
||||
);
|
||||
|
||||
if (settingsOpen) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-screen bg-background"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
{t("looks.repair.back")}
|
||||
</Button>
|
||||
</div>
|
||||
<SurfaceScope kind="tab" className="overflow-hidden">
|
||||
<ShellConfirmCapture />
|
||||
<TabStage />
|
||||
</SurfaceScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const safe = status.reason === "safe-url" || status.reason === "safe-env";
|
||||
|
||||
return (
|
||||
<SurfaceScope kind="tab" className="h-dvh w-screen bg-background">
|
||||
<ShellConfirmCapture />
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-4 py-10">
|
||||
<div className="flex w-full max-w-xl flex-col gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<TriangleAlert className="size-4 text-warning" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<h1 className="text-sm font-semibold text-foreground">
|
||||
{safe ? t("looks.repair.safeTitle") : t("looks.repair.title")}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(`looks.repair.reasons.${status.reason}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2">
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{name}
|
||||
{status.pluginId && (
|
||||
<span className="text-muted-foreground">
|
||||
{" | "}
|
||||
{record?.summary.name ?? status.pluginId}
|
||||
{status.version ? ` ${status.version}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="xs" variant="ghost" onClick={() => void copy()}>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
{copied ? t("looks.repair.copied") : t("looks.repair.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{status.error ?? t("looks.repair.noError")}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!safe && (
|
||||
<Button size="sm" onClick={onRetry}>
|
||||
<RefreshCw />
|
||||
{t("looks.repair.retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={resetOwn}>
|
||||
<RotateCcw />
|
||||
{t("looks.repair.reset")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="outline" onClick={resetForEveryone}>
|
||||
<RotateCcw />
|
||||
{t("looks.repair.resetEveryone")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
shell.tabs.openSingleton("settings", undefined, undefined, {
|
||||
section: "appearance",
|
||||
});
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings />
|
||||
{t("looks.repair.settings")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<AdminTools
|
||||
status={status}
|
||||
installOpen={installOpen}
|
||||
setInstallOpen={setInstallOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<p className="border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("looks.repair.askAdmin")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<LayoutTemplate className="size-3" />
|
||||
{t("looks.repair.switchLayout")}
|
||||
</h2>
|
||||
<div className="flex flex-col border border-border">
|
||||
{layouts.map((layout, index) => (
|
||||
<ListRow
|
||||
key={layout.id}
|
||||
stripe={index}
|
||||
title={layoutLabel(layout, t)}
|
||||
meta={layout.id}
|
||||
selected={layout.id === status.layoutId}
|
||||
onClick={run(() => chooseLayout(layout.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-1.5">
|
||||
<h2 className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Palette className="size-3" />
|
||||
{t("looks.repair.switchTheme")}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{themes.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
title={themeLabel(entry, t)}
|
||||
aria-pressed={theme === entry.id}
|
||||
onClick={() => chooseTheme(entry.id)}
|
||||
className={`flex items-center gap-1.5 border px-2 py-1 text-[11px] ${theme === entry.id ? "border-accent-brand text-foreground" : "border-border text-muted-foreground hover:border-foreground/40"}`}
|
||||
>
|
||||
<span
|
||||
className="size-3 border border-border/60"
|
||||
style={{ background: entry.swatch.background }}
|
||||
/>
|
||||
{themeLabel(entry, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceScope>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useSyncExternalStore, type ComponentType } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
/** The layout core ships with, used when nothing else is chosen or installed. */
|
||||
export const BUILTIN_LAYOUT_ID = "default";
|
||||
|
||||
/** A layout someone can choose, known from a manifest before its code loads. */
|
||||
export interface LayoutDescriptor {
|
||||
id: string;
|
||||
/** i18n key: core's for the built-in, the plugin namespace's for plugins. */
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultTheme?: string;
|
||||
/** Set for a plugin layout. */
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/** A layout whose code is running, from app.registerLayout or core. */
|
||||
export interface RegisteredLayout {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
component: ComponentType;
|
||||
/** Shown on phones and narrow windows. Never in the desktop app. */
|
||||
mobileComponent?: ComponentType;
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
const BUILTIN_DESCRIPTOR: LayoutDescriptor = {
|
||||
id: BUILTIN_LAYOUT_ID,
|
||||
label: "looks.builtinLayout",
|
||||
};
|
||||
|
||||
let pluginLayouts: LayoutDescriptor[] = [];
|
||||
let descriptorSnapshot: LayoutDescriptor[] = [BUILTIN_DESCRIPTOR];
|
||||
const descriptorListeners = new Set<() => void>();
|
||||
|
||||
const registered = createRegistry<RegisteredLayout>();
|
||||
|
||||
export const registerLayout = registered.register;
|
||||
export const getRegisteredLayout = registered.get;
|
||||
export const useRegisteredLayouts = registered.useList;
|
||||
export const resetRegisteredLayouts = registered.reset;
|
||||
|
||||
/** Every layout that can be chosen: the built-in, then plugin layouts. */
|
||||
export function listLayouts(): LayoutDescriptor[] {
|
||||
return descriptorSnapshot;
|
||||
}
|
||||
|
||||
export function getLayoutDescriptor(id: string): LayoutDescriptor | undefined {
|
||||
return descriptorSnapshot.find((layout) => layout.id === id);
|
||||
}
|
||||
|
||||
/** Replaces the plugin layouts at once, from the plugin list. */
|
||||
export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
|
||||
const key = (list: LayoutDescriptor[]) =>
|
||||
JSON.stringify(
|
||||
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
|
||||
);
|
||||
if (key(layouts) === key(pluginLayouts)) return;
|
||||
pluginLayouts = layouts;
|
||||
descriptorSnapshot = [
|
||||
BUILTIN_DESCRIPTOR,
|
||||
...layouts.filter((layout) => layout.id !== BUILTIN_LAYOUT_ID),
|
||||
];
|
||||
for (const listener of descriptorListeners) listener();
|
||||
}
|
||||
|
||||
function subscribeDescriptors(listener: () => void): () => void {
|
||||
descriptorListeners.add(listener);
|
||||
return () => {
|
||||
descriptorListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLayouts(): LayoutDescriptor[] {
|
||||
return useSyncExternalStore(subscribeDescriptors, listLayouts, listLayouts);
|
||||
}
|
||||
|
||||
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
|
||||
if (!layout.pluginId) return t(layout.label);
|
||||
return t(`${layout.pluginId}:${layout.label}`, {
|
||||
defaultValue: layout.label,
|
||||
});
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginLayouts(): void {
|
||||
setPluginLayouts([]);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { UnavailableLook } from "@/api/look-api";
|
||||
import type { PluginRecord } from "@/plugin-host/plugin-store";
|
||||
import type { RegisteredLayout } from "./layout-registry";
|
||||
|
||||
/** Why the repair view shows instead of the chosen layout. */
|
||||
export type RepairReason =
|
||||
/** ?safe-layout=1 in the address. */
|
||||
| "safe-url"
|
||||
/** TERMIX_SAFE_LAYOUT on the server. */
|
||||
| "safe-env"
|
||||
/** No installed plugin provides the layout. */
|
||||
| "missing"
|
||||
/** Its plugin is switched off. */
|
||||
| "disabled"
|
||||
/** Its plugin waits on a missing dependency or was refused. */
|
||||
| "blocked"
|
||||
/** Its plugin does not fit this Termix or plugin API. */
|
||||
| "incompatible"
|
||||
/** Its code threw while starting. */
|
||||
| "failed"
|
||||
/** It crashed while drawing, twice. */
|
||||
| "crashed";
|
||||
|
||||
export type LayoutStatus =
|
||||
| { kind: "ok"; layout: RegisteredLayout }
|
||||
| { kind: "loading" }
|
||||
| {
|
||||
kind: "repair";
|
||||
reason: RepairReason;
|
||||
layoutId: string;
|
||||
pluginId?: string;
|
||||
version?: string | null;
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
/** A crash this many times in a row hands over to the repair view. */
|
||||
export const MAX_LAYOUT_CRASHES = 2;
|
||||
|
||||
export interface LayoutStatusInput {
|
||||
layoutId: string;
|
||||
safeFromUrl: boolean;
|
||||
safeFromEnv: boolean;
|
||||
/** The look and the plugin list have both been read. */
|
||||
ready: boolean;
|
||||
registered: RegisteredLayout | undefined;
|
||||
/** The installed plugin whose manifest declares the layout, if any. */
|
||||
owner: PluginRecord | undefined;
|
||||
/** A plugin the server skipped that declares the layout, if any. */
|
||||
unavailable: UnavailableLook | undefined;
|
||||
crashes: number;
|
||||
lastCrash: string | null;
|
||||
}
|
||||
|
||||
/** Whether the chosen layout can be drawn, and why not when it cannot. */
|
||||
export function layoutStatus(input: LayoutStatusInput): LayoutStatus {
|
||||
const repair = (
|
||||
reason: RepairReason,
|
||||
extra: { error?: string | null } = {},
|
||||
): LayoutStatus => ({
|
||||
kind: "repair",
|
||||
reason,
|
||||
layoutId: input.layoutId,
|
||||
pluginId: input.owner?.summary.id ?? input.unavailable?.id,
|
||||
version: input.owner?.summary.version ?? input.unavailable?.version ?? null,
|
||||
error: extra.error ?? null,
|
||||
});
|
||||
|
||||
if (input.safeFromUrl) return repair("safe-url");
|
||||
if (input.safeFromEnv) return repair("safe-env");
|
||||
if (input.crashes >= MAX_LAYOUT_CRASHES) {
|
||||
return repair("crashed", { error: input.lastCrash });
|
||||
}
|
||||
if (input.registered) return { kind: "ok", layout: input.registered };
|
||||
if (!input.ready) return { kind: "loading" };
|
||||
|
||||
if (input.unavailable && !input.owner) {
|
||||
return repair("incompatible", { error: input.unavailable.error });
|
||||
}
|
||||
const owner = input.owner;
|
||||
if (!owner) return repair("missing");
|
||||
const summary = owner.summary;
|
||||
if (!summary.enabled) return repair("disabled");
|
||||
if (owner.frontend === "blocked" || summary.state === "blocked") {
|
||||
return repair("blocked", { error: owner.error ?? summary.lastError });
|
||||
}
|
||||
if (owner.frontend === "failed" || summary.state === "failed") {
|
||||
return repair("failed", { error: owner.error ?? summary.lastError });
|
||||
}
|
||||
if (owner.frontend === "loading") return { kind: "loading" };
|
||||
// Just chosen: the runtime is about to load its code.
|
||||
if (
|
||||
summary.frontend &&
|
||||
(owner.frontend === "inactive" || owner.frontend === "none")
|
||||
) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
// Running, but it never registered the layout it declares.
|
||||
return repair("missing");
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
getLook,
|
||||
saveDefaultLook,
|
||||
saveUserLook,
|
||||
type LookResponse,
|
||||
} from "@/api/look-api";
|
||||
import { resolveLayoutId, safeLayoutInUrl } from "./resolve-look";
|
||||
|
||||
const LAST_LAYOUT_KEY = "termix-layout";
|
||||
|
||||
interface LookState {
|
||||
/** The server answered, or failed to; the look is decided either way. */
|
||||
loaded: boolean;
|
||||
response: LookResponse | null;
|
||||
}
|
||||
|
||||
let state: LookState = { loaded: false, response: null };
|
||||
let request: Promise<LookResponse | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setState(next: LookState) {
|
||||
state = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function readLastLayout(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LAST_LAYOUT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the look once per session. Never rejects: no answer means defaults. */
|
||||
export function loadLook(): Promise<LookResponse | null> {
|
||||
request ??= getLook()
|
||||
.then((response) => {
|
||||
setState({ loaded: true, response });
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
setState({ loaded: true, response: null });
|
||||
return null;
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Fetches again, e.g. after an admin changed the defaults. */
|
||||
export function reloadLook(): Promise<LookResponse | null> {
|
||||
request = null;
|
||||
return loadLook();
|
||||
}
|
||||
|
||||
/** On logout: the next user has their own look. */
|
||||
export function resetLook(): void {
|
||||
request = null;
|
||||
setState({ loaded: false, response: null });
|
||||
}
|
||||
|
||||
export function getLookState(): LookState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeLook(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useLookState(): LookState {
|
||||
return useSyncExternalStore(subscribeLook, getLookState, getLookState);
|
||||
}
|
||||
|
||||
/** The layout to show now. Remembered so a failed fetch next time still knows. */
|
||||
export function chosenLayoutId(): string {
|
||||
const id = resolveLayoutId(state.response, readLastLayout());
|
||||
if (state.response) {
|
||||
try {
|
||||
localStorage.setItem(LAST_LAYOUT_KEY, id);
|
||||
} catch {
|
||||
// Private mode; the server is asked every time anyway.
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The URL or the server asks for the repair view. */
|
||||
export function safeLayoutRequested(): boolean {
|
||||
return (
|
||||
(typeof window !== "undefined" &&
|
||||
safeLayoutInUrl(window.location.search)) ||
|
||||
state.response?.safeLayout === true
|
||||
);
|
||||
}
|
||||
|
||||
function patchResponse(patch: Partial<LookResponse>) {
|
||||
if (!state.response) return;
|
||||
setState({ ...state, response: { ...state.response, ...patch } });
|
||||
}
|
||||
|
||||
/** Tells the plugin runtime to load the chosen layout's code. */
|
||||
function announceLayoutChange() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event("termix:plugins-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
/** The user's own layout; null goes back to the default. */
|
||||
export async function chooseLayout(layoutId: string | null): Promise<void> {
|
||||
await saveUserLook({ layoutId });
|
||||
patchResponse({ layoutId });
|
||||
announceLayoutChange();
|
||||
}
|
||||
|
||||
/** The user's own theme; null goes back to the default. */
|
||||
export async function chooseThemeForAccount(
|
||||
themeId: string | null,
|
||||
): Promise<void> {
|
||||
await saveUserLook({ themeId });
|
||||
patchResponse({ themeId });
|
||||
}
|
||||
|
||||
/** Admin only: what everyone sees until they choose their own. */
|
||||
export async function chooseDefaultLook(defaults: {
|
||||
layout?: string | null;
|
||||
theme?: string | null;
|
||||
}): Promise<void> {
|
||||
const saved = await saveDefaultLook(defaults);
|
||||
patchResponse({ defaults: saved });
|
||||
announceLayoutChange();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { LookResponse } from "@/api/look-api";
|
||||
import { BUILTIN_LAYOUT_ID } from "./layout-registry";
|
||||
|
||||
/** The theme a fresh install starts in when nothing else says otherwise. */
|
||||
export const FALLBACK_THEME = "dark";
|
||||
|
||||
/**
|
||||
* The layout to show: the user's choice, else the admin default, else the
|
||||
* default look, else core's built-in. Without an answer from the server the
|
||||
* last layout this browser showed stands in.
|
||||
*/
|
||||
export function resolveLayoutId(
|
||||
look: LookResponse | null,
|
||||
lastShown: string | null = null,
|
||||
): string {
|
||||
if (!look) return lastShown ?? BUILTIN_LAYOUT_ID;
|
||||
return (
|
||||
look.layoutId ??
|
||||
look.defaults.layout ??
|
||||
look.defaultLook.layout ??
|
||||
BUILTIN_LAYOUT_ID
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme to show: the user's choice, else the one this browser kept
|
||||
* (today's per-device setting, so existing users see no change), else the
|
||||
* admin default, else the layout's own, else the default look's.
|
||||
*/
|
||||
export function resolveThemeId(
|
||||
look: LookResponse | null,
|
||||
localTheme: string | null,
|
||||
layoutDefaultTheme?: string,
|
||||
): string {
|
||||
return (
|
||||
look?.themeId ??
|
||||
localTheme ??
|
||||
look?.defaults.theme ??
|
||||
layoutDefaultTheme ??
|
||||
look?.defaultLook.theme ??
|
||||
FALLBACK_THEME
|
||||
);
|
||||
}
|
||||
|
||||
/** ?safe-layout=1 in the address bar opens the repair view. */
|
||||
export function safeLayoutInUrl(search: string): boolean {
|
||||
const value = new URLSearchParams(search).get("safe-layout");
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { arePluginThemesKnown, type ThemeDef } from "./theme-registry";
|
||||
|
||||
const LINK_ID = "termix-theme-css";
|
||||
/** The last plugin theme's stylesheet, so a reload shows it before login too. */
|
||||
const CACHE_KEY = "termix-theme-css";
|
||||
|
||||
function readCache(): { id: string; href: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
|
||||
if (
|
||||
parsed &&
|
||||
typeof (parsed as { id?: unknown }).id === "string" &&
|
||||
typeof (parsed as { href?: unknown }).href === "string"
|
||||
) {
|
||||
return parsed as { id: string; href: string };
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or private mode.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeCache(value: { id: string; href: string } | null) {
|
||||
try {
|
||||
if (value) localStorage.setItem(CACHE_KEY, JSON.stringify(value));
|
||||
else localStorage.removeItem(CACHE_KEY);
|
||||
} catch {
|
||||
// Private mode; the theme still applies once plugins load.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the one plugin theme stylesheet the shown theme needs, and only that
|
||||
* one. Built-in themes need none. A theme whose plugin has not loaded yet
|
||||
* keeps the stylesheet it had last time.
|
||||
*/
|
||||
export function applyThemeCss(id: string, theme: ThemeDef | undefined): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const cached = readCache();
|
||||
let href: string | null = null;
|
||||
if (theme?.cssHref) {
|
||||
href = theme.cssHref;
|
||||
writeCache({ id, href });
|
||||
} else if (cached?.id === id && !arePluginThemesKnown()) {
|
||||
// Its plugin has not said yet whether it still provides this theme.
|
||||
href = cached.href;
|
||||
} else if (cached && arePluginThemesKnown()) {
|
||||
writeCache(null);
|
||||
}
|
||||
|
||||
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
|
||||
if (!href) {
|
||||
link?.remove();
|
||||
return;
|
||||
}
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.id = LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
const absolute = new URL(href, window.location.href).href;
|
||||
if (link.href !== absolute) link.href = absolute;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/** Follows the OS between the light and dark theme. Not a real theme. */
|
||||
export const SYSTEM_THEME = "system";
|
||||
|
||||
export interface ThemeSwatch {
|
||||
background: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ThemeDef {
|
||||
/** Also the class put on <html> while the theme is active. */
|
||||
id: string;
|
||||
/** i18n key: core's for built-ins, the plugin namespace's for plugins. */
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
swatch: ThemeSwatch;
|
||||
/** Set for a plugin theme. */
|
||||
pluginId?: string;
|
||||
/** Where a plugin theme's CSS is served. Built-ins live in index.css. */
|
||||
cssHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes core ships, with their tokens in index.css. A plugin theme with the
|
||||
* same id takes the built-in's place while it is installed.
|
||||
*/
|
||||
export const BUILTIN_THEMES: readonly ThemeDef[] = [
|
||||
{
|
||||
id: "light",
|
||||
label: "newUi.sidebar.userProfile.themeLight",
|
||||
mode: "light",
|
||||
swatch: { background: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: "newUi.sidebar.userProfile.themeDark",
|
||||
mode: "dark",
|
||||
swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
label: "newUi.sidebar.userProfile.themeDracula",
|
||||
mode: "dark",
|
||||
swatch: { background: "#282a36", panel: "#44475a", text: "#f8f8f2" },
|
||||
},
|
||||
{
|
||||
id: "catppuccin",
|
||||
label: "newUi.sidebar.userProfile.themeCatppuccin",
|
||||
mode: "dark",
|
||||
swatch: { background: "#1e1e2e", panel: "#313244", text: "#cdd6f4" },
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
label: "newUi.sidebar.userProfile.themeNord",
|
||||
mode: "dark",
|
||||
swatch: { background: "#2e3440", panel: "#3b4252", text: "#eceff4" },
|
||||
},
|
||||
{
|
||||
id: "solarized",
|
||||
label: "newUi.sidebar.userProfile.themeSolarized",
|
||||
mode: "dark",
|
||||
swatch: { background: "#002b36", panel: "#073642", text: "#93a1a1" },
|
||||
},
|
||||
{
|
||||
id: "tokyo-night",
|
||||
label: "newUi.sidebar.userProfile.themeTokyoNight",
|
||||
mode: "dark",
|
||||
swatch: { background: "#1a1b26", panel: "#24283b", text: "#c0caf5" },
|
||||
},
|
||||
{
|
||||
id: "one-dark",
|
||||
label: "newUi.sidebar.userProfile.themeOneDark",
|
||||
mode: "dark",
|
||||
swatch: { background: "#282c34", panel: "#21252b", text: "#abb2bf" },
|
||||
},
|
||||
{
|
||||
id: "gruvbox",
|
||||
label: "newUi.sidebar.userProfile.themeGruvbox",
|
||||
mode: "dark",
|
||||
swatch: { background: "#282828", panel: "#3c3836", text: "#ebdbb2" },
|
||||
},
|
||||
];
|
||||
|
||||
const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme]));
|
||||
let pluginThemes = new Map<string, ThemeDef>();
|
||||
/** False until the plugin list has been read once this session. */
|
||||
let pluginThemesKnown = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: ThemeDef[] | null = null;
|
||||
|
||||
function emit() {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Built-ins in their order, a plugin theme in a built-in's place, then the rest. */
|
||||
export function listThemes(): ThemeDef[] {
|
||||
if (!snapshot) {
|
||||
const replaced = BUILTIN_THEMES.map(
|
||||
(theme) => pluginThemes.get(theme.id) ?? theme,
|
||||
);
|
||||
const extra = [...pluginThemes.values()]
|
||||
.filter((theme) => !builtins.has(theme.id))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
snapshot = [...replaced, ...extra];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function getTheme(id: string): ThemeDef | undefined {
|
||||
return pluginThemes.get(id) ?? builtins.get(id);
|
||||
}
|
||||
|
||||
/** Replaces every plugin theme at once, from the plugin list. */
|
||||
export function setPluginThemes(themes: ThemeDef[]): void {
|
||||
const first = !pluginThemesKnown;
|
||||
pluginThemesKnown = true;
|
||||
const next = new Map(themes.map((theme) => [theme.id, theme]));
|
||||
const same =
|
||||
next.size === pluginThemes.size &&
|
||||
[...next].every(([id, theme]) => {
|
||||
const current = pluginThemes.get(id);
|
||||
return (
|
||||
current?.cssHref === theme.cssHref &&
|
||||
current?.pluginId === theme.pluginId &&
|
||||
current?.label === theme.label &&
|
||||
current?.mode === theme.mode
|
||||
);
|
||||
});
|
||||
if (same && !first) return;
|
||||
pluginThemes = next;
|
||||
emit();
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function useThemes(): ThemeDef[] {
|
||||
return useSyncExternalStore(subscribeThemes, listThemes, listThemes);
|
||||
}
|
||||
|
||||
/** The light or dark mode a theme id stands for, following the OS for system. */
|
||||
export function themeMode(id: string): "light" | "dark" {
|
||||
if (id === SYSTEM_THEME) {
|
||||
return typeof window !== "undefined" &&
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
return getTheme(id)?.mode ?? "dark";
|
||||
}
|
||||
|
||||
export function themeLabel(theme: ThemeDef, t: TFunction): string {
|
||||
if (!theme.pluginId) return t(theme.label);
|
||||
return t(`${theme.pluginId}:${theme.label}`, { defaultValue: theme.label });
|
||||
}
|
||||
|
||||
export function arePluginThemesKnown(): boolean {
|
||||
return pluginThemesKnown;
|
||||
}
|
||||
|
||||
/** On logout, and a test seam. */
|
||||
export function resetPluginThemes(): void {
|
||||
pluginThemes = new Map();
|
||||
pluginThemesKnown = false;
|
||||
emit();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useLayoutEffect } from "react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import { chooseThemeForAccount, useLookState } from "./look-store";
|
||||
import { getLayoutDescriptor, getRegisteredLayout } from "./layout-registry";
|
||||
import { resolveThemeId } from "./resolve-look";
|
||||
|
||||
const LOCAL_THEME_KEY = "vite-ui-theme";
|
||||
|
||||
function readLocalTheme(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LOCAL_THEME_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the theme the look settles on once it is known. A theme someone
|
||||
* picked themselves is kept in this browser too; a default is only shown,
|
||||
* so a later default change still reaches them.
|
||||
*/
|
||||
export function useLookTheme(layoutId: string): void {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const look = useLookState();
|
||||
const layoutDefault =
|
||||
getRegisteredLayout(layoutId)?.defaultTheme ??
|
||||
getLayoutDescriptor(layoutId)?.defaultTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!look.loaded) return;
|
||||
const next = resolveThemeId(look.response, readLocalTheme(), layoutDefault);
|
||||
if (next === theme) return;
|
||||
setTheme(next, !!look.response?.themeId);
|
||||
// Only when the look itself changes, not on every theme switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [look, layoutDefault]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a theme for this user: shown now and kept in this browser. Unless
|
||||
* they keep settings on this device only, it is saved to their account too,
|
||||
* which is what other devices follow.
|
||||
*/
|
||||
export function useChooseTheme(): (themeId: string) => void {
|
||||
const { setTheme } = useTheme();
|
||||
return useCallback(
|
||||
(themeId: string) => {
|
||||
setTheme(themeId);
|
||||
void syncThemeChoice(themeId);
|
||||
},
|
||||
[setTheme],
|
||||
);
|
||||
}
|
||||
|
||||
async function syncThemeChoice(themeId: string): Promise<void> {
|
||||
const prefs = await getUserPreferences().catch(() => null);
|
||||
// A device-only profile must not be overridden by an account choice.
|
||||
const local = prefs?.storageMode === "local";
|
||||
await chooseThemeForAccount(local ? null : themeId).catch(() => {});
|
||||
// Older clients still read the profile field.
|
||||
if (prefs?.storageMode === "cloud") {
|
||||
await saveUserPreferences({ theme: themeId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,13 @@ import {
|
||||
import { shell, shellHost, tabsApi } from "./shell-bridge";
|
||||
import { onRemoteServerChange, remoteServerUrl } from "./desktop";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { withPluginScope, withIconBoundary, guardCallback } from "./scope";
|
||||
import {
|
||||
withPluginContext,
|
||||
withPluginScope,
|
||||
withIconBoundary,
|
||||
guardCallback,
|
||||
} from "./scope";
|
||||
import { registerLayout as registerLayoutComponent } from "@/look/layout-registry";
|
||||
import {
|
||||
scopeHostArgs,
|
||||
scopeHostCallback,
|
||||
@@ -157,7 +163,9 @@ export function createPluginApp(
|
||||
? "contributes.tabs"
|
||||
: kind === "panel"
|
||||
? "contributes.panels or contributes.tabs"
|
||||
: "contributes.dashboardCards";
|
||||
: kind === "layout"
|
||||
? "contributes.layouts"
|
||||
: "contributes.dashboardCards";
|
||||
throw new Error(
|
||||
`${pluginId}: ${what} "${id}" is not declared in manifest ${field}`,
|
||||
);
|
||||
@@ -578,6 +586,21 @@ export function createPluginApp(
|
||||
);
|
||||
},
|
||||
|
||||
registerLayout(layout) {
|
||||
requireDeclared("layout", layout.id, "layout");
|
||||
return track(
|
||||
registerLayoutComponent({
|
||||
id: layout.id,
|
||||
pluginId,
|
||||
component: withPluginContext(pluginId, layout.component),
|
||||
mobileComponent: layout.mobileComponent
|
||||
? withPluginContext(pluginId, layout.mobileComponent)
|
||||
: undefined,
|
||||
defaultTheme: layout.defaultTheme,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
t: ((key: string, options?: Record<string, unknown> | string) =>
|
||||
i18n.t(
|
||||
pluginKey(pluginId, key),
|
||||
|
||||
@@ -48,6 +48,8 @@ import { invokeAction } from "@/shell/action-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { usePluginComponent } from "./component-registry";
|
||||
import { themeMode, useThemes } from "@/look/theme-registry";
|
||||
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
||||
import {
|
||||
getLiveHostStatus,
|
||||
useOptionalHostStatusEntry,
|
||||
@@ -337,15 +339,13 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
|
||||
useTheme() {
|
||||
const { theme } = useTheme();
|
||||
if (theme === "light") return { theme: "light" };
|
||||
if ((theme as string) === "system" && typeof window !== "undefined") {
|
||||
return {
|
||||
theme: window.matchMedia?.("(prefers-color-scheme: light)").matches
|
||||
? "light"
|
||||
: "dark",
|
||||
};
|
||||
}
|
||||
return { theme: "dark" };
|
||||
// Re-render when plugin themes arrive, since they carry their mode.
|
||||
useThemes();
|
||||
return { theme: themeMode(theme) };
|
||||
},
|
||||
|
||||
useShell() {
|
||||
return usePluginShell(usePluginScope());
|
||||
},
|
||||
|
||||
toast: {
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
setPluginSummaries,
|
||||
} from "./plugin-store";
|
||||
import { syncHostFeatureTabs } from "@/settings/host-feature-tabs";
|
||||
import {
|
||||
chosenLayoutId,
|
||||
loadLook,
|
||||
safeLayoutRequested,
|
||||
} from "@/look/look-store";
|
||||
import { BUILTIN_THEMES, setPluginThemes } from "@/look/theme-registry";
|
||||
import { setPluginLayouts } from "@/look/layout-registry";
|
||||
|
||||
/**
|
||||
* Loads plugin frontends into the running shell.
|
||||
@@ -55,14 +62,22 @@ let queue: Promise<void> = Promise.resolve();
|
||||
let deps: PluginLoaderDeps | null = null;
|
||||
let started = false;
|
||||
let guestMode = false;
|
||||
/** Signed in with the full runtime: only the chosen layout's code loads. */
|
||||
let gateLayouts = false;
|
||||
|
||||
function assetUrlWithVersion(
|
||||
pluginId: string,
|
||||
file: string,
|
||||
version: string | null | undefined,
|
||||
): string {
|
||||
const query = version ? `?v=${encodeURIComponent(version)}` : "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(pluginId)}/${file}${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assetUrl(summary: PluginSummary, file: string): string {
|
||||
const version = summary.assetVersion
|
||||
? `?v=${encodeURIComponent(summary.assetVersion)}`
|
||||
: "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`,
|
||||
);
|
||||
return assetUrlWithVersion(summary.id, file, summary.assetVersion);
|
||||
}
|
||||
|
||||
/** Absolute URL, so import() resolves it against the page, not this module. */
|
||||
@@ -286,11 +301,75 @@ function namespaceVersion(summary: PluginSummary): string {
|
||||
return `${summary.version}|${summary.assetVersion ?? ""}`;
|
||||
}
|
||||
|
||||
/** Puts every running plugin's themes and every enabled plugin's layouts on offer. */
|
||||
function syncLooks(summaries: PluginSummary[]): void {
|
||||
const fallback = (mode: "light" | "dark") =>
|
||||
BUILTIN_THEMES.find((theme) => theme.id === mode)!.swatch;
|
||||
setPluginThemes(
|
||||
summaries.filter(serverServesAssets).flatMap((summary) =>
|
||||
summary.themeVersion
|
||||
? (summary.contributes?.themes ?? []).map((theme) => ({
|
||||
id: theme.id,
|
||||
label: theme.label,
|
||||
mode: theme.mode,
|
||||
swatch: theme.swatch ?? fallback(theme.mode),
|
||||
pluginId: summary.id,
|
||||
cssHref: assetUrlWithVersion(
|
||||
summary.id,
|
||||
`_themes/${theme.id}.css`,
|
||||
summary.themeVersion,
|
||||
),
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
);
|
||||
setPluginLayouts(
|
||||
summaries
|
||||
.filter((summary) => summary.enabled)
|
||||
.flatMap((summary) =>
|
||||
(summary.contributes?.layouts ?? []).map((layout) => ({
|
||||
id: layout.id,
|
||||
label: layout.label,
|
||||
description: layout.description,
|
||||
defaultTheme: layout.defaultTheme,
|
||||
pluginId: summary.id,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A layout plugin's code runs only while one of its layouts is the one
|
||||
* shown, and never in the repair view, so a broken layout cannot break the
|
||||
* way out of it.
|
||||
*/
|
||||
async function withoutUnchosenLayouts(
|
||||
summaries: PluginSummary[],
|
||||
): Promise<PluginSummary[]> {
|
||||
if (!gateLayouts) return summaries;
|
||||
await loadLook();
|
||||
const chosen = safeLayoutRequested() ? null : chosenLayoutId();
|
||||
return summaries.map((summary) => {
|
||||
const layouts = summary.contributes?.layouts ?? [];
|
||||
if (layouts.length === 0) return summary;
|
||||
if (layouts.some((layout) => layout.id === chosen)) return summary;
|
||||
if (
|
||||
summary.frontend &&
|
||||
getPluginRecord(summary.id)?.frontend !== "inactive"
|
||||
)
|
||||
setFrontendState(summary.id, "inactive");
|
||||
return { ...summary, frontend: false };
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||
setPluginSummaries(summaries);
|
||||
syncLooks(summaries);
|
||||
await loadNamespaces(summaries.filter(serverServesAssets));
|
||||
|
||||
const { order, blocked } = orderForActivation(summaries);
|
||||
const { order, blocked } = orderForActivation(
|
||||
await withoutUnchosenLayouts(summaries),
|
||||
);
|
||||
const wanted = new Map(order.map((summary) => [summary.id, summary]));
|
||||
|
||||
// Dependents go first, so nothing outlives what it depends on.
|
||||
@@ -393,6 +472,12 @@ export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Tries a plugin's frontend again even though this bundle failed before. */
|
||||
export function retryPluginFrontend(pluginId: string): Promise<void> {
|
||||
failedVersions.delete(pluginId);
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
|
||||
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
|
||||
@@ -503,6 +588,7 @@ export function startPluginRuntime(
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
gateLayouts = true;
|
||||
if (!started && typeof window !== "undefined") {
|
||||
started = true;
|
||||
lastFocusSync = Date.now();
|
||||
@@ -514,6 +600,7 @@ export function startPluginRuntime(
|
||||
|
||||
/** Deactivates every plugin, on logout. */
|
||||
export function stopPluginRuntime(): Promise<void> {
|
||||
gateLayouts = false;
|
||||
if (started && typeof window !== "undefined") {
|
||||
started = false;
|
||||
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
||||
|
||||
@@ -114,6 +114,28 @@ export function withPluginScope<P extends object>(
|
||||
return Scoped;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin id for the hooks without the error boundary, for a component
|
||||
* whose caller handles a crash itself (a layout, which the repair view takes
|
||||
* over from).
|
||||
*/
|
||||
export function withPluginContext<P extends object>(
|
||||
pluginId: string,
|
||||
Inner: ComponentType<P>,
|
||||
): ComponentType<P> {
|
||||
function Scoped(props: P) {
|
||||
return (
|
||||
<PluginScopeContext.Provider value={pluginId}>
|
||||
<Suspense fallback={null}>
|
||||
<Inner {...props} />
|
||||
</Suspense>
|
||||
</PluginScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`;
|
||||
return Scoped;
|
||||
}
|
||||
|
||||
/** Renders nothing when a plugin's icon throws, instead of a crash notice. */
|
||||
class IconBoundary extends Component<BoundaryProps, { error: boolean }> {
|
||||
state = { error: false };
|
||||
|
||||
@@ -152,6 +152,9 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext";
|
||||
|
||||
// Slots: a plugin can offer places for other plugins to fill.
|
||||
export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot";
|
||||
// For layouts: where tabs show, and the confirm a tab close asks with.
|
||||
export { TabStage } from "@/shell/stage/TabStage";
|
||||
export { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
|
||||
|
||||
// A plugin's manifest host settings, drawn inside its own host editor section.
|
||||
export {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getPluginStoreState, type PluginRecord } from "./plugin-store";
|
||||
* because a disabled plugin never runs its frontend and so never registers
|
||||
* anything, yet a saved tab or card of its still has to say whose it is.
|
||||
*/
|
||||
export type ViewKind = "tab" | "panel" | "card";
|
||||
export type ViewKind = "tab" | "panel" | "card" | "layout";
|
||||
|
||||
export type ViewStatus =
|
||||
/** Registered by a running plugin. */
|
||||
@@ -31,6 +31,9 @@ function declares(record: PluginRecord, kind: ViewKind, id: string): boolean {
|
||||
!!contributes.tabs?.some((tab) => tab.id === id)
|
||||
);
|
||||
}
|
||||
if (kind === "layout") {
|
||||
return !!contributes.layouts?.some((layout) => layout.id === id);
|
||||
}
|
||||
return !!contributes.dashboardCards?.some((card) => card.id === id);
|
||||
}
|
||||
|
||||
|
||||
@@ -184,18 +184,20 @@ export function PluginDetail({
|
||||
? t("plugins.manager.disable")
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={async () => {
|
||||
if (await manager.uninstall(plugin)) onUninstalled();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("plugins.manager.uninstall")}
|
||||
</Button>
|
||||
{!plugin.defaultLook && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={busy || locked}
|
||||
onClick={async () => {
|
||||
if (await manager.uninstall(plugin)) onUninstalled();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("plugins.manager.uninstall")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -806,20 +806,22 @@ function InstalledCard({
|
||||
variant="icon"
|
||||
className="ml-auto size-6"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-destructive",
|
||||
!plugin.docs && "ml-auto",
|
||||
)}
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
{!plugin.defaultLook && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-destructive",
|
||||
!plugin.docs && "ml-auto",
|
||||
)}
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -916,17 +918,19 @@ function InstalledRowActions({
|
||||
</Button>
|
||||
)}
|
||||
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
{!plugin.defaultLook && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface PluginEntry {
|
||||
registryName: string | null;
|
||||
/** A linked server decides whether it runs here. */
|
||||
managedByServer: boolean;
|
||||
/** The app is drawn with it by default, so it can be swapped but not uninstalled. */
|
||||
defaultLook: boolean;
|
||||
}
|
||||
|
||||
export type PluginSource =
|
||||
@@ -168,6 +170,7 @@ export function mergePlugins(
|
||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||
registryName: entry?.registryName ?? null,
|
||||
managedByServer: summary.managedByServer ?? false,
|
||||
defaultLook: summary.defaultLook ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,6 +215,7 @@ export function mergePlugins(
|
||||
registryId: entry.registryId ?? "official",
|
||||
registryName: entry.registryName ?? null,
|
||||
managedByServer: false,
|
||||
defaultLook: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,6 +257,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
registryId: null,
|
||||
registryName: null,
|
||||
managedByServer: false,
|
||||
defaultLook: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,8 @@ export function usePluginsManager() {
|
||||
const info = readError(error, t("plugins.manager.errors.generic"));
|
||||
if (info.code === "MANAGED_BY_SERVER") {
|
||||
toast.error(t("plugins.manager.errors.managedByServer"));
|
||||
} else if (info.code === "DEFAULT_LOOK") {
|
||||
toast.error(t("plugins.manager.errors.defaultLook", { name }));
|
||||
} else if (info.code === "HAS_DEPENDENTS") {
|
||||
toast.error(
|
||||
t("plugins.manager.errors.hasDependents", {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
useLookState,
|
||||
} from "@/look/look-store";
|
||||
import { layoutLabel, useLayouts } from "@/look/layout-registry";
|
||||
import { themeLabel, useThemes } from "@/look/theme-registry";
|
||||
|
||||
const selectClass =
|
||||
"w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7";
|
||||
|
||||
/**
|
||||
* The layout picker, plus the default layout and theme for everyone when an
|
||||
* admin is looking. Lists what the layout and theme registries hold, so
|
||||
* plugin layouts and themes show up as soon as they are installed.
|
||||
*/
|
||||
export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const look = useLookState();
|
||||
const layouts = useLayouts();
|
||||
const themes = useThemes();
|
||||
const response = look.response;
|
||||
|
||||
const failed = () => toast.error(t("looks.saveFailed"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select2
|
||||
value={response?.layoutId ?? ""}
|
||||
disabled={!response}
|
||||
onChange={(e) =>
|
||||
void chooseLayout(e.target.value || null).catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{layouts.map((layout) => (
|
||||
<option key={layout.id} value={layout.id}>
|
||||
{layoutLabel(layout, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("looks.layoutHint")}
|
||||
</p>
|
||||
|
||||
{isAdmin && response && (
|
||||
<div className="flex flex-col gap-1.5 border border-border p-2">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{t("looks.defaultsLabel")}
|
||||
</span>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("looks.defaultsHint")}
|
||||
</p>
|
||||
<Select2
|
||||
value={response.defaults.layout ?? ""}
|
||||
onChange={(e) =>
|
||||
void chooseDefaultLook({ layout: e.target.value || null })
|
||||
.then(() => toast.success(t("looks.defaultSaved")))
|
||||
.catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{layouts.map((layout) => (
|
||||
<option key={layout.id} value={layout.id}>
|
||||
{layoutLabel(layout, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<Select2
|
||||
value={response.defaults.theme ?? ""}
|
||||
onChange={(e) =>
|
||||
void chooseDefaultLook({ theme: e.target.value || null })
|
||||
.then(() => toast.success(t("looks.defaultSaved")))
|
||||
.catch(failed)
|
||||
}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">{t("looks.followDefault")}</option>
|
||||
{themes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{themeLabel(theme, t)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,50 +17,55 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { LANGUAGES } from "@/i18n/languages";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||
import {
|
||||
BUILTIN_THEMES,
|
||||
SYSTEM_THEME,
|
||||
themeLabel,
|
||||
useThemes,
|
||||
type ThemeSwatch,
|
||||
} from "@/look/theme-registry";
|
||||
import { useChooseTheme } from "@/look/use-look-theme";
|
||||
|
||||
export const DEFAULT_ACCENT = "#f59145";
|
||||
|
||||
/** The built-in themes, with System first. Plugin themes come from useThemeOptions. */
|
||||
export const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
{ id: "system", preview: "auto" },
|
||||
{ id: "light", preview: "#ffffff" },
|
||||
{ id: "dark", preview: "#1a1c22" },
|
||||
{ id: "dracula", preview: "#282a36" },
|
||||
{ id: "catppuccin", preview: "#1e1e2e" },
|
||||
{ id: "nord", preview: "#2e3440" },
|
||||
{ id: "solarized", preview: "#002b36" },
|
||||
{ id: "tokyo-night", preview: "#1a1b26" },
|
||||
{ id: "one-dark", preview: "#282c34" },
|
||||
{ id: "gruvbox", preview: "#282828" },
|
||||
{ id: SYSTEM_THEME, preview: "auto" },
|
||||
...BUILTIN_THEMES.map((theme) => ({
|
||||
id: theme.id,
|
||||
preview: theme.swatch.background,
|
||||
})),
|
||||
];
|
||||
|
||||
/** Background, panel and text colours for the little theme previews. */
|
||||
const THEME_PALETTES: Record<
|
||||
Exclude<ThemeId, "system">,
|
||||
{ bg: string; panel: string; text: string }
|
||||
> = {
|
||||
light: { bg: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
|
||||
dark: { bg: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
|
||||
dracula: { bg: "#282a36", panel: "#44475a", text: "#f8f8f2" },
|
||||
catppuccin: { bg: "#1e1e2e", panel: "#313244", text: "#cdd6f4" },
|
||||
nord: { bg: "#2e3440", panel: "#3b4252", text: "#eceff4" },
|
||||
solarized: { bg: "#002b36", panel: "#073642", text: "#93a1a1" },
|
||||
"tokyo-night": { bg: "#1a1b26", panel: "#24283b", text: "#c0caf5" },
|
||||
"one-dark": { bg: "#282c34", panel: "#21252b", text: "#abb2bf" },
|
||||
gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" },
|
||||
};
|
||||
export interface ThemeOption {
|
||||
id: ThemeId;
|
||||
label: string;
|
||||
/** Null for System, which previews light and dark side by side. */
|
||||
swatch: ThemeSwatch | null;
|
||||
}
|
||||
|
||||
export const THEME_LABEL_KEYS: Record<ThemeId, string> = {
|
||||
system: "newUi.sidebar.userProfile.themeSystem",
|
||||
light: "newUi.sidebar.userProfile.themeLight",
|
||||
dark: "newUi.sidebar.userProfile.themeDark",
|
||||
dracula: "newUi.sidebar.userProfile.themeDracula",
|
||||
catppuccin: "newUi.sidebar.userProfile.themeCatppuccin",
|
||||
nord: "newUi.sidebar.userProfile.themeNord",
|
||||
solarized: "newUi.sidebar.userProfile.themeSolarized",
|
||||
"tokyo-night": "newUi.sidebar.userProfile.themeTokyoNight",
|
||||
"one-dark": "newUi.sidebar.userProfile.themeOneDark",
|
||||
gruvbox: "newUi.sidebar.userProfile.themeGruvbox",
|
||||
};
|
||||
/** Every theme to pick from, System first, plugin themes included. */
|
||||
export function useThemeOptions(): ThemeOption[] {
|
||||
const { t } = useTranslation();
|
||||
const themes = useThemes();
|
||||
return [
|
||||
{
|
||||
id: SYSTEM_THEME,
|
||||
label: t("newUi.sidebar.userProfile.themeSystem"),
|
||||
swatch: null,
|
||||
},
|
||||
...themes.map((theme) => ({
|
||||
id: theme.id,
|
||||
label: themeLabel(theme, t),
|
||||
swatch: theme.swatch,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const LIGHT_SWATCH = BUILTIN_THEMES.find(
|
||||
(theme) => theme.id === "light",
|
||||
)!.swatch;
|
||||
const DARK_SWATCH = BUILTIN_THEMES.find((theme) => theme.id === "dark")!.swatch;
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value);
|
||||
@@ -83,7 +88,8 @@ async function saveIfCloud(prefs: CloudPrefs): Promise<void> {
|
||||
* way the profile settings store them.
|
||||
*/
|
||||
export function useAppearanceSettings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const chooseTheme = useChooseTheme();
|
||||
const [accent, setAccentState] = useState(
|
||||
() => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT,
|
||||
);
|
||||
@@ -105,8 +111,7 @@ export function useAppearanceSettings() {
|
||||
uiFont,
|
||||
language,
|
||||
setTheme(id: ThemeId) {
|
||||
setTheme(id);
|
||||
void saveIfCloud({ theme: id });
|
||||
chooseTheme(id);
|
||||
},
|
||||
setAccent(value: string) {
|
||||
setAccentState(value);
|
||||
@@ -134,37 +139,29 @@ export function useAppearanceSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
function ThemePreview({ id }: { id: ThemeId }) {
|
||||
if (id === "system") {
|
||||
function ThemePreview({ swatch }: { swatch: ThemeSwatch | null }) {
|
||||
if (!swatch) {
|
||||
return (
|
||||
<div className="flex h-14 w-full">
|
||||
<div className="flex-1">
|
||||
<MiniWindow {...THEME_PALETTES.light} />
|
||||
<MiniWindow {...LIGHT_SWATCH} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<MiniWindow {...THEME_PALETTES.dark} />
|
||||
<MiniWindow {...DARK_SWATCH} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="h-14 w-full">
|
||||
<MiniWindow {...THEME_PALETTES[id]} />
|
||||
<MiniWindow {...swatch} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniWindow({
|
||||
bg,
|
||||
panel,
|
||||
text,
|
||||
}: {
|
||||
bg: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
}) {
|
||||
function MiniWindow({ background, panel, text }: ThemeSwatch) {
|
||||
return (
|
||||
<div className="flex h-full w-full" style={{ background: bg }}>
|
||||
<div className="flex h-full w-full" style={{ background }}>
|
||||
<div
|
||||
className="flex w-1/4 flex-col gap-1 p-1"
|
||||
style={{ background: panel }}
|
||||
@@ -198,26 +195,34 @@ export function ThemeGrid({
|
||||
value: ThemeId;
|
||||
onChange: (id: ThemeId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setThemePreview } = useTheme();
|
||||
const options = useThemeOptions();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{THEMES.map(({ id }) => {
|
||||
{options.map(({ id, label, swatch }) => {
|
||||
const active = value === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(id)}
|
||||
onClick={() => {
|
||||
setThemePreview(null);
|
||||
onChange(id);
|
||||
}}
|
||||
onMouseEnter={() => setThemePreview(id)}
|
||||
onMouseLeave={() => setThemePreview(null)}
|
||||
onFocus={() => setThemePreview(id)}
|
||||
onBlur={() => setThemePreview(null)}
|
||||
className={`flex flex-col overflow-hidden border text-left transition-colors ${
|
||||
active
|
||||
? "border-accent-brand ring-1 ring-accent-brand"
|
||||
: "border-border hover:border-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<ThemePreview id={id} />
|
||||
<ThemePreview swatch={swatch} />
|
||||
<span className="flex items-center justify-between gap-1 border-t border-border bg-card px-2 py-1.5 text-[11px]">
|
||||
{t(THEME_LABEL_KEYS[id])}
|
||||
{label}
|
||||
{active && <Check size={11} className="text-accent-brand" />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -322,6 +322,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
|
||||
"newUi.sidebar.userProfile.sectionApiKeys",
|
||||
],
|
||||
"appearance": [
|
||||
"looks.layoutLabel",
|
||||
"newUi.sidebar.userProfile.accentColorLabel",
|
||||
"newUi.sidebar.userProfile.appRailHoverPushesContent",
|
||||
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { OnboardingHost } from "@/onboarding/OnboardingHost";
|
||||
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
|
||||
import { getTabType } from "@/shell/tab-registry";
|
||||
import { TabPortals } from "@/shell/stage/TabPortals";
|
||||
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
||||
import {
|
||||
ShellControllerProvider,
|
||||
useShellController,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
|
||||
// Not needed for first paint.
|
||||
const CommandPalette = lazy(() =>
|
||||
import("@/shell/CommandPalette").then((m) => ({
|
||||
default: m.CommandPalette,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Core's palette, opened by any layout through the shell's palette.open(). */
|
||||
function ShellPalette() {
|
||||
const shell = useShellController();
|
||||
const { tabs, hosts, palette } = shell;
|
||||
if (!palette.isOpen) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CommandPalette
|
||||
isOpen={palette.isOpen}
|
||||
setIsOpen={palette.setOpen}
|
||||
hosts={hosts.list}
|
||||
terminalTabs={tabs.terminalTabs}
|
||||
activeTabId={tabs.activeId}
|
||||
onOpenPanel={(view) => shell.regions.railClick(view)}
|
||||
onEditHost={hosts.edit}
|
||||
onOpenSettings={(section) =>
|
||||
tabs.openSingleton(
|
||||
"settings",
|
||||
undefined,
|
||||
undefined,
|
||||
section ? { section } : undefined,
|
||||
)
|
||||
}
|
||||
onOpenTab={(type, label, pendingEvent) => {
|
||||
if (
|
||||
[
|
||||
"dashboard",
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"plugins",
|
||||
].includes(type)
|
||||
) {
|
||||
tabs.openSingleton(type, pendingEvent);
|
||||
} else if (getTabType(type)?.multiInstance) {
|
||||
tabs.openMultiInstance(type);
|
||||
} else if (getTabType(type)?.singleton) {
|
||||
// A singleton plugin tab, optionally preselecting a host.
|
||||
tabs.openSingleton(
|
||||
type,
|
||||
undefined,
|
||||
label ? hosts.list.find((h) => h.name === label) : undefined,
|
||||
);
|
||||
} else if (label) {
|
||||
const host = hosts.list.find((h) => h.name === label);
|
||||
if (host) tabs.open(host, type);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in app: the shell's state, the chosen layout, every tab's
|
||||
* content and the pieces core always owns (palette, overlays, onboarding).
|
||||
*/
|
||||
export function ShellRoot({
|
||||
username,
|
||||
onLogout,
|
||||
}: {
|
||||
username: string;
|
||||
onLogout: (options?: { manual?: boolean }) => void;
|
||||
}) {
|
||||
return (
|
||||
<ServerStatusProvider isAuthenticated={!!username}>
|
||||
<ShellControllerProvider username={username} onLogout={onLogout}>
|
||||
<LayoutHost />
|
||||
<TabPortals />
|
||||
<ComponentSlot slotId="shell.overlay" />
|
||||
<ShellPalette />
|
||||
<OnboardingHost />
|
||||
{isElectron() && <DesktopUpdateToasts />}
|
||||
</ShellControllerProvider>
|
||||
</ServerStatusProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import {
|
||||
useConfirm,
|
||||
type ConfirmOptions,
|
||||
} from "@/components/surface/surface-scope";
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import { requestManage } from "@/manage/manage-requests";
|
||||
import { useRailItems } from "@/sidebar/rail-items";
|
||||
import { usePanels } from "@/shell/panel-registry";
|
||||
import { getTabType, type TabShellCallbacks } from "@/shell/tab-registry";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import { setShellCallbacks } from "@/plugin-host/shell-bridge";
|
||||
import {
|
||||
createTabStageStore,
|
||||
type TabStageStore,
|
||||
} from "@/shell/stage/tab-stage-store";
|
||||
import { useRegions } from "./use-regions";
|
||||
import { useHostList } from "./use-hosts";
|
||||
import { useTabs } from "./use-tabs";
|
||||
import { useSplit } from "./use-split";
|
||||
import { useWorkspaceLayout } from "./use-workspace-layout";
|
||||
import { useShellKeybindings } from "./use-shell-keybindings";
|
||||
import { useShellLifecycle } from "./use-shell-lifecycle";
|
||||
import { publishShellController } from "./shell-store";
|
||||
import type { ShellController, ShellPalette } from "./types";
|
||||
|
||||
const ShellControllerContext = createContext<ShellController | null>(null);
|
||||
const TabStageContext = createContext<TabStageStore | null>(null);
|
||||
|
||||
/** The live shell, for core layout code. Throws outside the shell. */
|
||||
export function useShellController(): ShellController {
|
||||
const controller = useContext(ShellControllerContext);
|
||||
if (!controller) throw new Error("useShellController outside the shell");
|
||||
return controller;
|
||||
}
|
||||
|
||||
/** The live shell, or null outside it. */
|
||||
export function useOptionalShellController(): ShellController | null {
|
||||
return useContext(ShellControllerContext);
|
||||
}
|
||||
|
||||
export function useTabStageStore(): TabStageStore {
|
||||
const store = useContext(TabStageContext);
|
||||
if (!store) throw new Error("useTabStageStore outside the shell");
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the main area's confirm to code outside it, like closeTab. A layout
|
||||
* renders it inside the scope its tab-close questions should show in.
|
||||
*/
|
||||
export function ShellConfirmCapture(): null {
|
||||
const { confirmRef } = useShellController();
|
||||
const confirm = useConfirm();
|
||||
confirmRef.current = confirm;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Owns every bit of shell state and behaviour a layout draws. */
|
||||
export function ShellControllerProvider({
|
||||
username,
|
||||
onLogout,
|
||||
children,
|
||||
}: {
|
||||
username: string;
|
||||
onLogout: (options?: { manual?: boolean }) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Re-render when plugins add or remove rail items, panels or tabs.
|
||||
useRailItems();
|
||||
usePanels();
|
||||
const { settled: pluginsSettled } = usePluginStore();
|
||||
const isMobile = useIsMobile();
|
||||
const [stageStore] = useState(createTabStageStore);
|
||||
// The root confirm, for questions the shell itself asks (closing a tab).
|
||||
const confirmRef = useRef<
|
||||
((options: ConfirmOptions) => Promise<boolean>) | null
|
||||
>(null);
|
||||
const openSingletonRef = useRef<(type: TabType) => string | undefined>(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const lifecycle = useShellLifecycle({
|
||||
onLogout,
|
||||
openSingletonTabRef: openSingletonRef,
|
||||
});
|
||||
const regions = useRegions(isMobile);
|
||||
const hosts = useHostList();
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
const tabsState = useTabs({
|
||||
hosts: hosts.list,
|
||||
hostsRef: hosts.listRef,
|
||||
hostsLoaded: hosts.loaded,
|
||||
reloadHosts: hosts.reload,
|
||||
userPrefs: lifecycle.userPrefs,
|
||||
userPrefsLoaded: lifecycle.userPrefsLoaded,
|
||||
pluginsSettled,
|
||||
confirmRef,
|
||||
layoutDeps: [
|
||||
regions.left.view,
|
||||
regions.left.open,
|
||||
regions.left.width,
|
||||
regions.right.view,
|
||||
regions.right.width,
|
||||
],
|
||||
});
|
||||
openSingletonRef.current = tabsState.openSingletonTab;
|
||||
const { tabs, activeTabId } = tabsState;
|
||||
|
||||
// Keeps the desktop's sync status polled while the app is open; a pass
|
||||
// that changed data tells the panels to reload.
|
||||
useSyncStatus();
|
||||
|
||||
const palette: ShellPalette = {
|
||||
isOpen: paletteOpen,
|
||||
open: () => setPaletteOpen(true),
|
||||
close: () => setPaletteOpen(false),
|
||||
setOpen: setPaletteOpen,
|
||||
};
|
||||
|
||||
function editHostInManager(host: Host) {
|
||||
requestManage({ kind: "host", hostId: host.id });
|
||||
if (isMobile) regions.left.setOpen(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* What plugins may ask of the shell. Rebuilt every render so it always
|
||||
* closes over current state, and published to the plugin runtime.
|
||||
*/
|
||||
const shellCallbacksImpl: TabShellCallbacks = {
|
||||
openTab: (host, type, options) => {
|
||||
if (!host || getTabType(type)?.singleton) {
|
||||
tabsState.openSingletonTab(
|
||||
type,
|
||||
undefined,
|
||||
host ?? undefined,
|
||||
options?.data,
|
||||
options?.label,
|
||||
);
|
||||
return;
|
||||
}
|
||||
tabsState.openTab(host, type, undefined, options);
|
||||
},
|
||||
openSingletonTab: (type, options) =>
|
||||
tabsState.openSingletonTab(type, undefined, undefined, options?.data),
|
||||
connectHost: (host, type) => tabsState.connectHost(host, type as TabType),
|
||||
closeTab: (tabId) => tabsState.closeTab(tabId),
|
||||
renameTab: (tabId, label) => tabsState.renameTab(tabId, label),
|
||||
|
||||
openRailView: (id) => {
|
||||
regions.left.setView(id);
|
||||
regions.left.setOpen(true);
|
||||
},
|
||||
closeRailView: (id) => {
|
||||
regions.left.setView((prev) => (prev === id ? "hosts" : prev));
|
||||
regions.right.setView((prev) => (prev === id ? null : prev));
|
||||
},
|
||||
openHostEditor: (draft) =>
|
||||
requestManage({ kind: "host", hostId: null, draft }),
|
||||
saveQuickConnect: tabsState.saveQuickConnectHost,
|
||||
confirm: (options) =>
|
||||
confirmRef.current ? confirmRef.current(options) : Promise.resolve(false),
|
||||
};
|
||||
|
||||
// Tabs get one stable bag that forwards to the latest callbacks, so a shell
|
||||
// render does not re-render every open tab.
|
||||
const shellImplRef = useRef(shellCallbacksImpl);
|
||||
useLayoutEffect(() => {
|
||||
shellImplRef.current = shellCallbacksImpl;
|
||||
});
|
||||
const callbacks = useMemo<TabShellCallbacks>(
|
||||
() => ({
|
||||
openTab: (...args) => shellImplRef.current.openTab(...args),
|
||||
openSingletonTab: (...args) =>
|
||||
shellImplRef.current.openSingletonTab(...args),
|
||||
connectHost: (...args) => shellImplRef.current.connectHost(...args),
|
||||
closeTab: (...args) => shellImplRef.current.closeTab(...args),
|
||||
renameTab: (...args) => shellImplRef.current.renameTab(...args),
|
||||
openRailView: (...args) => shellImplRef.current.openRailView(...args),
|
||||
closeRailView: (...args) => shellImplRef.current.closeRailView(...args),
|
||||
openHostEditor: (...args) =>
|
||||
shellImplRef.current.openHostEditor?.(...args),
|
||||
saveQuickConnect: (...args) =>
|
||||
shellImplRef.current.saveQuickConnect!(...args),
|
||||
confirm: (...args) => shellImplRef.current.confirm!(...args),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Panels close the sidebar on mobile after opening something, the same as
|
||||
// the hosts panel does.
|
||||
const panelCallbacks = useMemo<TabShellCallbacks>(
|
||||
() => ({
|
||||
...callbacks,
|
||||
openTab: (...args) => {
|
||||
callbacks.openTab(...args);
|
||||
if (isMobile) regions.left.setOpen(false);
|
||||
},
|
||||
}),
|
||||
[callbacks, isMobile],
|
||||
);
|
||||
|
||||
// Resize all terminals in panes + active terminal when split mode or sidebar changes
|
||||
const resizeAllTerminals = useCallback(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
tabs.forEach((tab) => {
|
||||
if (!tab.terminalRef) return;
|
||||
const ref = tab.terminalRef.current;
|
||||
ref?.fit?.();
|
||||
ref?.notifyResize?.();
|
||||
});
|
||||
});
|
||||
return id;
|
||||
}, [tabs]);
|
||||
|
||||
const { split, splitShapeKey, splitActionsRef } = useSplit({
|
||||
tabsState,
|
||||
isMobile,
|
||||
paneEls: stageStore.paneEls,
|
||||
callbacks,
|
||||
resizeAllTerminals,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Refitting every terminal on each drag frame is what made sidebar drags
|
||||
// stutter; fit once when the drag ends instead.
|
||||
if (regions.left.dragging || regions.right.dragging) return;
|
||||
const id = resizeAllTerminals();
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [
|
||||
splitShapeKey,
|
||||
regions.left.width,
|
||||
regions.left.open,
|
||||
regions.right.width,
|
||||
regions.right.view,
|
||||
regions.left.dragging,
|
||||
regions.right.dragging,
|
||||
]);
|
||||
|
||||
useWorkspaceLayout(tabsState, regions);
|
||||
useShellKeybindings({
|
||||
tabsState,
|
||||
regions,
|
||||
palette,
|
||||
splitActionsRef,
|
||||
toggleFullscreen: lifecycle.toggleAppFullscreen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setShellCallbacks(callbacks);
|
||||
});
|
||||
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||
const controller: ShellController = {
|
||||
username,
|
||||
isAdmin: lifecycle.isAdmin,
|
||||
isMobile,
|
||||
isElectron: isElectron(),
|
||||
pluginsSettled,
|
||||
logout: onLogout,
|
||||
tabs: {
|
||||
list: tabs,
|
||||
topLevel: tabs.filter((tab) => !tab.parentSplitTabId),
|
||||
activeId: activeTabId,
|
||||
active: activeTab,
|
||||
ready: tabsState.tabsReady,
|
||||
terminalTabs: tabsState.terminalTabs,
|
||||
targetTerminal: tabsState.targetTerminalTab,
|
||||
showNumbers: tabsState.showTabNumbers,
|
||||
background: tabsState.backgroundTabRecords,
|
||||
setBackground: tabsState.setBackgroundTabRecords,
|
||||
setActive: tabsState.setActiveTabId,
|
||||
setList: tabsState.setTabs,
|
||||
reorder: tabsState.reorderTopLevelTabs,
|
||||
open: tabsState.openTab,
|
||||
openSingleton: tabsState.openSingletonTab,
|
||||
openMultiInstance: tabsState.openMultiInstanceTab,
|
||||
connectHost: tabsState.connectHost,
|
||||
close: tabsState.closeTab,
|
||||
rename: tabsState.renameTab,
|
||||
refresh: tabsState.refreshTab,
|
||||
reconnectAll: tabsState.reconnectAllDisconnected,
|
||||
handleFor: tabsState.handleFor,
|
||||
},
|
||||
split,
|
||||
hosts: {
|
||||
list: hosts.list,
|
||||
tree: hosts.tree,
|
||||
loading: hosts.loading,
|
||||
loaded: hosts.loaded,
|
||||
reload: hosts.reload,
|
||||
edit: editHostInManager,
|
||||
},
|
||||
regions,
|
||||
palette,
|
||||
fullscreen: {
|
||||
active: lifecycle.isAppFullscreen,
|
||||
toggle: lifecycle.toggleAppFullscreen,
|
||||
},
|
||||
callbacks,
|
||||
panelCallbacks,
|
||||
settings: {
|
||||
username,
|
||||
isAdmin: lifecycle.isAdmin,
|
||||
onLogout,
|
||||
userPrefs: lifecycle.userPrefs,
|
||||
onPrefsChange: (updates) =>
|
||||
lifecycle.setUserPrefs((current) => ({ ...current, ...updates })),
|
||||
onOpenHostTab: (host: Host) => tabsState.connectHost(host),
|
||||
},
|
||||
confirmRef,
|
||||
resizeAllTerminals,
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishShellController(controller);
|
||||
});
|
||||
useEffect(() => () => publishShellController(null), []);
|
||||
|
||||
return (
|
||||
<TabStageContext.Provider value={stageStore}>
|
||||
<ShellControllerContext.Provider value={controller}>
|
||||
{children}
|
||||
</ShellControllerContext.Provider>
|
||||
</TabStageContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
PluginHostRecord,
|
||||
ShellController as PluginShellController,
|
||||
ShellTabState,
|
||||
} from "@termix-ssh/plugin-sdk/frontend";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useBranding } from "@/contexts/BrandingContext";
|
||||
import { useRailItems } from "@/sidebar/rail-items";
|
||||
import { usePanels } from "@/shell/panel-registry";
|
||||
import { useTabTypes } from "@/shell/tab-registry";
|
||||
import { useRegisteredDashboardCards as useDashboardCards } from "@/dashboard/dashboard-cards-registry";
|
||||
import type { TabSplitAction } from "@/shell/split/split-tabs";
|
||||
import { toPluginHostRecord } from "@/plugin-host/bridge";
|
||||
import { useOptionalShellController } from "./ShellControllerProvider";
|
||||
import { useShellSnapshot } from "./shell-store";
|
||||
|
||||
const registries: PluginShellController["registries"] = {
|
||||
useRailItems: () =>
|
||||
useRailItems() as unknown as ReturnType<
|
||||
PluginShellController["registries"]["useRailItems"]
|
||||
>,
|
||||
usePanels: () =>
|
||||
usePanels() as unknown as ReturnType<
|
||||
PluginShellController["registries"]["usePanels"]
|
||||
>,
|
||||
useTabTypes: () =>
|
||||
useTabTypes() as unknown as ReturnType<
|
||||
PluginShellController["registries"]["useTabTypes"]
|
||||
>,
|
||||
useDashboardCards: () =>
|
||||
useDashboardCards() as unknown as ReturnType<
|
||||
PluginShellController["registries"]["useDashboardCards"]
|
||||
>,
|
||||
};
|
||||
|
||||
/**
|
||||
* The shell as a plugin layout sees it, through useShell() in the SDK. Inside
|
||||
* the shell this reads the controller's context; outside (an overlay a
|
||||
* plugin mounted elsewhere) the last published one.
|
||||
*/
|
||||
export function usePluginShell(pluginId: string): PluginShellController | null {
|
||||
const fromContext = useOptionalShellController();
|
||||
const published = useShellSnapshot();
|
||||
const shell = fromContext ?? published;
|
||||
const { permissions } = usePermissions();
|
||||
const branding = useBranding();
|
||||
|
||||
const hosts = shell?.hosts.list;
|
||||
const hostRecords = useMemo<PluginHostRecord[]>(
|
||||
() => (hosts ?? []).map((host) => toPluginHostRecord(host, pluginId)),
|
||||
[hosts, pluginId],
|
||||
);
|
||||
|
||||
if (!shell) return null;
|
||||
const { tabs, split, regions } = shell;
|
||||
return {
|
||||
tabs: {
|
||||
list: tabs.list as unknown as ShellTabState[],
|
||||
topLevel: tabs.topLevel as unknown as ShellTabState[],
|
||||
activeId: tabs.activeId,
|
||||
ready: tabs.ready,
|
||||
showNumbers: tabs.showNumbers,
|
||||
setActive: tabs.setActive,
|
||||
reorder: (next) => tabs.reorder(next as unknown as typeof tabs.topLevel),
|
||||
close: tabs.close,
|
||||
rename: tabs.rename,
|
||||
refresh: tabs.refresh,
|
||||
reconnectAll: tabs.reconnectAll,
|
||||
openSingleton: (type) => tabs.openSingleton(type),
|
||||
},
|
||||
split: {
|
||||
activeId: split.active?.id ?? null,
|
||||
focusedPaneId: split.focusedPaneId,
|
||||
summaries: split.summaries,
|
||||
activeFull: split.activeFull,
|
||||
handleAction: (action) =>
|
||||
split.handleAction(action as unknown as TabSplitAction),
|
||||
},
|
||||
hosts: {
|
||||
list: hostRecords,
|
||||
loading: shell.hosts.loading,
|
||||
loaded: shell.hosts.loaded,
|
||||
reload: shell.hosts.reload,
|
||||
},
|
||||
regions: {
|
||||
left: {
|
||||
...regions.left,
|
||||
setView: (view) => regions.left.setView(view ?? "hosts"),
|
||||
startResize: (event) =>
|
||||
regions.left.startResize(event as unknown as React.MouseEvent),
|
||||
},
|
||||
right: {
|
||||
...regions.right,
|
||||
editing: false,
|
||||
setView: (view) => regions.right.setView(view),
|
||||
startResize: (event) =>
|
||||
regions.right.startResize(event as unknown as React.MouseEvent),
|
||||
} as PluginShellController["regions"]["right"],
|
||||
railClick: regions.railClick,
|
||||
openInRightDock: regions.openInRightDock,
|
||||
toggleRightDock: regions.toggleRightDock,
|
||||
},
|
||||
palette: {
|
||||
isOpen: shell.palette.isOpen,
|
||||
open: shell.palette.open,
|
||||
close: shell.palette.close,
|
||||
},
|
||||
fullscreen: shell.fullscreen,
|
||||
actions: shell.callbacks as unknown as PluginShellController["actions"],
|
||||
env: {
|
||||
isMobile: shell.isMobile,
|
||||
isElectron: shell.isElectron,
|
||||
user: { username: shell.username, isAdmin: shell.isAdmin },
|
||||
permissions,
|
||||
branding: {
|
||||
appName: branding.appName || null,
|
||||
tagline: branding.tagline || null,
|
||||
logo: branding.logo,
|
||||
},
|
||||
},
|
||||
registries,
|
||||
logout: shell.logout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { ShellController } from "./types";
|
||||
|
||||
/**
|
||||
* The last rendered shell, for code that renders outside the shell's React
|
||||
* tree (a plugin component in an overlay, the plugin bridge). Inside the
|
||||
* shell, the context in ShellControllerProvider is the same value without the
|
||||
* one render of lag.
|
||||
*/
|
||||
let current: ShellController | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function publishShellController(next: ShellController | null): void {
|
||||
if (next === current) return;
|
||||
current = next;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function getShellController(): ShellController | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function useShellSnapshot(): ShellController | null {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => current,
|
||||
() => current,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import type {
|
||||
ConfirmOptions,
|
||||
EditingWidth,
|
||||
} from "@/components/surface/surface-scope";
|
||||
import type { Host, HostFolder, Tab, TabType } from "@/types/ui-types";
|
||||
import type { OpenTabRecord, UserPreferences } from "@/main-axios";
|
||||
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||
import type { SplitViewActions } from "@/shell/split/SplitView";
|
||||
import type {
|
||||
NewTabOption,
|
||||
PickHostTarget,
|
||||
} from "@/shell/split/EmptyPanePicker";
|
||||
import type {
|
||||
SplitSummary,
|
||||
SplitTab,
|
||||
TabSplitAction,
|
||||
} from "@/shell/split/split-tabs";
|
||||
|
||||
export interface DockRegion {
|
||||
view: string;
|
||||
open: boolean;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
editing: EditingWidth;
|
||||
setView: (view: string | ((prev: string) => string)) => void;
|
||||
setOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setWidth: (width: number) => void;
|
||||
startResize: (e: React.MouseEvent) => void;
|
||||
resetWidth: () => void;
|
||||
}
|
||||
|
||||
export interface RightDockRegion {
|
||||
view: string | null;
|
||||
open: boolean;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
setView: (
|
||||
view: string | null | ((prev: string | null) => string | null),
|
||||
) => void;
|
||||
setWidth: (width: number) => void;
|
||||
startResize: (e: React.MouseEvent) => void;
|
||||
resetWidth: () => void;
|
||||
}
|
||||
|
||||
export interface ShellRegions {
|
||||
left: DockRegion;
|
||||
right: RightDockRegion;
|
||||
/** What clicking a rail item does: toggles or switches the left dock. */
|
||||
railClick: (view: string) => void;
|
||||
openInRightDock: (view: string) => void;
|
||||
toggleRightDock: () => void;
|
||||
previousView: () => string | null;
|
||||
/** Stable setters a panel reports its wanted sidebar width with. */
|
||||
panelEditingSetter: (view: string) => (editing: EditingWidth) => void;
|
||||
scopeEditingSetter: (view: string) => (editing: EditingWidth) => void;
|
||||
}
|
||||
|
||||
export interface OpenTabOptions {
|
||||
data?: Record<string, unknown>;
|
||||
label?: string;
|
||||
forceNewTab?: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreTarget {
|
||||
instanceId: string;
|
||||
restoredSessionId: string | null;
|
||||
savedLabel?: string;
|
||||
}
|
||||
|
||||
export interface ShellTabs {
|
||||
list: Tab[];
|
||||
/** Tabs shown in a tab bar: everything not sitting inside a split. */
|
||||
topLevel: Tab[];
|
||||
activeId: string;
|
||||
active: Tab | undefined;
|
||||
ready: boolean;
|
||||
/** Tabs panels can type into. */
|
||||
terminalTabs: Tab[];
|
||||
/** The terminal command panels act on right now. */
|
||||
targetTerminal: Tab | undefined;
|
||||
showNumbers: boolean;
|
||||
background: OpenTabRecord[];
|
||||
setBackground: (
|
||||
next: OpenTabRecord[] | ((prev: OpenTabRecord[]) => OpenTabRecord[]),
|
||||
) => void;
|
||||
setActive: (id: string) => void;
|
||||
setList: (next: Tab[] | ((prev: Tab[]) => Tab[])) => void;
|
||||
reorder: (topLevel: Tab[]) => void;
|
||||
open: (
|
||||
host: Host,
|
||||
type: TabType,
|
||||
restore?: RestoreTarget,
|
||||
options?: OpenTabOptions,
|
||||
) => string;
|
||||
openSingleton: (
|
||||
type: TabType,
|
||||
pendingEvent?: string,
|
||||
host?: Host,
|
||||
data?: Record<string, unknown>,
|
||||
label?: string,
|
||||
) => string | undefined;
|
||||
openMultiInstance: (
|
||||
type: TabType,
|
||||
options?: { host?: Host; data?: Record<string, unknown>; label?: string },
|
||||
) => string;
|
||||
connectHost: (host: Host, type?: TabType, options?: OpenTabOptions) => void;
|
||||
close: (id: string) => Promise<void>;
|
||||
rename: (id: string, label: string) => void;
|
||||
refresh: (id: string) => void;
|
||||
reconnectAll: () => void;
|
||||
handleFor: (id: string) => TabHandle | null | undefined;
|
||||
}
|
||||
|
||||
export interface EmptyPaneApi {
|
||||
freeTabs: Tab[];
|
||||
newTabOptions: NewTabOption[];
|
||||
pickTab: (splitTabId: string, paneId: string, tabId: string) => void;
|
||||
pickHost: (
|
||||
splitTabId: string,
|
||||
paneId: string,
|
||||
host: Host,
|
||||
pick: PickHostTarget,
|
||||
) => void;
|
||||
openType: (splitTabId: string, paneId: string, type: string) => void;
|
||||
closePane: (splitTabId: string, paneId: string) => void;
|
||||
}
|
||||
|
||||
export interface ShellSplit {
|
||||
active: SplitTab | null;
|
||||
/** The pane each on-screen tab sits in, for the active split only. */
|
||||
paneIdByTabId: Map<string, string>;
|
||||
focusedPaneId: string | null;
|
||||
summaries: SplitSummary[];
|
||||
activeFull: boolean;
|
||||
handleAction: (action: TabSplitAction) => void;
|
||||
viewActions: (splitTabId: string) => SplitViewActions;
|
||||
emptyPane: EmptyPaneApi;
|
||||
}
|
||||
|
||||
export interface ShellHosts {
|
||||
list: Host[];
|
||||
tree: HostFolder | null;
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
edit: (host: Host) => void;
|
||||
}
|
||||
|
||||
export interface ShellPalette {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
setOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
||||
}
|
||||
|
||||
export interface ShellSettingsProps {
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
onLogout: (options?: { manual?: boolean }) => void;
|
||||
userPrefs: UserPreferences;
|
||||
onPrefsChange: (updates: Partial<UserPreferences>) => void;
|
||||
onOpenHostTab: (host: Host) => void;
|
||||
}
|
||||
|
||||
/** Everything the shell knows and can do, minus drawing it. */
|
||||
export interface ShellController {
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isMobile: boolean;
|
||||
isElectron: boolean;
|
||||
pluginsSettled: boolean;
|
||||
logout: (options?: { manual?: boolean }) => void;
|
||||
tabs: ShellTabs;
|
||||
split: ShellSplit;
|
||||
hosts: ShellHosts;
|
||||
regions: ShellRegions;
|
||||
palette: ShellPalette;
|
||||
fullscreen: { active: boolean; toggle: () => void };
|
||||
/** One stable bag that forwards to the latest shell, for tabs and plugins. */
|
||||
callbacks: TabShellCallbacks;
|
||||
/** Same, but closes the sidebar on mobile after opening a tab. */
|
||||
panelCallbacks: TabShellCallbacks;
|
||||
settings: ShellSettingsProps;
|
||||
/** Where the main area's confirm is captured, see ShellConfirmCapture. */
|
||||
confirmRef: MutableRefObject<
|
||||
((options: ConfirmOptions) => Promise<boolean>) | null
|
||||
>;
|
||||
/** Refits every visible terminal, e.g. after a pane resize ends. */
|
||||
resizeAllTerminals: () => number;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
import { getSSHFolders, getSSHHosts } from "@/main-axios";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { buildHostTree } from "@/sidebar/build-host-tree";
|
||||
import { setShellHosts } from "@/plugin-host/shell-bridge";
|
||||
|
||||
/** The saved hosts and their folder tree, kept fresh on change events. */
|
||||
export function useHostList() {
|
||||
const [tree, setTree] = useState<HostFolder | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [list, setList] = useState<Host[]>([]);
|
||||
const listRef = useRef(list);
|
||||
listRef.current = list;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const [raw, folders] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getSSHFolders().catch(() => []),
|
||||
]);
|
||||
const converted = raw.map(sshHostToHost);
|
||||
setList(converted);
|
||||
const folderMeta = new Map<
|
||||
string,
|
||||
{
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
sortOrder?: number | null;
|
||||
localOnly?: boolean;
|
||||
}
|
||||
>();
|
||||
for (const f of folders) {
|
||||
folderMeta.set(f.name, {
|
||||
color: f.color ?? undefined,
|
||||
icon: f.icon ?? undefined,
|
||||
credentialId: f.credentialId ?? null,
|
||||
sortOrder: f.sortOrder ?? null,
|
||||
localOnly: !!f.localOnly,
|
||||
});
|
||||
}
|
||||
setTree(buildHostTree(raw, folderMeta));
|
||||
} catch {
|
||||
// Keep empty state on error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const onHostsChanged = () => {
|
||||
void reload();
|
||||
};
|
||||
window.addEventListener("termix:hosts-changed", onHostsChanged);
|
||||
window.addEventListener("ssh-hosts:changed", onHostsChanged);
|
||||
window.addEventListener("hosts:refresh", onHostsChanged);
|
||||
return () => {
|
||||
window.removeEventListener("termix:hosts-changed", onHostsChanged);
|
||||
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
|
||||
window.removeEventListener("hosts:refresh", onHostsChanged);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
setShellHosts(list, loaded);
|
||||
}, [list, loaded]);
|
||||
|
||||
return { list, listRef, tree, loading, loaded, reload };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { EditingWidth } from "@/components/surface/surface-scope";
|
||||
import { remScale } from "@/lib/rem";
|
||||
import { rightDockableIds } from "@/sidebar/rail-items";
|
||||
import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel";
|
||||
import type { ShellRegions } from "./types";
|
||||
|
||||
const MIN_WIDTH = 160;
|
||||
const MAX_WIDTH = 480;
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
|
||||
}
|
||||
|
||||
/** A drag on a dock edge, one width update per frame. `sign` is -1 for the right dock. */
|
||||
function startDockDrag(
|
||||
e: React.MouseEvent,
|
||||
startW: number,
|
||||
sign: 1 | -1,
|
||||
setWidth: (width: number) => void,
|
||||
setDragging: (dragging: boolean) => void,
|
||||
) {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
const startX = e.clientX;
|
||||
// Widths are kept in Normal-size pixels, so a drag is scaled back.
|
||||
const scale = remScale();
|
||||
let frame = 0;
|
||||
let clientX = startX;
|
||||
function onMove(ev: MouseEvent) {
|
||||
clientX = ev.clientX;
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
setWidth(clampWidth(startW + (sign * (clientX - startX)) / scale));
|
||||
});
|
||||
}
|
||||
function onUp() {
|
||||
cancelAnimationFrame(frame);
|
||||
setWidth(clampWidth(startW + (sign * (clientX - startX)) / scale));
|
||||
setDragging(false);
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
/** The left sidebar and the right dock: what they show, open state and size. */
|
||||
export function useRegions(isMobile: boolean): ShellRegions {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<string>("hosts");
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
});
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
// How far each panel wants the sidebar widened: what the panel says itself,
|
||||
// and what its open inline views say.
|
||||
const [panelEditing, setPanelEditing] = useState<
|
||||
Record<string, EditingWidth>
|
||||
>({});
|
||||
const [scopeEditing, setScopeEditing] = useState<
|
||||
Record<string, EditingWidth>
|
||||
>({});
|
||||
const editingSetters = useRef(
|
||||
new Map<string, (editing: EditingWidth) => void>(),
|
||||
);
|
||||
const panelEditingSetter = useCallback((view: string) => {
|
||||
const key = `panel:${view}`;
|
||||
if (!editingSetters.current.has(key))
|
||||
editingSetters.current.set(key, (editing) =>
|
||||
setPanelEditing((prev) =>
|
||||
prev[view] === editing ? prev : { ...prev, [view]: editing },
|
||||
),
|
||||
);
|
||||
return editingSetters.current.get(key)!;
|
||||
}, []);
|
||||
const scopeEditingSetter = useCallback((view: string) => {
|
||||
const key = `scope:${view}`;
|
||||
if (!editingSetters.current.has(key))
|
||||
editingSetters.current.set(key, (editing) =>
|
||||
setScopeEditing((prev) =>
|
||||
prev[view] === editing ? prev : { ...prev, [view]: editing },
|
||||
),
|
||||
);
|
||||
return editingSetters.current.get(key)!;
|
||||
}, []);
|
||||
|
||||
// Right dock: a second panel column so reference panels like history can
|
||||
// stay visible while the left sidebar is used for something else.
|
||||
const [rightRailView, setRightRailView] = useState<string | null>(() => {
|
||||
const saved = localStorage.getItem("termix_rightRailView");
|
||||
return saved && rightDockableIds().includes(saved) ? saved : null;
|
||||
});
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_rightSidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
});
|
||||
const [rightSidebarDragging, setRightSidebarDragging] = useState(false);
|
||||
// Remembers the last panel shown in the dock so the tab bar toggle can bring
|
||||
// it back instead of always falling back to the same default.
|
||||
const lastRightRailViewRef = useRef<string | null>(
|
||||
localStorage.getItem("termix_lastRightRailView"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_sidebarWidth", String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth));
|
||||
}, [rightSidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rightRailView) {
|
||||
localStorage.setItem("termix_rightRailView", rightRailView);
|
||||
lastRightRailViewRef.current = rightRailView;
|
||||
localStorage.setItem("termix_lastRightRailView", rightRailView);
|
||||
} else {
|
||||
localStorage.removeItem("termix_rightRailView");
|
||||
}
|
||||
}, [rightRailView]);
|
||||
|
||||
const sidebarOpenBeforeMobile = useRef(sidebarOpen);
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
sidebarOpenBeforeMobile.current = sidebarOpen;
|
||||
setSidebarOpen(false);
|
||||
} else {
|
||||
setSidebarOpen(sidebarOpenBeforeMobile.current);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// The panel shown before the current one, for Ctrl+Shift+E.
|
||||
const previousRailViewRef = useRef<string | null>(null);
|
||||
const currentRailViewRef = useRef(railView);
|
||||
useEffect(() => {
|
||||
if (currentRailViewRef.current !== railView) {
|
||||
previousRailViewRef.current = currentRailViewRef.current;
|
||||
currentRailViewRef.current = railView;
|
||||
}
|
||||
}, [railView]);
|
||||
|
||||
// Moving a panel to the right dock rather than copying it: two live copies of
|
||||
// the same panel would fight over the shared editing state.
|
||||
function openInRightDock(view: string) {
|
||||
setRightRailView(view);
|
||||
if (railView === view) setSidebarOpen(false);
|
||||
}
|
||||
|
||||
// Tab bar toggle: reopens whatever was last in the dock, so it behaves like a
|
||||
// show/hide rather than losing the user's choice each time.
|
||||
function toggleRightDock() {
|
||||
if (rightRailView) {
|
||||
lastRightRailViewRef.current = rightRailView;
|
||||
setRightRailView(null);
|
||||
return;
|
||||
}
|
||||
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
|
||||
if (fallback) setRightRailView(fallback);
|
||||
}
|
||||
|
||||
function railClick(view: string) {
|
||||
if (railView === view && sidebarOpen) {
|
||||
setSidebarOpen(false);
|
||||
} else {
|
||||
// A panel lives in one dock at a time, so the left dock reclaims it.
|
||||
if (rightRailView === view) setRightRailView(null);
|
||||
setRailView(view);
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
const startLeftResize = useCallback(
|
||||
(e: React.MouseEvent) =>
|
||||
startDockDrag(e, sidebarWidth, 1, setSidebarWidth, setSidebarDragging),
|
||||
[sidebarWidth],
|
||||
);
|
||||
// Same drag, mirrored: the right dock grows as the pointer moves left.
|
||||
const startRightResize = useCallback(
|
||||
(e: React.MouseEvent) =>
|
||||
startDockDrag(
|
||||
e,
|
||||
rightSidebarWidth,
|
||||
-1,
|
||||
setRightSidebarWidth,
|
||||
setRightSidebarDragging,
|
||||
),
|
||||
[rightSidebarWidth],
|
||||
);
|
||||
|
||||
const leftEditing: EditingWidth = (() => {
|
||||
const values = [panelEditing[railView], scopeEditing[railView]];
|
||||
if (values.includes("wide")) return "wide";
|
||||
return values.some(Boolean);
|
||||
})();
|
||||
|
||||
return {
|
||||
left: {
|
||||
view: railView,
|
||||
open: sidebarOpen,
|
||||
width: sidebarWidth,
|
||||
dragging: sidebarDragging,
|
||||
editing: leftEditing,
|
||||
setView: setRailView,
|
||||
setOpen: setSidebarOpen,
|
||||
setWidth: setSidebarWidth,
|
||||
startResize: startLeftResize,
|
||||
resetWidth: () => setSidebarWidth(DOCK_DEFAULT_WIDTH),
|
||||
},
|
||||
right: {
|
||||
view: rightRailView,
|
||||
open: rightRailView !== null,
|
||||
width: rightSidebarWidth,
|
||||
dragging: rightSidebarDragging,
|
||||
setView: setRightRailView,
|
||||
setWidth: setRightSidebarWidth,
|
||||
startResize: startRightResize,
|
||||
resetWidth: () => setRightSidebarWidth(DOCK_DEFAULT_WIDTH),
|
||||
},
|
||||
railClick,
|
||||
openInRightDock,
|
||||
toggleRightDock,
|
||||
previousView: () => previousRailViewRef.current,
|
||||
panelEditingSetter,
|
||||
scopeEditingSetter,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
dispatchCtrlW,
|
||||
createCommandPaletteShortcutMatcher,
|
||||
isShiftKey,
|
||||
} from "@/lib/app-keyboard-shortcuts";
|
||||
import { getUserPreferences } from "@/main-axios";
|
||||
import { parseCustomKeybindings } from "@/api/open-tabs-api";
|
||||
import { findMatchingKeybinding } from "@/lib/keybinding-match";
|
||||
import type {
|
||||
CustomKeybinding,
|
||||
KeybindingActionType,
|
||||
} from "@/types/keybindings";
|
||||
import {
|
||||
GLOBAL_KEYBINDING_EVENT,
|
||||
getKeybindingAction,
|
||||
runKeybindingAction,
|
||||
} from "@/shell/keybinding-registry";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
|
||||
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
||||
import type { TabsState } from "./use-tabs";
|
||||
import type { SplitActions } from "./use-split";
|
||||
import type { ShellPalette, ShellRegions } from "./types";
|
||||
|
||||
/** Every shell-wide shortcut: custom bindings, palette, panes and tabs. */
|
||||
export function useShellKeybindings({
|
||||
tabsState,
|
||||
regions,
|
||||
palette,
|
||||
splitActionsRef,
|
||||
toggleFullscreen,
|
||||
}: {
|
||||
tabsState: TabsState;
|
||||
regions: ShellRegions;
|
||||
palette: ShellPalette;
|
||||
splitActionsRef: MutableRefObject<SplitActions | null>;
|
||||
toggleFullscreen: () => void;
|
||||
}) {
|
||||
const { tabsRef, activeTabIdRef, setActiveTabId, closeActiveTabRef } =
|
||||
tabsState;
|
||||
const globalKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||
const toggleFullscreenRef = useRef(toggleFullscreen);
|
||||
toggleFullscreenRef.current = toggleFullscreen;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
if (cancelled) return;
|
||||
globalKeybindingsRef.current = parseCustomKeybindings(
|
||||
prefs.customKeybindings,
|
||||
).filter((binding) => binding.enabled);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
window.addEventListener("customKeybindingsChanged", load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("customKeybindingsChanged", load);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const runAction = (type: KeybindingActionType) => {
|
||||
if (type === "openCommandPalette") {
|
||||
palette.setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (type === "reconnectSession") {
|
||||
const tabId = tabsState.focusedSessionTabId();
|
||||
if (!tabId) return;
|
||||
const termRef = tabsState.terminalRefs.current.get(tabId);
|
||||
(termRef?.current as TabHandle | null)?.reconnect?.();
|
||||
return;
|
||||
}
|
||||
const currentTabs = tabsRef.current.filter(
|
||||
(tab) => !tab.parentSplitTabId,
|
||||
);
|
||||
if (currentTabs.length < 2) return;
|
||||
const index = currentTabs.findIndex(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
const offset = type === "nextTab" ? 1 : -1;
|
||||
const next = (index + offset + currentTabs.length) % currentTabs.length;
|
||||
setActiveTabId(currentTabs[next].id);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest("[data-keybinding-recorder]")
|
||||
)
|
||||
return;
|
||||
// Only actions that run anywhere; a terminal's own are its to handle.
|
||||
const binding = findMatchingKeybinding(
|
||||
event,
|
||||
globalKeybindingsRef.current.filter(
|
||||
(entry) => getKeybindingAction(entry.action.type)?.scope === "global",
|
||||
),
|
||||
);
|
||||
if (!binding) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
runKeybindingAction(binding.action);
|
||||
};
|
||||
const handleAction = (event: Event) =>
|
||||
runAction(
|
||||
(event as CustomEvent<{ type: KeybindingActionType }>).detail.type,
|
||||
);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return window.electronAPI?.onCloseActiveTab?.(() => {
|
||||
if (dispatchCtrlW(document.activeElement)) return;
|
||||
closeActiveTabRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
||||
useState<boolean>(() => {
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
|
||||
// Double-shift or Ctrl+K opens the command palette. Double-shift alone was
|
||||
// hard to discover.
|
||||
useEffect(() => {
|
||||
if (!commandPaletteShortcutEnabled) return;
|
||||
const shortcut = createCommandPaletteShortcutMatcher();
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!shortcut.matches(e)) return;
|
||||
if (!isShiftKey(e)) e.preventDefault();
|
||||
palette.setOpen((prev) => !prev);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("blur", shortcut.reset);
|
||||
window.addEventListener("compositionstart", shortcut.reset);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("blur", shortcut.reset);
|
||||
window.removeEventListener("compositionstart", shortcut.reset);
|
||||
};
|
||||
}, [commandPaletteShortcutEnabled]);
|
||||
|
||||
// Ctrl+Shift+E toggles between the two most recent sidebar panels.
|
||||
const railView = regions.left.view;
|
||||
const sidebarOpen = regions.left.open;
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return;
|
||||
e.preventDefault();
|
||||
const previous = regions.previousView();
|
||||
if (!sidebarOpen) {
|
||||
regions.left.setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (previous && previous !== railView) regions.railClick(previous);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [railView, sidebarOpen]);
|
||||
|
||||
// Split-screen and tab navigation hotkeys
|
||||
// Also registered in globalShortcutHandler so xterm can invoke directly
|
||||
// without going through synthetic DOM events (which are unreliable).
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
if (e.code === "KeyF") {
|
||||
e.preventDefault();
|
||||
toggleFullscreenRef.current();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter
|
||||
// zooms the focused pane.
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
if (e.code === "Backslash" || e.code === "Minus") {
|
||||
e.preventDefault();
|
||||
splitActionsRef.current?.splitActive(
|
||||
e.code === "Backslash" ? "right" : "bottom",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
|
||||
const paneDirection = getPaneNavDirection(e);
|
||||
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
||||
const tabDigit = getTabJumpDigit(e);
|
||||
if (tabDigit !== null) {
|
||||
const currentTabs = tabsRef.current.filter(
|
||||
(tab) => !tab.parentSplitTabId,
|
||||
);
|
||||
const index = tabDigit - 1;
|
||||
if (index < currentTabs.length) {
|
||||
e.preventDefault();
|
||||
setActiveTabId(currentTabs[index].id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+] / Ctrl+Shift+[: cycle through open tabs (] = next, [ = previous)
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
if (e.code === "BracketRight" || e.code === "BracketLeft") {
|
||||
e.preventDefault();
|
||||
const currentTabs = tabsRef.current.filter(
|
||||
(tab) => !tab.parentSplitTabId,
|
||||
);
|
||||
if (currentTabs.length < 2) return;
|
||||
const currentId = activeTabIdRef.current;
|
||||
const idx = currentTabs.findIndex((t) => t.id === currentId);
|
||||
const next =
|
||||
e.code === "BracketRight"
|
||||
? (idx + 1) % currentTabs.length
|
||||
: (idx - 1 + currentTabs.length) % currentTabs.length;
|
||||
setActiveTabId(currentTabs[next].id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
globalShortcutHandler.current = handleKeyDown;
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
globalShortcutHandler.current = null;
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true);
|
||||
};
|
||||
window.addEventListener("commandPaletteShortcutEnabledChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"commandPaletteShortcutEnabledChanged",
|
||||
handler,
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useCallback, useEffect, useState, type MutableRefObject } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
||||
import { resetPermissionsCache } from "@/hooks/use-permissions";
|
||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
getUserInfo,
|
||||
getUserPreferences,
|
||||
saveUserPreferences,
|
||||
type UserPreferences,
|
||||
} from "@/main-axios";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
|
||||
import {
|
||||
MANAGE_REQUEST_EVENT,
|
||||
requestFromLegacyEvent,
|
||||
requestManage,
|
||||
} from "@/manage/manage-requests";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
|
||||
/** Keys a cloud-synced profile overwrites, snapshotted first so they can come back. */
|
||||
const SNAPSHOT_KEYS = [
|
||||
"termix-accent",
|
||||
"termix-font-size",
|
||||
"termix-ui-font",
|
||||
"i18nextLng",
|
||||
"commandPaletteShortcutEnabled",
|
||||
"showHostTags",
|
||||
"hostTrayOnClick",
|
||||
"pinAppRail",
|
||||
"expandAppRailOnHover",
|
||||
"disableUpdateCheck",
|
||||
"confirmTabClose",
|
||||
"hiddenRailTabs",
|
||||
];
|
||||
|
||||
/** Applies a cloud-synced profile to this browser. */
|
||||
function applyCloudPreferences(
|
||||
prefs: UserPreferences,
|
||||
setTheme: (theme: ThemeId) => void,
|
||||
currentLanguage: string,
|
||||
loginLanguage: string | null,
|
||||
) {
|
||||
// Persist the current browser values before overwriting, so any tab can restore them
|
||||
if (!localStorage.getItem("termix-local-snapshot")) {
|
||||
const snap: Record<string, string | null> = {
|
||||
__theme: localStorage.getItem("termix-theme"),
|
||||
};
|
||||
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
|
||||
localStorage.setItem("termix-local-snapshot", JSON.stringify(snap));
|
||||
}
|
||||
// The account's own pick wins over the older profile field.
|
||||
const cloudTheme = prefs.themeId ?? prefs.theme;
|
||||
if (cloudTheme) setTheme(cloudTheme as ThemeId);
|
||||
if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId);
|
||||
if (prefs.accentColor) {
|
||||
localStorage.setItem("termix-accent", prefs.accentColor);
|
||||
applyAccentColor(prefs.accentColor);
|
||||
}
|
||||
const preferredLanguage = loginLanguage ?? prefs.language;
|
||||
if (preferredLanguage && preferredLanguage !== currentLanguage) {
|
||||
void changeAppLanguage(preferredLanguage);
|
||||
}
|
||||
if (loginLanguage && loginLanguage !== prefs.language) {
|
||||
void saveUserPreferences({ language: loginLanguage });
|
||||
}
|
||||
if (
|
||||
prefs.commandPaletteEnabled !== null &&
|
||||
prefs.commandPaletteEnabled !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"commandPaletteShortcutEnabled",
|
||||
String(prefs.commandPaletteEnabled),
|
||||
);
|
||||
if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) {
|
||||
localStorage.setItem("showHostTags", String(prefs.showHostTags));
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
}
|
||||
if (prefs.hostTrayOnClick !== null && prefs.hostTrayOnClick !== undefined)
|
||||
localStorage.setItem("hostTrayOnClick", String(prefs.hostTrayOnClick));
|
||||
if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) {
|
||||
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
}
|
||||
if (
|
||||
prefs.expandAppRailOnHover !== null &&
|
||||
prefs.expandAppRailOnHover !== undefined
|
||||
) {
|
||||
localStorage.setItem(
|
||||
"expandAppRailOnHover",
|
||||
String(prefs.expandAppRailOnHover),
|
||||
);
|
||||
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||
}
|
||||
if (
|
||||
prefs.disableUpdateCheck !== null &&
|
||||
prefs.disableUpdateCheck !== undefined
|
||||
)
|
||||
localStorage.setItem(
|
||||
"disableUpdateCheck",
|
||||
String(prefs.disableUpdateCheck),
|
||||
);
|
||||
if (prefs.confirmTabClose !== null && prefs.confirmTabClose !== undefined)
|
||||
localStorage.setItem("confirmTabClose", String(prefs.confirmTabClose));
|
||||
if (prefs.hiddenRailTabs !== null && prefs.hiddenRailTabs !== undefined) {
|
||||
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
|
||||
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Who is signed in and what the shell does around the tabs: prefs, health, logout. */
|
||||
export function useShellLifecycle({
|
||||
onLogout,
|
||||
openSingletonTabRef,
|
||||
}: {
|
||||
onLogout: (options?: { manual?: boolean }) => void;
|
||||
openSingletonTabRef: MutableRefObject<(type: TabType) => string | undefined>;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
const [userPrefs, setUserPrefs] = useState<UserPreferences>({
|
||||
reopenTabsOnLogin: false,
|
||||
});
|
||||
const [userPrefsLoaded, setUserPrefsLoaded] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isAppFullscreen, setIsAppFullscreen] = useState(
|
||||
() => !!document.fullscreenElement,
|
||||
);
|
||||
|
||||
// Anything that asks to add or edit a host, a credential or host defaults
|
||||
// ends up in the Manage tab, whichever event it still speaks.
|
||||
useEffect(() => {
|
||||
const names = [
|
||||
"host-manager:add-host",
|
||||
"host-manager:edit-host",
|
||||
"host-manager:add-credential",
|
||||
"host-manager:show-credentials",
|
||||
"host-manager:edit-defaults",
|
||||
"termix:open-host-defaults",
|
||||
];
|
||||
const handler = (e: Event) => {
|
||||
const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail);
|
||||
if (request) requestManage(request);
|
||||
};
|
||||
const open = () => openSingletonTabRef.current("host-manager");
|
||||
for (const name of names) window.addEventListener(name, handler);
|
||||
window.addEventListener(MANAGE_REQUEST_EVENT, open);
|
||||
return () => {
|
||||
for (const name of names) window.removeEventListener(name, handler);
|
||||
window.removeEventListener(MANAGE_REQUEST_EVENT, open);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
.then((info) => setIsAdmin(info.is_admin))
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
const toggleAppFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.fullscreenEnabled) {
|
||||
toast.error(t("nav.fullscreenUnsupported"));
|
||||
return;
|
||||
}
|
||||
|
||||
await document.documentElement.requestFullscreen();
|
||||
} catch {
|
||||
toast.error(t("nav.fullscreenFailed"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsAppFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
return () =>
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = (event: Event) => {
|
||||
const manual =
|
||||
event instanceof CustomEvent &&
|
||||
(event.detail as { manual?: boolean } | undefined)?.manual === true;
|
||||
// Drop the cached grants so the next user never inherits them.
|
||||
resetPermissionsCache();
|
||||
onLogout(manual ? { manual: true } : undefined);
|
||||
};
|
||||
window.addEventListener("termix:logout", handle);
|
||||
return () => window.removeEventListener("termix:logout", handle);
|
||||
}, [onLogout]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionExpired = () => onLogout();
|
||||
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
}, [onLogout]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDegraded = () => {
|
||||
toast.loading(t("common.connectionDegraded"), {
|
||||
id: "db-connection-degraded",
|
||||
duration: Infinity,
|
||||
dismissible: false,
|
||||
action: {
|
||||
label: t("common.reload"),
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestored = () => {
|
||||
toast.dismiss("db-connection-degraded");
|
||||
toast.success(t("common.backendReconnected"), { duration: 3000 });
|
||||
};
|
||||
|
||||
dbHealthMonitor.on("database-connection-degraded", handleDegraded);
|
||||
dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored);
|
||||
|
||||
return () => {
|
||||
dbHealthMonitor.off("database-connection-degraded", handleDegraded);
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-degraded-cleared",
|
||||
handleRestored,
|
||||
);
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => {
|
||||
const loginLanguage = consumeLoginLanguage();
|
||||
setUserPrefs(prefs);
|
||||
if (prefs.storageMode === "cloud") {
|
||||
applyCloudPreferences(prefs, setTheme, i18n.language, loginLanguage);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setUserPrefsLoaded(true));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
userPrefs,
|
||||
setUserPrefs,
|
||||
userPrefsLoaded,
|
||||
isAppFullscreen,
|
||||
toggleAppFullscreen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react";
|
||||
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { createId } from "@/lib/create-id";
|
||||
import { registerPaletteEntry } from "@/shell/palette-registry";
|
||||
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||
import { runHostAction } from "@/sidebar/host-contributions";
|
||||
import type { SplitViewActions } from "@/shell/split/SplitView";
|
||||
import type { PickHostTarget } from "@/shell/split/EmptyPanePicker";
|
||||
import {
|
||||
addSplitTab,
|
||||
canJoinSplit,
|
||||
closeSplitTab,
|
||||
isSplitTab,
|
||||
makeSplitTab,
|
||||
nextSplitNumber,
|
||||
placeTabInPane,
|
||||
splitTabOf,
|
||||
summarizeSplits,
|
||||
updateSplit,
|
||||
type SplitTab,
|
||||
type TabSplitAction,
|
||||
} from "@/shell/split/split-tabs";
|
||||
import {
|
||||
addPaneAtEdge,
|
||||
assignTab,
|
||||
canAddPane,
|
||||
createPane,
|
||||
createSplitNode,
|
||||
createSplitState,
|
||||
equalizeSplit,
|
||||
findPane,
|
||||
findPaneByTab,
|
||||
focusPane,
|
||||
listPanes,
|
||||
MAX_PANES,
|
||||
movePane,
|
||||
neighborPane,
|
||||
removePane,
|
||||
resizeSplit,
|
||||
shownPanes,
|
||||
splitPane,
|
||||
swapPanes,
|
||||
toggleZoom,
|
||||
type NavDirection,
|
||||
type PaneEdge,
|
||||
type PaneRect,
|
||||
type SplitState,
|
||||
} from "@/shell/split/split-tree";
|
||||
import {
|
||||
applyPreset,
|
||||
buildPreset,
|
||||
type SplitPresetId,
|
||||
} from "@/shell/split/split-presets";
|
||||
import {
|
||||
setSplitDropHandler,
|
||||
type SplitDragSource,
|
||||
type SplitDropHover,
|
||||
} from "@/shell/split/split-drag";
|
||||
import {
|
||||
publishSplitTargets,
|
||||
setSplitOpener,
|
||||
type SplitOpenTarget,
|
||||
} from "@/shell/split/split-targets";
|
||||
import { useTabTypes } from "@/shell/tab-registry";
|
||||
import { useSplitLabel, type TabsState } from "./use-tabs";
|
||||
import type { ShellSplit } from "./types";
|
||||
|
||||
export interface SplitActions {
|
||||
splitActive: (edge: "right" | "bottom") => void;
|
||||
navigate: (direction: NavDirection) => boolean;
|
||||
zoomFocused: () => boolean;
|
||||
closeFocusedPane: () => void;
|
||||
}
|
||||
|
||||
export function useSplit({
|
||||
tabsState,
|
||||
isMobile,
|
||||
paneEls,
|
||||
callbacks,
|
||||
resizeAllTerminals,
|
||||
}: {
|
||||
tabsState: TabsState;
|
||||
isMobile: boolean;
|
||||
paneEls: Map<string, HTMLDivElement>;
|
||||
callbacks: TabShellCallbacks;
|
||||
resizeAllTerminals: () => number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const splitLabel = useSplitLabel();
|
||||
const tabTypes = useTabTypes();
|
||||
const {
|
||||
tabs,
|
||||
setTabs,
|
||||
tabsReady,
|
||||
activeTabId,
|
||||
setActiveTabId,
|
||||
tabsRef,
|
||||
activeTabIdRef,
|
||||
targetPane,
|
||||
} = tabsState;
|
||||
|
||||
/**
|
||||
* Edits a split with a change computed from the last committed state, for
|
||||
* edits that create panes and so have to know the new pane's id.
|
||||
*/
|
||||
function commitSplit(
|
||||
splitTabId: string,
|
||||
fn: (state: SplitState) => SplitState | null,
|
||||
): SplitState | null {
|
||||
const current = tabsRef.current.find((tab) => tab.id === splitTabId);
|
||||
if (!isSplitTab(current)) return null;
|
||||
const next = fn(current.split);
|
||||
setTabs((prev) =>
|
||||
updateSplit(prev, splitTabId, (state) =>
|
||||
state === current.split ? next : fn(state),
|
||||
),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
function editSplit(
|
||||
splitTabId: string,
|
||||
fn: (state: SplitState) => SplitState | null,
|
||||
) {
|
||||
setTabs((prev) => updateSplit(prev, splitTabId, fn));
|
||||
}
|
||||
|
||||
function warnSplitFull() {
|
||||
toast.error(t("splitScreen.maxPanes", { count: MAX_PANES }));
|
||||
}
|
||||
|
||||
function openSplitTab(state: SplitState): SplitTab {
|
||||
const number = nextSplitNumber(tabsRef.current, splitLabel);
|
||||
const splitTab = makeSplitTab(createId(), splitLabel(number), state);
|
||||
setTabs((prev) => addSplitTab(prev, splitTab));
|
||||
setActiveTabId(splitTab.id);
|
||||
return splitTab;
|
||||
}
|
||||
|
||||
/** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */
|
||||
function startSplit(tabId: string | null, edge: PaneEdge): SplitTab {
|
||||
const own = createPane(tabId);
|
||||
const fresh = createPane();
|
||||
const leading = edge === "left" || edge === "top";
|
||||
const root = createSplitNode(
|
||||
edge === "left" || edge === "right" ? "row" : "column",
|
||||
leading ? [fresh, own] : [own, fresh],
|
||||
);
|
||||
return openSplitTab(createSplitState(root, fresh.id));
|
||||
}
|
||||
|
||||
function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) {
|
||||
commitSplit(splitTabId, (state) => {
|
||||
const result = splitPane(state, paneId, edge);
|
||||
if (!result) warnSplitFull();
|
||||
return result ? result.state : state;
|
||||
});
|
||||
setActiveTabId(splitTabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a tab: a split grows at its focused pane, a tab inside a split
|
||||
* grows at its own pane, anything else becomes a new split.
|
||||
*/
|
||||
function splitTabAt(tabId: string, edge: PaneEdge) {
|
||||
const current = tabsRef.current;
|
||||
const tab = current.find((t) => t.id === tabId);
|
||||
if (!tab) return;
|
||||
if (isSplitTab(tab)) {
|
||||
splitPaneOf(tab.id, tab.split.focusedPaneId, edge);
|
||||
return;
|
||||
}
|
||||
const parent = splitTabOf(current, tabId);
|
||||
const pane = parent ? findPaneByTab(parent.split, tabId) : undefined;
|
||||
if (parent && pane) {
|
||||
splitPaneOf(parent.id, pane.id, edge);
|
||||
return;
|
||||
}
|
||||
startSplit(canJoinSplit(tab) ? tab.id : null, edge);
|
||||
}
|
||||
|
||||
function applyLayoutPreset(presetId: SplitPresetId) {
|
||||
const active = tabsRef.current.find(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
if (isSplitTab(active)) {
|
||||
commitSplit(active.id, (state) => applyPreset(state, presetId));
|
||||
return;
|
||||
}
|
||||
const root = buildPreset(
|
||||
presetId,
|
||||
active && canJoinSplit(active) ? [active.id] : [],
|
||||
);
|
||||
openSplitTab(createSplitState(root));
|
||||
}
|
||||
|
||||
/** Closes a split tab, sending its tabs back to the tab bar. */
|
||||
function unsplit(splitTabId: string) {
|
||||
const split = tabsRef.current.find((tab) => tab.id === splitTabId);
|
||||
if (!isSplitTab(split)) return;
|
||||
const focusedTabId =
|
||||
findPane(split.split, split.split.focusedPaneId)?.tabId ??
|
||||
listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ??
|
||||
null;
|
||||
setTabs((prev) => closeSplitTab(prev, splitTabId));
|
||||
if (activeTabIdRef.current === splitTabId) {
|
||||
const fallback = tabsRef.current.filter(
|
||||
(tab) => tab.id !== splitTabId && !tab.parentSplitTabId,
|
||||
);
|
||||
setActiveTabId(
|
||||
focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a pane; its tab, if any, goes back to the tab bar. */
|
||||
function closePaneOf(splitTabId: string, paneId: string) {
|
||||
const split = tabsRef.current.find((tab) => tab.id === splitTabId);
|
||||
if (!isSplitTab(split)) return;
|
||||
if (listPanes(split.split.root).length <= 1) {
|
||||
unsplit(splitTabId);
|
||||
return;
|
||||
}
|
||||
editSplit(splitTabId, (state) => removePane(state, paneId));
|
||||
}
|
||||
|
||||
tabsState.splitOpsRef.current = { unsplit, closePaneOf };
|
||||
|
||||
function handleTabSplitAction(action: TabSplitAction) {
|
||||
switch (action.kind) {
|
||||
case "split":
|
||||
splitTabAt(action.tabId, action.edge);
|
||||
break;
|
||||
case "splitActive":
|
||||
splitTabAt(activeTabIdRef.current, action.edge);
|
||||
break;
|
||||
case "preset":
|
||||
applyLayoutPreset(action.presetId);
|
||||
break;
|
||||
case "unsplit":
|
||||
unsplit(action.splitTabId);
|
||||
break;
|
||||
case "addToPane":
|
||||
setTabs((prev) =>
|
||||
placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId),
|
||||
);
|
||||
setActiveTabId(action.splitTabId);
|
||||
break;
|
||||
case "addPane":
|
||||
commitSplit(action.splitTabId, (state) => {
|
||||
const result = addPaneAtEdge(state, action.edge, action.tabId);
|
||||
if (!result) warnSplitFull();
|
||||
return result ? result.state : state;
|
||||
});
|
||||
setActiveTabId(action.splitTabId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** "Open in split" from the host list: aim the next tab, then open it. */
|
||||
const splitOpenerRef = useRef<
|
||||
(target: SplitOpenTarget, open: () => void) => void
|
||||
>(() => {});
|
||||
splitOpenerRef.current = (target, open) => {
|
||||
if (target.kind === "pane") {
|
||||
targetPane(target.splitTabId, target.paneId);
|
||||
} else if (target.kind === "newPane") {
|
||||
const split = tabsRef.current.find((tab) => tab.id === target.splitTabId);
|
||||
if (!isSplitTab(split)) return;
|
||||
const result = addPaneAtEdge(split.split, "right");
|
||||
if (!result) {
|
||||
warnSplitFull();
|
||||
return;
|
||||
}
|
||||
editSplit(split.id, (state) =>
|
||||
state === split.split ? result.state : state,
|
||||
);
|
||||
setActiveTabId(split.id);
|
||||
targetPane(split.id, result.paneId, true);
|
||||
} else {
|
||||
const active = tabsRef.current.find(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
if (!active || !canJoinSplit(active)) return;
|
||||
const splitTab = startSplit(active.id, "right");
|
||||
targetPane(splitTab.id, splitTab.split.focusedPaneId, true);
|
||||
}
|
||||
open();
|
||||
};
|
||||
|
||||
/** A tab or pane dropped on the main area. */
|
||||
const splitDropRef = useRef<
|
||||
(source: SplitDragSource, hover: SplitDropHover) => void
|
||||
>(() => {});
|
||||
splitDropRef.current = (source, hover) => {
|
||||
if (source.kind === "pane") {
|
||||
const toPaneId = hover.paneId;
|
||||
if (!toPaneId) return;
|
||||
editSplit(source.splitTabId, (state) =>
|
||||
movePane(state, source.paneId, toPaneId, hover.target),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const active = tabsRef.current.find(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
if (hover.paneId === null) {
|
||||
if (hover.target === "center") return;
|
||||
const other =
|
||||
active && canJoinSplit(active) && active.id !== source.tabId
|
||||
? active.id
|
||||
: null;
|
||||
const dragged = createPane(source.tabId);
|
||||
const rest = createPane(other);
|
||||
const leading = hover.target === "left" || hover.target === "top";
|
||||
const root = createSplitNode(
|
||||
hover.target === "left" || hover.target === "right" ? "row" : "column",
|
||||
leading ? [dragged, rest] : [rest, dragged],
|
||||
);
|
||||
openSplitTab(createSplitState(root, dragged.id));
|
||||
return;
|
||||
}
|
||||
if (!isSplitTab(active)) return;
|
||||
const paneId = hover.paneId;
|
||||
if (hover.target === "center") {
|
||||
setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId));
|
||||
return;
|
||||
}
|
||||
const edge = hover.target;
|
||||
commitSplit(active.id, (state) => {
|
||||
const result = splitPane(state, paneId, edge, source.tabId);
|
||||
if (!result) warnSplitFull();
|
||||
return result ? result.state : state;
|
||||
});
|
||||
};
|
||||
|
||||
// Split actions for handlers registered once, e.g. the global hotkeys.
|
||||
const splitActionsRef = useRef<SplitActions | null>(null);
|
||||
splitActionsRef.current = {
|
||||
splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge),
|
||||
navigate: (direction) => {
|
||||
const active = tabsRef.current.find(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
if (!isSplitTab(active)) return false;
|
||||
const view = document.querySelector(`[data-split-view="${active.id}"]`);
|
||||
if (!view) return false;
|
||||
const rects: Record<string, PaneRect> = {};
|
||||
view
|
||||
.querySelectorAll<HTMLElement>("[data-split-pane-id]")
|
||||
.forEach((el) => {
|
||||
const id = el.dataset.splitPaneId;
|
||||
if (id) rects[id] = el.getBoundingClientRect();
|
||||
});
|
||||
const next = neighborPane(active.split.focusedPaneId, direction, rects);
|
||||
if (next) editSplit(active.id, (state) => focusPane(state, next));
|
||||
return true;
|
||||
},
|
||||
zoomFocused: () => {
|
||||
const active = tabsRef.current.find(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
if (!isSplitTab(active) || listPanes(active.split.root).length < 2) {
|
||||
return false;
|
||||
}
|
||||
editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId));
|
||||
return true;
|
||||
},
|
||||
closeFocusedPane: () => {
|
||||
const active = tabsRef.current.find(
|
||||
(tab) => tab.id === activeTabIdRef.current,
|
||||
);
|
||||
if (isSplitTab(active)) {
|
||||
closePaneOf(active.id, active.split.focusedPaneId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const disposeOpener = setSplitOpener((target, open) =>
|
||||
splitOpenerRef.current(target, open),
|
||||
);
|
||||
const disposeDrop = setSplitDropHandler((source, hover) =>
|
||||
splitDropRef.current(source, hover),
|
||||
);
|
||||
const activeIsSplit = () =>
|
||||
isSplitTab(
|
||||
tabsRef.current.find((tab) => tab.id === activeTabIdRef.current),
|
||||
);
|
||||
const entries = [
|
||||
registerPaletteEntry({
|
||||
id: "core.reconnectDisconnected",
|
||||
titleKey: "nav.reconnectDisconnectedTerminals",
|
||||
icon: RotateCcw,
|
||||
keywords: ["reconnect", "disconnected", "ssh", "all", "network"],
|
||||
scope: "global",
|
||||
run: () => tabsState.reconnectAllRef.current(),
|
||||
}),
|
||||
registerPaletteEntry({
|
||||
id: "core.split.right",
|
||||
titleKey: "splitScreen.splitRight",
|
||||
icon: Columns2,
|
||||
keywords: ["split", "pane"],
|
||||
scope: "global",
|
||||
run: () => splitActionsRef.current?.splitActive("right"),
|
||||
}),
|
||||
registerPaletteEntry({
|
||||
id: "core.split.down",
|
||||
titleKey: "splitScreen.splitDown",
|
||||
icon: Rows2,
|
||||
keywords: ["split", "pane"],
|
||||
scope: "global",
|
||||
run: () => splitActionsRef.current?.splitActive("bottom"),
|
||||
}),
|
||||
registerPaletteEntry({
|
||||
id: "core.split.zoom",
|
||||
titleKey: "splitScreen.zoom",
|
||||
icon: Maximize2,
|
||||
keywords: ["split", "pane"],
|
||||
scope: "global",
|
||||
when: activeIsSplit,
|
||||
run: () => void splitActionsRef.current?.zoomFocused(),
|
||||
}),
|
||||
registerPaletteEntry({
|
||||
id: "core.split.closePane",
|
||||
titleKey: "splitScreen.closePane",
|
||||
icon: X,
|
||||
keywords: ["split", "pane"],
|
||||
scope: "global",
|
||||
when: activeIsSplit,
|
||||
run: () => splitActionsRef.current?.closeFocusedPane(),
|
||||
}),
|
||||
];
|
||||
return () => {
|
||||
disposeOpener();
|
||||
disposeDrop();
|
||||
for (const dispose of entries) dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeTab = tabs.find((tab) => tab.id === activeTabId);
|
||||
const activeSplit = isSplitTab(activeTab) ? activeTab : null;
|
||||
// The pane each on-screen tab sits in, for the active split only.
|
||||
const paneIdByTabId = new Map<string, string>();
|
||||
if (activeSplit) {
|
||||
for (const pane of shownPanes(activeSplit.split, isMobile)) {
|
||||
if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id);
|
||||
}
|
||||
}
|
||||
// Changes when panes appear, move or change tab, but not on a divider drag,
|
||||
// which fits the terminals itself once it ends.
|
||||
const splitShapeKey = activeSplit
|
||||
? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[
|
||||
...paneIdByTabId.entries(),
|
||||
].join(",")}|${listPanes(activeSplit.split.root).length}`
|
||||
: "";
|
||||
|
||||
// A tab inside a split is never shown on its own: activating one (tab jump,
|
||||
// the connections panel, an existing session) shows its split instead,
|
||||
// focused on its pane. Also falls back when the active tab is gone.
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTab) {
|
||||
if (!tabsReady) return;
|
||||
const topLevel = tabs.filter((tab) => !tab.parentSplitTabId);
|
||||
setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard");
|
||||
return;
|
||||
}
|
||||
if (!activeTab.parentSplitTabId) return;
|
||||
const parent = splitTabOf(tabs, activeTab.id);
|
||||
if (!parent) return;
|
||||
const childId = activeTab.id;
|
||||
setTabs((prev) =>
|
||||
updateSplit(prev, parent.id, (state) => {
|
||||
const pane = findPaneByTab(state, childId);
|
||||
return pane ? focusPane(state, pane.id) : state;
|
||||
}),
|
||||
);
|
||||
setActiveTabId(parent.id);
|
||||
}, [activeTab, tabs, tabsReady]);
|
||||
|
||||
// Moving focus to a pane moves keyboard focus into its session, unless the
|
||||
// user is in a menu or already typing in that pane.
|
||||
const focusedPaneId = activeSplit?.split.focusedPaneId ?? null;
|
||||
const focusedPaneTabId = focusedPaneId
|
||||
? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null)
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (!focusedPaneId || !focusedPaneTabId) return;
|
||||
let attempts = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const attempt = () => {
|
||||
const focused = document.activeElement as HTMLElement | null;
|
||||
const paneEl = paneEls.get(focusedPaneId);
|
||||
if (focused && paneEl?.contains(focused)) return;
|
||||
if (
|
||||
focused?.closest?.(
|
||||
'[data-split-pane-header], [role="menu"], [role="dialog"]',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId);
|
||||
const handle = tab?.terminalRef?.current as TabHandle | null | undefined;
|
||||
if (handle?.focus) {
|
||||
handle.focus();
|
||||
return;
|
||||
}
|
||||
// A session opened into the pane a moment ago may not have mounted yet.
|
||||
if (attempts++ < 10) timer = setTimeout(attempt, 100);
|
||||
};
|
||||
const frame = requestAnimationFrame(attempt);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [focusedPaneId, focusedPaneTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
publishSplitTargets({
|
||||
splits: summarizeSplits(tabs, MAX_PANES),
|
||||
canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId,
|
||||
});
|
||||
}, [tabs, activeTab]);
|
||||
|
||||
function splitViewActions(splitTabId: string): SplitViewActions {
|
||||
return {
|
||||
focusPane: (paneId) =>
|
||||
editSplit(splitTabId, (state) => focusPane(state, paneId)),
|
||||
resize: (nodeId, sizes) =>
|
||||
editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)),
|
||||
equalize: (nodeId) =>
|
||||
editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)),
|
||||
splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge),
|
||||
closePane: (paneId) => closePaneOf(splitTabId, paneId),
|
||||
closeSession: (tabId) => tabsState.closeTab(tabId),
|
||||
moveToTab: (paneId) => closePaneOf(splitTabId, paneId),
|
||||
showInPane: (paneId, tabId) => {
|
||||
if (tabId) {
|
||||
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId));
|
||||
} else {
|
||||
editSplit(splitTabId, (state) => assignTab(state, paneId, null));
|
||||
}
|
||||
},
|
||||
swapPanes: (firstId, secondId) =>
|
||||
editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)),
|
||||
toggleZoom: (paneId) =>
|
||||
editSplit(splitTabId, (state) => toggleZoom(state, paneId)),
|
||||
resizeEnd: () => resizeAllTerminals(),
|
||||
};
|
||||
}
|
||||
|
||||
// Picking a host for a pane always starts a new session, so it never pulls
|
||||
// a session out of the tab bar or another split.
|
||||
const pickerShell: TabShellCallbacks = {
|
||||
...callbacks,
|
||||
openTab: (host, type, options) =>
|
||||
callbacks.openTab(host, type, { ...options, forceNewTab: true }),
|
||||
};
|
||||
|
||||
const split: ShellSplit = {
|
||||
active: activeSplit,
|
||||
paneIdByTabId,
|
||||
focusedPaneId,
|
||||
summaries: summarizeSplits(tabs, MAX_PANES),
|
||||
activeFull: activeSplit ? !canAddPane(activeSplit.split) : false,
|
||||
handleAction: handleTabSplitAction,
|
||||
viewActions: splitViewActions,
|
||||
emptyPane: {
|
||||
freeTabs: tabs.filter(
|
||||
(tab) => !tab.parentSplitTabId && canJoinSplit(tab),
|
||||
),
|
||||
newTabOptions: tabTypes
|
||||
.filter((def) => def.multiInstance)
|
||||
.map((def) => ({
|
||||
type: def.id,
|
||||
label: def.titleKey ? t(def.titleKey) : def.id,
|
||||
icon: def.icon,
|
||||
})),
|
||||
pickTab: (splitTabId, paneId, tabId) =>
|
||||
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)),
|
||||
pickHost: (splitTabId, paneId, host: Host, pick: PickHostTarget) => {
|
||||
targetPane(splitTabId, paneId);
|
||||
if ("item" in pick) {
|
||||
const item = pick.action
|
||||
.items?.(host)
|
||||
.find((candidate) => candidate.id === pick.item.id);
|
||||
item?.run(host, pickerShell);
|
||||
} else {
|
||||
runHostAction(pick.action, host, pickerShell);
|
||||
}
|
||||
},
|
||||
openType: (splitTabId, paneId, type) => {
|
||||
targetPane(splitTabId, paneId);
|
||||
tabsState.openMultiInstanceTab(type);
|
||||
},
|
||||
closePane: closePaneOf,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
split,
|
||||
splitShapeKey,
|
||||
splitActionsRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
createRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import type { ConfirmOptions } from "@/components/surface/surface-scope";
|
||||
import type { Host, Tab, TabType } from "@/types/ui-types";
|
||||
import {
|
||||
addOpenTab,
|
||||
createSSHHost,
|
||||
deleteOpenTab,
|
||||
getActiveSessions,
|
||||
getOpenTabs,
|
||||
patchOpenTab,
|
||||
type OpenTabRecord,
|
||||
type UserPreferences,
|
||||
} from "@/main-axios";
|
||||
import { createId } from "@/lib/create-id";
|
||||
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
||||
import { railItemLabel } from "@/sidebar/rail-items";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
|
||||
import { canCloseTab } from "@/shell/tab-close-guards";
|
||||
import {
|
||||
canRestoreTabType,
|
||||
getTabType,
|
||||
isPersistentTabType,
|
||||
isSessionTabType,
|
||||
} from "@/shell/tab-registry";
|
||||
import {
|
||||
requestFromLegacyEvent,
|
||||
requestManage,
|
||||
} from "@/manage/manage-requests";
|
||||
import {
|
||||
addSplitTab,
|
||||
canJoinSplit,
|
||||
isSplitTab,
|
||||
makeSplitTab,
|
||||
placeTabInPane,
|
||||
removeTab,
|
||||
restoreSplitTabs,
|
||||
serializeSplitTabs,
|
||||
} from "@/shell/split/split-tabs";
|
||||
import {
|
||||
createSplitState,
|
||||
findPane,
|
||||
listPanes,
|
||||
} from "@/shell/split/split-tree";
|
||||
import { fromLegacyConfig } from "@/shell/split/split-presets";
|
||||
import {
|
||||
notifyShellReady,
|
||||
notifyTabsChanged,
|
||||
} from "@/plugin-host/shell-bridge";
|
||||
import {
|
||||
OPEN_PLUGINS_EVENT,
|
||||
OPEN_PLUGIN_SETTINGS_EVENT,
|
||||
} from "@/plugins/open-plugins";
|
||||
import type { OpenTabOptions, RestoreTarget } from "./types";
|
||||
|
||||
const PENDING_TARGET_MS = 60_000;
|
||||
|
||||
export function dashboardTab(label: string): Tab {
|
||||
return {
|
||||
id: "dashboard",
|
||||
instanceId: "dashboard",
|
||||
type: "dashboard",
|
||||
label,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** What the tab code needs from the split code, filled in by useSplit. */
|
||||
export interface SplitOps {
|
||||
unsplit: (splitTabId: string) => void;
|
||||
closePaneOf: (splitTabId: string, paneId: string) => void;
|
||||
}
|
||||
|
||||
export function useSplitLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (number: number) => t("splitScreen.defaultLabel", { number });
|
||||
}
|
||||
|
||||
export function useTabs({
|
||||
hosts,
|
||||
hostsRef,
|
||||
hostsLoaded,
|
||||
reloadHosts,
|
||||
userPrefs,
|
||||
userPrefsLoaded,
|
||||
pluginsSettled,
|
||||
confirmRef,
|
||||
layoutDeps,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
hostsRef: MutableRefObject<Host[]>;
|
||||
hostsLoaded: boolean;
|
||||
reloadHosts: () => Promise<void>;
|
||||
userPrefs: UserPreferences;
|
||||
userPrefsLoaded: boolean;
|
||||
pluginsSettled: boolean;
|
||||
confirmRef: MutableRefObject<
|
||||
((options: ConfirmOptions) => Promise<boolean>) | null
|
||||
>;
|
||||
/** Region state whose change also counts as a layout change for plugins. */
|
||||
layoutDeps: unknown[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const splitLabel = useSplitLabel();
|
||||
const [tabs, setTabs] = useState<Tab[]>([dashboardTab(t("nav.dashboard"))]);
|
||||
const [activeTabId, setActiveTabId] = useState("dashboard");
|
||||
// Flips to true once the initial DB read (restore or skip) is done. Sync must not fire before this
|
||||
const [tabsReady, setTabsReady] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
OpenTabRecord[]
|
||||
>([]);
|
||||
// Split tabs are restored once their child tabs have stable live ids.
|
||||
const paneLayoutRestoredRef = useRef(false);
|
||||
const splitTabsRestoredRef = useRef(false);
|
||||
const splitOpsRef = useRef<SplitOps>({
|
||||
unsplit: () => {},
|
||||
closePaneOf: () => {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitTabsRestoredRef.current) return;
|
||||
localStorage.setItem(
|
||||
"termix_splitTabs",
|
||||
JSON.stringify(serializeSplitTabs(tabs)),
|
||||
);
|
||||
}, [tabs]);
|
||||
|
||||
const tabsRef = useRef(tabs);
|
||||
const activeTabIdRef = useRef(activeTabId);
|
||||
const closeActiveTabRef = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
tabsRef.current = tabs;
|
||||
}, [tabs]);
|
||||
useEffect(() => {
|
||||
activeTabIdRef.current = activeTabId;
|
||||
}, [activeTabId]);
|
||||
|
||||
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
/** The tab the user is working in: the focused pane's inside a split. */
|
||||
function focusedSessionTabId(): string | null {
|
||||
const id = activeTabIdRef.current;
|
||||
const active = tabsRef.current.find((tab) => tab.id === id);
|
||||
if (!isSplitTab(active)) return id || null;
|
||||
return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null;
|
||||
}
|
||||
|
||||
// ─── Where opened tabs go ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pane the next opened tab should fill, set when the user picks one
|
||||
* (the empty pane picker, "Open in split"). `fresh` marks a pane created in
|
||||
* the same click, which the last committed tabs do not show yet.
|
||||
*/
|
||||
const pendingPaneTargetRef = useRef<{
|
||||
splitTabId: string;
|
||||
paneId: string;
|
||||
at: number;
|
||||
fresh: boolean;
|
||||
} | null>(null);
|
||||
// Set while a saved layout is applied, so its tabs stay where it puts them.
|
||||
const suppressPaneTargetRef = useRef(false);
|
||||
|
||||
function targetPane(splitTabId: string, paneId: string, fresh = false) {
|
||||
pendingPaneTargetRef.current = {
|
||||
splitTabId,
|
||||
paneId,
|
||||
at: Date.now(),
|
||||
fresh,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a newly opened (or re-focused) tab. It fills the pane the user
|
||||
* picked, else the focused pane of the active split when that is empty,
|
||||
* else it simply becomes the active tab.
|
||||
*/
|
||||
const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {});
|
||||
placeOpenedTabRef.current = (tabId: string) => {
|
||||
const current = tabsRef.current;
|
||||
const pending = pendingPaneTargetRef.current;
|
||||
pendingPaneTargetRef.current = null;
|
||||
if (suppressPaneTargetRef.current || tabId === "dashboard") {
|
||||
setActiveTabId(tabId);
|
||||
return;
|
||||
}
|
||||
const existing = current.find((tab) => tab.id === tabId);
|
||||
|
||||
let target: { splitTabId: string; paneId: string } | null = null;
|
||||
if (pending && Date.now() - pending.at < PENDING_TARGET_MS) {
|
||||
const split = current.find((tab) => tab.id === pending.splitTabId);
|
||||
if (
|
||||
pending.fresh ||
|
||||
(isSplitTab(split) && findPane(split.split, pending.paneId))
|
||||
) {
|
||||
target = pending;
|
||||
}
|
||||
}
|
||||
// Opening a session that already sits in a split shows it there.
|
||||
if (!target && existing?.parentSplitTabId) {
|
||||
setActiveTabId(tabId);
|
||||
return;
|
||||
}
|
||||
if (!target) {
|
||||
const active = current.find((tab) => tab.id === activeTabIdRef.current);
|
||||
if (isSplitTab(active)) {
|
||||
const focused = findPane(active.split, active.split.focusedPaneId);
|
||||
if (focused && focused.tabId === null) {
|
||||
target = { splitTabId: active.id, paneId: focused.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!target || (existing && !canJoinSplit(existing))) {
|
||||
setActiveTabId(tabId);
|
||||
return;
|
||||
}
|
||||
const { splitTabId, paneId } = target;
|
||||
setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId));
|
||||
setActiveTabId(splitTabId);
|
||||
};
|
||||
|
||||
// Panels that type into a terminal act on "the terminal you're working in".
|
||||
// Once those panels can themselves be the active tab, activeTabId points at
|
||||
// the panel and the lookup misses, so remember the last terminal instead.
|
||||
// In a split, the tab being worked in is the focused pane's.
|
||||
const workingTabId = (() => {
|
||||
const active = tabs.find((t) => t.id === activeTabId);
|
||||
if (!isSplitTab(active)) return activeTabId;
|
||||
return findPane(active.split, active.split.focusedPaneId)?.tabId ?? "";
|
||||
})();
|
||||
const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId);
|
||||
useEffect(() => {
|
||||
const working = tabs.find((t) => t.id === workingTabId);
|
||||
if (working && getTabType(working.type)?.commandTarget) {
|
||||
setLastTerminalTabId(working.id);
|
||||
}
|
||||
}, [workingTabId, tabs]);
|
||||
|
||||
const [showTabNumbers, setShowTabNumbers] = useState<boolean>(
|
||||
() => localStorage.getItem("showTabNumbers") === "true",
|
||||
);
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true");
|
||||
};
|
||||
window.addEventListener("showTabNumbersChanged", handler);
|
||||
return () => window.removeEventListener("showTabNumbersChanged", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!activeTab?.terminalRef) return;
|
||||
let innerRafId: number;
|
||||
const outerRafId = requestAnimationFrame(() => {
|
||||
innerRafId = requestAnimationFrame(() => {
|
||||
const ref = activeTab.terminalRef?.current;
|
||||
ref?.fit?.();
|
||||
ref?.notifyResize?.();
|
||||
ref?.refresh?.();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(outerRafId);
|
||||
cancelAnimationFrame(innerRafId);
|
||||
};
|
||||
}, [activeTabId]);
|
||||
|
||||
// Sync tab host data when hosts update (e.g. after editing terminal theme in host settings)
|
||||
useEffect(() => {
|
||||
if (hosts.length === 0) return;
|
||||
const byId = new Map(hosts.map((h) => [h.id, h]));
|
||||
setTabs((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((t) => {
|
||||
const fresh = t.host ? byId.get(t.host.id) : undefined;
|
||||
if (!fresh || fresh === t.host) return t;
|
||||
// A reload hands back new objects; only replace hosts that changed so
|
||||
// untouched tabs keep their identity and skip a re-render.
|
||||
if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t;
|
||||
changed = true;
|
||||
return { ...t, host: fresh };
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [hosts]);
|
||||
|
||||
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
|
||||
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
|
||||
const tabRestoreAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
|
||||
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return;
|
||||
if (tabRestoreAttemptedRef.current) return;
|
||||
tabRestoreAttemptedRef.current = true;
|
||||
|
||||
async function loadSavedTabs() {
|
||||
try {
|
||||
const [savedTabs, activeSessions] = await Promise.all([
|
||||
getOpenTabs(),
|
||||
getActiveSessions(),
|
||||
]);
|
||||
|
||||
if (!Array.isArray(savedTabs) || savedTabs.length === 0) return;
|
||||
|
||||
const sessionByInstanceId = new Map(
|
||||
(Array.isArray(activeSessions) ? activeSessions : [])
|
||||
.filter((s) => s.tabInstanceId != null)
|
||||
.map((s) => [s.tabInstanceId, s]),
|
||||
);
|
||||
|
||||
if (userPrefs.reopenTabsOnLogin) {
|
||||
const hasPersistentTabs = tabs.some((t) =>
|
||||
isPersistentTabType(t.type),
|
||||
);
|
||||
if (!hasPersistentTabs) {
|
||||
const restoredTabs: Tab[] = [];
|
||||
for (const saved of savedTabs as OpenTabRecord[]) {
|
||||
const host = saved.hostId
|
||||
? hosts.find((h) => h.id === String(saved.hostId))
|
||||
: undefined;
|
||||
if (!canRestoreTabType(saved.tabType, host)) continue;
|
||||
|
||||
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
|
||||
const tabId = host
|
||||
? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}`
|
||||
: saved.id;
|
||||
const liveSession = sessionByInstanceId.get(saved.id);
|
||||
const restoredSessionId =
|
||||
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
||||
|
||||
const isCustomLabel =
|
||||
host &&
|
||||
saved.label !== host.name &&
|
||||
!/^.+ \(\d+\)$/.test(saved.label);
|
||||
|
||||
restoredTabs.push({
|
||||
id: tabId,
|
||||
instanceId: saved.id,
|
||||
type: saved.tabType as TabType,
|
||||
label: saved.label,
|
||||
customLabel: isCustomLabel ? saved.label : undefined,
|
||||
host,
|
||||
openedAt: new Date(saved.createdAt).getTime(),
|
||||
restoredSessionId,
|
||||
terminalRef: isSessionTabType(saved.tabType)
|
||||
? createRef()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (restoredTabs.length > 0) {
|
||||
setTabs((prev) => {
|
||||
const existingIds = new Set(prev.map((t) => t.id));
|
||||
const newTabs = restoredTabs.filter(
|
||||
(t) => !existingIds.has(t.id),
|
||||
);
|
||||
return newTabs.length > 0 ? [...prev, ...newTabs] : prev;
|
||||
});
|
||||
setActiveTabId(restoredTabs[0].id);
|
||||
}
|
||||
// Restored tabs are in the tab bar, not in background records
|
||||
}
|
||||
} else {
|
||||
// Not restoring to tab bar, keep as background records for ConnectionsPanel
|
||||
setBackgroundTabRecords(savedTabs as OpenTabRecord[]);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setTabsReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
loadSavedTabs();
|
||||
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
|
||||
|
||||
// Plugins that act once the session is back (the workspaces plugin applies
|
||||
// a default workspace) wait for this.
|
||||
useEffect(() => {
|
||||
if (tabsReady) notifyShellReady();
|
||||
}, [tabsReady]);
|
||||
|
||||
// Restore split tabs once their child sessions have stable live ids. The
|
||||
// oldest single-split keys are migrated once into a first split.
|
||||
useEffect(() => {
|
||||
if (!tabsReady || paneLayoutRestoredRef.current) return;
|
||||
paneLayoutRestoredRef.current = true;
|
||||
|
||||
try {
|
||||
const savedSplitTabs = JSON.parse(
|
||||
localStorage.getItem("termix_splitTabs") ?? "[]",
|
||||
) as unknown;
|
||||
if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) {
|
||||
setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev));
|
||||
return;
|
||||
}
|
||||
|
||||
const savedInstanceIds = JSON.parse(
|
||||
localStorage.getItem("termix_paneInstanceIds") ?? "null",
|
||||
) as unknown;
|
||||
const savedMode = localStorage.getItem("termix_splitMode");
|
||||
if (!Array.isArray(savedInstanceIds) || !savedMode) return;
|
||||
const savedSizes = JSON.parse(
|
||||
localStorage.getItem("termix_paneSizes") ?? "null",
|
||||
) as { rowSizes?: unknown; rowColSizes?: unknown } | null;
|
||||
const tabIdByInstanceId = new Map(
|
||||
tabs.map((tab) => [tab.instanceId, tab.id]),
|
||||
);
|
||||
const root = fromLegacyConfig(
|
||||
savedMode,
|
||||
savedInstanceIds.map((instanceId) =>
|
||||
typeof instanceId === "string"
|
||||
? (tabIdByInstanceId.get(instanceId) ?? null)
|
||||
: null,
|
||||
),
|
||||
savedSizes?.rowSizes,
|
||||
savedSizes?.rowColSizes,
|
||||
);
|
||||
if (root && listPanes(root).some((pane) => pane.tabId)) {
|
||||
const splitTab = makeSplitTab(
|
||||
createId(),
|
||||
splitLabel(1),
|
||||
createSplitState(root),
|
||||
);
|
||||
setTabs((prev) => addSplitTab(prev, splitTab));
|
||||
setActiveTabId(splitTab.id);
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
splitTabsRestoredRef.current = true;
|
||||
localStorage.removeItem("termix_splitMode");
|
||||
localStorage.removeItem("termix_paneInstanceIds");
|
||||
localStorage.removeItem("termix_paneSizes");
|
||||
}
|
||||
}, [tabsReady, tabs]);
|
||||
|
||||
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
|
||||
const orderSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const prevTabOrderRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (!tabsReady) return;
|
||||
const persistable = tabs.filter((t) => isPersistentTabType(t.type));
|
||||
const orderKey = persistable.map((t) => t.instanceId).join(",");
|
||||
if (orderKey === prevTabOrderRef.current) return;
|
||||
prevTabOrderRef.current = orderKey;
|
||||
|
||||
if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current);
|
||||
orderSyncTimeoutRef.current = setTimeout(() => {
|
||||
persistable.forEach((t, i) => {
|
||||
patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {});
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (orderSyncTimeoutRef.current)
|
||||
clearTimeout(orderSyncTimeoutRef.current);
|
||||
};
|
||||
}, [tabs, tabsReady]);
|
||||
|
||||
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
|
||||
// keep its last-session snapshot current.
|
||||
useEffect(() => {
|
||||
if (tabsReady) notifyTabsChanged();
|
||||
}, [tabs, tabsReady, ...layoutDeps]);
|
||||
|
||||
// ─── Tab management ──────────────────────────────────────────────────────
|
||||
|
||||
const openTab = useCallback(function openTab(
|
||||
host: Host,
|
||||
type: TabType,
|
||||
restore?: RestoreTarget,
|
||||
options?: OpenTabOptions,
|
||||
) {
|
||||
if (!restore && !options?.forceNewTab) {
|
||||
const dataKey = JSON.stringify(options?.data ?? null);
|
||||
const existing = tabsRef.current.find(
|
||||
(t) =>
|
||||
t.type === type &&
|
||||
t.host?.id === host.id &&
|
||||
JSON.stringify(t.data ?? null) === dataKey,
|
||||
);
|
||||
if (existing) {
|
||||
placeOpenedTabRef.current(existing.id);
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
const instanceId = restore?.instanceId ?? createId();
|
||||
// Unique per open; a timestamp collides when a workspace opens several
|
||||
// tabs in the same millisecond.
|
||||
const tabId = `${type}-${createId()}`;
|
||||
const openedAt = Date.now();
|
||||
const ref = isSessionTabType(type) ? createRef() : undefined;
|
||||
if (ref) terminalRefs.current.set(tabId, ref);
|
||||
|
||||
let finalLabel = host.name;
|
||||
const savedLabel = restore?.savedLabel;
|
||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||
const isCustomLabel =
|
||||
savedLabel != null &&
|
||||
savedLabel !== host.name &&
|
||||
!/^.+ \(\d+\)$/.test(savedLabel);
|
||||
|
||||
setTabs((prev) => {
|
||||
if (isCustomLabel && savedLabel) {
|
||||
finalLabel = savedLabel;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: tabId,
|
||||
instanceId,
|
||||
type,
|
||||
label: finalLabel,
|
||||
customLabel: finalLabel,
|
||||
host,
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
data: options?.data,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const same = prev.filter(
|
||||
(t) =>
|
||||
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
||||
);
|
||||
finalLabel = options?.label
|
||||
? options.label
|
||||
: same.length === 0
|
||||
? host.name
|
||||
: `${host.name} (${same.length + 1})`;
|
||||
|
||||
// Retrofit the first duplicate's label to "(1)" if needed
|
||||
const next =
|
||||
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
|
||||
? prev.map((t) =>
|
||||
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
|
||||
)
|
||||
: prev;
|
||||
|
||||
return [
|
||||
...next,
|
||||
{
|
||||
id: tabId,
|
||||
instanceId,
|
||||
type,
|
||||
label: finalLabel,
|
||||
host,
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
data: options?.data,
|
||||
},
|
||||
];
|
||||
});
|
||||
placeOpenedTabRef.current(tabId);
|
||||
|
||||
if (isPersistentTabType(type)) {
|
||||
addOpenTab({
|
||||
id: instanceId,
|
||||
tabType: type,
|
||||
hostId: host ? parseInt(host.id) : null,
|
||||
label: finalLabel,
|
||||
tabOrder: 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return tabId;
|
||||
}, []);
|
||||
|
||||
function connectHost(
|
||||
host: Host,
|
||||
preferredType?: TabType,
|
||||
options?: OpenTabOptions,
|
||||
) {
|
||||
const type = resolveHostTabType(host, preferredType);
|
||||
if (!type) return;
|
||||
openTab(host, type, undefined, options);
|
||||
}
|
||||
|
||||
const saveQuickConnectHost = useCallback(
|
||||
async (tab: Tab, host: Host) => {
|
||||
try {
|
||||
const savedHost = await createSSHHost(quickConnectHostToPayload(host));
|
||||
await patchOpenTab(tab.instanceId, { hostId: savedHost.id });
|
||||
await reloadHosts();
|
||||
toast.success(t("hosts.hostCreated"));
|
||||
} catch (error) {
|
||||
toast.error(t("hosts.failedToSave"));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[reloadHosts, t],
|
||||
);
|
||||
|
||||
/** A tab type that opens a fresh tab every time (a local shell). */
|
||||
function openMultiInstanceTab(
|
||||
type: TabType,
|
||||
options?: { host?: Host; data?: Record<string, unknown>; label?: string },
|
||||
): string {
|
||||
const instanceId = createId();
|
||||
const id = `${type}-${instanceId}`;
|
||||
const titleKey = getTabType(type)?.titleKey;
|
||||
const title = options?.label ?? (titleKey ? t(titleKey) : type);
|
||||
setTabs((current) => {
|
||||
const count = current.filter((tab) => tab.type === type).length;
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
instanceId,
|
||||
type,
|
||||
label:
|
||||
count === 0 || options?.label ? title : `${title} (${count + 1})`,
|
||||
openedAt: Date.now(),
|
||||
...(options?.host ? { host: options.host } : {}),
|
||||
...(options?.data !== undefined ? { data: options.data } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
placeOpenedTabRef.current(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const openSingletonTab = useCallback(
|
||||
// `host` optionally preselects a host for a singleton plugin tab.
|
||||
function openSingletonTab(
|
||||
type: TabType,
|
||||
pendingEvent?: string,
|
||||
host?: Host,
|
||||
data?: Record<string, unknown>,
|
||||
tabLabel?: string,
|
||||
): string | undefined {
|
||||
// A local shell is never a singleton: each open is its own session.
|
||||
if (getTabType(type)?.multiInstance) {
|
||||
return openMultiInstanceTab(type, { host, data, label: tabLabel });
|
||||
}
|
||||
if (type === "host-manager" && pendingEvent) {
|
||||
const request = requestFromLegacyEvent(pendingEvent);
|
||||
if (request) requestManage(request);
|
||||
}
|
||||
// Profile and admin are pages of the one Settings tab now.
|
||||
if (type === "user-profile" || type === "admin-settings") {
|
||||
return openSingletonTab(
|
||||
"settings",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
section:
|
||||
typeof data?.section === "string"
|
||||
? data.section
|
||||
: type === "admin-settings"
|
||||
? "admin-general"
|
||||
: "account",
|
||||
},
|
||||
tabLabel,
|
||||
);
|
||||
}
|
||||
// Asking for a page again still goes there after the user moved away
|
||||
if (type === "settings" && data?.section && data.revealAt === undefined) {
|
||||
data = { ...data, revealAt: Date.now() };
|
||||
}
|
||||
const id = type;
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.manage"),
|
||||
settings: t("nav.settings"),
|
||||
plugins: t("nav.plugins"),
|
||||
};
|
||||
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||
// label so the two stay in sync.
|
||||
const titleKey = getTabType(type)?.titleKey;
|
||||
const label =
|
||||
singletonLabels[type] ??
|
||||
(titleKey ? t(titleKey) : railItemLabel(type, t));
|
||||
setTabs((prev) => {
|
||||
const existing = prev.find((t) => t.id === id);
|
||||
if (existing) {
|
||||
// Refocusing a singleton with a host or data passes it on.
|
||||
if (!host && data === undefined) return prev;
|
||||
return prev.map((t) =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
...(host ? { host } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
}
|
||||
: t,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
instanceId: id,
|
||||
type,
|
||||
label,
|
||||
openedAt: Date.now(),
|
||||
...(host ? { host } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
placeOpenedTabRef.current(id);
|
||||
if (isPersistentTabType(type)) {
|
||||
addOpenTab({
|
||||
id,
|
||||
tabType: type,
|
||||
hostId: null,
|
||||
label,
|
||||
tabOrder: 0,
|
||||
}).catch(() => {});
|
||||
}
|
||||
return id;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const openSingletonTabRef = useRef(openSingletonTab);
|
||||
openSingletonTabRef.current = openSingletonTab;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => openSingletonTabRef.current("plugins");
|
||||
window.addEventListener(OPEN_PLUGINS_EVENT, handler);
|
||||
return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const pluginId = (event as CustomEvent<string>).detail;
|
||||
if (typeof pluginId !== "string") return;
|
||||
openSingletonTabRef.current("settings", undefined, undefined, {
|
||||
section: `plugin:${pluginId}`,
|
||||
revealAt: Date.now(),
|
||||
});
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const getTabCloseLabel = useCallback((tab: Tab) => {
|
||||
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
||||
}, []);
|
||||
|
||||
const isActiveConnectionTab = useCallback((tab: Tab) => {
|
||||
if (!isSessionTabType(tab.type)) return false;
|
||||
return tab.terminalRef?.current?.isConnected?.() === true;
|
||||
}, []);
|
||||
|
||||
const hasActiveConnection = useCallback(() => {
|
||||
return tabsRef.current.some(isActiveConnectionTab);
|
||||
}, [isActiveConnectionTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!hasActiveConnection()) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
return "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [hasActiveConnection]);
|
||||
|
||||
const doCloseTabRef = useRef<(id: string) => void>(() => {});
|
||||
doCloseTabRef.current = doCloseTab;
|
||||
function doCloseTab(id: string) {
|
||||
const tabToClose = tabs.find((t) => t.id === id);
|
||||
if (tabToClose?.terminalRef?.current?.disconnect) {
|
||||
tabToClose.terminalRef.current.disconnect();
|
||||
}
|
||||
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
|
||||
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
||||
}
|
||||
|
||||
terminalRefs.current.delete(id);
|
||||
if (isSplitTab(tabToClose)) {
|
||||
splitOpsRef.current.unsplit(id);
|
||||
return;
|
||||
}
|
||||
if (id === activeTabId) {
|
||||
const remaining = tabs.filter(
|
||||
(tab) => tab.id !== id && !tab.parentSplitTabId,
|
||||
);
|
||||
setActiveTabId(
|
||||
remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard",
|
||||
);
|
||||
}
|
||||
setTabs((prev) => {
|
||||
// A pane that held the tab stays, empty and focused, ready for the next one.
|
||||
const next = removeTab(prev, id);
|
||||
if (next.length === 0) return [dashboardTab(t("nav.dashboard"))];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function reconnectAllDisconnected() {
|
||||
const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current);
|
||||
if (reconnected || !failed)
|
||||
toast(
|
||||
t(
|
||||
reconnected
|
||||
? "nav.reconnectingTerminals"
|
||||
: "nav.noDisconnectedTerminals",
|
||||
{ count: reconnected },
|
||||
),
|
||||
);
|
||||
if (failed)
|
||||
toast.error(t("nav.reconnectTerminalsFailed", { count: failed }));
|
||||
}
|
||||
const reconnectAllRef = useRef<() => void>(() => {});
|
||||
reconnectAllRef.current = reconnectAllDisconnected;
|
||||
|
||||
function refreshTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
const handle = tab?.terminalRef?.current;
|
||||
if (!handle) return;
|
||||
// Session tabs expose one or the other on their handle.
|
||||
if (handle.reconnect) handle.reconnect();
|
||||
else handle.refresh?.();
|
||||
}
|
||||
|
||||
async function closeTab(id: string) {
|
||||
const tab = tabsRef.current.find((t) => t.id === id);
|
||||
const guard = tab?.terminalRef?.current?.confirmClose;
|
||||
if (guard && !(await guard())) return;
|
||||
if (!(await canCloseTab(id))) return;
|
||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||
if (tab && confirmEnabled && isActiveConnectionTab(tab)) {
|
||||
const ask = confirmRef.current;
|
||||
const ok = ask
|
||||
? await ask({
|
||||
title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }),
|
||||
confirmLabel: t("nav.close"),
|
||||
})
|
||||
: true;
|
||||
if (!ok) return;
|
||||
}
|
||||
doCloseTabRef.current(id);
|
||||
}
|
||||
|
||||
// In a split, closing acts on the focused pane: its session, or the pane
|
||||
// itself once it is empty.
|
||||
closeActiveTabRef.current = () => {
|
||||
const id = activeTabIdRef.current;
|
||||
const active = tabsRef.current.find((tab) => tab.id === id);
|
||||
if (isSplitTab(active)) {
|
||||
const tabId = focusedSessionTabId();
|
||||
if (tabId) closeTab(tabId);
|
||||
else
|
||||
splitOpsRef.current.closePaneOf(active.id, active.split.focusedPaneId);
|
||||
return;
|
||||
}
|
||||
if (id !== "dashboard") closeTab(id);
|
||||
};
|
||||
|
||||
function renameTab(tabId: string, newLabel: string) {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t,
|
||||
),
|
||||
);
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (tab?.instanceId && tab.type !== "split-screen") {
|
||||
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function reorderTopLevelTabs(reordered: Tab[]) {
|
||||
setTabs((prev) => [
|
||||
...reordered,
|
||||
...prev.filter((tab) => tab.parentSplitTabId),
|
||||
]);
|
||||
}
|
||||
|
||||
const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget);
|
||||
// What command-target panels act on. Falls back to the remembered
|
||||
// terminal when the active tab isn't one, and drops it once it's closed.
|
||||
const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId)
|
||||
? workingTabId
|
||||
: terminalTabs.some((t) => t.id === lastTerminalTabId)
|
||||
? lastTerminalTabId
|
||||
: "";
|
||||
|
||||
return {
|
||||
tabs,
|
||||
setTabs,
|
||||
activeTabId,
|
||||
setActiveTabId,
|
||||
tabsReady,
|
||||
backgroundTabRecords,
|
||||
setBackgroundTabRecords,
|
||||
tabsRef,
|
||||
activeTabIdRef,
|
||||
closeActiveTabRef,
|
||||
reconnectAllRef,
|
||||
terminalRefs,
|
||||
splitOpsRef,
|
||||
suppressPaneTargetRef,
|
||||
pendingPaneTargetRef,
|
||||
openSingletonTabRef,
|
||||
hostsRef,
|
||||
focusedSessionTabId,
|
||||
targetPane,
|
||||
openTab,
|
||||
connectHost,
|
||||
saveQuickConnectHost,
|
||||
openMultiInstanceTab,
|
||||
openSingletonTab,
|
||||
doCloseTab,
|
||||
closeTab,
|
||||
renameTab,
|
||||
refreshTab,
|
||||
reconnectAllDisconnected,
|
||||
reorderTopLevelTabs,
|
||||
showTabNumbers,
|
||||
terminalTabs,
|
||||
targetTerminalTab: terminalTabs.find((t) => t.id === targetTerminalTabId),
|
||||
handleFor: (id: string) =>
|
||||
terminalRefs.current.get(id)?.current as TabHandle | null | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type TabsState = ReturnType<typeof useTabs>;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect } from "react";
|
||||
import type { WorkspacePayload } from "@/types/ui-types";
|
||||
import { createId } from "@/lib/create-id";
|
||||
import { rightDockableIds } from "@/sidebar/rail-items";
|
||||
import {
|
||||
buildLayoutPayload,
|
||||
resolveLayoutSplits,
|
||||
resolveLayoutTabTarget,
|
||||
snapshotData,
|
||||
} from "@/shell/shell-layout";
|
||||
import { addSplitTab, makeSplitTab } from "@/shell/split/split-tabs";
|
||||
import { listPanes } from "@/shell/split/split-tree";
|
||||
import { setShellLayoutProvider } from "@/plugin-host/shell-bridge";
|
||||
import { useSplitLabel, type TabsState } from "./use-tabs";
|
||||
import type { ShellRegions } from "./types";
|
||||
|
||||
/** Saving and applying a whole arrangement, for workspaces and plugins. */
|
||||
export function useWorkspaceLayout(
|
||||
tabsState: TabsState,
|
||||
regions: ShellRegions,
|
||||
) {
|
||||
const splitLabel = useSplitLabel();
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
tabsReady,
|
||||
tabsRef,
|
||||
hostsRef,
|
||||
setTabs,
|
||||
setActiveTabId,
|
||||
suppressPaneTargetRef,
|
||||
pendingPaneTargetRef,
|
||||
} = tabsState;
|
||||
|
||||
function buildWorkspacePayload(): WorkspacePayload {
|
||||
return buildLayoutPayload({
|
||||
tabs,
|
||||
activeTabId,
|
||||
sidebar: {
|
||||
left: {
|
||||
view: regions.left.view,
|
||||
open: regions.left.open,
|
||||
width: regions.left.width,
|
||||
},
|
||||
right: {
|
||||
view: regions.right.view,
|
||||
open: regions.right.view !== null,
|
||||
width: regions.right.width,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function applyLayout(
|
||||
payload: WorkspacePayload,
|
||||
name: string,
|
||||
): Promise<{ skipped: string[] }> {
|
||||
// Tear down the current arrangement the same way an individual tab close does.
|
||||
for (const tab of [...tabsRef.current]) {
|
||||
tabsState.doCloseTab(tab.id);
|
||||
}
|
||||
// Tabs reopened here must not be pulled into a pane of the old layout.
|
||||
suppressPaneTargetRef.current = true;
|
||||
pendingPaneTargetRef.current = null;
|
||||
try {
|
||||
const slotIdToNewTabId = new Map<string, string>();
|
||||
const skippedTabs: string[] = [];
|
||||
|
||||
for (const snapshot of payload.tabs) {
|
||||
const target = resolveLayoutTabTarget(snapshot, hostsRef.current);
|
||||
|
||||
if (target.kind === "skip") {
|
||||
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.kind === "singleton") {
|
||||
const newTabId = tabsState.openSingletonTab(
|
||||
snapshot.type,
|
||||
undefined,
|
||||
target.host,
|
||||
snapshotData(snapshot),
|
||||
);
|
||||
slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.kind === "host") {
|
||||
const newTabId = tabsState.openTab(
|
||||
target.host,
|
||||
snapshot.type,
|
||||
{
|
||||
instanceId: createId(),
|
||||
restoredSessionId: null,
|
||||
savedLabel: snapshot.customLabel ?? snapshot.label,
|
||||
},
|
||||
{ data: snapshotData(snapshot) },
|
||||
);
|
||||
slotIdToNewTabId.set(snapshot.slotId, newTabId);
|
||||
}
|
||||
}
|
||||
|
||||
const splitIdBySlotId = new Map<string, string>();
|
||||
const splitIdByTabId = new Map<string, string>();
|
||||
let firstSplitId: string | null = null;
|
||||
resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => {
|
||||
const splitTab = makeSplitTab(
|
||||
createId(),
|
||||
saved.label || name || splitLabel(index + 1),
|
||||
saved.split,
|
||||
);
|
||||
if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id);
|
||||
for (const pane of listPanes(saved.split.root)) {
|
||||
if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id);
|
||||
}
|
||||
firstSplitId ??= splitTab.id;
|
||||
setTabs((prev) => addSplitTab(prev, splitTab));
|
||||
});
|
||||
|
||||
// A tab that now sits in a split is shown through its split.
|
||||
const activeSlot = payload.activeSlotId;
|
||||
const activeTabInLayout = activeSlot
|
||||
? slotIdToNewTabId.get(activeSlot)
|
||||
: undefined;
|
||||
const activeId =
|
||||
(activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ??
|
||||
(activeTabInLayout
|
||||
? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout)
|
||||
: undefined) ??
|
||||
firstSplitId ??
|
||||
"dashboard";
|
||||
setActiveTabId(activeId);
|
||||
|
||||
// Older payloads predate the sidebar field, so leave the docks alone then.
|
||||
const sidebar = payload.sidebar;
|
||||
if (sidebar) {
|
||||
if (sidebar.left.view) {
|
||||
regions.left.setView(
|
||||
sidebar.left.view === "split-screen" ? "hosts" : sidebar.left.view,
|
||||
);
|
||||
}
|
||||
regions.left.setOpen(sidebar.left.open);
|
||||
if (sidebar.left.width) regions.left.setWidth(sidebar.left.width);
|
||||
|
||||
const right = sidebar.right.open ? sidebar.right.view : null;
|
||||
regions.right.setView(
|
||||
right && rightDockableIds().includes(right) ? right : null,
|
||||
);
|
||||
if (sidebar.right.width) regions.right.setWidth(sidebar.right.width);
|
||||
}
|
||||
|
||||
return { skipped: skippedTabs };
|
||||
} finally {
|
||||
suppressPaneTargetRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setShellLayoutProvider({
|
||||
getLayout: () => (tabsReady ? buildWorkspacePayload() : null),
|
||||
applyLayout: (layout, options) =>
|
||||
applyLayout(layout as WorkspacePayload, options?.name ?? ""),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Component, useMemo, useState, type ReactNode } from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import { retryPluginFrontend, syncPlugins } from "@/plugin-host/loader";
|
||||
import { chosenLayoutId, useLookState } from "@/look/look-store";
|
||||
import {
|
||||
getRegisteredLayout,
|
||||
useRegisteredLayouts,
|
||||
} from "@/look/layout-registry";
|
||||
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 { registerBuiltinLayout } from "./builtin";
|
||||
|
||||
registerBuiltinLayout();
|
||||
|
||||
/** Catches a crash while a layout draws, so the repair view can take over. */
|
||||
class LayoutErrorBoundary extends Component<
|
||||
{ onCrash: (error: Error) => void; children: ReactNode },
|
||||
{ crashed: boolean }
|
||||
> {
|
||||
state = { crashed: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { crashed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error("[layout] crashed while drawing", error);
|
||||
this.props.onCrash(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.crashed ? null : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface CrashState {
|
||||
layoutId: string | null;
|
||||
count: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the chosen layout, or the repair view when it cannot be drawn. A
|
||||
* crash remounts the layout once; a second hands over to the repair view.
|
||||
*/
|
||||
export function LayoutHost() {
|
||||
const look = useLookState();
|
||||
const store = usePluginStore();
|
||||
useRegisteredLayouts();
|
||||
const isMobile = useIsMobile();
|
||||
const [crash, setCrash] = useState<CrashState>({
|
||||
layoutId: null,
|
||||
count: 0,
|
||||
error: null,
|
||||
});
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
// The look store changes identity whenever the choice does.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const layoutId = useMemo(() => chosenLayoutId(), [look]);
|
||||
useLookTheme(layoutId);
|
||||
|
||||
const crashes = crash.layoutId === layoutId ? crash.count : 0;
|
||||
const owner = [...store.records.values()].find((record) =>
|
||||
record.summary.contributes?.layouts?.some((l) => l.id === layoutId),
|
||||
);
|
||||
const status = layoutStatus({
|
||||
layoutId,
|
||||
safeFromUrl: safeLayoutInUrl(window.location.search),
|
||||
safeFromEnv: look.response?.safeLayout === true,
|
||||
ready: look.loaded && store.settled,
|
||||
registered: getRegisteredLayout(layoutId),
|
||||
owner,
|
||||
unavailable: look.response?.unavailable.find((plugin) =>
|
||||
plugin.layouts.includes(layoutId),
|
||||
),
|
||||
crashes,
|
||||
lastCrash: crash.layoutId === layoutId ? crash.error : null,
|
||||
});
|
||||
|
||||
const retry = () => {
|
||||
setCrash({ layoutId: null, count: 0, error: null });
|
||||
setAttempt((value) => value + 1);
|
||||
if (owner) void retryPluginFrontend(owner.summary.id);
|
||||
else void syncPlugins();
|
||||
};
|
||||
|
||||
if (status.kind === "loading") {
|
||||
return <div className="h-dvh w-screen bg-background" />;
|
||||
}
|
||||
if (status.kind === "repair") {
|
||||
return <RepairView status={status} onRetry={retry} />;
|
||||
}
|
||||
|
||||
const { layout } = status;
|
||||
// The desktop app always gets the desktop layout.
|
||||
const Draw =
|
||||
isMobile && !isElectron() && layout.mobileComponent
|
||||
? layout.mobileComponent
|
||||
: layout.component;
|
||||
return (
|
||||
<LayoutErrorBoundary
|
||||
key={`${layoutId}:${crashes}:${attempt}`}
|
||||
onCrash={(error) =>
|
||||
setCrash((previous) => ({
|
||||
layoutId,
|
||||
count: (previous.layoutId === layoutId ? previous.count : 0) + 1,
|
||||
error: error.message,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Draw />
|
||||
</LayoutErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sheet, SheetContent } from "@/components/sheet";
|
||||
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { MobileBar } from "@/shell/MobileBar";
|
||||
import { AppRail } from "@/sidebar/AppRail";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import {
|
||||
isCoreRailView,
|
||||
railItemLabel,
|
||||
promotableIds,
|
||||
rightDockableIds,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { DockPanel, DockReopenStrip } from "@/shell/DockPanel";
|
||||
import { TabBar } from "@/shell/TabBar";
|
||||
import { requestManage, type ManageMode } from "@/manage/manage-requests";
|
||||
import { getTabType } from "@/shell/tab-registry";
|
||||
import { getPanel, usePanels } from "@/shell/panel-registry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import {
|
||||
ShellConfirmCapture,
|
||||
useShellController,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
import { TabStage } from "@/shell/stage/TabStage";
|
||||
|
||||
const HostsPanel = lazy(() =>
|
||||
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
|
||||
);
|
||||
const QuickConnectPanel = lazy(() =>
|
||||
import("@/sidebar/QuickConnectPanel").then((m) => ({
|
||||
default: m.QuickConnectPanel,
|
||||
})),
|
||||
);
|
||||
const SyncPanel = lazy(() =>
|
||||
import("@/settings/sync/SyncPanel").then((m) => ({
|
||||
default: m.SyncPanel,
|
||||
})),
|
||||
);
|
||||
const CredentialsPanel = lazy(() =>
|
||||
import("@/sidebar/CredentialsPanel").then((m) => ({
|
||||
default: m.CredentialsPanel,
|
||||
})),
|
||||
);
|
||||
const ConnectionsPanel = lazy(() =>
|
||||
import("@/sidebar/ConnectionsPanel").then((m) => ({
|
||||
default: m.ConnectionsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
function SidebarPanelFallback() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The look Termix has always had: icon rail, left sidebar, tab bar, right
|
||||
* dock and the mobile bottom bar. Built in until it moves to its own plugin.
|
||||
*/
|
||||
export function DefaultLayout() {
|
||||
const { t } = useTranslation();
|
||||
const shell = useShellController();
|
||||
const registeredPanels = usePanels();
|
||||
const { tabs, regions, hosts, isMobile } = shell;
|
||||
const { left, right } = regions;
|
||||
const closeOnMobile = () => {
|
||||
if (isMobile) left.setOpen(false);
|
||||
};
|
||||
|
||||
// Every panel gets its own surface, so its inline views and confirms stay
|
||||
// inside it. Only the left dock widens for them.
|
||||
const panelScope = (
|
||||
view: string,
|
||||
owned: boolean,
|
||||
shown: boolean,
|
||||
children: React.ReactNode,
|
||||
className = "",
|
||||
) => (
|
||||
<SurfaceScope
|
||||
key={view}
|
||||
kind="panel"
|
||||
onEditingChange={owned ? regions.scopeEditingSetter(view) : undefined}
|
||||
className={`${shown ? "" : "hidden"} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</SurfaceScope>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sidebar panel content, shared between the desktop sidebar, the mobile
|
||||
* sheet and the right dock. Takes the view rather than reading the left
|
||||
* dock's so both docks can render from the same code.
|
||||
*
|
||||
* `owned` marks the dock responsible for the panels that stay mounted while
|
||||
* hidden (hosts, credentials, fleets). Only one dock may own them, otherwise
|
||||
* two live instances fight over the shared editing state.
|
||||
*/
|
||||
const renderSidebarPanels = (railView: string, owned = true) => (
|
||||
<Suspense fallback={<SidebarPanelFallback />}>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{owned && (
|
||||
<>
|
||||
{panelScope(
|
||||
"hosts",
|
||||
owned,
|
||||
railView === "hosts",
|
||||
<HostsPanel
|
||||
onOpenTab={(host, type, options) => {
|
||||
tabs.connectHost(host, type, options);
|
||||
closeOnMobile();
|
||||
}}
|
||||
onEditHost={hosts.edit}
|
||||
onCloseSidebar={() => left.setOpen(false)}
|
||||
hostTree={hosts.tree ?? undefined}
|
||||
loading={hosts.loading}
|
||||
onEditingChange={regions.panelEditingSetter("hosts")}
|
||||
active={railView === "hosts"}
|
||||
/>,
|
||||
)}
|
||||
{panelScope(
|
||||
"credentials",
|
||||
owned,
|
||||
railView === "credentials",
|
||||
<CredentialsPanel
|
||||
onEditingChange={regions.panelEditingSetter("credentials")}
|
||||
active={railView === "credentials"}
|
||||
/>,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{railView === "quick-connect" &&
|
||||
panelScope(
|
||||
"quick-connect",
|
||||
owned,
|
||||
true,
|
||||
<QuickConnectPanel
|
||||
onConnect={(host, type) => {
|
||||
tabs.open(host, type);
|
||||
closeOnMobile();
|
||||
}}
|
||||
/>,
|
||||
)}
|
||||
|
||||
{registeredPanels.map((panel) => {
|
||||
const shown = railView === panel.id;
|
||||
// A kept-mounted panel lives in the owning dock only, so two live
|
||||
// copies never fight over the same state.
|
||||
if (panel.keepMounted ? !owned : !shown) return null;
|
||||
const Panel = panel.component;
|
||||
return panelScope(
|
||||
panel.id,
|
||||
owned,
|
||||
shown,
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<Panel
|
||||
targetTab={tabs.targetTerminal}
|
||||
active={shown}
|
||||
shell={shell.panelCallbacks}
|
||||
setEditing={
|
||||
owned ? regions.panelEditingSetter(panel.id) : () => undefined
|
||||
}
|
||||
activeTabType={tabs.active?.type ?? undefined}
|
||||
placement={owned ? "left" : "right"}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
})}
|
||||
|
||||
{!isCoreRailView(railView) && !getPanel(railView) && (
|
||||
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
|
||||
)}
|
||||
|
||||
{railView === "connections" &&
|
||||
panelScope(
|
||||
"connections",
|
||||
owned,
|
||||
true,
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<ConnectionsPanel
|
||||
tabs={tabs.list}
|
||||
activeTabId={tabs.activeId}
|
||||
allHosts={hosts.list}
|
||||
backgroundTabRecords={tabs.background}
|
||||
onSwitchToTab={(tabId) => {
|
||||
tabs.setActive(tabId);
|
||||
closeOnMobile();
|
||||
}}
|
||||
onCloseTab={tabs.close}
|
||||
onReopenTab={(record, restoredSessionId) => {
|
||||
const host = record.hostId
|
||||
? hosts.list.find((h) => h.id === String(record.hostId))
|
||||
: undefined;
|
||||
if (!host && !getTabType(record.tabType)?.hostless) return;
|
||||
tabs.setBackground((prev) =>
|
||||
prev.filter((r) => r.id !== record.id),
|
||||
);
|
||||
if (host) {
|
||||
const effectiveSessionId =
|
||||
restoredSessionId ?? record.backendSessionId ?? null;
|
||||
tabs.open(host, record.tabType as TabType, {
|
||||
instanceId: record.id,
|
||||
restoredSessionId: effectiveSessionId,
|
||||
savedLabel: record.label,
|
||||
});
|
||||
} else {
|
||||
tabs.openSingleton(record.tabType as TabType);
|
||||
}
|
||||
closeOnMobile();
|
||||
}}
|
||||
onForgetBackground={(recordId) => {
|
||||
tabs.setBackground((prev) =>
|
||||
prev.filter((r) => r.id !== recordId),
|
||||
);
|
||||
}}
|
||||
onRenameTab={tabs.rename}
|
||||
onReorderTabs={tabs.setList}
|
||||
/>
|
||||
</div>,
|
||||
)}
|
||||
|
||||
{railView === "sync" &&
|
||||
panelScope(
|
||||
"sync",
|
||||
owned,
|
||||
true,
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SyncPanel />
|
||||
</div>,
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const sidebarPanelContent = renderSidebarPanels(left.view);
|
||||
|
||||
const dockProps = (view: string) => ({
|
||||
// Titles come from the shared rail definitions so they stay translated
|
||||
// and in step with the rail itself.
|
||||
title: railItemLabel(view, t),
|
||||
onOpenAsTab: isMobile
|
||||
? undefined
|
||||
: view === "hosts" || view === "credentials"
|
||||
? () => requestManage({ kind: "browse", mode: view as ManageMode })
|
||||
: promotableIds().includes(view)
|
||||
? () => tabs.openSingleton(view as TabType)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col w-screen bg-background"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Skinny icon rail, desktop only */}
|
||||
<AppRail
|
||||
railView={left.view}
|
||||
sidebarOpen={left.open}
|
||||
username={shell.username}
|
||||
isAdmin={shell.isAdmin}
|
||||
pluginsSettled={shell.pluginsSettled}
|
||||
onRailClick={regions.railClick}
|
||||
onOpenTab={tabs.openSingleton}
|
||||
onOpenInRightDock={regions.openInRightDock}
|
||||
onOpenSettings={() => tabs.openSingleton("settings")}
|
||||
onOpenNavigationSettings={() =>
|
||||
tabs.openSingleton("settings", undefined, undefined, {
|
||||
section: "appearance",
|
||||
reveal: "settings-navigation",
|
||||
revealAt: Date.now(),
|
||||
})
|
||||
}
|
||||
onOpenPalette={shell.palette.open}
|
||||
onLogout={shell.logout}
|
||||
/>
|
||||
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<DockPanel
|
||||
side="left"
|
||||
{...dockProps(left.view)}
|
||||
open={left.open}
|
||||
width={left.width}
|
||||
editing={left.editing}
|
||||
dragging={left.dragging}
|
||||
onResizeStart={left.startResize}
|
||||
onClose={() => left.setOpen(false)}
|
||||
onMoveToRightDock={
|
||||
rightDockableIds().includes(left.view)
|
||||
? () => regions.openInRightDock(left.view)
|
||||
: undefined
|
||||
}
|
||||
onResetWidth={left.resetWidth}
|
||||
>
|
||||
{sidebarPanelContent}
|
||||
</DockPanel>
|
||||
)}
|
||||
|
||||
{/* Mobile: sidebar as overlay sheet */}
|
||||
{isMobile && (
|
||||
<Sheet open={left.open} onOpenChange={left.setOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${left.editing ? "w-screen" : "w-[min(85vw,360px)]"}`}
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
<DockPanel
|
||||
side="left"
|
||||
{...dockProps(left.view)}
|
||||
open
|
||||
fill
|
||||
width={left.width}
|
||||
onClose={() => left.setOpen(false)}
|
||||
>
|
||||
{sidebarPanelContent}
|
||||
</DockPanel>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !left.open ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !left.open && (
|
||||
<DockReopenStrip onClick={() => left.setOpen(true)} />
|
||||
)}
|
||||
<SurfaceScope kind="tab" className="overflow-hidden">
|
||||
<ShellConfirmCapture />
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<TabBar
|
||||
tabs={tabs.topLevel}
|
||||
activeTabId={tabs.activeId}
|
||||
splits={shell.split.summaries}
|
||||
activeSplitFull={shell.split.activeFull}
|
||||
onSetActiveTab={tabs.setActive}
|
||||
onCloseTab={tabs.close}
|
||||
onRefreshTab={tabs.refresh}
|
||||
onReconnectDisconnected={tabs.reconnectAll}
|
||||
onReorderTabs={tabs.reorder}
|
||||
onSplitAction={shell.split.handleAction}
|
||||
onRenameTab={tabs.rename}
|
||||
isAppFullscreen={shell.fullscreen.active}
|
||||
onToggleAppFullscreen={shell.fullscreen.toggle}
|
||||
rightDockOpen={right.view !== null}
|
||||
onToggleRightDock={
|
||||
isMobile ? undefined : regions.toggleRightDock
|
||||
}
|
||||
showTabNumbers={tabs.showNumbers}
|
||||
/>
|
||||
<TabStage />
|
||||
</div>
|
||||
</SurfaceScope>
|
||||
|
||||
{/* Bottom nav bar, mobile only. The desktop app keeps the rail. */}
|
||||
{!shell.isElectron && (
|
||||
<MobileBar
|
||||
railView={left.view}
|
||||
sidebarOpen={left.open}
|
||||
username={shell.username}
|
||||
onRailClick={regions.railClick}
|
||||
onOpenTab={(type) => tabs.openSingleton(type)}
|
||||
onOpenSettings={() => tabs.openSingleton("settings")}
|
||||
onOpenPalette={shell.palette.open}
|
||||
onLogout={() => shell.logout({ manual: true })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right dock, desktop only, holds a second reference panel */}
|
||||
{!isMobile && right.view && (
|
||||
<DockPanel
|
||||
side="right"
|
||||
{...dockProps(right.view)}
|
||||
open
|
||||
width={right.width}
|
||||
dragging={right.dragging}
|
||||
onResizeStart={right.startResize}
|
||||
onClose={() => right.setView(null)}
|
||||
onResetWidth={right.resetWidth}
|
||||
>
|
||||
{renderSidebarPanels(right.view, false)}
|
||||
</DockPanel>
|
||||
)}
|
||||
|
||||
{/* Plugin panels pinned to the right edge, sized by the plugin */}
|
||||
<ComponentSlot slotId="shell.rightEdge" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { BUILTIN_LAYOUT_ID, registerLayout } from "@/look/layout-registry";
|
||||
import { DefaultLayout } from "./DefaultLayout";
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Core's own layout, there whatever plugins are installed. */
|
||||
export function registerBuiltinLayout(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerLayout({ id: BUILTIN_LAYOUT_ID, component: DefaultLayout });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useLayoutEffect, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { getTabType } from "@/shell/tab-registry";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
import {
|
||||
useShellController,
|
||||
useTabStageStore,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
|
||||
const ownBackground = (tab: Tab) => !!getTabType(tab.type)?.ownBackground;
|
||||
|
||||
/**
|
||||
* Renders every open tab into its own stable node. Core mounts this outside
|
||||
* the layout, so a layout that moves, hides or crashes never remounts a tab;
|
||||
* a TabStage only decides where the nodes sit.
|
||||
*/
|
||||
export function TabPortals() {
|
||||
const shell = useShellController();
|
||||
const store = useTabStageStore();
|
||||
// Pane elements and the stage's container come and go with the layout.
|
||||
useSyncExternalStore(store.subscribe, store.version, store.version);
|
||||
const { list: tabs, activeId, targetTerminal } = shell.tabs;
|
||||
const { paneIdByTabId, focusedPaneId } = shell.split;
|
||||
|
||||
// useLayoutEffect (not useEffect) so visibility/display are corrected before
|
||||
// the browser paints, otherwise the previous tab's node can flash on screen
|
||||
// for a frame while still visible.
|
||||
useLayoutEffect(() => {
|
||||
store.place(tabs, activeId, paneIdByTabId, ownBackground);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{store.portalOrder(tabs).map((tab) => {
|
||||
const paneId = paneIdByTabId.get(tab.id);
|
||||
const inPane = !!paneId;
|
||||
const activeInline = !inPane && tab.id === activeId;
|
||||
const isFocusedPane = inPane ? paneId === focusedPaneId : activeInline;
|
||||
return createPortal(
|
||||
<SurfaceScope
|
||||
kind="tab"
|
||||
className="h-full w-full"
|
||||
onClose={
|
||||
tab.id === "dashboard"
|
||||
? undefined
|
||||
: () => shell.tabs.close(tab.id)
|
||||
}
|
||||
>
|
||||
{renderTabContent(tab, {
|
||||
shell: shell.callbacks,
|
||||
settings: shell.settings,
|
||||
panelTargetTab: targetTerminal,
|
||||
isVisible: inPane || activeInline,
|
||||
isFocusedPane,
|
||||
inSplit: inPane,
|
||||
})}
|
||||
</SurfaceScope>,
|
||||
store.getNode(tab.id, ownBackground(tab)),
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitView } from "@/shell/split/SplitView";
|
||||
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
|
||||
import { EmptyPanePicker } from "@/shell/split/EmptyPanePicker";
|
||||
import {
|
||||
useShellController,
|
||||
useTabStageStore,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
|
||||
/**
|
||||
* The area tabs show in: the active tab, or the active split's panes, plus
|
||||
* the drop zones for dragging a tab or pane into a split. A layout places it
|
||||
* wherever its main area is. Tabs are never mounted here, only moved in, so
|
||||
* moving or remounting the stage keeps every session alive.
|
||||
*/
|
||||
export function TabStage({ className }: { className?: string }) {
|
||||
const shell = useShellController();
|
||||
const store = useTabStageStore();
|
||||
// The area below the tab bar, where tabs and panes are dropped to split.
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
const activeSplit = shell.split.active;
|
||||
const empty = shell.split.emptyPane;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mainAreaRef}
|
||||
className={cn(
|
||||
"relative flex flex-col flex-1 min-h-0 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{activeSplit && (
|
||||
<div className="motion-workspace-layout absolute inset-0 flex flex-col">
|
||||
<SplitView
|
||||
splitTab={activeSplit}
|
||||
tabs={shell.tabs.list}
|
||||
isMobile={shell.isMobile}
|
||||
actions={shell.split.viewActions(activeSplit.id)}
|
||||
onPaneContentRef={store.setPaneEl}
|
||||
renderEmptyPane={(paneId, paneIndex) => (
|
||||
<EmptyPanePicker
|
||||
paneIndex={paneIndex}
|
||||
freeTabs={empty.freeTabs}
|
||||
hosts={shell.hosts.list}
|
||||
newTabOptions={empty.newTabOptions}
|
||||
onPickTab={(tabId) =>
|
||||
empty.pickTab(activeSplit.id, paneId, tabId)
|
||||
}
|
||||
onPickHost={(host, pick) =>
|
||||
empty.pickHost(activeSplit.id, paneId, host, pick)
|
||||
}
|
||||
onOpenType={(type) =>
|
||||
empty.openType(activeSplit.id, paneId, type)
|
||||
}
|
||||
onClosePane={() => empty.closePane(activeSplit.id, paneId)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SplitDropOverlay containerRef={mainAreaRef} />
|
||||
|
||||
{/* Normal-view container. Tab nodes are moved in here (or into pane
|
||||
elements) by TabPortals. Hidden while a split is active. */}
|
||||
<div
|
||||
ref={store.setNormalView}
|
||||
className="absolute inset-0"
|
||||
style={{ display: activeSplit ? "none" : undefined }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* Where tab content lives in the DOM.
|
||||
*
|
||||
* Every tab gets one stable node, created once and kept while the tab lives.
|
||||
* React portals the tab into that node and never changes the portal target,
|
||||
* since changing it remounts the tab (and drops a live terminal). The node is
|
||||
* moved with plain DOM between the stage's normal view and pane elements, or
|
||||
* left detached while no stage is mounted, e.g. while a layout is swapped.
|
||||
*/
|
||||
export interface TabStageStore {
|
||||
getNode: (tabId: string, ownBackground: boolean) => HTMLDivElement;
|
||||
/** Tab ids in the order their portals render: append only, never reordered. */
|
||||
portalOrder: (tabs: Tab[]) => Tab[];
|
||||
paneEls: Map<string, HTMLDivElement>;
|
||||
setPaneEl: (paneId: string, el: HTMLDivElement | null) => void;
|
||||
normalView: () => HTMLDivElement | null;
|
||||
setNormalView: (el: HTMLDivElement | null) => void;
|
||||
/** Moves every node where it belongs and sets its visibility. */
|
||||
place: (
|
||||
tabs: Tab[],
|
||||
activeTabId: string,
|
||||
paneIdByTabId: Map<string, string>,
|
||||
ownBackground: (tab: Tab) => boolean,
|
||||
) => void;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
version: () => number;
|
||||
}
|
||||
|
||||
export function createTabStageStore(): TabStageStore {
|
||||
const nodes = new Map<string, HTMLDivElement>();
|
||||
const paneEls = new Map<string, HTMLDivElement>();
|
||||
const listeners = new Set<() => void>();
|
||||
let normalView: HTMLDivElement | null = null;
|
||||
let version = 0;
|
||||
let order: string[] = [];
|
||||
// Tab id the enter animation has already played for, so a re-render while
|
||||
// the tab stays active (there can be several right after a switch) doesn't
|
||||
// replay it, only a genuine switch to a different tab should.
|
||||
let lastAnimatedTabId: string | null = null;
|
||||
|
||||
const bump = () => {
|
||||
version += 1;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
function getNode(tabId: string, ownBackground: boolean) {
|
||||
let el = nodes.get(tabId);
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.style.position = "absolute";
|
||||
el.style.inset = "0";
|
||||
el.style.overflow = "hidden";
|
||||
if (!ownBackground) el.classList.add("bg-background");
|
||||
nodes.set(tabId, el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// Portal render order for tab content, kept independent of the tab bar's
|
||||
// visual order. Reordering tabs in the bar reorders `tabs`, and mapping
|
||||
// that array directly to portals reshuffles the Suspense-wrapped portal
|
||||
// children's sibling order in the fiber tree, React then runs its
|
||||
// Offscreen disconnect/reconnect pass on the ones that moved, which tears
|
||||
// down and rebuilds every passive effect underneath (including
|
||||
// react-xtermjs's terminal-creation effect), dropping the live terminal
|
||||
// and its WebSocket. Portal position doesn't need to track tab order at
|
||||
// all, so we only ever append new ids and drop closed ones here.
|
||||
function portalOrder(tabs: Tab[]) {
|
||||
const liveIds = new Set(tabs.map((t) => t.id));
|
||||
order = order.filter((id) => liveIds.has(id));
|
||||
const known = new Set(order);
|
||||
for (const tab of tabs) {
|
||||
if (!known.has(tab.id)) order.push(tab.id);
|
||||
}
|
||||
return order
|
||||
.map((id) => tabs.find((t) => t.id === id))
|
||||
.filter((t): t is Tab => t !== undefined);
|
||||
}
|
||||
|
||||
function setPaneEl(paneId: string, el: HTMLDivElement | null) {
|
||||
if (el) {
|
||||
if (paneEls.get(paneId) === el) return;
|
||||
paneEls.set(paneId, el);
|
||||
} else {
|
||||
if (!paneEls.has(paneId)) return;
|
||||
paneEls.delete(paneId);
|
||||
}
|
||||
bump();
|
||||
}
|
||||
|
||||
function setNormalView(el: HTMLDivElement | null) {
|
||||
if (normalView === el) return;
|
||||
normalView = el;
|
||||
bump();
|
||||
}
|
||||
|
||||
function place(
|
||||
tabs: Tab[],
|
||||
activeTabId: string,
|
||||
paneIdByTabId: Map<string, string>,
|
||||
ownBackground: (tab: Tab) => boolean,
|
||||
) {
|
||||
const tabIds = new Set(tabs.map((t) => t.id));
|
||||
|
||||
// Remove nodes for closed tabs
|
||||
for (const [id, node] of nodes) {
|
||||
if (!tabIds.has(id)) {
|
||||
node.remove();
|
||||
nodes.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tab of tabs) {
|
||||
const isTerminal = ownBackground(tab);
|
||||
const node = getNode(tab.id, isTerminal);
|
||||
const paneId = paneIdByTabId.get(tab.id);
|
||||
const paneEl = paneId ? paneEls.get(paneId) : undefined;
|
||||
const inPane = !!paneId;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
|
||||
if (inPane && paneEl) {
|
||||
if (node.parentElement !== paneEl) paneEl.appendChild(node);
|
||||
node.style.visibility = "visible";
|
||||
node.style.pointerEvents = "auto";
|
||||
node.style.display = "";
|
||||
node.style.zIndex = "";
|
||||
node.style.contentVisibility = "";
|
||||
continue;
|
||||
}
|
||||
// With no stage mounted the node stays where it is, still rendered.
|
||||
if (normalView && node.parentElement !== normalView) {
|
||||
normalView.appendChild(node);
|
||||
}
|
||||
if (isTerminal) {
|
||||
node.style.display = "";
|
||||
node.style.visibility = activeInline ? "visible" : "hidden";
|
||||
node.style.pointerEvents = activeInline ? "auto" : "none";
|
||||
node.style.zIndex = activeInline ? "1" : "0";
|
||||
// xterm renders to a <canvas>; visibility:hidden alone can still let
|
||||
// a stale composited frame flash through for a tick when switching
|
||||
// to/from a non-terminal tab. content-visibility:hidden fully skips
|
||||
// painting the subtree while keeping its layout box intact, so
|
||||
// fitAddon.fit() still sees correct dimensions once it's shown again.
|
||||
node.style.contentVisibility = activeInline ? "" : "hidden";
|
||||
} else {
|
||||
// Plays a quick opacity fade-in on the tab that just became active.
|
||||
// Only play it on an actual switch into this tab -- gating on the
|
||||
// class alone replayed the animation on every unrelated re-render
|
||||
// that happened while the tab was still active (any render after
|
||||
// animationend had stripped the class), which looked like the
|
||||
// panel kept growing for up to a second after switching.
|
||||
if (activeInline && lastAnimatedTabId !== tab.id) {
|
||||
lastAnimatedTabId = tab.id;
|
||||
node.classList.remove("motion-workspace-enter");
|
||||
// Force a reflow so re-adding the class restarts the animation
|
||||
// instead of no-oping because it was already removed this tick.
|
||||
void node.offsetWidth;
|
||||
node.classList.add("motion-workspace-enter");
|
||||
node.addEventListener(
|
||||
"animationend",
|
||||
() => node.classList.remove("motion-workspace-enter"),
|
||||
{ once: true },
|
||||
);
|
||||
} else if (!activeInline) {
|
||||
node.classList.remove("motion-workspace-enter");
|
||||
if (lastAnimatedTabId === tab.id) lastAnimatedTabId = null;
|
||||
}
|
||||
node.style.visibility = "";
|
||||
node.style.pointerEvents = "";
|
||||
node.style.zIndex = activeInline ? "2" : "";
|
||||
node.style.display = activeInline ? "" : "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getNode,
|
||||
portalOrder,
|
||||
paneEls,
|
||||
setPaneEl,
|
||||
normalView: () => normalView,
|
||||
setNormalView,
|
||||
place,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
version: () => version,
|
||||
};
|
||||
}
|
||||
@@ -66,7 +66,9 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||
import { toast } from "sonner";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { THEMES } from "@/settings/appearance/appearance-controls";
|
||||
import { useThemeOptions } from "@/settings/appearance/appearance-controls";
|
||||
import { LookSettings } from "@/settings/appearance/LookSettings";
|
||||
import { useChooseTheme } from "@/look/use-look-theme";
|
||||
import { LANGUAGES } from "@/i18n/languages";
|
||||
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
|
||||
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
|
||||
@@ -446,18 +448,6 @@ export function UserProfilePanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const themeLabel: Record<ThemeId, string> = {
|
||||
system: t("newUi.sidebar.userProfile.themeSystem"),
|
||||
light: t("newUi.sidebar.userProfile.themeLight"),
|
||||
dark: t("newUi.sidebar.userProfile.themeDark"),
|
||||
dracula: t("newUi.sidebar.userProfile.themeDracula"),
|
||||
catppuccin: t("newUi.sidebar.userProfile.themeCatppuccin"),
|
||||
nord: t("newUi.sidebar.userProfile.themeNord"),
|
||||
solarized: t("newUi.sidebar.userProfile.themeSolarized"),
|
||||
"tokyo-night": t("newUi.sidebar.userProfile.themeTokyoNight"),
|
||||
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
|
||||
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
|
||||
};
|
||||
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
|
||||
() => new Set(["account"]),
|
||||
);
|
||||
@@ -492,7 +482,9 @@ export function UserProfilePanel({
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [newKeyOpen, setNewKeyOpen] = useState(false);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, setThemePreview } = useTheme();
|
||||
const themeOptions = useThemeOptions();
|
||||
const chooseTheme = useChooseTheme();
|
||||
const localSnapshot = useRef<Record<string, string | null>>({});
|
||||
|
||||
// Appearance state, initialized from localStorage
|
||||
@@ -956,8 +948,8 @@ export function UserProfilePanel({
|
||||
}
|
||||
|
||||
function handleThemeChange(id: ThemeId) {
|
||||
setTheme(id);
|
||||
if (storageMode === "cloud") saveToCloud({ theme: id });
|
||||
setThemePreview(null);
|
||||
chooseTheme(id);
|
||||
}
|
||||
|
||||
function handleAccentChange(value: string) {
|
||||
@@ -1439,26 +1431,39 @@ export function UserProfilePanel({
|
||||
onChange={(e) => handleThemeChange(e.target.value as ThemeId)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7"
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
{themeOptions.map((th) => (
|
||||
<option key={th.id} value={th.id}>
|
||||
{themeLabel[th.id]}
|
||||
{th.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
{THEMES.filter((th) => th.id !== "system").map((th) => (
|
||||
<button
|
||||
key={th.id}
|
||||
title={themeLabel[th.id]}
|
||||
onClick={() => handleThemeChange(th.id)}
|
||||
className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`}
|
||||
style={{ background: th.preview }}
|
||||
/>
|
||||
))}
|
||||
{themeOptions
|
||||
.filter((th) => th.swatch)
|
||||
.map((th) => (
|
||||
<button
|
||||
key={th.id}
|
||||
title={th.label}
|
||||
onClick={() => handleThemeChange(th.id)}
|
||||
onMouseEnter={() => setThemePreview(th.id)}
|
||||
onMouseLeave={() => setThemePreview(null)}
|
||||
onFocus={() => setThemePreview(th.id)}
|
||||
onBlur={() => setThemePreview(null)}
|
||||
className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`}
|
||||
style={{ background: th.swatch!.background }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("looks.layoutLabel")}
|
||||
</span>
|
||||
<LookSettings isAdmin={isAdmin} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Type className="size-3" />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { ThemeProvider, useTheme } from "@/components/theme-provider";
|
||||
import { resetPluginThemes, setPluginThemes } from "@/look/theme-registry";
|
||||
|
||||
let api: ReturnType<typeof useTheme> | null = null;
|
||||
function Capture() {
|
||||
api = useTheme();
|
||||
return null;
|
||||
}
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<ThemeProvider defaultTheme="dark" storageKey="test-theme">
|
||||
<Capture />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const classes = () => [...document.documentElement.classList];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetPluginThemes();
|
||||
localStorage.clear();
|
||||
document.documentElement.className = "";
|
||||
document.getElementById("termix-theme-css")?.remove();
|
||||
});
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
it("puts exactly one theme class on <html> and keeps the choice", () => {
|
||||
mount();
|
||||
expect(classes()).toEqual(["dark"]);
|
||||
act(() => api!.setTheme("nord"));
|
||||
expect(classes()).toEqual(["nord"]);
|
||||
expect(localStorage.getItem("test-theme")).toBe("nord");
|
||||
});
|
||||
|
||||
it("can show a theme without keeping it", () => {
|
||||
mount();
|
||||
act(() => api!.setTheme("gruvbox", false));
|
||||
expect(classes()).toEqual(["gruvbox"]);
|
||||
expect(localStorage.getItem("test-theme")).toBeNull();
|
||||
});
|
||||
|
||||
it("previews a theme and goes back", () => {
|
||||
mount();
|
||||
act(() => api!.setThemePreview("light"));
|
||||
expect(classes()).toEqual(["light"]);
|
||||
expect(api!.theme).toBe("dark");
|
||||
act(() => api!.setThemePreview(null));
|
||||
expect(classes()).toEqual(["dark"]);
|
||||
});
|
||||
|
||||
it("applies a plugin theme's class and stylesheet, and drops them after", () => {
|
||||
mount();
|
||||
act(() =>
|
||||
setPluginThemes([
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
swatch: { background: "#fff", panel: "#eee", text: "#111" },
|
||||
pluginId: "paper-themes",
|
||||
cssHref: "/plugin-assets/paper-themes/_themes/paper.css?v=1",
|
||||
},
|
||||
]),
|
||||
);
|
||||
act(() => api!.setTheme("paper"));
|
||||
expect(classes()).toEqual(["paper"]);
|
||||
expect(
|
||||
(document.getElementById("termix-theme-css") as HTMLLinkElement).href,
|
||||
).toContain("paper.css");
|
||||
act(() => api!.setTheme("dark"));
|
||||
expect(classes()).toEqual(["dark"]);
|
||||
expect(document.getElementById("termix-theme-css")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
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 shell = vi.hoisted(() => ({ openSingleton: vi.fn() }));
|
||||
vi.mock("@/shell/controller/ShellControllerProvider", () => ({
|
||||
useShellController: () => ({ tabs: { openSingleton: shell.openSingleton } }),
|
||||
ShellConfirmCapture: () => null,
|
||||
}));
|
||||
vi.mock("@/shell/stage/TabStage", () => ({
|
||||
TabStage: () => <div data-testid="stage" />,
|
||||
}));
|
||||
|
||||
const look = vi.hoisted(() => ({
|
||||
chooseLayout: vi.fn(async () => {}),
|
||||
chooseThemeForAccount: vi.fn(async () => {}),
|
||||
chooseDefaultLook: vi.fn(async () => {}),
|
||||
chooseTheme: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/look/look-store", () => ({
|
||||
chooseLayout: look.chooseLayout,
|
||||
chooseThemeForAccount: look.chooseThemeForAccount,
|
||||
chooseDefaultLook: look.chooseDefaultLook,
|
||||
}));
|
||||
vi.mock("@/look/use-look-theme", () => ({
|
||||
useChooseTheme: () => look.chooseTheme,
|
||||
}));
|
||||
|
||||
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,
|
||||
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"] }],
|
||||
},
|
||||
{
|
||||
id: "docker",
|
||||
name: "Docker",
|
||||
installed: false,
|
||||
versions: [{ version: "1.0.0", capabilities: ["ui:surface"] }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { RepairView } from "@/look/RepairView";
|
||||
import { setPluginLayouts } from "@/look/layout-registry";
|
||||
|
||||
const status = {
|
||||
kind: "repair" as const,
|
||||
reason: "failed" as const,
|
||||
layoutId: "fancy",
|
||||
pluginId: "fancy-layout",
|
||||
version: "1.0.0",
|
||||
error: "activate threw",
|
||||
};
|
||||
|
||||
const onRetry = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
permissions.isAdmin = true;
|
||||
for (const fn of [
|
||||
...Object.values(look),
|
||||
...Object.values(manager),
|
||||
shell.openSingleton,
|
||||
onRetry,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
setPluginLayouts([{ id: "fancy", label: "Fancy", pluginId: "fancy-layout" }]);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RepairView", () => {
|
||||
it("names the layout and the error, and copies the details", async () => {
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
expect(screen.getByText("activate threw")).toBeTruthy();
|
||||
expect(screen.getByText("looks.repair.reasons.failed")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("looks.repair.copy"));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalled());
|
||||
const copied = String((writeText.mock.calls[0] as unknown[])[0]);
|
||||
expect(copied).toContain("fancy");
|
||||
expect(copied).toContain("activate threw");
|
||||
});
|
||||
|
||||
it("retries", () => {
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("looks.repair.retry"));
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches to another layout or theme", () => {
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("looks.builtinLayout"));
|
||||
expect(look.chooseLayout).toHaveBeenCalledWith("default");
|
||||
fireEvent.click(
|
||||
screen.getAllByTitle("newUi.sidebar.userProfile.themeNord")[0],
|
||||
);
|
||||
expect(look.chooseTheme).toHaveBeenCalledWith("nord");
|
||||
});
|
||||
|
||||
it("resets to the default look", async () => {
|
||||
localStorage.setItem("vite-ui-theme", "dracula");
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("looks.repair.reset"));
|
||||
await waitFor(() => expect(look.chooseLayout).toHaveBeenCalledWith(null));
|
||||
expect(look.chooseThemeForAccount).toHaveBeenCalledWith(null);
|
||||
expect(localStorage.getItem("vite-ui-theme")).toBeNull();
|
||||
fireEvent.click(screen.getByText("looks.repair.resetEveryone"));
|
||||
expect(look.chooseDefaultLook).toHaveBeenCalledWith({
|
||||
layout: null,
|
||||
theme: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens Settings in place and comes back", () => {
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("looks.repair.settings"));
|
||||
expect(shell.openSingleton).toHaveBeenCalledWith(
|
||||
"settings",
|
||||
undefined,
|
||||
undefined,
|
||||
{ section: "appearance" },
|
||||
);
|
||||
expect(screen.getByTestId("stage")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("looks.repair.back"));
|
||||
expect(screen.queryByTestId("stage")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets an admin update or turn off the plugin", () => {
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
fireEvent.click(
|
||||
screen.getByText(
|
||||
'looks.repair.update:{"name":"Fancy","version":"1.1.0"}',
|
||||
),
|
||||
);
|
||||
expect(manager.requestUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "fancy-layout" }),
|
||||
);
|
||||
fireEvent.click(screen.getByText('looks.repair.disable:{"name":"Fancy"}'));
|
||||
expect(manager.toggle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "fancy-layout" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets an admin install a layout or theme from the registries", () => {
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("looks.repair.install"));
|
||||
expect(screen.getByText("Paper themes")).toBeTruthy();
|
||||
expect(screen.queryByText("Docker")).toBeNull();
|
||||
fireEvent.click(screen.getByText("looks.repair.installButton"));
|
||||
expect(manager.requestInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "paper-themes" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives everyone else switch and reset, and a note to ask an admin", () => {
|
||||
permissions.isAdmin = false;
|
||||
render(<RepairView status={status} onRetry={onRetry} />);
|
||||
expect(screen.getByText("looks.repair.askAdmin")).toBeTruthy();
|
||||
expect(screen.getByText("looks.repair.reset")).toBeTruthy();
|
||||
expect(screen.getByText("looks.repair.switchLayout")).toBeTruthy();
|
||||
expect(screen.queryByText("looks.repair.resetEveryone")).toBeNull();
|
||||
expect(screen.queryByText("looks.repair.install")).toBeNull();
|
||||
expect(screen.queryByText(/looks\.repair\.update/)).toBeNull();
|
||||
expect(screen.queryByText(/looks\.repair\.disable/)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no retry in safe mode", () => {
|
||||
render(
|
||||
<RepairView
|
||||
status={{ ...status, reason: "safe-url" }}
|
||||
onRetry={onRetry}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("looks.repair.safeTitle")).toBeTruthy();
|
||||
expect(screen.queryByText("looks.repair.retry")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import type { PluginRecord } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
layoutStatus,
|
||||
MAX_LAYOUT_CRASHES,
|
||||
type LayoutStatusInput,
|
||||
} from "@/look/layout-status";
|
||||
|
||||
const Layout = () => null;
|
||||
const registered = { id: "fancy", pluginId: "fancy-layout", component: Layout };
|
||||
|
||||
function owner(
|
||||
patch: Partial<PluginSummary> = {},
|
||||
frontend: PluginRecord["frontend"] = "active",
|
||||
error?: string,
|
||||
): PluginRecord {
|
||||
return {
|
||||
summary: {
|
||||
id: "fancy-layout",
|
||||
name: "Fancy",
|
||||
version: "1.2.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: { layouts: [{ id: "fancy", label: "Fancy" }] },
|
||||
frontend: true,
|
||||
...patch,
|
||||
},
|
||||
frontend,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function input(patch: Partial<LayoutStatusInput> = {}): LayoutStatusInput {
|
||||
return {
|
||||
layoutId: "fancy",
|
||||
safeFromUrl: false,
|
||||
safeFromEnv: false,
|
||||
ready: true,
|
||||
registered: undefined,
|
||||
owner: undefined,
|
||||
unavailable: undefined,
|
||||
crashes: 0,
|
||||
lastCrash: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("layoutStatus", () => {
|
||||
it("draws a registered layout", () => {
|
||||
expect(layoutStatus(input({ registered }))).toEqual({
|
||||
kind: "ok",
|
||||
layout: registered,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the repair view for ?safe-layout=1 and the env var first", () => {
|
||||
expect(
|
||||
layoutStatus(input({ registered, safeFromUrl: true })),
|
||||
).toMatchObject({ kind: "repair", reason: "safe-url" });
|
||||
expect(
|
||||
layoutStatus(input({ registered, safeFromEnv: true })),
|
||||
).toMatchObject({ kind: "repair", reason: "safe-env" });
|
||||
});
|
||||
|
||||
it("gives up after the boundary trips twice, not once", () => {
|
||||
expect(layoutStatus(input({ registered, crashes: 1 }))).toMatchObject({
|
||||
kind: "ok",
|
||||
});
|
||||
expect(
|
||||
layoutStatus(
|
||||
input({
|
||||
registered,
|
||||
owner: owner(),
|
||||
crashes: MAX_LAYOUT_CRASHES,
|
||||
lastCrash: "boom",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "repair",
|
||||
reason: "crashed",
|
||||
layoutId: "fancy",
|
||||
pluginId: "fancy-layout",
|
||||
version: "1.2.0",
|
||||
error: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits until the look and plugins are known", () => {
|
||||
expect(layoutStatus(input({ ready: false }))).toEqual({ kind: "loading" });
|
||||
});
|
||||
|
||||
it("says missing when nothing provides the layout", () => {
|
||||
expect(layoutStatus(input())).toMatchObject({
|
||||
kind: "repair",
|
||||
reason: "missing",
|
||||
});
|
||||
// Running, but it never registered what it declares.
|
||||
expect(layoutStatus(input({ owner: owner() }))).toMatchObject({
|
||||
reason: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("says disabled, blocked or failed from the plugin's state", () => {
|
||||
expect(
|
||||
layoutStatus(input({ owner: owner({ enabled: false }) })),
|
||||
).toMatchObject({ reason: "disabled", pluginId: "fancy-layout" });
|
||||
expect(
|
||||
layoutStatus(input({ owner: owner({}, "blocked", "needs x") })),
|
||||
).toMatchObject({ reason: "blocked", error: "needs x" });
|
||||
expect(
|
||||
layoutStatus(input({ owner: owner({}, "failed", "activate threw") })),
|
||||
).toMatchObject({ reason: "failed", error: "activate threw" });
|
||||
expect(
|
||||
layoutStatus(
|
||||
input({
|
||||
owner: owner({ state: "failed", lastError: "server" }, "none"),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ reason: "failed", error: "server" });
|
||||
});
|
||||
|
||||
it("says incompatible when the server skipped its plugin", () => {
|
||||
expect(
|
||||
layoutStatus(
|
||||
input({
|
||||
unavailable: {
|
||||
id: "fancy-layout",
|
||||
version: "2.0.0",
|
||||
layouts: ["fancy"],
|
||||
themes: [],
|
||||
error: "needs Termix >=27",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "repair",
|
||||
reason: "incompatible",
|
||||
layoutId: "fancy",
|
||||
pluginId: "fancy-layout",
|
||||
version: "2.0.0",
|
||||
error: "needs Termix >=27",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for a layout plugin that is about to load", () => {
|
||||
expect(layoutStatus(input({ owner: owner({}, "loading") }))).toEqual({
|
||||
kind: "loading",
|
||||
});
|
||||
expect(layoutStatus(input({ owner: owner({}, "inactive") }))).toEqual({
|
||||
kind: "loading",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getLook: vi.fn(),
|
||||
saveUserLook: vi.fn(async () => {}),
|
||||
saveDefaultLook: vi.fn(
|
||||
async (d: { layout?: string | null; theme?: string | null }) => ({
|
||||
layout: d.layout ?? null,
|
||||
theme: d.theme ?? null,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/api/look-api", () => api);
|
||||
|
||||
import {
|
||||
chooseDefaultLook,
|
||||
chooseLayout,
|
||||
chooseThemeForAccount,
|
||||
chosenLayoutId,
|
||||
getLookState,
|
||||
loadLook,
|
||||
resetLook,
|
||||
safeLayoutRequested,
|
||||
} from "@/look/look-store";
|
||||
|
||||
const response = {
|
||||
layoutId: null,
|
||||
themeId: null,
|
||||
defaults: { layout: "admin-pick", theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetLook();
|
||||
api.getLook.mockReset();
|
||||
api.saveUserLook.mockClear();
|
||||
});
|
||||
afterEach(() => window.history.replaceState(null, "", "/"));
|
||||
|
||||
describe("look store", () => {
|
||||
it("fetches once and resolves the layout", async () => {
|
||||
api.getLook.mockResolvedValue(response);
|
||||
await Promise.all([loadLook(), loadLook()]);
|
||||
expect(api.getLook).toHaveBeenCalledTimes(1);
|
||||
expect(chosenLayoutId()).toBe("admin-pick");
|
||||
expect(localStorage.getItem("termix-layout")).toBe("admin-pick");
|
||||
});
|
||||
|
||||
it("never rejects, and remembers the last layout when the server fails", async () => {
|
||||
localStorage.setItem("termix-layout", "remembered");
|
||||
api.getLook.mockRejectedValue(new Error("offline"));
|
||||
await expect(loadLook()).resolves.toBeNull();
|
||||
expect(getLookState().loaded).toBe(true);
|
||||
expect(chosenLayoutId()).toBe("remembered");
|
||||
});
|
||||
|
||||
it("saves choices and asks plugins to reload for a new layout", async () => {
|
||||
api.getLook.mockResolvedValue(response);
|
||||
await loadLook();
|
||||
const changed = vi.fn();
|
||||
window.addEventListener("termix:plugins-changed", changed);
|
||||
await chooseLayout("mine");
|
||||
expect(api.saveUserLook).toHaveBeenCalledWith({ layoutId: "mine" });
|
||||
expect(chosenLayoutId()).toBe("mine");
|
||||
expect(changed).toHaveBeenCalled();
|
||||
await chooseThemeForAccount("nord");
|
||||
expect(getLookState().response?.themeId).toBe("nord");
|
||||
await chooseDefaultLook({ theme: "dracula" });
|
||||
expect(getLookState().response?.defaults.theme).toBe("dracula");
|
||||
window.removeEventListener("termix:plugins-changed", changed);
|
||||
});
|
||||
|
||||
it("asks for the repair view from the address or the server", async () => {
|
||||
api.getLook.mockResolvedValue({ ...response, safeLayout: true });
|
||||
expect(safeLayoutRequested()).toBe(false);
|
||||
await loadLook();
|
||||
expect(safeLayoutRequested()).toBe(true);
|
||||
resetLook();
|
||||
window.history.replaceState(null, "", "/?safe-layout=1");
|
||||
expect(safeLayoutRequested()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LookResponse } from "@/api/look-api";
|
||||
import {
|
||||
FALLBACK_THEME,
|
||||
resolveLayoutId,
|
||||
resolveThemeId,
|
||||
safeLayoutInUrl,
|
||||
} from "@/look/resolve-look";
|
||||
import { BUILTIN_LAYOUT_ID } from "@/look/layout-registry";
|
||||
|
||||
function look(patch: Partial<LookResponse> = {}): LookResponse {
|
||||
return {
|
||||
layoutId: null,
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveLayoutId", () => {
|
||||
it("prefers the user, then the admin default, then the default look", () => {
|
||||
const full = look({
|
||||
layoutId: "mine",
|
||||
defaults: { layout: "admin", theme: null },
|
||||
defaultLook: { layout: "shipped", theme: null },
|
||||
});
|
||||
expect(resolveLayoutId(full)).toBe("mine");
|
||||
expect(resolveLayoutId({ ...full, layoutId: null })).toBe("admin");
|
||||
expect(
|
||||
resolveLayoutId({
|
||||
...full,
|
||||
layoutId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
}),
|
||||
).toBe("shipped");
|
||||
expect(resolveLayoutId(look())).toBe(BUILTIN_LAYOUT_ID);
|
||||
});
|
||||
|
||||
it("falls back to the last layout shown when the server did not answer", () => {
|
||||
expect(resolveLayoutId(null, "remembered")).toBe("remembered");
|
||||
expect(resolveLayoutId(null)).toBe(BUILTIN_LAYOUT_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveThemeId", () => {
|
||||
it("keeps today's per-browser theme ahead of the defaults", () => {
|
||||
const defaults = look({
|
||||
defaults: { layout: null, theme: "admin" },
|
||||
defaultLook: { layout: null, theme: "shipped" },
|
||||
});
|
||||
expect(resolveThemeId(defaults, "local")).toBe("local");
|
||||
expect(resolveThemeId({ ...defaults, themeId: "mine" }, "local")).toBe(
|
||||
"mine",
|
||||
);
|
||||
expect(resolveThemeId(defaults, null, "layout")).toBe("admin");
|
||||
expect(
|
||||
resolveThemeId(
|
||||
{ ...defaults, defaults: { layout: null, theme: null } },
|
||||
null,
|
||||
"layout",
|
||||
),
|
||||
).toBe("layout");
|
||||
expect(
|
||||
resolveThemeId(
|
||||
{ ...defaults, defaults: { layout: null, theme: null } },
|
||||
null,
|
||||
),
|
||||
).toBe("shipped");
|
||||
expect(resolveThemeId(null, null)).toBe(FALLBACK_THEME);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeLayoutInUrl", () => {
|
||||
it("reads ?safe-layout=1", () => {
|
||||
expect(safeLayoutInUrl("?safe-layout=1")).toBe(true);
|
||||
expect(safeLayoutInUrl("?x=1&safe-layout=true")).toBe(true);
|
||||
expect(safeLayoutInUrl("?safe-layout=0")).toBe(false);
|
||||
expect(safeLayoutInUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyThemeCss } from "@/look/theme-css";
|
||||
import {
|
||||
getTheme,
|
||||
resetPluginThemes,
|
||||
setPluginThemes,
|
||||
} from "@/look/theme-registry";
|
||||
|
||||
const swatch = { background: "#000000", panel: "#111111", text: "#ffffff" };
|
||||
const link = () =>
|
||||
document.getElementById("termix-theme-css") as HTMLLinkElement | null;
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginThemes();
|
||||
link()?.remove();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("applyThemeCss", () => {
|
||||
it("loads only the shown plugin theme's stylesheet", () => {
|
||||
setPluginThemes([
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
swatch,
|
||||
pluginId: "x",
|
||||
cssHref: "/plugin-assets/x/_themes/paper.css?v=1",
|
||||
},
|
||||
]);
|
||||
applyThemeCss("paper", getTheme("paper"));
|
||||
expect(link()?.href).toContain("/plugin-assets/x/_themes/paper.css?v=1");
|
||||
|
||||
applyThemeCss("dark", getTheme("dark"));
|
||||
expect(link()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the last plugin theme's stylesheet until plugins are known", () => {
|
||||
setPluginThemes([
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
swatch,
|
||||
pluginId: "x",
|
||||
cssHref: "/plugin-assets/x/_themes/paper.css?v=1",
|
||||
},
|
||||
]);
|
||||
applyThemeCss("paper", getTheme("paper"));
|
||||
link()?.remove();
|
||||
|
||||
// A reload: no plugin themes yet.
|
||||
resetPluginThemes();
|
||||
applyThemeCss("paper", getTheme("paper"));
|
||||
expect(link()?.href).toContain("paper.css");
|
||||
|
||||
// Plugins answered and the theme is gone.
|
||||
setPluginThemes([]);
|
||||
applyThemeCss("paper", getTheme("paper"));
|
||||
expect(link()).toBeNull();
|
||||
expect(localStorage.getItem("termix-theme-css")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILTIN_THEMES,
|
||||
arePluginThemesKnown,
|
||||
getTheme,
|
||||
listThemes,
|
||||
resetPluginThemes,
|
||||
setPluginThemes,
|
||||
themeLabel,
|
||||
themeMode,
|
||||
} from "@/look/theme-registry";
|
||||
|
||||
afterEach(() => resetPluginThemes());
|
||||
|
||||
const swatch = { background: "#000000", panel: "#111111", text: "#ffffff" };
|
||||
|
||||
describe("theme registry", () => {
|
||||
it("lists the built-ins in their order", () => {
|
||||
expect(listThemes().map((theme) => theme.id)).toEqual(
|
||||
BUILTIN_THEMES.map((theme) => theme.id),
|
||||
);
|
||||
expect(arePluginThemesKnown()).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a plugin theme take a built-in's place and adds new ones after", () => {
|
||||
setPluginThemes([
|
||||
{
|
||||
id: "nord",
|
||||
label: "themes.nord",
|
||||
mode: "dark",
|
||||
swatch,
|
||||
pluginId: "more-themes",
|
||||
cssHref: "/plugin-assets/more-themes/_themes/nord.css",
|
||||
},
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
swatch,
|
||||
pluginId: "more-themes",
|
||||
},
|
||||
]);
|
||||
const ids = listThemes().map((theme) => theme.id);
|
||||
expect(ids.indexOf("nord")).toBe(
|
||||
BUILTIN_THEMES.findIndex((theme) => theme.id === "nord"),
|
||||
);
|
||||
expect(ids[ids.length - 1]).toBe("paper");
|
||||
expect(getTheme("nord")?.pluginId).toBe("more-themes");
|
||||
expect(arePluginThemesKnown()).toBe(true);
|
||||
|
||||
setPluginThemes([]);
|
||||
expect(getTheme("nord")?.pluginId).toBeUndefined();
|
||||
expect(getTheme("paper")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports each theme's mode, following the OS for system", () => {
|
||||
expect(themeMode("light")).toBe("light");
|
||||
expect(themeMode("dracula")).toBe("dark");
|
||||
expect(["light", "dark"]).toContain(themeMode("system"));
|
||||
setPluginThemes([
|
||||
{ id: "paper", label: "Paper", mode: "light", swatch, pluginId: "x" },
|
||||
]);
|
||||
expect(themeMode("paper")).toBe("light");
|
||||
});
|
||||
|
||||
it("translates core labels in core and plugin labels in the plugin", () => {
|
||||
const t = ((key: string, options?: { defaultValue?: string }) =>
|
||||
key.includes(":")
|
||||
? (options?.defaultValue ?? key)
|
||||
: `t(${key})`) as never;
|
||||
expect(themeLabel(BUILTIN_THEMES[0], t)).toBe(
|
||||
`t(${BUILTIN_THEMES[0].label})`,
|
||||
);
|
||||
expect(
|
||||
themeLabel(
|
||||
{ id: "paper", label: "Paper", mode: "light", swatch, pluginId: "x" },
|
||||
t,
|
||||
),
|
||||
).toBe("Paper");
|
||||
});
|
||||
});
|
||||
@@ -75,3 +75,54 @@ describe("toHostRecord", () => {
|
||||
expect(loose).not.toHaveProperty("enableDocker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTheme", () => {
|
||||
it("reports the mode of the shown theme, plugin themes included", async () => {
|
||||
const { ThemeProvider, useTheme: useAppTheme } =
|
||||
await import("@/components/theme-provider");
|
||||
const { setPluginThemes, resetPluginThemes } =
|
||||
await import("@/look/theme-registry");
|
||||
const { createElement } = await import("react");
|
||||
let setTheme: ((id: string) => void) | null = null;
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
setTheme = useAppTheme().setTheme;
|
||||
return pluginHostBridge.useTheme();
|
||||
},
|
||||
{
|
||||
wrapper: ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(ThemeProvider, {
|
||||
storageKey: "bridge-theme",
|
||||
children,
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(result.current.theme).toBe("dark");
|
||||
act(() => setTheme!("light"));
|
||||
expect(result.current.theme).toBe("light");
|
||||
act(() =>
|
||||
setPluginThemes([
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
swatch: { background: "#fff", panel: "#eee", text: "#111" },
|
||||
pluginId: "x",
|
||||
},
|
||||
]),
|
||||
);
|
||||
act(() => setTheme!("paper"));
|
||||
expect(result.current.theme).toBe("light");
|
||||
act(() => setTheme!("dracula"));
|
||||
expect(result.current.theme).toBe("dark");
|
||||
resetPluginThemes();
|
||||
localStorage.removeItem("bridge-theme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useShell", () => {
|
||||
it("is null outside the signed-in shell", () => {
|
||||
const { result } = renderHook(() => pluginHostBridge.useShell());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const lookApi = vi.hoisted(() => ({
|
||||
getLook: vi.fn(async (): Promise<unknown> => {
|
||||
throw new Error("no server");
|
||||
}),
|
||||
saveUserLook: vi.fn(),
|
||||
saveDefaultLook: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/look-api", () => lookApi);
|
||||
import { Puzzle } from "lucide-react";
|
||||
import type {
|
||||
FrontendModule,
|
||||
@@ -25,6 +34,14 @@ import {
|
||||
} from "@/sidebar/rail-items";
|
||||
import { getTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { resetLook } from "@/look/look-store";
|
||||
import {
|
||||
getRegisteredLayout,
|
||||
listLayouts,
|
||||
resetPluginLayouts,
|
||||
resetRegisteredLayouts,
|
||||
} from "@/look/layout-registry";
|
||||
import { getTheme, resetPluginThemes } from "@/look/theme-registry";
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
@@ -84,6 +101,11 @@ beforeEach(() => {
|
||||
|
||||
afterEach(async () => {
|
||||
await resetPluginLoader();
|
||||
resetLook();
|
||||
resetRegisteredLayouts();
|
||||
resetPluginLayouts();
|
||||
resetPluginThemes();
|
||||
window.history.replaceState(null, "", "/");
|
||||
resetPluginStore();
|
||||
resetRegisteredRailItems();
|
||||
resetTabTypes();
|
||||
@@ -393,3 +415,103 @@ describe("activation order", () => {
|
||||
expect(railIds()).toContain("beta-panel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("layouts and themes", () => {
|
||||
function look(layoutId: string | null) {
|
||||
return {
|
||||
layoutId,
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
};
|
||||
}
|
||||
|
||||
function layoutPlugin(id: string, layout: string): FrontendModule {
|
||||
return {
|
||||
activate(app: TermixApp) {
|
||||
app.registerLayout({
|
||||
id: layout,
|
||||
label: layout,
|
||||
component: () => null,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function layoutSummary(id: string, layout: string) {
|
||||
return summary(id, {
|
||||
contributes: { layouts: [{ id: layout, label: layout }] },
|
||||
});
|
||||
}
|
||||
|
||||
it("loads only the chosen layout's plugin", async () => {
|
||||
lookApi.getLook.mockResolvedValueOnce(look("two"));
|
||||
modules["one-layout"] = layoutPlugin("one-layout", "one");
|
||||
modules["two-layout"] = layoutPlugin("two-layout", "two");
|
||||
modules.alpha = railPlugin("alpha");
|
||||
plugins = [
|
||||
layoutSummary("one-layout", "one"),
|
||||
layoutSummary("two-layout", "two"),
|
||||
summary("alpha"),
|
||||
];
|
||||
|
||||
await startPluginRuntime();
|
||||
expect(getPluginRecord("one-layout")?.frontend).toBe("inactive");
|
||||
expect(getPluginRecord("two-layout")?.frontend).toBe("active");
|
||||
expect(getPluginRecord("alpha")?.frontend).toBe("active");
|
||||
expect(getRegisteredLayout("two")).toBeDefined();
|
||||
expect(getRegisteredLayout("one")).toBeUndefined();
|
||||
// Both can still be chosen.
|
||||
expect(listLayouts().map((layout) => layout.id)).toEqual(
|
||||
expect.arrayContaining(["one", "two"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads no layout plugin in safe mode", async () => {
|
||||
window.history.replaceState(null, "", "/?safe-layout=1");
|
||||
lookApi.getLook.mockResolvedValueOnce(look("two"));
|
||||
modules["two-layout"] = layoutPlugin("two-layout", "two");
|
||||
plugins = [layoutSummary("two-layout", "two")];
|
||||
await startPluginRuntime();
|
||||
expect(getPluginRecord("two-layout")?.frontend).toBe("inactive");
|
||||
});
|
||||
|
||||
it("refuses a layout the manifest does not declare", async () => {
|
||||
lookApi.getLook.mockResolvedValueOnce(look("two"));
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
modules["two-layout"] = layoutPlugin("two-layout", "undeclared");
|
||||
plugins = [layoutSummary("two-layout", "two")];
|
||||
await startPluginRuntime();
|
||||
expect(getPluginRecord("two-layout")?.frontend).toBe("failed");
|
||||
expect(getPluginRecord("two-layout")?.error).toContain(
|
||||
"contributes.layouts",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a running plugin's themes with their stylesheet", async () => {
|
||||
plugins = [
|
||||
summary("paper", {
|
||||
frontend: false,
|
||||
themeVersion: "t1",
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
await syncPlugins();
|
||||
const theme = getTheme("paper");
|
||||
expect(theme?.mode).toBe("light");
|
||||
expect(theme?.cssHref).toContain(
|
||||
"/plugin-assets/paper/_themes/paper.css?v=t1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { defaultValue?: string }) =>
|
||||
options?.defaultValue ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const look = vi.hoisted(() => ({
|
||||
chooseLayout: vi.fn(async () => {}),
|
||||
chooseDefaultLook: vi.fn(async () => {}),
|
||||
state: {
|
||||
loaded: true,
|
||||
response: {
|
||||
layoutId: null as string | null,
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/look/look-store", () => ({
|
||||
chooseLayout: look.chooseLayout,
|
||||
chooseDefaultLook: look.chooseDefaultLook,
|
||||
useLookState: () => look.state,
|
||||
}));
|
||||
|
||||
import { LookSettings } from "@/settings/appearance/LookSettings";
|
||||
import { resetPluginLayouts, setPluginLayouts } from "@/look/layout-registry";
|
||||
|
||||
beforeEach(() => {
|
||||
look.chooseLayout.mockClear();
|
||||
look.chooseDefaultLook.mockClear();
|
||||
setPluginLayouts([{ id: "fancy", label: "Fancy", pluginId: "fancy-layout" }]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetPluginLayouts();
|
||||
});
|
||||
|
||||
describe("LookSettings", () => {
|
||||
it("lists every layout and saves the user's pick", () => {
|
||||
render(<LookSettings isAdmin={false} />);
|
||||
const select = screen.getAllByRole("combobox")[0];
|
||||
expect(screen.getAllByText("Fancy").length).toBeGreaterThan(0);
|
||||
fireEvent.change(select, { target: { value: "fancy" } });
|
||||
expect(look.chooseLayout).toHaveBeenCalledWith("fancy");
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
expect(look.chooseLayout).toHaveBeenCalledWith(null);
|
||||
expect(screen.queryByText("looks.defaultsLabel")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets an admin set the default layout and theme", () => {
|
||||
render(<LookSettings isAdmin />);
|
||||
expect(screen.getByText("looks.defaultsLabel")).toBeTruthy();
|
||||
const [, layoutDefault, themeDefault] = screen.getAllByRole("combobox");
|
||||
fireEvent.change(layoutDefault, { target: { value: "fancy" } });
|
||||
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ layout: "fancy" });
|
||||
fireEvent.change(themeDefault, { target: { value: "nord" } });
|
||||
expect(look.chooseDefaultLook).toHaveBeenCalledWith({ theme: "nord" });
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
|
||||
const prefsApi = vi.hoisted(() => ({
|
||||
@@ -13,9 +14,24 @@ const prefsApi = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock("@/api/open-tabs-api", () => prefsApi);
|
||||
|
||||
const theme = vi.hoisted(() => ({ value: "dark", setTheme: vi.fn() }));
|
||||
const lookApi = vi.hoisted(() => ({
|
||||
getLook: vi.fn(),
|
||||
saveUserLook: vi.fn(async () => {}),
|
||||
saveDefaultLook: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/look-api", () => lookApi);
|
||||
|
||||
const theme = vi.hoisted(() => ({
|
||||
value: "dark",
|
||||
setTheme: vi.fn(),
|
||||
setThemePreview: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/theme-provider", () => ({
|
||||
useTheme: () => ({ theme: theme.value, setTheme: theme.setTheme }),
|
||||
useTheme: () => ({
|
||||
theme: theme.value,
|
||||
setTheme: theme.setTheme,
|
||||
setThemePreview: theme.setThemePreview,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -31,6 +47,8 @@ beforeEach(() => {
|
||||
prefsApi.getUserPreferences.mockReset();
|
||||
prefsApi.saveUserPreferences.mockClear();
|
||||
theme.setTheme.mockClear();
|
||||
theme.setThemePreview.mockClear();
|
||||
lookApi.saveUserLook.mockClear();
|
||||
});
|
||||
|
||||
describe("isHexColor", () => {
|
||||
@@ -59,9 +77,12 @@ describe("useAppearanceSettings", () => {
|
||||
result.current.setFontSize("lg");
|
||||
});
|
||||
expect(theme.setTheme).toHaveBeenCalledWith("nord");
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
theme: "nord",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
theme: "nord",
|
||||
}),
|
||||
);
|
||||
expect(lookApi.saveUserLook).toHaveBeenCalledWith({ themeId: "nord" });
|
||||
expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({
|
||||
fontSize: "lg",
|
||||
});
|
||||
@@ -77,6 +98,32 @@ describe("ThemeGrid", () => {
|
||||
fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]);
|
||||
expect(onChange).toHaveBeenCalledWith("gruvbox");
|
||||
});
|
||||
|
||||
it("previews a theme while it is hovered", () => {
|
||||
render(<ThemeGrid value="dark" onChange={vi.fn()} />);
|
||||
const nord =
|
||||
screen.getAllByRole("button")[THEMES.findIndex((t) => t.id === "nord")];
|
||||
fireEvent.mouseEnter(nord);
|
||||
expect(theme.setThemePreview).toHaveBeenLastCalledWith("nord");
|
||||
fireEvent.mouseLeave(nord);
|
||||
expect(theme.setThemePreview).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAppearanceSettings in local mode", () => {
|
||||
it("keeps the theme off the account", async () => {
|
||||
prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "local" });
|
||||
const { result } = renderHook(() => useAppearanceSettings());
|
||||
await act(async () => {
|
||||
result.current.setTheme("nord");
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(lookApi.saveUserLook).toHaveBeenCalledWith({ themeId: null }),
|
||||
);
|
||||
expect(prefsApi.saveUserPreferences).not.toHaveBeenCalledWith({
|
||||
theme: "nord",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccentPicker", () => {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getUserInfo: vi.fn(async () => ({ is_admin: true })),
|
||||
getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })),
|
||||
saveUserPreferences: vi.fn(async () => {}),
|
||||
getSSHHosts: vi.fn(async () => [] as unknown[]),
|
||||
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("@/main-axios", () => api);
|
||||
vi.mock("@/hooks/use-sync-status", () => ({
|
||||
useSyncStatus: () => undefined,
|
||||
useSyncAttentionCount: () => 0,
|
||||
}));
|
||||
|
||||
import {
|
||||
ShellControllerProvider,
|
||||
useShellController,
|
||||
} from "@/shell/controller/ShellControllerProvider";
|
||||
import { getShellController } from "@/shell/controller/shell-store";
|
||||
import type { ShellController } from "@/shell/controller/types";
|
||||
import { registerTabType, resetTabTypes } from "@/shell/tab-registry";
|
||||
import {
|
||||
resetShellBridge,
|
||||
shell as bridgeShell,
|
||||
} from "@/plugin-host/shell-bridge";
|
||||
|
||||
let shell: ShellController | null = null;
|
||||
function Capture() {
|
||||
shell = useShellController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const logout = vi.fn();
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<ShellControllerProvider username="ada" onLogout={logout}>
|
||||
<Capture />
|
||||
</ShellControllerProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
shell = null;
|
||||
logout.mockClear();
|
||||
registerTabType({
|
||||
id: "notes",
|
||||
component: () => null,
|
||||
hostless: true,
|
||||
multiInstance: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetTabTypes();
|
||||
resetShellBridge();
|
||||
});
|
||||
|
||||
describe("ShellControllerProvider", () => {
|
||||
it("starts on the dashboard and knows the user", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(shell?.isAdmin).toBe(true));
|
||||
expect(shell!.username).toBe("ada");
|
||||
expect(shell!.tabs.list.map((tab) => tab.id)).toEqual(["dashboard"]);
|
||||
expect(shell!.tabs.activeId).toBe("dashboard");
|
||||
});
|
||||
|
||||
it("opens, renames and closes tabs, back to the dashboard", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
let id = "";
|
||||
act(() => {
|
||||
id = shell!.tabs.openMultiInstance("notes");
|
||||
});
|
||||
expect(shell!.tabs.activeId).toBe(id);
|
||||
act(() => shell!.tabs.rename(id, "Mine"));
|
||||
expect(shell!.tabs.list.find((tab) => tab.id === id)?.label).toBe("Mine");
|
||||
await act(async () => {
|
||||
await shell!.tabs.close(id);
|
||||
});
|
||||
expect(shell!.tabs.list.map((tab) => tab.id)).toEqual(["dashboard"]);
|
||||
expect(shell!.tabs.activeId).toBe("dashboard");
|
||||
});
|
||||
|
||||
it("opens the settings singleton once and passes the section on", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
act(() => {
|
||||
shell!.tabs.openSingleton("settings");
|
||||
});
|
||||
act(() => {
|
||||
shell!.tabs.openSingleton("admin-settings");
|
||||
});
|
||||
const settings = shell!.tabs.list.filter((tab) => tab.id === "settings");
|
||||
expect(settings).toHaveLength(1);
|
||||
expect(settings[0].data).toMatchObject({ section: "admin-general" });
|
||||
});
|
||||
|
||||
it("toggles the palette from the keyboard and the controller", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "k", code: "KeyK", ctrlKey: true }),
|
||||
);
|
||||
});
|
||||
expect(shell!.palette.isOpen).toBe(true);
|
||||
act(() => shell!.palette.close());
|
||||
expect(shell!.palette.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes itself for plugins and the old bridge", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(getShellController()).not.toBeNull());
|
||||
act(() => bridgeShell.openSingletonTab("plugins"));
|
||||
await waitFor(() =>
|
||||
expect(shell!.tabs.list.some((tab) => tab.id === "plugins")).toBe(true),
|
||||
);
|
||||
act(() => bridgeShell.openRailView("credentials"));
|
||||
expect(shell!.regions.left).toMatchObject({
|
||||
view: "credentials",
|
||||
open: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs out on the logout event", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:logout", { detail: { manual: true } }),
|
||||
);
|
||||
});
|
||||
expect(logout).toHaveBeenCalledWith({ manual: true });
|
||||
});
|
||||
|
||||
it("splits the active tab and closes the split", async () => {
|
||||
mount();
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
let id = "";
|
||||
act(() => {
|
||||
id = shell!.tabs.openMultiInstance("notes");
|
||||
});
|
||||
act(() =>
|
||||
shell!.split.handleAction({ kind: "splitActive", edge: "right" }),
|
||||
);
|
||||
expect(shell!.split.active).not.toBeNull();
|
||||
expect(shell!.split.paneIdByTabId.has(id)).toBe(true);
|
||||
const splitId = shell!.split.active!.id;
|
||||
act(() =>
|
||||
shell!.split.handleAction({ kind: "unsplit", splitTabId: splitId }),
|
||||
);
|
||||
expect(shell!.split.active).toBeNull();
|
||||
expect(shell!.tabs.activeId).toBe(id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import type { ShellController as PluginShell } from "@termix-ssh/plugin-sdk/frontend";
|
||||
|
||||
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,
|
||||
}));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({
|
||||
permissions: ["hosts.view"],
|
||||
isAdmin: false,
|
||||
loaded: true,
|
||||
has: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
|
||||
import { usePluginShell } from "@/shell/controller/plugin-shell";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
let shell: PluginShell | null = null;
|
||||
function Capture() {
|
||||
shell = usePluginShell("my-layout");
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("usePluginShell", () => {
|
||||
it("gives a layout the live shell", async () => {
|
||||
render(
|
||||
<ShellControllerProvider username="ada" onLogout={() => {}}>
|
||||
<Capture />
|
||||
</ShellControllerProvider>,
|
||||
);
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
expect(shell!.env).toMatchObject({
|
||||
user: { username: "ada", isAdmin: false },
|
||||
permissions: ["hosts.view"],
|
||||
isElectron: false,
|
||||
});
|
||||
expect(shell!.tabs.activeId).toBe("dashboard");
|
||||
expect(typeof shell!.registries.useRailItems).toBe("function");
|
||||
|
||||
act(() => shell!.actions.openSingletonTab("settings"));
|
||||
await waitFor(() => expect(shell!.tabs.activeId).toBe("settings"));
|
||||
|
||||
act(() => shell!.regions.railClick("credentials"));
|
||||
expect(shell!.regions.left).toMatchObject({
|
||||
view: "credentials",
|
||||
open: true,
|
||||
});
|
||||
|
||||
act(() => shell!.palette.open());
|
||||
expect(shell!.palette.isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
vi.mock("@/sidebar/rail-items", () => ({
|
||||
rightDockableIds: () => ["history", "alerts"],
|
||||
}));
|
||||
|
||||
import { useRegions } from "@/shell/controller/use-regions";
|
||||
import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("useRegions", () => {
|
||||
it("toggles the left dock on a second click and switches on another", () => {
|
||||
const { result } = renderHook(() => useRegions(false));
|
||||
expect(result.current.left).toMatchObject({ view: "hosts", open: true });
|
||||
|
||||
act(() => result.current.railClick("hosts"));
|
||||
expect(result.current.left.open).toBe(false);
|
||||
|
||||
act(() => result.current.railClick("credentials"));
|
||||
expect(result.current.left).toMatchObject({
|
||||
view: "credentials",
|
||||
open: true,
|
||||
});
|
||||
expect(result.current.previousView()).toBe("hosts");
|
||||
});
|
||||
|
||||
it("moves a panel between docks instead of copying it", () => {
|
||||
const { result } = renderHook(() => useRegions(false));
|
||||
act(() => result.current.railClick("history"));
|
||||
act(() => result.current.openInRightDock("history"));
|
||||
expect(result.current.right.view).toBe("history");
|
||||
expect(result.current.left.open).toBe(false);
|
||||
|
||||
act(() => result.current.railClick("history"));
|
||||
expect(result.current.right.view).toBeNull();
|
||||
expect(result.current.left).toMatchObject({ view: "history", open: true });
|
||||
});
|
||||
|
||||
it("brings back the last right dock panel and remembers sizes", () => {
|
||||
const { result, unmount } = renderHook(() => useRegions(false));
|
||||
act(() => result.current.right.setView("alerts"));
|
||||
act(() => result.current.toggleRightDock());
|
||||
expect(result.current.right.view).toBeNull();
|
||||
act(() => result.current.toggleRightDock());
|
||||
expect(result.current.right.view).toBe("alerts");
|
||||
|
||||
act(() => result.current.left.setWidth(400));
|
||||
unmount();
|
||||
const again = renderHook(() => useRegions(false));
|
||||
expect(again.result.current.left.width).toBe(400);
|
||||
expect(again.result.current.right.view).toBe("alerts");
|
||||
act(() => again.result.current.left.resetWidth());
|
||||
expect(again.result.current.left.width).toBe(DOCK_DEFAULT_WIDTH);
|
||||
});
|
||||
|
||||
it("closes the sidebar on mobile and restores it after", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ mobile }) => useRegions(mobile),
|
||||
{ initialProps: { mobile: false } },
|
||||
);
|
||||
rerender({ mobile: true });
|
||||
expect(result.current.left.open).toBe(false);
|
||||
rerender({ mobile: false });
|
||||
expect(result.current.left.open).toBe(true);
|
||||
});
|
||||
|
||||
it("widens for the widest editing panel or inline view", () => {
|
||||
const { result } = renderHook(() => useRegions(false));
|
||||
act(() => result.current.panelEditingSetter("hosts")(true));
|
||||
expect(result.current.left.editing).toBe(true);
|
||||
act(() => result.current.scopeEditingSetter("hosts")("wide"));
|
||||
expect(result.current.left.editing).toBe("wide");
|
||||
expect(result.current.panelEditingSetter("hosts")).toBe(
|
||||
result.current.panelEditingSetter("hosts"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const lookApi = vi.hoisted(() => ({
|
||||
getLook: vi.fn(),
|
||||
saveUserLook: vi.fn(async () => {}),
|
||||
saveDefaultLook: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/api/look-api", () => lookApi);
|
||||
|
||||
const loader = vi.hoisted(() => ({
|
||||
retryPluginFrontend: vi.fn(async () => {}),
|
||||
syncPlugins: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/plugin-host/loader", () => loader);
|
||||
|
||||
const mobile = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => mobile.value }));
|
||||
|
||||
// The built-in layout pulls in the whole classic shell; these tests register their own.
|
||||
vi.mock("@/shell/layout/builtin", () => ({ registerBuiltinLayout: () => {} }));
|
||||
|
||||
vi.mock("@/look/RepairView", () => ({
|
||||
RepairView: ({
|
||||
status,
|
||||
onRetry,
|
||||
}: {
|
||||
status: { reason: string; error?: string | null };
|
||||
onRetry: () => void;
|
||||
}) => (
|
||||
<div data-testid="repair" data-reason={status.reason}>
|
||||
{status.error}
|
||||
<button type="button" onClick={onRetry}>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { LayoutHost } from "@/shell/layout/LayoutHost";
|
||||
import { registerLayout, resetRegisteredLayouts } 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";
|
||||
|
||||
function lookFor(layoutId: string, safeLayout = false) {
|
||||
return {
|
||||
layoutId,
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout,
|
||||
unavailable: [] as unknown[],
|
||||
};
|
||||
}
|
||||
|
||||
function plugin(patch: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "fancy-layout",
|
||||
name: "Fancy",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
frontend: true,
|
||||
contributes: { layouts: [{ id: "fancy", label: "Fancy" }] },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
async function start(
|
||||
layoutId: string,
|
||||
summaries: PluginSummary[] = [],
|
||||
safe = false,
|
||||
) {
|
||||
lookApi.getLook.mockResolvedValue(lookFor(layoutId, safe));
|
||||
await loadLook();
|
||||
setPluginSummaries(summaries);
|
||||
markPluginsSettled();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetLook();
|
||||
resetPluginStore();
|
||||
resetRegisteredLayouts();
|
||||
mobile.value = false;
|
||||
loader.retryPluginFrontend.mockClear();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, "", "/");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("LayoutHost", () => {
|
||||
it("draws the chosen layout", async () => {
|
||||
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
|
||||
await start("fancy");
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("fancy layout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("draws the mobile component on a phone", async () => {
|
||||
mobile.value = true;
|
||||
registerLayout({
|
||||
id: "fancy",
|
||||
component: () => <p>desktop</p>,
|
||||
mobileComponent: () => <p>phone</p>,
|
||||
});
|
||||
await start("fancy");
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("phone")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("remounts after one crash and repairs after the second", async () => {
|
||||
// React retries a failed render once by itself before a boundary sees it.
|
||||
let throwsLeft = 2;
|
||||
function Flaky() {
|
||||
if (throwsLeft > 0) {
|
||||
throwsLeft -= 1;
|
||||
throw new Error("draw failed");
|
||||
}
|
||||
return <p>recovered</p>;
|
||||
}
|
||||
// React reports the crash it recovered from; that report is expected.
|
||||
const expected = (event: ErrorEvent) => {
|
||||
if (String(event.error?.cause ?? event.message).includes("draw failed"))
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener("error", expected);
|
||||
registerLayout({ id: "fancy", component: Flaky });
|
||||
await start("fancy");
|
||||
render(<LayoutHost />);
|
||||
expect(await screen.findByText("recovered")).toBeTruthy();
|
||||
window.removeEventListener("error", expected);
|
||||
|
||||
cleanup();
|
||||
resetRegisteredLayouts();
|
||||
function Broken(): never {
|
||||
throw new Error("always broken");
|
||||
}
|
||||
registerLayout({ id: "fancy", component: Broken });
|
||||
render(<LayoutHost />);
|
||||
const repair = await screen.findByTestId("repair");
|
||||
expect(repair.dataset.reason).toBe("crashed");
|
||||
expect(repair.textContent).toContain("always broken");
|
||||
});
|
||||
|
||||
it("repairs with ?safe-layout=1 even when the layout works", async () => {
|
||||
window.history.replaceState(null, "", "/?safe-layout=1");
|
||||
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
|
||||
await start("fancy");
|
||||
render(<LayoutHost />);
|
||||
expect((await screen.findByTestId("repair")).dataset.reason).toBe(
|
||||
"safe-url",
|
||||
);
|
||||
});
|
||||
|
||||
it("repairs when the server sets TERMIX_SAFE_LAYOUT", async () => {
|
||||
registerLayout({ id: "fancy", component: () => <p>fancy layout</p> });
|
||||
await start("fancy", [], true);
|
||||
render(<LayoutHost />);
|
||||
expect((await screen.findByTestId("repair")).dataset.reason).toBe(
|
||||
"safe-env",
|
||||
);
|
||||
});
|
||||
|
||||
it("repairs a missing layout", async () => {
|
||||
await start("ghost");
|
||||
render(<LayoutHost />);
|
||||
expect((await screen.findByTestId("repair")).dataset.reason).toBe(
|
||||
"missing",
|
||||
);
|
||||
});
|
||||
|
||||
it("repairs a layout whose plugin is disabled", async () => {
|
||||
await start("fancy", [plugin({ enabled: false, state: "disabled" })]);
|
||||
render(<LayoutHost />);
|
||||
expect((await screen.findByTestId("repair")).dataset.reason).toBe(
|
||||
"disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("repairs a layout that throws on activate, and retries it", async () => {
|
||||
await start("fancy", [plugin()]);
|
||||
setFrontendState("fancy-layout", "failed", "activate threw");
|
||||
render(<LayoutHost />);
|
||||
const repair = await screen.findByTestId("repair");
|
||||
expect(repair.dataset.reason).toBe("failed");
|
||||
expect(repair.textContent).toContain("activate threw");
|
||||
screen.getByText("retry").click();
|
||||
await waitFor(() =>
|
||||
expect(loader.retryPluginFrontend).toHaveBeenCalledWith("fancy-layout"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect, 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,
|
||||
}));
|
||||
|
||||
import type { TabRenderProps } from "@/shell/tab-registry";
|
||||
import { registerTabType } 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";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mounts = { count: 0 };
|
||||
|
||||
function Probe({ tab }: TabRenderProps) {
|
||||
useEffect(() => {
|
||||
mounts.count += 1;
|
||||
}, []);
|
||||
return <div data-testid={`probe-${tab.id}`}>probe</div>;
|
||||
}
|
||||
|
||||
registerTabType({
|
||||
id: "probe",
|
||||
component: Probe,
|
||||
hostless: true,
|
||||
multiInstance: true,
|
||||
});
|
||||
|
||||
let shell: ShellController | null = null;
|
||||
|
||||
function Capture() {
|
||||
shell = useShellController();
|
||||
return null;
|
||||
}
|
||||
|
||||
function Harness({ stage }: { stage: boolean }) {
|
||||
return (
|
||||
<ShellControllerProvider username="ada" onLogout={() => {}}>
|
||||
<Capture />
|
||||
{stage && <TabStage />}
|
||||
<TabPortals />
|
||||
</ShellControllerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle() {
|
||||
const [stage, setStage] = useState(true);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setStage((v) => !v)}>
|
||||
toggle
|
||||
</button>
|
||||
<Harness stage={stage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TabStage", () => {
|
||||
it("never remounts a tab moved between inline, a pane and a new stage", async () => {
|
||||
mounts.count = 0;
|
||||
render(<Toggle />);
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
|
||||
let tabId = "";
|
||||
act(() => {
|
||||
tabId = shell!.tabs.openMultiInstance("probe");
|
||||
});
|
||||
const element = await screen.findByTestId(`probe-${tabId}`);
|
||||
const node = element.closest<HTMLElement>("div[style]")!;
|
||||
expect(mounts.count).toBe(1);
|
||||
expect(node.isConnected).toBe(true);
|
||||
|
||||
// Into a split pane.
|
||||
act(() => {
|
||||
shell!.split.handleAction({ kind: "split", tabId, edge: "right" });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(element.closest("[data-split-pane-id]")).not.toBeNull(),
|
||||
);
|
||||
expect(screen.getByTestId(`probe-${tabId}`)).toBe(element);
|
||||
|
||||
// Back to the normal view.
|
||||
const splitId = shell!.split.active!.id;
|
||||
act(() => {
|
||||
shell!.split.handleAction({ kind: "unsplit", splitTabId: splitId });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(element.closest("[data-split-pane-id]")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByTestId(`probe-${tabId}`)).toBe(element);
|
||||
|
||||
// The stage goes away (a layout swap) and comes back.
|
||||
act(() => screen.getByText("toggle").click());
|
||||
expect(element.isConnected).toBe(false);
|
||||
act(() => screen.getByText("toggle").click());
|
||||
await waitFor(() => expect(element.isConnected).toBe(true));
|
||||
|
||||
expect(screen.getByTestId(`probe-${tabId}`)).toBe(element);
|
||||
expect(mounts.count).toBe(1);
|
||||
});
|
||||
|
||||
it("shows only the active inline tab", async () => {
|
||||
render(<Harness stage />);
|
||||
await waitFor(() => expect(shell).not.toBeNull());
|
||||
let first = "";
|
||||
let second = "";
|
||||
act(() => {
|
||||
first = shell!.tabs.openMultiInstance("probe");
|
||||
});
|
||||
act(() => {
|
||||
second = shell!.tabs.openMultiInstance("probe");
|
||||
});
|
||||
const a = await screen.findByTestId(`probe-${first}`);
|
||||
const b = await screen.findByTestId(`probe-${second}`);
|
||||
const nodeOf = (el: HTMLElement) => el.closest<HTMLElement>("div[style]")!;
|
||||
expect(nodeOf(b).style.display).toBe("");
|
||||
expect(nodeOf(a).style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user