feat: shell controller, TabStage, layout and theme plugins with repair view

This commit is contained in:
LukeGus committed 2026-10-18 16:37:36 -05:00
1 parent aff29a9581
commit 86161d66e2
120 files changed
+23693 -3381

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 26.10.3
### Added
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
- Plugins can add layouts and themes
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
- Hovering a theme in Settings previews it
### Changed
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
## 26.10.2
### Added
@@ -0,0 +1,2 @@
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
ALTER TABLE `user_preferences` ADD `theme_id` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -512,6 +512,13 @@
"when": 1791935283211,
"tag": "0072_unique_username",
"breakpoints": true
},
{
"idx": 73,
"version": "5",
"when": 1792357129912,
"tag": "0073_user_look_preferences",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -512,6 +512,13 @@
"when": 1791935282340,
"tag": "0072_unique_username",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1792357128746,
"tag": "0073_user_look_preferences",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
ALTER TABLE `user_preferences` ADD `theme_id` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -477,6 +477,13 @@
"when": 1791935281343,
"tag": "0067_unique_username",
"breakpoints": true
},
{
"idx": 68,
"version": "6",
"when": 1792357127581,
"tag": "0068_user_look_preferences",
"breakpoints": true
}
]
}
+6
View File
@@ -370,6 +370,12 @@
"default": "false",
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
},
{
"name": "TERMIX_SAFE_LAYOUT",
"group": "plugins",
"default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
},
{
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins",
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.2",
"version": "26.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.2",
"version": "26.10.3",
"hasInstallScript": true,
"workspaces": [
"packages/*"
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.11",
"version": "1.0.12",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.2",
"version": "26.10.3",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## 1.0.12
Plugin API 1.4.0.
### Added
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
### Fixed
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
## 1.0.11
Plugin API 1.3.0.
+64 -3
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.11 | Plugin API 1.3.0
SDK 1.0.12 | Plugin API 1.4.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -478,6 +478,7 @@ your plugin's error boundary and Suspense.
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
| `registerOnboardingStep` | A first run setup step |
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
@@ -485,7 +486,7 @@ your plugin's error boundary and Suspense.
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
@@ -496,6 +497,64 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
https://docs.termix.site/develop/more-extension-points.
## Layouts and themes
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
admins set the default for everyone.
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
it in `activate`:
```tsx
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
import {
ShellConfirmCapture,
SurfaceScope,
TabStage,
} from "@termix-ssh/plugin-sdk/ui";
function MyLayout() {
const shell = useShell();
if (!shell) return null;
return (
<div className="flex h-dvh w-screen flex-col bg-background">
<header>
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
</header>
<SurfaceScope kind="tab">
<ShellConfirmCapture />
<TabStage />
</SurfaceScope>
</div>
);
}
export function activate(app: TermixApp) {
app.registerLayout({
id: "my-layout",
label: "layout.name",
component: MyLayout,
});
}
```
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
every session.
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
keeps its connection. The command palette, overlays and onboarding stay in core.
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
screen where people can switch, reset, update or turn it off. `?safe-layout=1` opens it any time.
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
`--foreground`, `--card`, `--border`, `--muted` and the rest).
## Looking native
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
@@ -775,6 +834,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
@@ -794,6 +854,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
| `ui:theme` | low | Add color themes, CSS only |
An update that adds a capability waits for an admin to approve it.
+12 -2
View File
@@ -1,7 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "../lib/plugin-dir.mjs";
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
import {
collectFiles,
createTmxplug,
themeFiles,
TMXPLUG_CONTENTS,
} from "../lib/tmxplug.mjs";
import { sha256 } from "../lib/signing.mjs";
import { validate } from "./validate.mjs";
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
fs.mkdirSync(outDir, { recursive: true });
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
const archive = createTmxplug(cwd, collectFiles(cwd));
const archive = createTmxplug(
cwd,
collectFiles(cwd, [
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
]),
);
fs.writeFileSync(outfile, archive);
console.log(`packed ${path.basename(outfile)}`);
+26 -1
View File
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const { parseManifest } = await loadSdkModule("manifest");
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
const { errors } = parseManifest(raw);
const problems = [...errors];
@@ -34,6 +34,7 @@ export async function validate({ cwd }) {
}
}
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
problems.push(
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
);
@@ -53,6 +54,30 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/** Every declared theme file exists and holds only CSS a theme may hold. */
export function validateThemeFiles(cwd, raw, validateThemeCss) {
const problems = [];
const themes = raw?.contributes?.themes;
if (!Array.isArray(themes)) return problems;
for (const theme of themes) {
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
continue;
}
const file = path.join(cwd, theme.css);
if (!fs.existsSync(file)) {
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
continue;
}
for (const error of validateThemeCss(
fs.readFileSync(file, "utf8"),
theme.id,
)) {
problems.push(`theme ${theme.id}: ${error}`);
}
}
return problems;
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
+9
View File
@@ -2,6 +2,15 @@ import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/** Theme CSS files a manifest declares, which travel with the plugin too. */
export function themeFiles(manifest) {
const themes = manifest?.contributes?.themes;
if (!Array.isArray(themes)) return [];
return themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string");
}
/** What a .tmxplug holds, in the order the spec lists it. */
export const TMXPLUG_CONTENTS = [
"manifest.json",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.11",
"version": "1.0.12",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+111 -8
View File
@@ -82,7 +82,11 @@
"features": {
"type": "array",
"maxItems": 20,
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"description": "Short lines listing what the plugin does, shown on its page."
},
"docs": {
@@ -98,11 +102,24 @@
"additionalProperties": false,
"required": ["name", "description"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
"default": { "type": "string" },
"required": { "type": "boolean" },
"secret": { "type": "boolean" }
"name": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 300
},
"default": {
"type": "string"
},
"required": {
"type": "boolean"
},
"secret": {
"type": "boolean"
}
}
}
},
@@ -155,7 +172,11 @@
"files:own",
"secrets:own",
"settings:read-core",
"ui:surface"
"ui:surface",
"notify:hub",
"plugins:read",
"ui:layout",
"ui:theme"
]
}
},
@@ -944,7 +965,9 @@
"defaultLevels": {
"type": "array",
"minItems": 1,
"items": { "enum": ["admin", "user", "folder"] },
"items": {
"enum": ["admin", "user", "folder"]
},
"description": "Levels that may set a default for the field. All three by default."
},
"personal": {
@@ -1127,6 +1150,86 @@
"type": "string",
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
}
},
"layouts": {
"type": "array",
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$"
},
"label": {
"type": "string",
"minLength": 1,
"description": "i18n key in the plugin namespace, or plain text."
},
"description": {
"type": "string",
"minLength": 1
},
"defaultTheme": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "Theme picked with this layout when nobody chose one."
},
"default": {
"type": "boolean",
"description": "The layout a default-look plugin starts with. At most one."
}
}
}
},
"themes": {
"type": "array",
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label", "mode", "css"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$"
},
"label": {
"type": "string",
"minLength": 1
},
"mode": {
"type": "string",
"enum": ["light", "dark"]
},
"css": {
"type": "string",
"pattern": ".css$",
"description": "Path of the CSS file inside the plugin."
},
"swatch": {
"type": "object",
"additionalProperties": false,
"required": ["background", "panel", "text"],
"properties": {
"background": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{3,8}$"
},
"panel": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{3,8}$"
},
"text": {
"type": "string",
"pattern": "^#[0-9a-fA-F]{3,8}$"
}
}
}
}
}
}
}
},
+2
View File
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("auth:provide", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("ui:layout", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
@@ -70,6 +71,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("settings:read-core", "low"),
entry("plugins:read", "low"),
entry("ui:surface", "low"),
entry("ui:theme", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
+138
View File
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
onReady: (listener: () => void) => Disposer;
}
// ---------------------------------------------------------------------------
// Layouts (plugin API 1.4)
// ---------------------------------------------------------------------------
/**
* A whole-app layout. Core renders the chosen one inside its providers and an
* error boundary; everything it shows comes from useShell(). Declare it in
* manifest contributes.layouts and add the ui:layout capability.
*/
export interface LayoutContribution {
id: string;
/** Your own i18n key or plain text, the same as the manifest's label. */
label: string;
component: ComponentType;
/** Shown on phones and narrow windows. The desktop app never uses it. */
mobileComponent?: ComponentType;
/** Theme to start with when nobody chose one. */
defaultTheme?: string;
}
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
export interface ShellTabState extends PluginTabRecord {
instanceId: string;
customLabel?: string;
/** Set while the tab sits in a split pane. */
parentSplitTabId?: string;
openedAt?: number;
}
/** What a dock region shows and how it is sized, in Normal-size pixels. */
export interface ShellDockState {
view: string | null;
open: boolean;
width: number;
dragging: boolean;
setView: (view: string | null) => void;
setWidth: (width: number) => void;
/** Starts a drag from the dock's edge, from a mousedown. */
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
resetWidth: () => void;
}
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
export interface ShellController {
tabs: {
list: ShellTabState[];
/** What a tab bar shows: tabs not sitting in a split. */
topLevel: ShellTabState[];
activeId: string;
/** True once saved tabs were restored after login. */
ready: boolean;
/** The user turned on tab numbers. */
showNumbers: boolean;
setActive: (id: string) => void;
reorder: (topLevel: ShellTabState[]) => void;
close: (id: string) => Promise<void>;
rename: (id: string, label: string) => void;
/** Reconnects or reloads a session tab. */
refresh: (id: string) => void;
reconnectAll: () => void;
openSingleton: (type: string) => string | undefined;
};
split: {
/** The split shown now, or null. */
activeId: string | null;
focusedPaneId: string | null;
/** Every split with its panes, for a split menu. */
summaries: Array<{
id: string;
label: string;
full: boolean;
panes: Array<{
id: string;
index: number;
tabId: string | null;
label: string | null;
}>;
}>;
activeFull: boolean;
/** Split a tab, apply a preset, unsplit, add to a pane. */
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
};
hosts: {
list: PluginHostRecord[];
loading: boolean;
loaded: boolean;
reload: () => Promise<void>;
};
regions: {
left: ShellDockState & { view: string };
right: ShellDockState;
/** What a rail click does: toggles or switches the left dock. */
railClick: (view: string) => void;
openInRightDock: (view: string) => void;
toggleRightDock: () => void;
};
palette: { isOpen: boolean; open: () => void; close: () => void };
fullscreen: { active: boolean; toggle: () => void };
/** The same callbacks tabs and panels get. */
actions: ShellApi;
env: {
isMobile: boolean;
isElectron: boolean;
user: { username: string; isAdmin: boolean };
permissions: string[];
branding: {
appName: string | null;
tagline: string | null;
logo: string | null;
};
};
/** Core registries, as hooks a layout calls while rendering. */
registries: {
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
useDashboardCards: () => ReadonlyArray<
{ id: string } & Record<string, unknown>
>;
};
logout: (options?: { manual?: boolean }) => void;
}
// ---------------------------------------------------------------------------
// Registrations
// ---------------------------------------------------------------------------
@@ -988,6 +1111,12 @@ export interface TermixApp extends TermixAppInfo {
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
/**
* A whole-app layout. Declared in manifest contributes.layouts with the
* ui:layout capability. Only the chosen layout's plugin is loaded, so
* register it in activate. Needs plugin API 1.4.
*/
registerLayout: (layout: LayoutContribution) => Disposer;
/**
* Translates a key from the plugin's own locales, for code outside a
@@ -1122,6 +1251,7 @@ export interface PluginHostBridge {
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
useCurrentUser: () => CurrentUser | null;
useTheme: () => { theme: "light" | "dark" };
useShell: () => ShellController | null;
toast: ToastApi;
getApi: (pluginId: string) => PluginApiClient;
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
@@ -1372,6 +1502,14 @@ export function useTheme(): { theme: "light" | "dark" } {
return requireHost().useTheme();
}
/**
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
* API 1.4.
*/
export function useShell(): ShellController | null {
return requireHost().useShell();
}
export function useToast(): ToastApi {
return requireHost().toast;
}
+164 -1
View File
@@ -19,6 +19,7 @@ import {
type PluginEnvVar,
} from "./docs.js";
export { validateThemeCss } from "./theme-css.js";
export {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
@@ -36,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.3.0";
export const PLUGIN_API_VERSION = "1.4.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
keyboardInteractive?: string[];
}
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
/**
* A whole-app layout the frontend registers with app.registerLayout. Needs
* the ui:layout capability. Declared here so core can list and choose it
* before any plugin code runs.
*/
export interface PluginLayoutContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
description?: string;
/** Theme picked with this layout when nobody chose one. */
defaultTheme?: string;
/** The layout a default-look plugin starts with. */
default?: boolean;
}
/**
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
*/
export interface PluginThemeContribution {
id: string;
/** i18n key in the plugin's namespace, or plain text. */
label: string;
mode: "light" | "dark";
/** Path of the CSS file inside the plugin. */
css: string;
/** Colors the theme picker previews it with. */
swatch?: { background: string; panel: string; text: string };
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
@@ -487,6 +522,10 @@ export interface PluginContributions {
* ctx.credentials.resolveHostProtocol.
*/
protocols?: PluginProtocolContribution[];
/** Whole-app layouts. Needs the ui:layout capability. */
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
}
/** A field a protocol login carries besides username and password. */
@@ -708,6 +747,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"uses",
"keybindingActions",
"protocols",
"layouts",
"themes",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -1300,6 +1341,111 @@ function validateContributes(
validateUiPresets(contributes.uiPresets, errors);
validateKeybindingActions(contributes.keybindingActions, errors);
validateProtocols(contributes.protocols, errors);
validateLayouts(contributes.layouts, errors);
validateThemes(contributes.themes, errors);
}
/** An array of objects with unique look ids; calls `each` for every valid entry. */
function validateLookList(
value: unknown,
field: string,
allowed: string[],
errors: string[],
each: (raw: Record<string, unknown>, where: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`Field "contributes.${field}" must be an array`);
return;
}
const seen = new Set<string>();
value.forEach((raw, index) => {
const where = `contributes.${field}[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, allowed, where, errors);
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
errors.push(
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
} else {
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
seen.add(raw.id);
}
requireString(raw.label, `${where}.label`, errors);
each(raw, where);
});
}
function validateLayouts(value: unknown, errors: string[]): void {
validateLookList(
value,
"layouts",
["id", "label", "description", "defaultTheme", "default"],
errors,
(raw, where) => {
if (raw.description !== undefined)
requireString(raw.description, `${where}.description`, errors);
if (
raw.defaultTheme !== undefined &&
(typeof raw.defaultTheme !== "string" ||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
) {
errors.push(`${where}.defaultTheme must be a theme id`);
}
if (raw.default !== undefined && typeof raw.default !== "boolean") {
errors.push(`${where}.default must be a boolean`);
}
},
);
if (
Array.isArray(value) &&
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
) {
errors.push('Only one entry in "contributes.layouts" may set default');
}
}
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
function validateThemes(value: unknown, errors: string[]): void {
validateLookList(
value,
"themes",
["id", "label", "mode", "css", "swatch"],
errors,
(raw, where) => {
if (raw.mode !== "light" && raw.mode !== "dark") {
errors.push(`${where}.mode must be "light" or "dark"`);
}
if (requireString(raw.css, `${where}.css`, errors)) {
requireRelativePath(raw.css, `${where}.css`, errors);
if (!raw.css.endsWith(".css")) {
errors.push(`${where}.css must be a .css file`);
}
}
if (raw.swatch !== undefined) {
if (!isPlainObject(raw.swatch)) {
errors.push(`${where}.swatch must be an object`);
return;
}
rejectUnknown(
raw.swatch,
["background", "panel", "text"],
`${where}.swatch`,
errors,
);
for (const key of ["background", "panel", "text"]) {
const color = raw.swatch[key];
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
errors.push(`${where}.swatch.${key} must be a hex color`);
}
}
}
},
);
}
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
@@ -2324,6 +2470,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
if (
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:layout")
) {
errors.push(
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
);
}
if (
(manifest.contributes?.themes?.length ?? 0) > 0 &&
!manifest.capabilities.includes("ui:theme")
) {
errors.push(
'contributes.themes needs the "ui:theme" capability in the capabilities array',
);
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
+1
View File
@@ -97,6 +97,7 @@ const offline = () =>
useHostStatus: () => null,
useCurrentUser: () => null,
useTheme: () => ({ theme: "dark" }),
useShell: () => null,
toast: { success: noop, error: noop, info: noop, warning: noop },
getApi: () => ({
get: offline,
+150
View File
@@ -0,0 +1,150 @@
/**
* Checks a theme's CSS before core serves it.
*
* A theme is colors and styles only, so its file may hold nothing but rules
* whose every selector starts with the theme's own class (`.nord`,
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
* Anything that could load from elsewhere or run code is refused: @import,
* url() to another host, other at-rules, expression() and javascript:.
*/
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
function stripComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}
/** Splits on commas that are not inside parentheses or brackets. */
function splitSelectors(prelude: string): string[] {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const ch of prelude) {
if (ch === "(" || ch === "[") depth += 1;
if (ch === ")" || ch === "]") depth -= 1;
if (ch === "," && depth === 0) {
parts.push(current);
current = "";
continue;
}
current += ch;
}
parts.push(current);
return parts.map((part) => part.trim());
}
function withoutStrings(css: string): string {
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Index of the brace closing the one at `open`, skipping strings. */
function matchingBrace(css: string, open: number): number {
let depth = 0;
let quote: string | null = null;
for (let i = open; i < css.length; i += 1) {
const ch = css[i];
if (quote) {
if (ch === "\\") i += 1;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") depth += 1;
else if (ch === "}") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function checkUrls(css: string, errors: string[]): void {
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
for (const match of urls) {
const target = match[2].trim();
if (target.startsWith("data:")) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
errors.push(`url(${target}) points outside the plugin`);
}
}
}
function checkRules(
css: string,
scope: RegExp,
themeId: string,
errors: string[],
): void {
let index = 0;
while (index < css.length) {
const rest = css.slice(index);
if (rest.trim() === "") return;
const open = css.indexOf("{", index);
const semicolon = css.indexOf(";", index);
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
const statement = css.slice(index, semicolon).trim();
errors.push(
statement.startsWith("@")
? `${statement.split(/\s/)[0]} is not allowed in a theme`
: `Unexpected "${statement}" outside a rule`,
);
index = semicolon + 1;
continue;
}
if (open === -1) {
errors.push(`Unexpected "${rest.trim()}" at the end`);
return;
}
const close = matchingBrace(css, open);
if (close === -1) {
errors.push("A rule is missing its closing brace");
return;
}
const prelude = css.slice(index, open).trim();
const body = css.slice(open + 1, close);
if (prelude.startsWith("@")) {
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
checkRules(body, scope, themeId, errors);
} else {
errors.push(`@${name} is not allowed in a theme`);
}
} else {
for (const selector of splitSelectors(prelude)) {
if (!scope.test(selector)) {
errors.push(
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
);
}
}
if (withoutStrings(body).includes("{")) {
errors.push(`Nested rules are not allowed in "${prelude}"`);
}
}
index = close + 1;
}
}
/** Every problem with a theme's CSS, empty when it is safe to serve. */
export function validateThemeCss(css: string, themeId: string): string[] {
const errors: string[] = [];
const clean = stripComments(css);
if (/@import\b/i.test(clean))
errors.push("@import is not allowed in a theme");
if (/expression\s*\(/i.test(clean))
errors.push("expression() is not allowed in a theme");
if (/javascript:/i.test(clean))
errors.push("javascript: is not allowed in a theme");
if (/-moz-binding|behavior\s*:/i.test(clean))
errors.push("Script bindings are not allowed in a theme");
checkUrls(clean, errors);
const id = escapeRegExp(themeId);
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
checkRules(clean, scope, themeId, errors);
return [...new Set(errors)];
}
+2
View File
@@ -173,6 +173,7 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ShellConfirmCapture (value): () => null
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
@@ -183,6 +184,7 @@ SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }:
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
TabStage (value): ({ className }: { className?: string; }) => Element
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
TabStripItem (type): TabStripItem
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
+68
View File
@@ -416,3 +416,71 @@ describe("rebuilding local plugins", () => {
expect(run.sort()).toEqual(["a", "b"]);
});
});
describe("default look", () => {
const entry = { source: "tmxplug", path: "a", sha256: SHA };
it("reads the defaultLook flag and refuses anything but a boolean", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{ ...entry, id: "look", defaultLook: true },
{ ...entry, id: "plain" },
{ ...entry, id: "odd", defaultLook: "yes" },
],
});
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
expect(
plugins.map((p: { id: string; defaultLook: boolean }) => [
p.id,
p.defaultLook,
]),
).toEqual([
["look", true],
["plain", false],
]);
});
it("carries it into the bundled index and through a re-pin", () => {
expect(
buildBundledIndex(
[
{ id: "look", onboarding: null, defaultLook: true },
{ id: "plain", onboarding: null, defaultLook: false },
],
["look", "plain"],
),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
const index = {
plugins: [
{
id: "look",
versions: [
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
],
},
],
};
expect(
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
).toMatchObject({ id: "look", defaultLook: true });
});
it("copies a local build's theme files", () => {
const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(
path.join(repo, "manifest.json"),
JSON.stringify({
id: "inks",
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
}),
);
const out = path.join(tempDir(), "inks");
copyLocalBuild(repo, out);
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
});
});
+2
View File
@@ -194,6 +194,7 @@ const ALLOWED = [
"SheetHeader",
"SheetTitle",
"SheetTrigger",
"ShellConfirmCapture",
"Skeleton",
"Slider",
"Sparkline",
@@ -204,6 +205,7 @@ const ALLOWED = [
"Switch",
"SwitchRow",
"TOTPDialog",
"TabStage",
"TabStrip",
"TabStripItem",
"TagInput",
+38 -3
View File
@@ -15,6 +15,11 @@
* enabledByEnv counts the plugin as recommended when any of those
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*
* "defaultLook": true marks the plugin whose layout (and that layout's
* theme) a fresh install shows. Core finds the default look this way, so it
* never names a plugin. Such a plugin always starts enabled and cannot be
* uninstalled, only swapped for another look.
*/
const crypto = require("node:crypto");
@@ -110,6 +115,12 @@ function parseBundledPlugins(raw) {
}
const before = problems.length;
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
if (
entry.defaultLook !== undefined &&
typeof entry.defaultLook !== "boolean"
) {
problems.push(`${entry.id} defaultLook must be a boolean`);
}
if (problems.length > before) continue;
plugins.push({
id: entry.id,
@@ -118,6 +129,7 @@ function parseBundledPlugins(raw) {
path: hasPath ? entry.path : null,
sha256: entry.sha256,
onboarding,
defaultLook: entry.defaultLook === true,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -218,8 +230,12 @@ function buildBundledIndex(plugins, stagedIds) {
const staged = new Set(stagedIds);
const out = {};
for (const plugin of plugins) {
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
out[plugin.id] = { onboarding: plugin.onboarding };
if (!staged.has(plugin.id)) continue;
if (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = {
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
...(plugin.defaultLook ? { defaultLook: true } : {}),
};
}
return { version: 1, plugins: out };
}
@@ -238,12 +254,14 @@ function pinsFromIndex(index, ids, current = []) {
const latest = byId.get(id)?.versions?.[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}),
};
});
}
@@ -357,9 +375,26 @@ function rebuildLocalPlugins(dir, ids, run) {
}
/** Copies a local plugin build into the staging folder. */
/** Theme CSS files the repo's manifest declares, which ship with it too. */
function localThemeFiles(repo) {
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
);
const themes = manifest?.contributes?.themes;
return Array.isArray(themes)
? themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string")
: [];
} catch {
return [];
}
}
function copyLocalBuild(repo, destination) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of LOCAL_FILES) {
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
const from = path.join(repo, entry);
if (fs.existsSync(from)) {
fs.cpSync(from, path.join(destination, entry), { recursive: true });
+26
View File
@@ -142,3 +142,29 @@ describe("termix-plugin pack", () => {
expect(fs.readdirSync(out)).toEqual([]);
});
});
describe("termix-plugin pack themes", () => {
it("ships the CSS files the manifest's themes declare", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const dir = fixturePlugin({
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "styles/paper.css",
},
],
},
});
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
writeFile(dir, "styles/draft.css", ".draft {}\n");
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
const names = await entries(file);
expect(names).toContain("styles/paper.css");
expect(names).not.toContain("styles/draft.css");
});
});
+50
View File
@@ -49,3 +49,53 @@ describe("termix-plugin validate migrations", () => {
expect(output).not.toMatch(/migrations\/\w+ is missing/);
});
});
describe("termix-plugin validate themes", () => {
function themed(css: string | null) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
}),
);
if (css !== null) {
fs.mkdirSync(path.join(root, "themes"));
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
}
return root;
}
it("reports a missing theme file", async () => {
expect(await problems(themed(null))).toContain(
"theme paper: themes/paper.css does not exist",
);
});
it("reports theme CSS that reaches outside or leaves its class", async () => {
const output = await problems(
themed('@import "x.css"; body { color: red }'),
);
expect(output).toContain("theme paper: @import is not allowed in a theme");
expect(output).toContain(
'theme paper: Selector "body" must start with .paper',
);
});
it("says nothing about CSS that is fine", async () => {
const output = await problems(themed(".paper { --background: #fff; }"));
expect(output).not.toContain("theme paper");
});
});
+2
View File
@@ -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);
+2
View File
@@ -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");
+2
View File
@@ -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)`),
+2
View File
@@ -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`),
+2
View File
@@ -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`),
+205
View File
@@ -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;
+3 -1
View File
@@ -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" });
+36 -3
View File
@@ -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 {
+21 -1
View File
@@ -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";
}
+91 -1
View File
@@ -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)),
+57
View File
@@ -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";
+11 -1
View File
@@ -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) {
+1
View File
@@ -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);
});
});
+1 -1
View File
@@ -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",
);
+46
View File
@@ -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");
});
});
+79
View File
@@ -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> }>,
+105
View File
@@ -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);
});
});
+26
View File
@@ -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([]);
});
});
+8
View File
@@ -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) {
+2 -1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+48
View File
@@ -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;
}
+4
View File
@@ -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(
+22
View File
@@ -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;
+23 -27
View File
@@ -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) => {
+57
View File
@@ -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.",
+376
View File
@@ -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>
);
}
+90
View File
@@ -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([]);
}
+99
View File
@@ -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");
}
+133
View File
@@ -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();
}
+49
View File
@@ -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";
}
+65
View File
@@ -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;
}
+174
View File
@@ -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();
}
+65
View File
@@ -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(() => {});
}
}
+25 -2
View File
@@ -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),
+9 -9
View File
@@ -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: {
+94 -7
View File
@@ -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);
+22
View File
@@ -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 };
+3
View File
@@ -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 {
+4 -1
View File
@@ -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);
}
+14 -12
View File
@@ -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
+29 -25
View File
@@ -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>
);
}
+5
View File
@@ -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,
};
}
+2
View File
@@ -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>
+1
View File
@@ -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",
+98
View File
@@ -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>
);
}
+125
View File
@@ -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,
};
}
+32
View File
@@ -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,
);
}
+192
View File
@@ -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;
}
+76
View File
@@ -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 };
}
+230
View File
@@ -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,
};
}
+610
View File
@@ -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,
};
}
+944
View File
@@ -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 ?? ""),
});
});
}
+118
View File
@@ -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>
);
}
+11
View File
@@ -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 });
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more